feat(agent-map): open a workspace from the worktree ring popover

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.
This commit is contained in:
Brennan Benson
2026-08-14 17:18:44 -07:00
parent 3a212584ec
commit 8dda7de9aa
21 changed files with 444 additions and 144 deletions
+22 -3
View File
@@ -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<string, unknown>
return (
isBoundedString(args.worktreeId, MAX_ID_LENGTH) &&
isOptionalExecutionHostId(args.executionHostId)
)
}
export function isDashboardPaneKey(value: unknown): value is string {
return isBoundedString(value, MAX_ID_LENGTH)
}
+21
View File
@@ -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()
})
})
+29 -9
View File
@@ -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 => {
+3
View File
@@ -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<void>
revealWorktree: (args: DashboardRevealWorktreeArgs) => Promise<void>
ackAgent: (paneKey: string) => Promise<void>
spawnAgent: (args: DashboardSpawnAgentArgs) => Promise<void>
sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise<void>
+11
View File
@@ -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<void> =>
ipcRenderer.invoke('dashboardPopout:revealAgent', args),
revealWorktree: (args: DashboardRevealWorktreeArgs): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:revealWorktree', args),
ackAgent: (paneKey: string): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }),
spawnAgent: (args: DashboardSpawnAgentArgs): Promise<void> =>
@@ -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}
/>
@@ -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}
@@ -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(
<TooltipProvider>
<AgentMap
cards={[card({ executionHostId: 'ssh:build-box', hostKind: 'ssh' })]}
now={NOW}
onOpenTerminal={vi.fn()}
onRevealWorktree={onRevealWorktree}
/>
</TooltipProvider>
)
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' }))
@@ -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}
/>
@@ -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<AgentMapCanvasHandle, AgentMapCanvasPro
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange,
onSelectAgent,
onRevealWorktree,
onSpawnAgent,
onSleepWorkspace
},
@@ -221,16 +225,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
onSelectAgent(agent.card)
return
}
const direction =
event.key === 'ArrowLeft'
? { x: -1, y: 0 }
: event.key === 'ArrowRight'
? { x: 1, y: 0 }
: event.key === 'ArrowUp'
? { x: 0, y: -1 }
: event.key === 'ArrowDown'
? { x: 0, y: 1 }
: null
const direction = agentMapArrowDirection(event.key)
if (!direction) {
return
}
@@ -391,6 +386,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
onRevealWorktree={onRevealWorktree}
onSpawnAgent={onSpawnAgent}
onOpenProjectContextMenu={onOpenProjectContextMenu}
onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu}
@@ -1,7 +1,11 @@
import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { translate } from '@/i18n/i18n'
import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
import type {
DashboardCard,
DashboardRevealWorktreeArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
@@ -34,6 +38,7 @@ type AgentMapSceneProps = {
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenProjectContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
@@ -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}
@@ -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 (
<PopoverContent align="center" sideOffset={10} className="w-80 p-0">
<header className="flex items-start gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<span className="block text-[11px] break-words text-muted-foreground">
{project.name}
</span>
<strong className="block text-[13px] break-words">{worktree.name}</strong>
<span className="mt-1 block text-[11px] text-muted-foreground">
{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
}
)}
</span>
</div>
{onRevealWorktree ? (
<Button
type="button"
variant="outline"
size="icon-xs"
className="shrink-0"
// Icon-only so a long workspace name keeps the full header width.
aria-label={openLabel}
onClick={() => {
onRevealWorktree({
worktreeId: worktree.worktreeId,
executionHostId: worktree.executionHostId
})
onDone()
}}
>
<SquareArrowOutUpRight />
</Button>
) : null}
</header>
<section className={onSpawnAgent ? 'border-b border-border px-2 py-2' : 'px-2 py-2'}>
<h3 className="mb-1 px-1 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.runningAgents', 'Agents')}
</h3>
<div className="scrollbar-sleek max-h-56 space-y-0.5 overflow-y-auto">
{worktree.agents.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')}
</p>
) : (
worktree.agents.map((agent) => (
<button
key={agent.card.paneKey}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => {
onSelectAgent(agent.card)
onDone()
}}
>
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
<span className="min-w-0 flex-1">
<span className="block text-[12px] font-medium break-words">
{agentName(agent.card)}
</span>
<span className="block text-[11px] break-words text-muted-foreground">
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot
state={agent.status === 'done-seen' ? 'done' : agent.status}
size="md"
/>
</button>
))
)}
</div>
</section>
{onSpawnAgent ? (
<section className="px-3 py-2.5">
<h3 className="mb-1.5 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.spawnAgent', 'Start a new agent')}
</h3>
{launchableAgents && launchableAgents.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{launchableAgents.map((agent) => (
<Button
key={agent}
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={() => {
onSpawnAgent({ worktreeId: worktree.worktreeId, agent })
onDone()
}}
>
<Plus className="size-3" />
<AgentIcon agent={agent} size={12} />
{getAgentLabel(agent)}
</Button>
))}
</div>
) : (
<p className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')}
</p>
)}
</section>
) : null}
</PopoverContent>
)
}
@@ -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<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
onRevealWorktree?: (args: DashboardRevealWorktreeArgs) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenWorkspaceContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
@@ -49,116 +52,6 @@ type AgentMapWorktreeRingNodeProps = {
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, 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 (
<PopoverContent align="center" sideOffset={10} className="w-80 p-0">
<header className="border-b border-border px-3 py-2.5">
<span className="block truncate text-[11px] text-muted-foreground">{project.name}</span>
<strong className="block truncate text-[13px]">{worktree.name}</strong>
<span className="mt-1 block text-[11px] text-muted-foreground">
{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
}
)}
</span>
</header>
<section className={onSpawnAgent ? 'border-b border-border px-2 py-2' : 'px-2 py-2'}>
<h3 className="mb-1 px-1 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.runningAgents', 'Agents')}
</h3>
<div className="scrollbar-sleek max-h-56 space-y-0.5 overflow-y-auto">
{worktree.agents.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')}
</p>
) : (
worktree.agents.map((agent) => (
<button
key={agent.card.paneKey}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => {
onSelectAgent(agent.card)
onDone()
}}
>
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">
{agentName(agent.card)}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot
state={agent.status === 'done-seen' ? 'done' : agent.status}
size="md"
/>
</button>
))
)}
</div>
</section>
{onSpawnAgent ? (
<section className="px-3 py-2.5">
<h3 className="mb-1.5 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.spawnAgent', 'Start a new agent')}
</h3>
{launchableAgents && launchableAgents.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{launchableAgents.map((agent) => (
<Button
key={agent}
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={() => {
onSpawnAgent({ worktreeId: worktree.worktreeId, agent })
onDone()
}}
>
<Plus className="size-3" />
<AgentIcon agent={agent} size={12} />
{getAgentLabel(agent)}
</Button>
))}
</div>
) : (
<p className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')}
</p>
)}
</section>
) : null}
</PopoverContent>
)
}
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({
</>
)}
</g>
<WorktreeDetails
<AgentMapWorktreeDetailsCard
project={project}
worktree={worktree}
launchableAgents={launchableAgents}
onSelectAgent={onSelectAgent}
onRevealWorktree={onRevealWorktree}
onSpawnAgent={onSpawnAgent}
onDone={() => setDetailsOpen(false)}
/>
@@ -3,6 +3,18 @@ import { shouldAggregateAgentMapWorktree } from './agent-map-layout'
type Direction = { x: number; y: number }
const ARROW_KEY_DIRECTIONS: Readonly<Record<string, Direction>> = {
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)
@@ -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<string, TuiAgent[]>
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<typeof render> {
return render(
@@ -76,6 +79,7 @@ export function renderMap(
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
onRevealWorktree={onRevealWorktree}
/>
)
}
@@ -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}
@@ -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(<Harness enabled />))
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)
@@ -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
+2
View File
@@ -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",
+10 -3
View File
@@ -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.
+8
View File
@@ -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