From d56e2fbbe4fa68cbb072bc3d6ef766f967281823 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:48:18 -0700 Subject: [PATCH] feat(agent-dashboard): choose in-window board or pop-out window (#10243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-dashboard): choose in-window screen popover or pop-out window The experimental Agent Dashboard opened only as a separate pop-out window. Add an "Open as" mode under the experimental toggle so it can open as an in-window screen popover (new default) or a pop-out window (prior behavior). The mode row appears only when the feature is on. - New setting `experimentalAgentDashboardMode: 'in-window' | 'popout'` (default in-window); sidebar entry branches on it. - In-window: AgentDashboardOverlay renders the shared AgentKanbanBoard in a near-fullscreen dialog, snapshot built locally via useLiveDashboardSnapshot (the pop-out relays over IPC; in-window has no relay). Ack/reveal act on the local store — the pop-out IPC handlers are gated to the pop-out renderer. - AgentKanbanBoard gains containerClassName/onAckAgent/onRevealAgent/ onClose props; defaults preserve the pop-out behavior. - Extracted AgentDashboardExperimentalSetting to keep ExperimentalPane under the max-lines cap. * fix(agent-dashboard): admit main renderer to terminal-preview IPC for in-window dialog The terminalPreview:* handlers gated every channel to the pop-out renderer, so the in-window overlay's terminal dialog (running in the main renderer) got { snapshot: null } from connect and falsely showed "No live terminal — this agent's pane has closed." for live agents. Accept the trusted UI renderer too — it already has full PTY access through the regular terminal channels, so this adds no reach. * fix(agent-dashboard): sync locale catalogs for new mode/close keys verify:localization-catalog (part of lint CI) fails when en.json keys are missing from the other locale catalogs; run sync:localization-catalog so the six new agent-dashboard keys exist everywhere (English fallback text; translated copy remains the documented follow-up). * feat(agent-dashboard): present in-window mode as a companion board sheet The in-window dashboard now uses the same non-modal left sheet as the workspace kanban board — anchored to the sidebar edge, chrome/status-bar bounds, sidebar stays interactive — instead of a near-fullscreen modal dialog. Both companion boards are mutually exclusive; the sidebar entry toggles the drawer. Removes the modal focus-restore timing coupling on reveal. * fix(agent-dashboard): ignore Radix dismiss requests like the workspace board Non-modal Radix layers also request dismissal for interactions the drawer's outside guards cannot classify — focus moving outside carries no pointer coordinates, and clicks in the status bar / top chrome fall outside the right-side dismiss band. Forward only open requests from the Sheet, matching WorkspaceKanbanDrawer, so only the drawer's own escape/outside/close paths close it. * fix(agent-dashboard): guard reveal relay and refresh stale mode copy CodeRabbit review: revealAgent lacked the ?. HMR-skew guard its sibling ackAgent has (both channels shipped together, so a stale dev preload lacks both). The es/ja/ko/zh catalogs also still described the dashboard as pop-out-only in stale English, contradicting the new in-window default; refreshed to the current English source. * feat(agent-dashboard): add board settings menu to the in-window header Mirrors the workspace board's settings gear: an Open as segmented control in the board header so the mode is changeable without opening Settings. Switching to pop-out hands the surface over (closes the drawer, opens the window) instead of leaving a board the setting says should be a window. In-window only via an optional headerActions slot - the pop-out renderer has no store to drive it. * fix(agent-dashboard): reset the settings-menu flag when the drawer closes The pop-out hand-off closes the sheet while the menu is still open, so the menu unmounts without Radix reporting onOpenChange(false). The stale menuOpen=true then blocked outside-dismiss permanently on the next in-window open. Mirror closeWorkspaceBoard by resetting the flag in close. * fix(agent-dashboard): reset the menu flag on store-driven drawer closes Cmd+B sidebar collapse and the workspace-board exclusivity effect close the drawer via setAgentDashboardDrawerOpen directly, bypassing close(); a settings menu open at that moment unmounted without Radix reporting onOpenChange(false), leaving menuOpen stuck true and outside-dismiss disabled on the next open. Sync the flag to the open state so every close path resets it. --- src/main/ipc/terminal-preview.test.ts | 49 +++- src/main/ipc/terminal-preview.ts | 19 +- .../dashboard-popout/AgentKanbanBoard.tsx | 93 +++++-- .../dashboard-popout/AgentTerminalDialog.tsx | 18 +- .../dashboard/AgentDashboardDrawer.tsx | 231 ++++++++++++++++++ .../dashboard/AgentDashboardSettingsMenu.tsx | 110 +++++++++ .../dashboard/useLiveDashboardSnapshot.ts | 66 +++++ .../AgentDashboardExperimentalSetting.tsx | 105 ++++++++ .../components/settings/ExperimentalPane.tsx | 45 +--- .../settings/experimental-search.ts | 10 +- .../src/components/sidebar/Sidebar.test.tsx | 2 + .../src/components/sidebar/SidebarNav.tsx | 19 +- .../sidebar/WorkspaceKanbanDrawer.tsx | 4 +- src/renderer/src/components/sidebar/index.tsx | 27 ++ .../sidebar/workspace-chrome-metrics.ts | 4 + src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 18 +- src/renderer/src/i18n/locales/ja.json | 18 +- src/renderer/src/i18n/locales/ko.json | 18 +- src/renderer/src/i18n/locales/zh.json | 18 +- src/renderer/src/store/slices/ui.ts | 5 + src/shared/constants.ts | 2 + src/shared/types.ts | 5 + 23 files changed, 795 insertions(+), 107 deletions(-) create mode 100644 src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx create mode 100644 src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx create mode 100644 src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts create mode 100644 src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-chrome-metrics.ts diff --git a/src/main/ipc/terminal-preview.test.ts b/src/main/ipc/terminal-preview.test.ts index 396b1d93f7a..a01fd7c70eb 100644 --- a/src/main/ipc/terminal-preview.test.ts +++ b/src/main/ipc/terminal-preview.test.ts @@ -1,21 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, ipcMainMock, isDashboardPopoutRendererMock } = vi.hoisted(() => { - const map = new Map unknown>() - return { - handlers: map, - ipcMainMock: { - removeHandler: vi.fn(), - handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) - }, - isDashboardPopoutRendererMock: vi.fn(() => true) - } -}) +const { handlers, ipcMainMock, isDashboardPopoutRendererMock, isTrustedUIRendererMock } = + vi.hoisted(() => { + const map = new Map unknown>() + return { + handlers: map, + ipcMainMock: { + removeHandler: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) + }, + isDashboardPopoutRendererMock: vi.fn(() => true), + isTrustedUIRendererMock: vi.fn(() => false) + } + }) vi.mock('electron', () => ({ ipcMain: ipcMainMock })) vi.mock('../window/dashboard-popout-window', () => ({ isDashboardPopoutRenderer: isDashboardPopoutRendererMock })) +vi.mock('./ui', () => ({ + isTrustedUIRenderer: isTrustedUIRendererMock +})) import { registerTerminalPreviewHandlers } from './terminal-preview' @@ -82,6 +87,7 @@ describe('registerTerminalPreviewHandlers', () => { beforeEach(() => { handlers.clear() isDashboardPopoutRendererMock.mockReturnValue(true) + isTrustedUIRendererMock.mockReturnValue(false) }) afterEach(() => { vi.clearAllMocks() @@ -281,6 +287,27 @@ describe('registerTerminalPreviewHandlers', () => { expect(runtime.writeTerminalPreviewInput).not.toHaveBeenCalled() }) + // The in-window dashboard overlay hosts the preview dialog from the main + // renderer, which is trusted but is not the popout window. + it('admits the trusted main renderer when it is not the popout', async () => { + const runtime = makeRuntime() + registerTerminalPreviewHandlers(runtime as never) + const sender = makeSender() + isDashboardPopoutRendererMock.mockReturnValue(false) + isTrustedUIRendererMock.mockReturnValue(true) + + await expect( + handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' }) + ).resolves.toEqual({ + snapshot: { data: 'screen', cols: 80, rows: 20, seq: 5 }, + replay: [] + }) + await expect( + handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'x' }) + ).resolves.toBe(true) + expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledWith('p1', 'x') + }) + it('pushes a resync only when the PTY grid dimensions change', async () => { const runtime = makeRuntime() registerTerminalPreviewHandlers(runtime as never) diff --git a/src/main/ipc/terminal-preview.ts b/src/main/ipc/terminal-preview.ts index 59193df6894..8aa14479109 100644 --- a/src/main/ipc/terminal-preview.ts +++ b/src/main/ipc/terminal-preview.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/terminal-preview' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window' +import { isTrustedUIRenderer } from './ui' import { TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES, TerminalPreviewOutputStream @@ -16,6 +17,12 @@ function isValidPtyId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH } +// Why: the preview dialog has two hosts — the pop-out window and the main +// renderer's in-window overlay. The trusted UI renderer already has full PTY +// access through the regular terminal channels, so admitting it adds no reach. +function isTerminalPreviewRenderer(sender: WebContents): boolean { + return isDashboardPopoutRenderer(sender) || isTrustedUIRenderer(sender) +} /** Pop-out terminal transport with an atomic snapshot/live boundary. */ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('terminalPreview:connect') @@ -27,7 +34,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo const subscriptionsByContents = new Map>() // Why: the preview dialog claims the PTY grid through the remote-desktop // viewer registry so the main-window pane parks and later reclaims its own - // geometry. Claims are tracked per popout webContents so an explicit + // geometry. Claims are tracked per viewer webContents so an explicit // unsubscribe or a destroyed window always releases the size floor. const fitClaimsByContents = new Map>() @@ -83,7 +90,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo event, args: { ptyId?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise => { - if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { return { snapshot: null, replay: [] } } const ptyId = args.ptyId @@ -159,7 +166,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo 'terminalPreview:input', (event, args: { ptyId?: unknown; data?: unknown }): Promise => { if ( - !isDashboardPopoutRenderer(event.sender) || + !isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.data !== 'string' ) { @@ -173,7 +180,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo 'terminalPreview:ack', (event, args: { ptyId?: unknown; bytes?: unknown }): void => { if ( - !isDashboardPopoutRenderer(event.sender) || + !isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.bytes !== 'number' || !Number.isFinite(args.bytes) || @@ -197,7 +204,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo args: { ptyId?: unknown; cols?: unknown; rows?: unknown } ): Promise<{ cols: number; rows: number } | null> => { if ( - !isDashboardPopoutRenderer(event.sender) || + !isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId) || typeof args.cols !== 'number' || typeof args.rows !== 'number' || @@ -244,7 +251,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo ) ipcMain.handle('terminalPreview:unsubscribe', (event, args: { ptyId?: unknown }): void => { - if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { + if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) { return } subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.dispose() diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 54cb1d68498..01c46072c9d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { XIcon } from 'lucide-react' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, @@ -8,10 +9,24 @@ import { import { cn } from '@/lib/utils' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' -import { AgentTerminalDialog } from './AgentTerminalDialog' +import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' +/** Ack an agent in the pop-out window: relayed over IPC to the main renderer. + * ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot, + * the preload only on app restart) — acks just no-op until restart. */ +function ackAgentViaPopoutRelay(paneKey: string): void { + void window.api.dashboard.ackAgent?.(paneKey) +} + +/** Reveal an agent from the pop-out window: raise the main window and route it + * to the agent's pane via IPC. Same `?.` HMR-skew guard as the ack relay — + * both channels ship together, so a stale preload lacks both. */ +function revealAgentViaPopoutRelay(args: AgentRevealArgs): void { + void window.api.dashboard.revealAgent?.(args) +} + function bucketLabel(bucket: DashboardBucket): string { switch (bucket) { case 'attention': @@ -87,8 +102,36 @@ function KanbanColumn({ ) } -/** The pop-out agent board: status columns fed by the relayed snapshot. */ -export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): React.JSX.Element { +type AgentKanbanBoardProps = { + snapshot: DashboardSnapshot + /** Sizing for the outermost container. The pop-out fills the window + * (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */ + containerClassName?: string + /** Marks an agent as seen. Defaults to the pop-out IPC relay; the in-window + * host acks the store directly. */ + onAckAgent?: (paneKey: string) => void + /** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window + * host activates the worktree/pane locally and closes the overlay. */ + onRevealAgent?: (args: AgentRevealArgs) => void + /** When provided, renders a close control in the header (in-window mode). The + * pop-out relies on its native window controls, so it omits this. */ + onClose?: () => void + /** Header controls rendered before the close button. The in-window host + * passes its settings menu; the pop-out renderer has no store to drive it. */ + headerActions?: React.ReactNode +} + +/** The agent board: status columns fed by a snapshot. Shared by the pop-out + * window and the in-window drawer — the two differ only in sizing and + * how ack/reveal are routed. */ +export function AgentKanbanBoard({ + snapshot, + containerClassName = 'h-screen w-screen', + onAckAgent = ackAgentViaPopoutRelay, + onRevealAgent = revealAgentViaPopoutRelay, + onClose, + headerActions +}: AgentKanbanBoardProps): React.JSX.Element { const grouped = useMemo(() => groupByBucket(snapshot.cards), [snapshot.cards]) const hasRelativeTimestamps = useMemo( () => snapshot.cards.some((card) => (card.finishedAt ?? card.startedAt) > 0), @@ -131,24 +174,25 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): }, []) // Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute - // rows): opening a dialog acks the agent in the main renderer via the relay, - // and the next snapshot comes back with unseen=false. - // ?. shields dialog-opening from dev-HMR preload skew (renderer updates - // hot, the preload only on app restart) — acks just no-op until restart. - const handleOpenTerminal = useCallback((card: DashboardCard) => { - void window.api.dashboard.ackAgent?.(card.paneKey) - setOpenedCard(card) - }, []) + // rows): opening a dialog acks the agent, and the next snapshot comes back + // with unseen=false. + const handleOpenTerminal = useCallback( + (card: DashboardCard) => { + onAckAgent(card.paneKey) + setOpenedCard(card) + }, + [onAckAgent] + ) // Watching the open dialog counts as seeing state changes as they happen — // without this, an agent finishing while you watch would re-flag its card. useEffect(() => { if (dialogCard?.unseen) { - void window.api.dashboard.ackAgent?.(dialogCard.paneKey) + onAckAgent(dialogCard.paneKey) } - }, [dialogCard?.unseen, dialogCard?.paneKey]) + }, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent]) return ( -
+

{translate('dashboardPopout.title', 'Agents')} @@ -158,6 +202,21 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): count: snapshot.cards.length })} + {headerActions || onClose ? ( +
+ {headerActions} + {onClose ? ( + + ) : null} +
+ ) : null}

