diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts
index 4918ca5b..02aec061 100644
--- a/packages/desktop/src/preload.ts
+++ b/packages/desktop/src/preload.ts
@@ -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(`
@@ -28,7 +28,7 @@ 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;
@@ -36,11 +36,11 @@ if (process.platform === 'darwin' && window.location.origin === APP_ORIGIN) {
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;
}
`);
}
diff --git a/packages/desktop/src/templates/splash.css b/packages/desktop/src/templates/splash.css
index adc2ce10..43c27488 100644
--- a/packages/desktop/src/templates/splash.css
+++ b/packages/desktop/src/templates/splash.css
@@ -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;
diff --git a/packages/desktop/src/window.ts b/packages/desktop/src/window.ts
index 64486101..cf3b786e 100644
--- a/packages/desktop/src/window.ts
+++ b/packages/desktop/src/window.ts
@@ -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')}),
diff --git a/packages/web/src/components/add-button.tsx b/packages/web/src/components/add-button.tsx
new file mode 100644
index 00000000..f39a5d62
--- /dev/null
+++ b/packages/web/src/components/add-button.tsx
@@ -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 = ;
+ return (
+
+ {to !== undefined ? (
+
+ {icon}
+
+ ) : (
+
+ {icon}
+
+ )}
+
+ );
+ }
+
+ const content = (
+ <>
+
+ {label}
+ >
+ );
+ if (to !== undefined) {
+ return (
+ }>
+ {content}
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/agent-shell.tsx b/packages/web/src/components/agent-shell.tsx
index 20549a3f..c0ad2c61 100644
--- a/packages/web/src/components/agent-shell.tsx
+++ b/packages/web/src/components/agent-shell.tsx
@@ -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';
@@ -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} : {}),
@@ -27,7 +29,10 @@ export function AgentShell({children, feedback = true}: {children: ReactNode; fe
return (
-
+
@@ -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.
)}
- {title}
+ {hasHiddenDesktopTitleBar() ? {title} : title}
{actions &&
{actions}
}
diff --git a/packages/web/src/components/desktop-history.tsx b/packages/web/src/components/desktop-history.tsx
index 9933917a..c9f5c900 100644
--- a/packages/web/src/components/desktop-history.tsx
+++ b/packages/web/src/components/desktop-history.tsx
@@ -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';
@@ -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);
@@ -56,7 +63,7 @@ export function DesktopHistory() {
return (
);
}
diff --git a/packages/web/src/components/root.tsx b/packages/web/src/components/root.tsx
index 083d01a6..136c06d4 100644
--- a/packages/web/src/components/root.tsx
+++ b/packages/web/src/components/root.tsx
@@ -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(null);
+ const [title, setTitle] = useState(null);
return (
- <>
+
- {hasHiddenDesktopTitleBar() && }
- >
+ {hasHiddenDesktopTitleBar() && (
+
+ )}
+
);
}
diff --git a/packages/web/src/components/title-bar-slot.tsx b/packages/web/src/components/title-bar-slot.tsx
new file mode 100644
index 00000000..3ced60cf
--- /dev/null
+++ b/packages/web/src/components/title-bar-slot.tsx
@@ -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);
+}
diff --git a/packages/web/src/hooks/use-overflowing.ts b/packages/web/src/hooks/use-overflowing.ts
deleted file mode 100644
index 40056bc2..00000000
--- a/packages/web/src/hooks/use-overflowing.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import {useCallback, useState} from 'react';
-
-/**
- * Whether the element the ref is put on holds more than it shows, so it scrolls: measured as it or
- * what is in it resizes.
- */
-export function useOverflowing(): [(element: HTMLElement | null) => void, boolean] {
- const [overflowing, setOverflowing] = useState(false);
- const measure = useCallback((element: HTMLElement | null) => {
- if (!element) {
- return;
- }
- const read = () => setOverflowing(element.scrollHeight > element.clientHeight);
- read();
- if (typeof ResizeObserver === 'undefined') {
- return;
- }
- const observer = new ResizeObserver(read);
- observer.observe(element);
- for (const child of element.children) {
- observer.observe(child);
- }
- return () => observer.disconnect();
- }, []);
- return [measure, overflowing];
-}
diff --git a/packages/web/src/hooks/use-title-bar-header.ts b/packages/web/src/hooks/use-title-bar-header.ts
new file mode 100644
index 00000000..464f7aa2
--- /dev/null
+++ b/packages/web/src/hooks/use-title-bar-header.ts
@@ -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('[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();
+ }, []);
+}
diff --git a/packages/web/src/hooks/use-title-bar-slot.ts b/packages/web/src/hooks/use-title-bar-slot.ts
new file mode 100644
index 00000000..d9930790
--- /dev/null
+++ b/packages/web/src/hooks/use-title-bar-slot.ts
@@ -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({controls: null, title: null});
+
+export function useTitleBarSlot(place: keyof TitleBarSlots): HTMLElement | null {
+ return useContext(TitleBarSlot)[place];
+}
diff --git a/packages/web/src/index.css b/packages/web/src/index.css
index 73259587..2208d474 100644
--- a/packages/web/src/index.css
+++ b/packages/web/src/index.css
@@ -11,8 +11,9 @@
below it while the native traffic lights remain above; 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;
+ /* Keep in step with the traffic lights' position in desktop's window.ts, which centers them in
+ it, the fallback in preload.ts and desktop's splash.css. */
+ --desktop-title-bar-height: 2.75rem;
}
.desktop-app body::before {
@@ -33,9 +34,9 @@
lights, over the drag surface and taking clicks rather than dragging the window. */
.desktop-app [data-desktop-history] {
position: fixed;
- z-index: 61;
+ z-index: 62;
top: 0;
- left: 5rem;
+ left: 5.5rem;
height: var(--desktop-title-bar-height);
-webkit-app-region: no-drag;
}
@@ -48,8 +49,56 @@
padding-top: var(--desktop-title-bar-height);
}
+/* A page's header is the title bar over its column, where a browser shows it below: as tall as the
+ bar, over its drag surface and dragging the window itself from anywhere but its controls, with
+ its contents clear of the traffic lights, back and forward and the page's controls and title
+ after them where the column begins under them, as useTitleBarHeader measures. The column then
+ starts at the window's top rather than under the bar. */
+.desktop-app [data-desktop-page]:has(> header) {
+ padding-top: 0;
+}
+
.desktop-app [data-desktop-page] > header {
- top: var(--desktop-title-bar-height);
+ position: sticky;
+ z-index: 61;
+ top: 0;
+ height: var(--desktop-title-bar-height);
+ padding-left: var(--desktop-header-inset, 1rem);
+ -webkit-app-region: drag;
+}
+
+/* The page's title, after back and forward and the page's controls: in a detail's weight, dragging
+ the window as the rest of the bar does, and cut short before the right edge. */
+.desktop-app [data-desktop-title] {
+ max-width: 40vw;
+ -webkit-app-region: drag;
+}
+
+.desktop-app [data-desktop-title] * {
+ font-weight: 400;
+}
+
+.desktop-app
+ [data-desktop-page]
+ > header
+ :is(a, button, input, select, textarea, [role='button'], [tabindex]) {
+ -webkit-app-region: no-drag;
+}
+
+/* Tooltips and menus float over the title bar and the headers in it, rather than under them, as
+ the back and forward tooltips, which open below the bar, would. */
+.desktop-app
+ :has(
+ > :is(
+ [data-slot='tooltip-content'],
+ [data-slot='dropdown-menu-content'],
+ [data-slot='dropdown-menu-sub-content'],
+ [data-slot='context-menu-content'],
+ [data-slot='context-menu-sub-content'],
+ [data-slot='select-content']
+ )
+ ) {
+ z-index: 70;
}
/* The sidebar dividers begin below the title bar. Its bottom border meets them at a clean corner
diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx
index 56f4aa91..79f160dc 100644
--- a/packages/web/src/pages/activity-page.tsx
+++ b/packages/web/src/pages/activity-page.tsx
@@ -88,7 +88,7 @@ export function ActivityPage() {
];
return (
- Activity}>
+ Activity}>
{timeline && (
)}
diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx
index b37fc923..4ea3f70e 100644
--- a/packages/web/src/pages/agents-page.tsx
+++ b/packages/web/src/pages/agents-page.tsx
@@ -1,12 +1,11 @@
-import {PlusIcon} from 'lucide-react';
-import {Link, useLoaderData} from 'react-router';
+import {useLoaderData} from 'react-router';
import {findAgentProvider} from '@proxy/utils/agent-providers';
import {AgentLogo} from '@/components/brand-logo';
import {Badge} from '@proxy/ui/components/badge';
import {AppShell, PageTitle} from '@/components/app-shell';
+import {AddButton} from '@/components/add-button';
import {Row, RowHeader, RowList} from '@/components/row-list';
import {SideEmpty, SideToggle} from '@/components/share-toggle';
-import {Button} from '@proxy/ui/components/button';
import {useHuman} from '@/hooks/use-human';
import {useSide} from '@/hooks/use-side';
import {formatAgo} from '@/lib/format';
@@ -36,22 +35,15 @@ export function AgentsPage() {
const activeOrg = human.org;
const agents = side.shown;
- const active = all.filter((agent) => agent.revokedAt === null).length;
return (
Agent permissions}
+ title={Agent permissions}
actions={
-
- }
- >
-
- New agent
-
+
}
>
diff --git a/packages/web/src/pages/alerts-page.tsx b/packages/web/src/pages/alerts-page.tsx
index d975e2c3..9e9a70af 100644
--- a/packages/web/src/pages/alerts-page.tsx
+++ b/packages/web/src/pages/alerts-page.tsx
@@ -67,7 +67,7 @@ export function AlertsPage() {
};
return (
- Alerts}>
+ Alerts}>
setShown(null)} />
void;
recallHeadPose: (personaId: string) => Pose | null;
rememberHeadPose: (personaId: string, pose: Pose) => void;
- onNew: () => void;
onEdit: (persona: ChatPersona) => void;
onDelete: (persona: ChatPersona) => void;
}) {
const org = useOrgSlug();
const unread = useUnread();
const [hovered, setHovered] = useState(takeOpeningPersona);
- const [measureList, listOverflows] = useOverflowing();
// Whether the persona said something the person hasn't seen, as the provider has it.
const isUnread = (persona: ChatPersona) =>
persona.id !== current && unread.unreadIds.has(persona.id);
return (
-