mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user