{/* Why: columns share the window width up to a readable cap; mx-auto @@ -176,7 +235,11 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): ))}
- +
) } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index b70ff8b1a6b..4752ad31483 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -9,10 +9,21 @@ import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import { AgentTerminalPreview } from './AgentTerminalPreview' import { translate } from '@/i18n/i18n' +/** Routing payload for focusing an agent's pane in the main window. */ +export type AgentRevealArgs = { + repoId: string + worktreeId: string + tabId: string + leafId: string | null +} + type AgentTerminalDialogProps = { /** The agent shown in the dialog; null renders the dialog closed. */ card: DashboardCard | null onOpenChange: (open: boolean) => void + /** Focus the agent's pane. The pop-out relays over IPC; the in-window host + * activates the worktree/pane locally. */ + onReveal: (args: AgentRevealArgs) => void } /** @@ -24,19 +35,20 @@ type AgentTerminalDialogProps = { */ export function AgentTerminalDialog({ card, - onOpenChange + onOpenChange, + onReveal }: AgentTerminalDialogProps): React.JSX.Element { const reveal = useCallback(() => { if (!card) { return } - void window.api.dashboard.revealAgent({ + onReveal({ repoId: card.repoId, worktreeId: card.worktreeId, tabId: card.tabId, leafId: card.leafId }) - }, [card]) + }, [card, onReveal]) return ( diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx new file mode 100644 index 00000000000..927c7930346 --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' +import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog' +import { + isWorkspaceBoardKeepOpenTarget, + useWorkspaceKanbanOutsideDismiss +} from '../sidebar/use-workspace-kanban-outside-dismiss' +import { + STATUS_BAR_RESERVE_HEIGHT, + WORKSPACE_TOP_CHROME_HEIGHT +} from '../sidebar/workspace-chrome-metrics' +import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' +import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' +import { translate } from '@/i18n/i18n' + +// Why: Escape should dismiss interactive nested overlays (e.g. the terminal +// preview dialog) before this companion sheet, which is excluded by its own +// data attribute because Radix marks it role="dialog" as well. +const AGENT_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [ + '[data-slot="dropdown-menu-content"][data-state="open"]', + '[data-slot="context-menu-content"][data-state="open"]', + '[data-slot="popover-content"][data-state="open"]', + '[role="dialog"][data-state="open"]:not([data-agent-dashboard-sheet])', + '[role="alertdialog"][data-state="open"]', + '[role="menu"][data-state="open"]', + '[role="listbox"][data-state="open"]' +].join(', ') + +/** The in-window Agent Dashboard body. Mounted only while open so the live + * snapshot derivation stays off the hot path when the drawer is closed. */ +function AgentDashboardDrawerBody({ + onClose, + onMenuOpenChange +}: { + onClose: () => void + onMenuOpenChange: (open: boolean) => void +}): React.JSX.Element { + const snapshot = useLiveDashboardSnapshot() + + // In-window ack/reveal act on the local store directly — the pop-out's IPC + // relay is gated to the pop-out renderer and would reject calls from here. + const handleAckAgent = useCallback((paneKey: string) => { + useAppStore.getState().acknowledgeAgents([paneKey]) + }, []) + const handleRevealAgent = useCallback( + (args: AgentRevealArgs) => { + useAppStore.getState().setActiveWorktree(args.worktreeId) + activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + onClose() + }, + [onClose] + ) + + // Switching to pop-out from the board hands the surface over rather than + // leaving an in-window board that the setting says should be a window. + const handleSwitchToPopout = useCallback(() => { + onClose() + void window.api.dashboard.openPopout?.() + }, [onClose]) + + return ( + + } + /> + ) +} + +type AgentDashboardDrawerProps = { + leftSidebarStyle?: React.CSSProperties + statusBarVisible: boolean +} + +/** + * The in-window Agent Dashboard surface: the same board as the pop-out window, + * presented like the workspace kanban board — a non-modal companion sheet that + * expands from the sidebar edge and keeps the rest of the app interactive. + */ +export function AgentDashboardDrawer({ + leftSidebarStyle, + statusBarVisible +}: AgentDashboardDrawerProps): React.JSX.Element { + const open = useAppStore((s) => s.agentDashboardDrawerOpen) + const setOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + const sidebarOpen = useAppStore((s) => s.sidebarOpen) + const sidebarWidth = useAppStore((s) => s.sidebarWidth) + const [menuOpen, setMenuOpen] = useState(false) + // Why: like closeWorkspaceBoard, reset the menu flag on close — Radix never + // reports close for a menu unmounted with the sheet (e.g. the pop-out + // hand-off), and a stale true would block outside-dismiss on reopen. + const close = useCallback(() => { + setMenuOpen(false) + setOpen(false) + }, [setOpen]) + // Why: sidebar collapse (Cmd+B) and workspace-board exclusivity close the + // drawer through the store setter, bypassing close(); sync the flag so a + // menu unmounted that way can't block outside-dismiss on the next open. + useEffect(() => { + if (!open) { + setMenuOpen(false) + } + }, [open]) + const handleSheetOpenChange = useCallback( + (nextOpen: boolean) => { + // Why: Radix also requests dismissal for unguardable interactions (focus + // moving outside has no pointer coordinates), so like the workspace board + // only the drawer's own escape/outside/close paths may close it. + if (nextOpen) { + setOpen(true) + } + }, + [setOpen] + ) + const boardRef = useRef(null) + + useWorkspaceKanbanOutsideDismiss({ + open, + boardRef, + preserveOpenForMenu: menuOpen, + onOpenChange: setOpen + }) + + useEffect(() => { + if (!open) { + return + } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') { + return + } + if (document.querySelector(AGENT_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR)) { + return + } + event.preventDefault() + close() + } + // Why: the board is a non-modal companion panel, so focus may be outside + // the sheet when Escape should still dismiss it. + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [close, open]) + + const drawerLeft = sidebarOpen ? sidebarWidth : 0 + const drawerLeftCss = sidebarOpen + ? `var(--workspace-sidebar-live-width, ${sidebarWidth}px)` + : '0px' + // Why: App reserves a bottom status row while visible; the portalled board + // must share that viewport bound instead of covering the status controls. + const drawerBottom = `${statusBarVisible ? STATUS_BAR_RESERVE_HEIGHT : 0}px` + + const guardSidebarInteraction = ( + event: CustomEvent<{ originalEvent: PointerEvent | FocusEvent }> + ): void => { + const originalEvent = event.detail.originalEvent + if (menuOpen || isWorkspaceBoardKeepOpenTarget(originalEvent.target)) { + // Why: the first outside click should close a board menu, not also + // dismiss the board that owns it. + event.preventDefault() + return + } + const liveDrawerLeft = + boardRef.current?.closest('[data-slot="sheet-content"]')?.getBoundingClientRect() + .left ?? drawerLeft + const pointerX = + 'clientX' in originalEvent && typeof originalEvent.clientX === 'number' + ? originalEvent.clientX + : null + if (pointerX !== null && pointerX < liveDrawerLeft) { + // Why: keep the workspace sidebar interactive while the companion board stays open. + event.preventDefault() + } + } + + return ( + + { + // Why: Radix focuses the first header button on open, which shows + // hover-style affordances without hover and makes the drawer noisy. + event.preventDefault() + }} + onPointerDownOutside={guardSidebarInteraction} + onInteractOutside={guardSidebarInteraction} + > + {translate('dashboardPopout.title', 'Agents')} + {/* Radix unmounts SheetContent while closed, so the live snapshot + derivation in the body stays off the closed path. */} +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx new file mode 100644 index 00000000000..7e960e1f91a --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx @@ -0,0 +1,110 @@ +import { Settings } from 'lucide-react' +import { useAppStore } from '@/store' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { SettingsSegmentedControl } from '../settings/SettingsFormControls' +import type { AgentDashboardMode } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +type AgentDashboardSettingsMenuProps = { + /** Called after the mode switches to pop-out so the host can hand the board + * over to the pop-out window instead of leaving a stale in-window board. */ + onSwitchToPopout: () => void + /** Lets the host keep the companion board open while this menu owns the + * next outside click, matching the workspace board's menu handling. */ + onOpenChange: (open: boolean) => void +} + +/** Board-header settings for the in-window Agent Dashboard, mirroring the + * workspace board's settings menu. In-window only — the pop-out renderer has + * no store access, so it never mounts this. */ +export function AgentDashboardSettingsMenu({ + onSwitchToPopout, + onOpenChange +}: AgentDashboardSettingsMenuProps): React.JSX.Element { + const mode = useAppStore((s) => s.settings?.experimentalAgentDashboardMode ?? 'in-window') + const updateSettings = useAppStore((s) => s.updateSettings) + + const handleModeChange = (next: AgentDashboardMode): void => { + if (next === mode) { + return + } + updateSettings({ experimentalAgentDashboardMode: next }) + if (next === 'popout') { + onSwitchToPopout() + } + } + + return ( + + + + + + + + + {translate('dashboardPopout.settingsTooltip', 'Board settings')} + + + +
+ + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel', + 'Open as' + )} + + + {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', + 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' + )} + + +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts new file mode 100644 index 00000000000..c6880ab0cd7 --- /dev/null +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { buildDashboardSnapshot } from './build-dashboard-snapshot' + +/** + * Builds the dashboard snapshot directly from the live renderer store for the + * in-window screen popover. The pop-out window can't read this store, so it + * relays a serialized snapshot instead (useDashboardSnapshot); in-window there + * is no relay, so we derive it here from the same builder the bridge uses. + */ +export function useLiveDashboardSnapshot(): DashboardSnapshot { + const repos = useAppStore((s) => s.repos) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) + const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) + const runtimeAgentOrchestrationByPaneKey = useAppStore( + (s) => s.runtimeAgentOrchestrationByPaneKey + ) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) + // Why: freshness can flip a bucket without any backing map changing; the epoch + // ticks on the freshness boundary so the memo re-derives stale-decayed cards. + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) + + return useMemo( + // Why: Date.now() is read inside the memo (not a dep) so stale-decay + // recalculates whenever agentStatusEpoch ticks, matching useDashboardData. + () => + buildDashboardSnapshot( + { + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + acknowledgedAgentsByPaneKey + }, + Date.now() + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + acknowledgedAgentsByPaneKey, + agentStatusEpoch + ] + ) +} diff --git a/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx new file mode 100644 index 00000000000..e971941327c --- /dev/null +++ b/src/renderer/src/components/settings/AgentDashboardExperimentalSetting.tsx @@ -0,0 +1,105 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { Label } from '../ui/label' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSegmentedControl, SettingsSwitch } from './SettingsFormControls' +import { getExperimentalSearchEntry } from './experimental-search' + +type AgentDashboardExperimentalSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function AgentDashboardExperimentalSetting({ + settings, + updateSettings +}: AgentDashboardExperimentalSettingProps): React.JSX.Element { + const enabled = settings.experimentalAgentDashboardPopout === true + const mode = settings.experimentalAgentDashboardMode ?? 'in-window' + + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.copy', + 'Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.' + )} +

+
+ updateSettings({ experimentalAgentDashboardPopout: !enabled })} + /> +
+ {enabled ? ( +
+
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy', + 'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.' + )} +

+
+ updateSettings({ experimentalAgentDashboardMode: next })} + ariaLabel={translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeAriaLabel', + 'Agent Dashboard open mode' + )} + size="sm" + options={[ + { + value: 'in-window', + label: translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modeInWindow', + 'In-window' + ) + }, + { + value: 'popout', + label: translate( + 'auto.components.settings.ExperimentalPane.agentDashboard.modePopout', + 'Pop-out' + ) + } + ]} + /> +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 1cf60d414f4..62d8420be13 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -8,6 +8,7 @@ import { HiddenExperimentalGroup } from './HiddenExperimentalGroup' import { NumberField, SettingsSwitch } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting' +import { AgentDashboardExperimentalSetting } from './AgentDashboardExperimentalSetting' import { EphemeralVmsExperimentalSetting } from './EphemeralVmsExperimentalSetting' import { MAX_AGENT_HIBERNATION_IDLE_MS, @@ -152,49 +153,7 @@ export function ExperimentalPane({ ) : null} {showAgentDashboard ? ( - -
-
- -

