Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ everything but the app's own origin out of the window: links out of the app open
web app can tell where it is, and takes the app's links, `personalagentproxy://…`.
- `src/window.ts` loads the web app and keeps the window to it. Where the web app would show its
login page, the window shows a page of its own with a button that opens the browser; with no
connection, one that says so.
connection, one that says so. On macOS it hides the native title bar while keeping the traffic
lights in place; the preload and web app provide the invisible drag surface and content inset.
- Updates (`src/updates.ts`): a new release of the app, checked for on start and every few hours,
is downloaded at once and announced to the page through the window's preload
(`src/preload.ts`, `window.personalAgentProxyDesktop` to the web app), which shows its release
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/app-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PRODUCTION_APP_URL = 'https://app.personalagentproxy.com';
2 changes: 1 addition & 1 deletion packages/desktop/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {app} from 'electron';

const PRODUCTION_APP_URL = 'https://app.personalagentproxy.com';
import {PRODUCTION_APP_URL} from './app-url';

// A packaged app is hosted Personal Agent Proxy's. In development, DESKTOP_APP_URL, which dev.ts
// sets from the root .env's APP_URL: the api refuses sign-ins from any other origin.
Expand Down
38 changes: 37 additions & 1 deletion packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
import {contextBridge, ipcRenderer, type IpcRendererEvent} from 'electron';
import {contextBridge, ipcRenderer, type IpcRendererEvent, webFrame} from 'electron';

import {PRODUCTION_APP_URL} from './app-url';
import {RESTART_CHANNEL, UPDATE_READY_CHANNEL} from './channels';

const APP_ORIGIN = new URL(
process.env.NODE_ENV === 'production'
? PRODUCTION_APP_URL
: (process.env.DESKTOP_APP_URL ?? 'http://localhost:5173'),
).origin;

// A newly installed shell can briefly load an older hosted web build during a rollout or rollback.
// Keep that page draggable and below the traffic lights until a title-bar-aware build adds the
// `desktop-app` class and takes over with its column-specific layout. Keep its 2rem height in step
// with packages/desktop/src/templates/splash.css and packages/web/src/index.css.
if (process.platform === 'darwin' && window.location.origin === APP_ORIGIN) {
void webFrame.insertCSS(`
html:not(.desktop-app) body::before {
position: fixed;
z-index: 60;
top: 0;
right: 0;
left: 0;
height: 2rem;
content: '';
-webkit-app-region: drag;
}

html:not(.desktop-app) #root > :first-child {
box-sizing: border-box;
padding-top: 2rem;
}

html:not(.desktop-app) [data-slot='sidebar-inner'] {
padding-top: 2rem;
}
`);
}

// What the page may ask of the app, as `window.personalAgentProxyDesktop`, typed on the web app's
// side in packages/web/src/lib/desktop.ts: whether an update is downloaded, word when one is, and
// the restart that installs it. The main process answers the app's own pages only (updates.ts).

