From 682ddba8df70bc6b97221043cfce874e3dfa93d6 Mon Sep 17 00:00:00 2001
From: hkonsti <45428767+hkonsti@users.noreply.github.com>
Date: Sat, 7 Nov 2026 02:39:13 +0000
Subject: [PATCH 1/2] move page headers into the desktop app's title bar, with
add buttons beside back and forward
The desktop title bar grows to 2.75rem with the traffic lights centered in it. Each page's title
sits after back, forward and the page's + in that bar, and its other actions on the right of it;
the web keeps its header. Page titles drop their counts, Chat's title is the agent's name alone,
and Chat's New row becomes the header's add button.
---
packages/desktop/src/preload.ts | 8 +-
packages/desktop/src/templates/splash.css | 2 +-
packages/desktop/src/window.ts | 10 +-
packages/web/src/components/add-button.tsx | 51 ++++
packages/web/src/components/agent-shell.tsx | 7 +-
packages/web/src/components/app-shell.tsx | 15 +-
.../web/src/components/desktop-history.tsx | 19 +-
packages/web/src/components/root.tsx | 14 +-
.../web/src/components/title-bar-slot.tsx | 13 +
packages/web/src/hooks/use-overflowing.ts | 26 --
.../web/src/hooks/use-title-bar-header.ts | 34 +++
packages/web/src/hooks/use-title-bar-slot.ts | 11 +
packages/web/src/index.css | 59 ++++-
packages/web/src/pages/activity-page.tsx | 2 +-
packages/web/src/pages/agents-page.tsx | 22 +-
packages/web/src/pages/alerts-page.tsx | 2 +-
packages/web/src/pages/chat-page.tsx | 232 ++++++++----------
packages/web/src/pages/connections-page.tsx | 24 +-
packages/web/src/pages/members-page.tsx | 2 +-
packages/web/src/pages/vault-page.tsx | 7 +-
20 files changed, 331 insertions(+), 229 deletions(-)
create mode 100644 packages/web/src/components/add-button.tsx
create mode 100644 packages/web/src/components/title-bar-slot.tsx
delete mode 100644 packages/web/src/hooks/use-overflowing.ts
create mode 100644 packages/web/src/hooks/use-title-bar-header.ts
create mode 100644 packages/web/src/hooks/use-title-bar-slot.ts
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 (
-