- {translate( - 'auto.components.settings.ExperimentalPane.agentDashboard.copy', - 'Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.' - )} -

-
- - updateSettings({ - experimentalAgentDashboardPopout: - settings.experimentalAgentDashboardPopout !== true - }) - } - /> -
-
+ ) : null} {showNativeChat ? ( diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 9a759e1ceb7..b15536edd2b 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -102,7 +102,7 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( ), description: translate( 'auto.components.settings.experimental.search.agentDashboard.description', - 'Pop-out Kanban board for monitoring agents across worktrees.' + 'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.' ), keywords: [ ...translateSearchKeyword( @@ -125,6 +125,14 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog( 'auto.components.settings.experimental.search.agentDashboard.popout', 'pop-out' ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.board', + 'board' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.inWindow', + 'in-window' + ), ...translateSearchKeyword( 'auto.components.settings.experimental.search.agentDashboard.worktrees', 'worktrees' diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 210e4f76cb6..63d19289b99 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -93,6 +93,8 @@ import Sidebar from './index' function setSidebarState(settings: GlobalSettings, statusBarVisible = true): void { mocks.state = { activeModal: null, + agentDashboardDrawerOpen: false, + setAgentDashboardDrawerOpen: vi.fn(), fetchAllWorktrees: vi.fn(), repos: [], setSidebarWidth: vi.fn(), diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index ccd04bf73b8..9daba6253fb 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -40,6 +40,14 @@ export function shouldShowAgentDashboardButton( return settings?.experimentalAgentDashboardPopout === true } +// Why: in-window is the default surface; only an explicit 'popout' choice opens +// the separate OS window. +function isAgentDashboardPopoutMode( + settings: Pick | null | undefined +): boolean { + return settings?.experimentalAgentDashboardMode === 'popout' +} + export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -101,12 +109,21 @@ function DashboardBucketCounts({ // agent-status churn only updates this opt-in row, not the full navigation. function AgentDashboardSidebarEntry(): React.JSX.Element { const dashboardBucketCounts = useAgentBucketCounts() + const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings)) + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) return (