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 ( + + ); + } + 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 (
void navigate(-1)}> - + void navigate(1)} > - + +
+
); } 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={ - + } > 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 ( -
-
- {personas.length === 0 && ( - No agents yet. - )} -
    - {personas.map((persona) => { - const last = persona.id === current ? lastOf(currentLog) : persona.lastMessage; - const fresh = isUnread(persona); - const row = ( - setHovered(persona.id)} - onPointerLeave={() => { - setHovered(null); - forgetOpeningPersona(persona.id); - }} - onPointerUp={(event) => { - if ( - persona.id === current || - event.pointerType !== 'mouse' || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - rememberOpeningPersona(persona.id); - }} - className={cn( - 'flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', - persona.id === current && 'bg-sidebar-accent text-sidebar-accent-foreground', - )} - > - recallHeadPose(persona.id)} - rememberPose={(pose) => rememberHeadPose(persona.id, pose)} - /> - - - - {persona.name} - - {last && ( - - {formatAgo(last.at)} - - )} +
    + {personas.length === 0 && ( + No agents yet. + )} +
      + {personas.map((persona) => { + const last = persona.id === current ? lastOf(currentLog) : persona.lastMessage; + const fresh = isUnread(persona); + const row = ( + setHovered(persona.id)} + onPointerLeave={() => { + setHovered(null); + forgetOpeningPersona(persona.id); + }} + onPointerUp={(event) => { + if ( + persona.id === current || + event.pointerType !== 'mouse' || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + rememberOpeningPersona(persona.id); + }} + className={cn( + 'flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', + persona.id === current && 'bg-sidebar-accent text-sidebar-accent-foreground', + )} + > + recallHeadPose(persona.id)} + rememberPose={(pose) => rememberHeadPose(persona.id, pose)} + /> + + + + {persona.name} - - - {subtitle(last)} + {last && ( + + {formatAgo(last.at)} - {fresh && ( - + )} + + + + {subtitle(last)} + {fresh && ( + + )} - - ); - if (!persona.mayChange) { - return
    • {row}
    • ; - } - return ( - - }>{row} - - onEdit(persona)}> - - Rename - - onDelete(persona)}> - - Delete - - - - ); - })} -
    -
    -
    - -
    +
    + + ); + if (!persona.mayChange) { + return
  • {row}
  • ; + } + return ( + + }>{row} + + onEdit(persona)}> + + Rename + + onDelete(persona)}> + + Delete + + + + ); + })} +
); } @@ -487,22 +464,8 @@ function Conversation({ return ( setEditing(current)} - className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50" - > - {current.name} - - - ) : ( - - {current.name} - - ) - } + title={{current.name}} + actions={ setEditing('new')} />} panel={ setEditing('new')} onEdit={setEditing} onDelete={setDeleting} /> diff --git a/packages/web/src/pages/connections-page.tsx b/packages/web/src/pages/connections-page.tsx index 4eb0ac77..48e0450d 100644 --- a/packages/web/src/pages/connections-page.tsx +++ b/packages/web/src/pages/connections-page.tsx @@ -1,18 +1,18 @@ import type {AgentLogin} from '@proxy/contracts/agents'; import type {Connection} from '@proxy/contracts/connections'; import {SANDBOX_INTEGRATION_ID, VAULT_INTEGRATION_ID} from '@proxy/integrations'; -import {PencilIcon, PlusIcon, Trash2Icon} from 'lucide-react'; +import {PencilIcon, Trash2Icon} from 'lucide-react'; import {useState} from 'react'; import {Link, useLoaderData, useRevalidator} from 'react-router'; import {deleteConnection, renameConnection} from '@/client/connections-client'; import {AppShell, PageTitle} from '@/components/app-shell'; +import {AddButton} from '@/components/add-button'; import {IntegrationLogo} from '@/components/brand-logo'; import {ConfirmDialog} from '@/components/confirm-dialog'; import {OrgNotice} from '@/components/org-notice'; import {RenameDialog} from '@/components/rename-dialog'; import {Row, RowHeader, RowList} from '@/components/row-list'; import {SideEmpty, SideToggle} from '@/components/share-toggle'; -import {Button} from '@proxy/ui/components/button'; import {ContextMenuItem} from '@proxy/ui/components/context-menu'; import {useHuman} from '@/hooks/use-human'; import {useSide} from '@/hooks/use-side'; @@ -78,22 +78,12 @@ export function ConnectionsPage() { return ( Connections} + title={Connections} actions={ - + } > diff --git a/packages/web/src/pages/home-page.test.tsx b/packages/web/src/pages/home-page.test.tsx index 30728ec9..16207483 100644 --- a/packages/web/src/pages/home-page.test.tsx +++ b/packages/web/src/pages/home-page.test.tsx @@ -339,7 +339,8 @@ describe('HomePage', () => { renderHomePage(); await typeInto('what'); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + // At least the first request: on a slow runner, typing's pause can send another before this looks. + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); await typeInto('whats hugos number'); answer(Response.json({widget: 'none', fields: [], ms: 1})); await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/packages/web/src/pages/members-page.tsx b/packages/web/src/pages/members-page.tsx index dc0e48c8..2d7c6137 100644 --- a/packages/web/src/pages/members-page.tsx +++ b/packages/web/src/pages/members-page.tsx @@ -70,7 +70,7 @@ export function MembersPage() { return ( + {organization.name} diff --git a/packages/web/src/pages/vault-page.tsx b/packages/web/src/pages/vault-page.tsx index 7e4155f5..335256ce 100644 --- a/packages/web/src/pages/vault-page.tsx +++ b/packages/web/src/pages/vault-page.tsx @@ -6,7 +6,6 @@ import { MailIcon, MapPinIcon, PhoneIcon, - PlusIcon, TextIcon, type LucideIcon, } from 'lucide-react'; @@ -14,6 +13,7 @@ import {useState} from 'react'; import {useLoaderData} from 'react-router'; import {createRecord, deleteRecord, updateRecord} from '@/client/records-client'; import {AppShell, PageTitle} from '@/components/app-shell'; +import {AddButton} from '@/components/add-button'; import {RecordForm} from '@/components/record-form'; import {Row, RowHeader, RowList} from '@/components/row-list'; import {SideEmpty, SideToggle} from '@/components/share-toggle'; @@ -104,10 +104,7 @@ export function VaultPage() { Vault} actions={ - + open({listed: null})} /> } >