From 8dda7de9aa50ad5fabb1c8302206a5f1f2d64d50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:18:44 -0700 Subject: [PATCH] feat(agent-map): open a workspace from the worktree ring popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent map could start agents in a workspace but had no way to go to it — the dashboard's "Open worktree" is agent-scoped and needs a tabId, so a workspace-level route was missing. Adds a workspace-level reveal: DashboardRevealWorktreeArgs carries the worktree id plus executionHostId (ids collide across hosts), relayed over dashboardPopout:revealWorktree with the same sender/args gate as the other popout actions. Both handlers route through activateAndRevealWorkspace rather than a raw setActiveWorktree. The board spans every repo, so a cross-repo jump also has to switch activeRepoId, reset activeView, reveal the sidebar row, clear filters hiding it, and run the folder-workspace path gate — all of which a bare setActiveWorktree drops. The shared dispatcher now takes an optional executionHostId so it can serve host-qualified callers. Extracts the popover into AgentMapWorktreeDetailsCard, and moves the arrow-key direction map into agent-map-navigation next to nextDirectionalAgent; both files were over the max-lines cap. --- src/main/ipc/dashboard-payload-validation.ts | 25 ++- src/main/ipc/dashboard-popout.test.ts | 21 +++ src/main/ipc/dashboard-popout.ts | 38 ++++- src/preload/api/dashboard-api.ts | 3 + src/preload/index.ts | 11 ++ .../AgentDashboardMapView.tsx | 4 + .../dashboard-popout/AgentKanbanBoard.tsx | 12 ++ .../dashboard-popout/AgentMap.test.tsx | 56 ++++++ .../components/dashboard-popout/AgentMap.tsx | 4 + .../dashboard-popout/AgentMapCanvas.tsx | 16 +- .../dashboard-popout/AgentMapScene.tsx | 9 +- .../AgentMapWorktreeDetailsCard.tsx | 161 ++++++++++++++++++ .../AgentMapWorktreeRingNode.tsx | 129 ++------------ .../dashboard-popout/agent-map-navigation.ts | 12 ++ .../agent-map-render-test-harness.tsx | 6 +- .../dashboard/AgentDashboardDrawer.tsx | 14 ++ .../useDashboardPopoutBridge.test.tsx | 30 ++++ .../dashboard/useDashboardPopoutBridge.ts | 14 ++ src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/lib/worktree-activation.ts | 13 +- src/shared/dashboard-snapshot.ts | 8 + 21 files changed, 444 insertions(+), 144 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeDetailsCard.tsx diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 6186e638fd1..df7f0cc5833 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -1,6 +1,7 @@ import { DASHBOARD_MAX_LABEL_LENGTH, type DashboardRevealAgentArgs, + type DashboardRevealWorktreeArgs, type DashboardSleepWorkspaceArgs, type DashboardSnapshot } from '../../shared/dashboard-snapshot' @@ -66,6 +67,13 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) } +function isOptionalExecutionHostId(value: unknown): boolean { + return ( + value === undefined || + (isBoundedString(value, MAX_ID_LENGTH) && normalizeExecutionHostId(value) !== null) + ) +} + export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRevealAgentArgs { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -74,14 +82,25 @@ export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRe return ( isBoundedString(args.repoId, MAX_ID_LENGTH) && isBoundedString(args.worktreeId, MAX_ID_LENGTH) && - (args.executionHostId === undefined || - (isBoundedString(args.executionHostId, MAX_ID_LENGTH) && - normalizeExecutionHostId(args.executionHostId) !== null)) && + isOptionalExecutionHostId(args.executionHostId) && isBoundedString(args.tabId, MAX_ID_LENGTH) && (args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) ) } +export function isDashboardRevealWorktreeArgs( + value: unknown +): value is DashboardRevealWorktreeArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return ( + isBoundedString(args.worktreeId, MAX_ID_LENGTH) && + isOptionalExecutionHostId(args.executionHostId) + ) +} + export function isDashboardPaneKey(value: unknown): value is string { return isBoundedString(value, MAX_ID_LENGTH) } diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index ce10b3556fc..438406bbcbf 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -291,4 +291,25 @@ describe('registerDashboardPopoutHandlers', () => { expect(mainSender.send).toHaveBeenCalledWith('ui:revealDashboardAgent', args) expect(appMock.focus).toHaveBeenCalledOnce() }) + + it('reveals a worktree in only the trusted main window', () => { + const main = makeWindow(mainSender) + getTrustedWindowMock.mockReturnValue(main) + const args = { worktreeId: 'w1', executionHostId: 'ssh:box' } + + handlers.get('dashboardPopout:revealWorktree')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:revealWorktree')!({ sender: popoutSender } as never, { + worktreeId: '' + }) + handlers.get('dashboardPopout:revealWorktree')!({ sender: popoutSender } as never, { + ...args, + executionHostId: 'not-a-host' + }) + expect(safelyRevealMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:revealWorktree')!({ sender: popoutSender } as never, args) + expect(safelyRevealMock).toHaveBeenCalledWith(main) + expect(mainSender.send).toHaveBeenCalledWith('ui:revealDashboardWorktree', args) + expect(appMock.focus).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index 5b4781ec761..6af8ba3d2f1 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -15,6 +15,7 @@ import { admitDashboardSnapshot, isDashboardPaneKey, isDashboardRevealAgentArgs, + isDashboardRevealWorktreeArgs, isDashboardSleepWorkspaceArgs, isDashboardSpawnAgentArgs } from './dashboard-payload-validation' @@ -28,6 +29,22 @@ function isDashboardEnabled(store: Store): boolean { return store.getSettings().experimentalAgentDashboardPopout === true } +/** Click-to-focus from the pop-out: bring the main window forward, then hand it + * the routing payload. */ +function raiseMainWindowAndSend(channel: string, args: unknown): void { + const mainWindow = getTrustedUIRendererWindow() + if (!mainWindow) { + return + } + safelyRevealWindow(mainWindow) + mainWindow.webContents.send(channel, args) + try { + app.focus({ steal: true }) + } catch { + // Best-effort; the per-window focus above may still bring it forward. + } +} + export function registerDashboardPopoutHandlers( store: Store, keybindings?: KeybindingService @@ -37,6 +54,7 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:requestSnapshot') ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') + ipcMain.removeHandler('dashboardPopout:revealWorktree') ipcMain.removeHandler('dashboardPopout:ackAgent') ipcMain.removeHandler('dashboardPopout:spawnAgent') ipcMain.removeHandler('dashboardPopout:sleepWorkspace') @@ -136,17 +154,19 @@ export function registerDashboardPopoutHandlers( ) { return } - const mainWindow = getTrustedUIRendererWindow() - if (!mainWindow) { + raiseMainWindowAndSend('ui:revealDashboardAgent', args) + }) + + // "Open worktree": same raise, but the payload names only the workspace. + ipcMain.handle('dashboardPopout:revealWorktree', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardRevealWorktreeArgs(args) + ) { return } - safelyRevealWindow(mainWindow) - mainWindow.webContents.send('ui:revealDashboardAgent', args) - try { - app.focus({ steal: true }) - } catch { - // Best-effort; the per-window focus above may still bring it forward. - } + raiseMainWindowAndSend('ui:revealDashboardWorktree', args) }) ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { diff --git a/src/preload/api/dashboard-api.ts b/src/preload/api/dashboard-api.ts index aa8d6dabd8a..88f5b06ffef 100644 --- a/src/preload/api/dashboard-api.ts +++ b/src/preload/api/dashboard-api.ts @@ -1,5 +1,6 @@ import type { DashboardRevealAgentArgs, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSnapshot, DashboardSpawnAgentArgs @@ -16,6 +17,7 @@ export type DashboardApi = { onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void + onRevealWorktree: (callback: (args: DashboardRevealWorktreeArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void @@ -23,6 +25,7 @@ export type DashboardApi = { onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise + revealWorktree: (args: DashboardRevealWorktreeArgs) => Promise ackAgent: (paneKey: string) => Promise spawnAgent: (args: DashboardSpawnAgentArgs) => Promise sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index c5bb2959816..5871105be87 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,7 @@ import type { AppIdentity } from '../shared/app-identity' import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { DashboardRevealAgentArgs, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSnapshot, DashboardSpawnAgentArgs @@ -2384,6 +2385,14 @@ const api = { ipcRenderer.on('ui:revealDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) }, + onRevealWorktree: (callback: (args: DashboardRevealWorktreeArgs) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + args: DashboardRevealWorktreeArgs + ): void => callback(args) + ipcRenderer.on('ui:revealDashboardWorktree', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardWorktree', listener) + }, onAckAgent: (callback: (paneKey: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => callback(paneKey) @@ -2421,6 +2430,8 @@ const api = { }, revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), + revealWorktree: (args: DashboardRevealWorktreeArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealWorktree', args), ackAgent: (paneKey: string): Promise => ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), spawnAgent: (args: DashboardSpawnAgentArgs): Promise => diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx index 0f861279438..b5004dcb8f4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx @@ -1,6 +1,7 @@ import { Suspense, useMemo, useState, type RefObject } from 'react' import type { DashboardCard, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSnapshot, DashboardSpawnAgentArgs @@ -38,6 +39,7 @@ type AgentDashboardMapViewProps = { onDialogOpenChange: (open: boolean) => void onRevealAgent: (args: AgentRevealArgs) => void onOpenTerminal: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void workspaceContextMenusEnabled: boolean @@ -58,6 +60,7 @@ export function AgentDashboardMapView({ onDialogOpenChange, onRevealAgent, onOpenTerminal, + onRevealWorktree, onSpawnAgent, onSleepWorkspace, workspaceContextMenusEnabled, @@ -170,6 +173,7 @@ export function AgentDashboardMapView({ workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} onOpenTerminal={onOpenTerminal} + onRevealWorktree={onRevealWorktree} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} /> diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 376f38a1755..6ed3b80be0b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -4,6 +4,7 @@ import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, type DashboardCard, + type DashboardRevealWorktreeArgs, type DashboardSleepWorkspaceArgs, type DashboardSnapshot, type DashboardSpawnAgentArgs @@ -47,6 +48,12 @@ function revealAgentViaPopoutRelay(args: AgentRevealArgs): void { void window.api.dashboard.revealAgent?.(args) } +/** Open a workspace from the pop-out window: raise the main window and activate + * the worktree, without targeting a pane. Same `?.` HMR-skew guard. */ +function revealWorktreeViaPopoutRelay(args: DashboardRevealWorktreeArgs): void { + void window.api.dashboard.revealWorktree?.(args) +} + /** Start an agent from the pop-out window: the main renderer owns the store and * the tab path, so the launch is relayed. Same `?.` HMR-skew guard. */ function spawnAgentViaPopoutRelay(args: DashboardSpawnAgentArgs): void { @@ -148,6 +155,9 @@ type AgentKanbanBoardProps = { /** 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 + /** Focuses a workspace without picking a pane. Defaults to the pop-out IPC + * relay; the in-window host activates the worktree locally and closes. */ + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void /** Starts a new agent in a workspace. Defaults to the pop-out IPC relay; the * in-window host launches through its own store. */ onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void @@ -177,6 +187,7 @@ export function AgentKanbanBoard({ containerClassName = 'h-screen w-screen', onAckAgent = ackAgentViaPopoutRelay, onRevealAgent = revealAgentViaPopoutRelay, + onRevealWorktree = revealWorktreeViaPopoutRelay, onSpawnAgent = spawnAgentViaPopoutRelay, onSleepWorkspace = sleepWorkspaceViaPopoutRelay, onClose, @@ -372,6 +383,7 @@ export function AgentKanbanBoard({ onDialogOpenChange={handleDialogOpenChange} onRevealAgent={onRevealAgent} onOpenTerminal={handleOpenTerminal} + onRevealWorktree={onRevealWorktree} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} workspaceContextMenusEnabled={workspaceContextMenusEnabled} diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 9db95d8cad7..102f661f75b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -193,6 +193,62 @@ describe('AgentMap', () => { expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' }) }) + it('opens the worktree from the details card', () => { + const onRevealWorktree = vi.fn() + renderMap([card()], { onRevealWorktree }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + + // The raw worktree id, not the host-qualified map identity. + expect(onRevealWorktree).toHaveBeenCalledWith({ + worktreeId: 'worktree-1', + executionHostId: undefined + }) + }) + + it('carries the execution host so colliding worktree ids route to the right one', () => { + const onRevealWorktree = vi.fn() + // Rendered directly: a remote host draws a tooltipped badge, and the shared + // harness has no TooltipProvider (adding one remounts the rerender tests). + render( + + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + + expect(onRevealWorktree).toHaveBeenCalledWith({ + worktreeId: 'worktree-1', + executionHostId: 'ssh:build-box' + }) + }) + + it('names the open control for folder workspaces', () => { + renderMap([card({ workspaceKind: 'folder', worktreeName: 'Documentation' })], { + onRevealWorktree: vi.fn() + }) + fireEvent.click( + screen.getByRole('button', { name: 'Open Documentation folder workspace details' }) + ) + + expect(screen.getByRole('button', { name: 'Open folder' })).toBeInTheDocument() + }) + + it('omits the open control on hosts that cannot route to a workspace', () => { + renderMap([card()]) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.queryByRole('button', { name: 'Open worktree' })).not.toBeInTheDocument() + }) + it('explains an empty picker rather than offering nothing', () => { renderMap([card()], { onSpawnAgent: vi.fn() }) fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index 7b7afc56bc7..108acccbbb5 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils' import type { DashboardCard, DashboardCardHostKind, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs, DashboardWorkspace @@ -32,6 +33,7 @@ type AgentMapProps = { workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void onOpenTerminal: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } @@ -59,6 +61,7 @@ export function AgentMap({ workspaceContextMenusEnabled = false, onWorkspaceContextMenuOpenChange, onOpenTerminal, + onRevealWorktree, onSpawnAgent, onSleepWorkspace }: AgentMapProps): React.JSX.Element { @@ -105,6 +108,7 @@ export function AgentMap({ workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} onSelectAgent={onOpenTerminal} + onRevealWorktree={onRevealWorktree} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} /> diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index 84290f36f40..ec42372ba5b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n' import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import type { DashboardCard, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' @@ -23,6 +24,7 @@ import { agentFocusZoom, clamp, MAX_ZOOM, MIN_ZOOM } from './agent-map-canvas-zo import { AgentMapViewportControls } from './AgentMapViewportControls' import { agentMapAgents, + agentMapArrowDirection, navigableAgentMapAgents, nextDirectionalAgent } from './agent-map-navigation' @@ -52,6 +54,7 @@ type AgentMapCanvasProps = { workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void onSelectAgent: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } @@ -69,6 +72,7 @@ export const AgentMapCanvas = forwardRef nodeRefs: MutableRefObject> onSelectAgent: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onOpenProjectContextMenu?: ( event: React.MouseEvent, @@ -78,6 +83,7 @@ export const AgentMapScene = memo(function AgentMapScene({ launchableAgentsByWorktreeId, nodeRefs, onSelectAgent, + onRevealWorktree, onSpawnAgent, onOpenProjectContextMenu, onOpenWorkspaceContextMenu, @@ -225,6 +231,7 @@ export const AgentMapScene = memo(function AgentMapScene({ launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]} nodeRefs={nodeRefs} onSelectAgent={onSelectAgent} + onRevealWorktree={onRevealWorktree} onSpawnAgent={onSpawnAgent} onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu} onLabelHoverChange={handleLabelHoverChange} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeDetailsCard.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeDetailsCard.tsx new file mode 100644 index 00000000000..6ae5d42e225 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeDetailsCard.tsx @@ -0,0 +1,161 @@ +import { Plus, SquareArrowOutUpRight } from 'lucide-react' +import { AgentStateDot } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { PopoverContent } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import type { + DashboardCard, + DashboardRevealWorktreeArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' +import { agentMapStatusLabel, agentName, formatDuration } from './agent-map-node-presentation' + +type AgentMapWorktreeDetailsCardProps = { + project: AgentMapProjectRing + worktree: AgentMapWorktreeRing + launchableAgents?: readonly TuiAgent[] + onSelectAgent: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + /** Closes the popover once an action has been taken. */ + onDone: () => void +} + +/** The worktree ring's popover: what is running in this workspace, plus the + * ways out of the map — open the workspace, or start something new in it. */ +export function AgentMapWorktreeDetailsCard({ + project, + worktree, + launchableAgents, + onSelectAgent, + onRevealWorktree, + onSpawnAgent, + onDone +}: AgentMapWorktreeDetailsCardProps): React.JSX.Element { + const activeCount = + worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen'] + const openLabel = + worktree.workspaceKind === 'folder' + ? translate('dashboardPopout.map.openFolderWorkspaceAction', 'Open folder') + : translate('dashboardPopout.map.openWorktreeAction', 'Open worktree') + return ( + +
+
+ + {project.name} + + {worktree.name} + + {translate( + 'dashboardPopout.map.worktreeSummary', + '{{total}} agents · {{active}} active · {{done}} done', + { + count: worktree.agents.length, + defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done', + defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done', + total: worktree.agents.length, + active: activeCount, + done: doneCount + } + )} + +
+ {onRevealWorktree ? ( + + ) : null} +
+
+

+ {translate('dashboardPopout.map.runningAgents', 'Agents')} +

+
+ {worktree.agents.length === 0 ? ( +

+ {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} +

+ ) : ( + worktree.agents.map((agent) => ( + + )) + )} +
+
+ {onSpawnAgent ? ( +
+

+ {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} +

+ {launchableAgents && launchableAgents.length > 0 ? ( +
+ {launchableAgents.map((agent) => ( + + ))} +
+ ) : ( +

+ {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} +

+ )} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index f4999a012bd..33eee4baec9 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -1,12 +1,13 @@ import { memo, useState, type MutableRefObject } from 'react' -import { Plus } from 'lucide-react' -import { AgentStateDot } from '@/components/AgentStateDot' -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverTrigger } from '@/components/ui/popover' import { translate } from '@/i18n/i18n' -import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent } from '@/lib/agent-status' -import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardRevealWorktreeArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentMapAgentNode, @@ -24,6 +25,7 @@ import { lineagePath } from './agent-map-node-presentation' import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' +import { AgentMapWorktreeDetailsCard } from './AgentMapWorktreeDetailsCard' type AgentMapWorktreeRingNodeProps = { project: AgentMapProjectRing @@ -39,6 +41,7 @@ type AgentMapWorktreeRingNodeProps = { launchableAgents?: readonly TuiAgent[] nodeRefs: MutableRefObject> onSelectAgent: (card: DashboardCard) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onOpenWorkspaceContextMenu?: ( event: React.MouseEvent, @@ -49,116 +52,6 @@ type AgentMapWorktreeRingNodeProps = { onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } -function WorktreeDetails({ - project, - worktree, - launchableAgents, - onSelectAgent, - onSpawnAgent, - onDone -}: Pick< - AgentMapWorktreeRingNodeProps, - 'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent' -> & { - onDone: () => void -}): React.JSX.Element { - const activeCount = - worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting - const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen'] - return ( - -
- {project.name} - {worktree.name} - - {translate( - 'dashboardPopout.map.worktreeSummary', - '{{total}} agents · {{active}} active · {{done}} done', - { - count: worktree.agents.length, - defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done', - defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done', - total: worktree.agents.length, - active: activeCount, - done: doneCount - } - )} - -
-
-

- {translate('dashboardPopout.map.runningAgents', 'Agents')} -

-
- {worktree.agents.length === 0 ? ( -

- {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} -

- ) : ( - worktree.agents.map((agent) => ( - - )) - )} -
-
- {onSpawnAgent ? ( -
-

- {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} -

- {launchableAgents && launchableAgents.length > 0 ? ( -
- {launchableAgents.map((agent) => ( - - ))} -
- ) : ( -

- {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} -

- )} -
- ) : null} -
- ) -} - export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ project, worktree, @@ -172,6 +65,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ launchableAgents, nodeRefs, onSelectAgent, + onRevealWorktree, onSpawnAgent, onOpenWorkspaceContextMenu, onLabelHoverChange, @@ -397,11 +291,12 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ )} - setDetailsOpen(false)} /> diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts index ae3cfdbea2b..92da2b3fdc2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts @@ -3,6 +3,18 @@ import { shouldAggregateAgentMapWorktree } from './agent-map-layout' type Direction = { x: number; y: number } +const ARROW_KEY_DIRECTIONS: Readonly> = { + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 } +} + +/** null for any key that is not an arrow, so callers can fall through. */ +export function agentMapArrowDirection(key: string): Direction | null { + return ARROW_KEY_DIRECTIONS[key] ?? null +} + export function agentMapAgents(layout: AgentMapLayout): AgentMapAgentNode[] { return layout.projects.flatMap((project) => project.worktrees.flatMap((worktree) => worktree.agents) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx index e428d43f5f6..222571218dd 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, vi } from 'vitest' import type { DashboardCard, DashboardCardHostKind, + DashboardRevealWorktreeArgs, DashboardSleepWorkspaceArgs, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' @@ -47,6 +48,7 @@ export type RenderMapOptions = { launchableAgentsByWorktreeId?: Record onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void + onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void } export function renderMap( @@ -60,7 +62,8 @@ export function renderMap( showOrchestrationLinks, launchableAgentsByWorktreeId, onSpawnAgent, - onSleepWorkspace + onSleepWorkspace, + onRevealWorktree }: RenderMapOptions = {} ): ReturnType { return render( @@ -76,6 +79,7 @@ export function renderMap( launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} + onRevealWorktree={onRevealWorktree} /> ) } diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index e9e0efcc13d..12750289c46 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -2,6 +2,8 @@ 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 { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import type { DashboardRevealWorktreeArgs } from '../../../../shared/dashboard-snapshot' import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog' import { @@ -55,6 +57,17 @@ function AgentDashboardDrawerBody({ [onClose] ) + // Shared activation, not a raw setActiveWorktree: the board lists every repo, + // so opening a workspace may also have to switch the active repo, reveal the + // sidebar row, and run the folder-workspace path gate. + const handleRevealWorktree = useCallback( + (args: DashboardRevealWorktreeArgs) => { + activateAndRevealWorkspace(args.worktreeId, args.executionHostId) + 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(() => { @@ -76,6 +89,7 @@ function AgentDashboardDrawerBody({ containerClassName="h-full w-full bg-transparent" onAckAgent={handleAckAgent} onRevealAgent={handleRevealAgent} + onRevealWorktree={handleRevealWorktree} onSpawnAgent={launchDashboardAgent} onClose={onClose} onOpenMap={handleOpenMap} diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 7cd7bf94c93..9f2f7651c72 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -8,7 +8,9 @@ const mocks = vi.hoisted(() => ({ acknowledgeAgents: vi.fn(), setActiveWorktree: vi.fn(), subscribeStore: vi.fn((_listener: (state: unknown, previousState: unknown) => void) => vi.fn()), + activateAndRevealWorkspace: vi.fn(), onRevealAgent: vi.fn(), + onRevealWorktree: vi.fn(), onAckAgent: vi.fn(), onPopoutOpenChanged: vi.fn(), onSnapshotRequested: vi.fn(), @@ -18,6 +20,7 @@ const mocks = vi.hoisted(() => ({ (_state: unknown, now: number): DashboardSnapshot => ({ generatedAt: now, cards: [] }) ), offRevealAgent: vi.fn(), + offRevealWorktree: vi.fn(), offAckAgent: vi.fn(), offPopoutOpenChanged: vi.fn(), offSnapshotRequested: vi.fn() @@ -37,6 +40,10 @@ vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: vi.fn() })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace +})) + vi.mock('./build-dashboard-snapshot', () => ({ buildDashboardSnapshot: mocks.buildDashboardSnapshot })) @@ -92,12 +99,14 @@ function Harness({ enabled }: { enabled: boolean }): null { function installDashboardApi(): void { mocks.onRevealAgent.mockReturnValue(mocks.offRevealAgent) + mocks.onRevealWorktree.mockReturnValue(mocks.offRevealWorktree) mocks.onAckAgent.mockReturnValue(mocks.offAckAgent) mocks.onPopoutOpenChanged.mockReturnValue(mocks.offPopoutOpenChanged) mocks.onSnapshotRequested.mockReturnValue(mocks.offSnapshotRequested) ;(window as unknown as { api: unknown }).api = { dashboard: { onRevealAgent: mocks.onRevealAgent, + onRevealWorktree: mocks.onRevealWorktree, onAckAgent: mocks.onAckAgent, onPopoutOpenChanged: mocks.onPopoutOpenChanged, onSnapshotRequested: mocks.onSnapshotRequested, @@ -173,6 +182,27 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') }) + // Why: the shared activation is what switches activeRepoId on a cross-repo + // jump and reveals the sidebar row; a raw setActiveWorktree does neither. + it('opens the worktree through the shared activation, on its exact host', async () => { + const { activateTabAndFocusPane } = await import('@/lib/activate-tab-and-focus-pane') + await act(async () => root.render()) + + await act(async () => + mocks.onRevealWorktree.mock.calls[0][0]({ + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1' + }) + ) + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith( + 'shared-worktree', + 'runtime:env-1' + ) + expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + expect(activateTabAndFocusPane).not.toHaveBeenCalled() + }) + it('ignores unrelated store writes while retaining every snapshot input', () => { const previousState = makeSnapshotWatchState() expect(dashboardSnapshotInputsChanged({ ...previousState }, previousState)).toBe(false) diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 3d58b9100c8..49dac6de495 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' @@ -136,6 +137,19 @@ export function useDashboardPopoutBridge(enabled: boolean): void { }) }, [enabled]) + // "Open worktree" names no pane, so it activates the workspace and leaves + // whichever tab it last had focused. Routed through the shared activation so + // a cross-repo jump also switches activeRepoId and the sidebar reveals the + // row — the dashboard spans every repo, unlike the sidebar it is opened from. + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onRevealWorktree?.((args) => { + activateAndRevealWorkspace(args.worktreeId, args.executionHostId) + }) + }, [enabled]) + // Opening a card's terminal dialog in the popout acks the agent here — the // same ack the sidebar's bold/mute treatment reads, keeping both in lockstep. // ?. shields App mount from dev-HMR preload skew (preload updates only on diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 61d3f026343..bb696920838 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15614,6 +15614,8 @@ "spawnAgent": "Start a new agent", "noLaunchableAgents": "No enabled agents detected.", "sleepWorkspace": "Sleep", + "openWorktreeAction": "Open worktree", + "openFolderWorkspaceAction": "Open folder", "projectCount": "{{agents}} agents · {{workspaces}} workspaces", "agentCount": "{{count}} agents", "agentCount_one": "{{count}} agent", diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index cd2f1bd1c5a..5f547947d63 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -740,13 +740,20 @@ function queueSetupAndIssueCommands( * plain worktree ids with `folder:` keys, so every caller that navigates by that * order must dispatch here — the folder branch is what enforces the path-status * gate that blocks a missing/unmounted/disconnected-SSH folder (#10716). + * + * executionHostId disambiguates ids that collide across hosts; omit it when the + * caller only knows the id (nav history replays this way). */ -export function activateAndRevealWorkspace(workspaceId: string): ActivateAndRevealResult | false { +export function activateAndRevealWorkspace( + workspaceId: string, + executionHostId?: ExecutionHostId +): ActivateAndRevealResult | false { const workspaceScope = parseWorkspaceKey(workspaceId) + const opts = executionHostId ? { executionHostId } : undefined if (workspaceScope?.type === 'folder') { - return activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) + return activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId, opts) } - return activateAndRevealWorktree(workspaceId) + return activateAndRevealWorktree(workspaceId, opts) } // Why: break the import cycle — nav-history slice (under @/store) can't import activation directly, so register the activator here. diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index a3a888d820f..67a1fb989f6 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -214,6 +214,14 @@ export type DashboardRevealAgentArgs = { leafId: string | null } +/** Routing payload for "Open worktree": activate the workspace in the main + * window without targeting any one pane. executionHostId disambiguates + * worktree ids that collide across hosts. */ +export type DashboardRevealWorktreeArgs = { + worktreeId: string + executionHostId?: ExecutionHostId +} + export type DashboardSpawnAgentArgs = { worktreeId: string agent: TuiAgent