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
8 changes: 4 additions & 4 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const APP_ORIGIN = new URL(

// 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
// `desktop-app` class and takes over with its column-specific layout. Keep its 2.75rem 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(`
Expand All @@ -28,19 +28,19 @@ if (process.platform === 'darwin' && window.location.origin === APP_ORIGIN) {
right: 0;
left: 0;
box-sizing: border-box;
height: 2rem;
height: 2.75rem;
border-bottom: 1px solid var(--border);
content: '';
-webkit-app-region: drag;
}

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

html:not(.desktop-app) [data-slot='sidebar-inner'] {
padding-top: 2rem;
padding-top: 2.75rem;
}
`);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/templates/splash.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ body::before {
left: 0;
box-sizing: border-box;
/* Keep in step with the fallback in preload.ts and the variable in packages/web/src/index.css. */
height: 2rem;
height: 2.75rem;
border-bottom: 1px solid var(--border);
content: '';
-webkit-app-region: drag;
Expand Down
10 changes: 7 additions & 3 deletions packages/desktop/src/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,13 @@ 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'} : {}),
// Let the page fill the native title bar on macOS, with its traffic lights centered in the
// page's taller bar (--desktop-title-bar-height in packages/web/src/index.css, 44px), where
// the pages' headers sit. The page provides the drag surface the hidden frame no longer
// supplies.
...(process.platform === 'darwin'
? {titleBarStyle: 'hidden', trafficLightPosition: {x: 16, y: 14}}
: {}),
// 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
51 changes: 51 additions & 0 deletions packages/web/src/components/add-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {PlusIcon} from 'lucide-react';
import {Link} from 'react-router';
import {IconButton} from '@/components/icon-button';
import {InTitleBar} from '@/components/title-bar-slot';
import {Button} from '@proxy/ui/components/button';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';

type Props = {
label: string;
variant?: 'default' | 'outline';
} & ({to: string; onClick?: never} | {onClick: () => void; to?: never});

// A header's add action. In a browser, a button with its label. In the desktop app, a plus alone
// in the title bar beside back and forward, as they are, with its label in a tooltip.
export function AddButton({label, variant = 'default', to, onClick}: Props) {
if (hasHiddenDesktopTitleBar()) {
const icon = <PlusIcon className="size-4" />;
return (
<InTitleBar place="controls">
{to !== undefined ? (
<IconButton label={label} size="xs" to={to}>
{icon}
</IconButton>
) : (
<IconButton label={label} size="xs" onClick={onClick}>
{icon}
</IconButton>
)}
</InTitleBar>
);
}

const content = (
<>
<PlusIcon data-icon="inline-start" />
{label}
</>
);
if (to !== undefined) {
return (
<Button size="sm" variant={variant} nativeButton={false} render={<Link to={to} />}>
{content}
</Button>
);
}
return (
<Button size="sm" variant={variant} onClick={onClick}>
{content}
</Button>
);
}
7 changes: 6 additions & 1 deletion packages/web/src/components/agent-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {AgentLogo} from '@/components/brand-logo';
import {Button} from '@proxy/ui/components/button';
import {cn} from '@proxy/ui/lib/utils';
import {useAgentMe, useAgentTask} from '@/hooks/use-agent-target';
import {useTitleBarHeader} from '@/hooks/use-title-bar-header';
import {agentErrorMessage} from '@/lib/agent-errors';
import {taskHome, TASKS_PATH} from '@/lib/agent-task';

Expand All @@ -19,6 +20,7 @@ export function AgentShell({children, feedback = true}: {children: ReactNode; fe
const agent = useAgentMe()?.agent;
const {connectionId, toolName} = useParams();
const task = useAgentTask();
const titleBarHeader = useTitleBarHeader();
const about = new URLSearchParams({
...(task ? {task: task.task} : {}),
...(connectionId ? {connection: connectionId} : {}),
Expand All @@ -27,7 +29,10 @@ export function AgentShell({children, feedback = true}: {children: ReactNode; fe

return (
<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">
<header
ref={titleBarHeader}
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}
className="shrink-0 text-sm font-medium whitespace-nowrap"
Expand Down
15 changes: 10 additions & 5 deletions packages/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {AppSidebar} from '@/components/nav';
import {Button} from '@proxy/ui/components/button';
import {Sheet, SheetContent, SheetTitle} from '@proxy/ui/components/sheet';
import {SidebarTrigger} from '@proxy/ui/components/sidebar';
import {InTitleBar} from '@/components/title-bar-slot';
import {useTitleBarHeader} from '@/hooks/use-title-bar-header';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';
import {useDesktop} from '@proxy/ui/hooks/use-media-query';
import {cn} from '@proxy/ui/lib/utils';

Expand Down Expand Up @@ -46,8 +49,8 @@ function PanelSheet({panel, label}: {panel: ReactNode; label: string}) {
);
}

// The human side's frame: the sidebar, a header with the page's title and actions, and the page
// in one centered column.
// The human side's frame: the sidebar, a header with the page's title and actions, in the desktop
// app the window's title bar, and the page in one centered column.
export function AppShell({
children,
title,
Expand All @@ -58,6 +61,7 @@ export function AppShell({
}: Props) {
const desktop = useDesktop();
const {pathname} = useLocation();
const titleBarHeader = useTitleBarHeader();

return (
<>
Expand All @@ -78,9 +82,10 @@ export function AppShell({
)}
>
{title !== undefined && (
// Sticky only when the window scrolls: scrolling inside, the page is the header's
// scroll container, and its desktop title bar padding would count twice.
// Sticky only when the window scrolls. In the desktop app it is the title bar over
// the page's column (index.css), its title after back and forward.
<header
ref={titleBarHeader}
className={cn(
'z-20 flex h-12 shrink-0 items-center justify-between gap-4 border-b bg-background px-4',
scroll === 'page' && 'sticky top-0',
Expand All @@ -91,7 +96,7 @@ export function AppShell({
{!desktop && panel !== undefined && (
<PanelSheet key={pathname} panel={panel} label={panelLabel} />
)}
{title}
{hasHiddenDesktopTitleBar() ? <InTitleBar place="title">{title}</InTitleBar> : title}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</header>
Expand Down
19 changes: 14 additions & 5 deletions packages/web/src/components/desktop-history.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ChevronLeftIcon, ChevronRightIcon} from 'lucide-react';
import {ArrowLeftIcon, ArrowRightIcon} from 'lucide-react';
import {useEffect, useSyncExternalStore} from 'react';
import {useNavigate} from 'react-router';
import {IconButton} from '@/components/icon-button';
Expand All @@ -15,8 +15,15 @@ function subscribe(onChange: () => void): () => void {
// Back and forward in the desktop app's title bar, beside the traffic lights, where a browser has
// its own: the window has none. They step through the window's history, kept by the Navigation
// API, which counts only the app's own pages, never the splash pages (`data:`) shown before it.
// ⌘[ and ⌘], and a mouse's side buttons, do the same.
export function DesktopHistory() {
// ⌘[ and ⌘], and a mouse's side buttons, do the same. After them come the page's controls, then
// its title, in the slots given to `controlsRef` and `titleRef` (title-bar-slot.tsx).
export function DesktopHistory({
controlsRef,
titleRef,
}: {
controlsRef?: (slot: HTMLElement | null) => void;
titleRef?: (slot: HTMLElement | null) => void;
}) {
const navigate = useNavigate();
const canGoBack = useSyncExternalStore(subscribe, () => navigation.canGoBack);
const canGoForward = useSyncExternalStore(subscribe, () => navigation.canGoForward);
Expand Down Expand Up @@ -56,16 +63,18 @@ export function DesktopHistory() {
return (
<div data-desktop-history className="flex items-center gap-0.5">
<IconButton label="Back" size="xs" disabled={!canGoBack} onClick={() => void navigate(-1)}>
<ChevronLeftIcon className="size-4" />
<ArrowLeftIcon className="size-4" />
</IconButton>
<IconButton
label="Forward"
size="xs"
disabled={!canGoForward}
onClick={() => void navigate(1)}
>
<ChevronRightIcon className="size-4" />
<ArrowRightIcon className="size-4" />
</IconButton>
<div ref={controlsRef} className="contents" />
<div ref={titleRef} data-desktop-title className="flex min-w-0 items-center gap-2 pl-2.5" />
</div>
);
}
14 changes: 10 additions & 4 deletions packages/web/src/components/root.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import {useState} from 'react';
import {Outlet} from 'react-router';
import {DesktopHistory} from '@/components/desktop-history';
import {ReleaseNotice} from '@/components/release-notice';
import {TitleBarSlot} from '@/hooks/use-title-bar-slot';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';

// Around every page, the human side's and the agents' alike: the page, word of a new release, and
// in the desktop app's title bar, back and forward.
// in the desktop app's title bar, back and forward, followed by the page's controls and title.
export function Root() {
const [controls, setControls] = useState<HTMLElement | null>(null);
const [title, setTitle] = useState<HTMLElement | null>(null);
return (
<>
<TitleBarSlot value={{controls, title}}>
<Outlet />
<ReleaseNotice />
{hasHiddenDesktopTitleBar() && <DesktopHistory />}
</>
{hasHiddenDesktopTitleBar() && (
<DesktopHistory controlsRef={setControls} titleRef={setTitle} />
)}
</TitleBarSlot>
);
}
13 changes: 13 additions & 0 deletions packages/web/src/components/title-bar-slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {ReactNode} from 'react';
import {createPortal} from 'react-dom';
import {type TitleBarSlots, useTitleBarSlot} from '@/hooks/use-title-bar-slot';

// Renders `children` in the desktop app's title bar, after back and forward: in `place`, the
// page's controls or, after them, its title. Nothing where there is no title bar.
export function InTitleBar({place, children}: {place: keyof TitleBarSlots; children: ReactNode}) {
const slot = useTitleBarSlot(place);
if (!slot) {
return null;
}
return createPortal(children, slot);
}
26 changes: 0 additions & 26 deletions packages/web/src/hooks/use-overflowing.ts

This file was deleted.

34 changes: 34 additions & 0 deletions packages/web/src/hooks/use-title-bar-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {useCallback} from 'react';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';

// The space between the header's contents and the title bar's controls, or the window's edge.
const EDGE_PX = 16;
const GAP_PX = 12;

/**
* For a page's header, which the desktop app puts in its title bar (index.css). Where the traffic
* lights, back and forward and the page's controls and title after them (title-bar-slot.tsx) reach
* into the header, its contents start after them: `--desktop-header-inset`. Measured again as the
* header or the controls resize, as with the window, a sidebar or a new title. In a browser,
* nothing.
*/
export function useTitleBarHeader(): (header: HTMLElement | null) => (() => void) | undefined {
return useCallback((header: HTMLElement | null) => {
if (!header || !hasHiddenDesktopTitleBar() || typeof ResizeObserver === 'undefined') {
return;
}
const controls = document.querySelector<HTMLElement>('[data-desktop-history]');
const read = () => {
const controlsEnd = controls ? controls.getBoundingClientRect().right + GAP_PX : 0;
const inset = Math.max(EDGE_PX, controlsEnd - header.getBoundingClientRect().left);
header.style.setProperty('--desktop-header-inset', `${inset}px`);
};
read();
const observer = new ResizeObserver(read);
observer.observe(header);
if (controls) {
observer.observe(controls);
}
return () => observer.disconnect();
}, []);
}
11 changes: 11 additions & 0 deletions packages/web/src/hooks/use-title-bar-slot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {createContext, useContext} from 'react';

// The desktop app's title bar's slots for a page (desktop-history.tsx): its controls, after back
// and forward, then its title. Null in a browser, and until the title bar is drawn.
export type TitleBarSlots = {controls: HTMLElement | null; title: HTMLElement | null};

export const TitleBarSlot = createContext<TitleBarSlots>({controls: null, title: null});

export function useTitleBarSlot(place: keyof TitleBarSlots): HTMLElement | null {
return useContext(TitleBarSlot)[place];
}
Loading
Loading