contextBridge.exposeInMainWorld('personalAgentProxyDesktop', {
titleBarHidden: process.platform === 'darwin',
updateReady: (): Promise<string | null> => ipcRenderer.invoke(UPDATE_READY_CHANNEL),
onUpdateReady: (listener: (version: string) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, version: string) => listener(version);
Expand Down
10 changes: 10 additions & 0 deletions packages/desktop/src/templates/splash.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body::before {
position: fixed;
top: 0;
right: 0;
left: 0;
/* Keep in step with the fallback in preload.ts and the variable in packages/web/src/index.css. */
height: 2rem;
content: '';
-webkit-app-region: drag;
}
main {
display: grid;
gap: 1.5rem;
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export function createMainWindow(): BrowserWindow {
show: false,
minWidth: 800,
minHeight: 600,
// Let the page fill the native title bar on macOS while keeping its traffic lights in their
// standard position. The page provides the drag surface the hidden frame no longer supplies.
...(process.platform === 'darwin' ? {titleBarStyle: 'hidden'} : {}),
// The window's icon where the window carries one, Windows and Linux, in a development run;
// a packaged app has its own, and no build/ beside it.
...(app.isPackaged ? {} : {icon: path.join(__dirname, '../build/icon.png')}),
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/agent-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function AgentShell({children, feedback = true}: {children: ReactNode; fe
}).toString();

return (
<div className="flex min-h-dvh w-full flex-col bg-background text-foreground">
<div data-desktop-page className="flex min-h-dvh w-full flex-col bg-background text-foreground">
<header className="sticky top-0 z-20 flex h-12 items-center justify-between gap-4 border-b bg-background px-4">
<Link
to={task ? taskHome(task.task) : TASKS_PATH}
Expand Down
8 changes: 6 additions & 2 deletions packages/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ export function AppShell({
<>
<AppSidebar />
{panel !== undefined && desktop && (
<aside className="sticky top-0 flex h-dvh w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
<aside
data-desktop-panel
className="sticky top-0 flex h-dvh w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground"
>
{panel}
</aside>
)}
<div
data-desktop-page
className={cn(
'relative flex w-full min-w-0 flex-1 flex-col bg-background text-foreground',
scroll === 'inside' ? 'h-dvh overflow-hidden' : 'min-h-dvh',
Expand All @@ -86,7 +90,7 @@ export function AppShell({
</header>
)}
{title === undefined && !desktop && (
<div className="absolute top-2 left-4 z-20">
<div data-desktop-floating-control className="absolute top-2 left-4 z-20">
<SidebarTrigger />
</div>
)}
Expand Down
42 changes: 42 additions & 0 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
@import '@proxy/ui/styles.css';

/* The macOS desktop window uses a hidden native title bar. The transparent drag surface leaves the
traffic lights over each column's own background, with no separate bar; browsers keep the page's
normal edge-to-edge layout. */
.desktop-app {
/* Keep in step with the fallback in preload.ts and desktop's splash.css. */
--desktop-title-bar-height: 2rem;
}

.desktop-app body::before {
position: fixed;
z-index: 60;
top: 0;
right: 0;
left: 0;
height: var(--desktop-title-bar-height);
content: '';
-webkit-app-region: drag;
}

/* The columns themselves reach the window's top, so their backgrounds continue under the traffic
lights. Only their contents make room for the controls and transparent drag surface. */
.desktop-app [data-slot='sidebar-inner'],
.desktop-app [data-desktop-panel],
.desktop-app [data-desktop-page] {
padding-top: var(--desktop-title-bar-height);
}

.desktop-app [data-desktop-page] > header {
top: var(--desktop-title-bar-height);
}

/* Zoom can move the 800px desktop window into the responsive layout. Keep its floating menu and
any left-side sheet below the traffic lights and drag surface too. */
.desktop-app [data-desktop-floating-control] {
top: calc(var(--desktop-title-bar-height) + 0.5rem);
}

.desktop-app [data-slot='sheet-content'][data-side='left'],
.desktop-app [data-mobile='true'][data-sidebar='sidebar'] {
padding-top: var(--desktop-title-bar-height);
}

/* The message scroller's viewport fades its bottom only while there is more below
(components/chat/message-scroller.tsx). A browser that drives the fade from the scroll
position itself (shadcn's scroll-fade) animates over this; one that can't gets the fade
Expand Down
32 changes: 32 additions & 0 deletions packages/web/src/lib/desktop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {afterEach, describe, expect, test} from 'bun:test';

import {hasHiddenDesktopTitleBar, type DesktopBridge} from '@/lib/desktop';

function bridge(titleBarHidden?: boolean): DesktopBridge {
return {
...(titleBarHidden === undefined ? {} : {titleBarHidden}),
updateReady: async () => null,
onUpdateReady: () => () => undefined,
restart: () => undefined,
};
}

describe('hasHiddenDesktopTitleBar', () => {
afterEach(() => {
delete window.personalAgentProxyDesktop;
});

test('is false unless the desktop shell explicitly hides its title bar', () => {
expect(hasHiddenDesktopTitleBar()).toBe(false);
window.personalAgentProxyDesktop = bridge();
expect(hasHiddenDesktopTitleBar()).toBe(false);
window.personalAgentProxyDesktop = bridge(false);
expect(hasHiddenDesktopTitleBar()).toBe(false);
});

test('is true only when the desktop shell says its title bar is hidden', () => {
window.personalAgentProxyDesktop = bridge(true);

expect(hasHiddenDesktopTitleBar()).toBe(true);
});
});
7 changes: 7 additions & 0 deletions packages/web/src/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function isDesktopApp(): boolean {
* it.
*/
export type DesktopBridge = {
// Optional while installed desktop apps from before the hidden title bar still load this page.
titleBarHidden?: boolean;
updateReady(): Promise<string | null>;
onUpdateReady(listener: (version: string) => void): () => void;
restart(): void;
Expand All @@ -37,6 +39,11 @@ export function desktopBridge(): DesktopBridge | null {
return window.personalAgentProxyDesktop ?? null;
}

/** Whether this desktop shell removed its native title bar and needs the page to replace it. */
export function hasHiddenDesktopTitleBar(): boolean {
return desktopBridge()?.titleBarHidden === true;
}

/** The link that hands the desktop app the one-time code it trades for a session. */
export function desktopAuthLink(code: string): string {
return `${DESKTOP_SCHEME}://auth-callback?code=${encodeURIComponent(code)}`;
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {RouterProvider} from 'react-router';
import './index.css';
import {TooltipProvider} from '@proxy/ui/components/tooltip';
import {router} from '@/router';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';
import {ThemeProvider} from '@proxy/ui/components/theme-provider';

const root = document.getElementById('root');
Expand All @@ -12,6 +13,10 @@ if (!root) {
throw new Error('Missing #root element');
}

if (hasHiddenDesktopTitleBar()) {
document.documentElement.classList.add('desktop-app');
}

createRoot(root).render(
<StrictMode>
<ThemeProvider>
Expand Down
Loading