diff --git a/src/main/ipc/dashboard-agent-launch-validation.ts b/src/main/ipc/dashboard-agent-launch-validation.ts new file mode 100644 index 00000000000..6bd9dbe5838 --- /dev/null +++ b/src/main/ipc/dashboard-agent-launch-validation.ts @@ -0,0 +1,38 @@ +import type { DashboardSpawnAgentArgs } from '../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../shared/tui-agent-config' + +const MAX_ID_LENGTH = 4_096 +const MAX_WORKTREES = 500 +const MAX_AGENTS_PER_WORKTREE = 64 + +function isBoundedId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH +} + +export function isDashboardSpawnAgentArgs(value: unknown): value is DashboardSpawnAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return isBoundedId(args.worktreeId) && isTuiAgent(args.agent) +} + +export function isDashboardLaunchOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= MAX_WORKTREES && + entries.every( + ([worktreeId, agents]) => + isBoundedId(worktreeId) && + Array.isArray(agents) && + agents.length <= MAX_AGENTS_PER_WORKTREE && + agents.every(isTuiAgent) + ) + ) +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 954723a6182..e24afc35517 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -19,6 +19,7 @@ vi.mock('../../shared/repo-icon', async (importOriginal) => { import { admitDashboardSnapshot, isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs, isDashboardSnapshot } from './dashboard-payload-validation' @@ -162,6 +163,26 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('validates bounded launch choices and spawn requests', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['codex', 'claude'] } + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['not-an-agent'] } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, launchableAgentsByWorktreeId: [] })).toBe(false) + + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'codex' })).toBe(true) + expect(isDashboardSpawnAgentArgs({ worktreeId: '', agent: 'codex' })).toBe(false) + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'unknown' })).toBe(false) + }) + it('bounds the conversation name', () => { expect( isDashboardSnapshot({ diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 0dfb80648d9..570fede47f6 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -11,6 +11,8 @@ import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_TYPE_MAX_LENGTH } from '../../shared/agent-status-types' +import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation' +export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_SUBAGENTS = 100 @@ -85,6 +87,7 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot snapshot.cards.every(isDashboardCard) && (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && isDashboardFilterOptions(snapshot.filterOptions) && + isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) && isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) } @@ -113,6 +116,7 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi snapshot.cards.length > MAX_DASHBOARD_CARDS || (snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') || !isDashboardFilterOptions(snapshot.filterOptions) || + !isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) || !isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) { return null diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index d516faa4554..e5f5ae65222 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -240,6 +240,19 @@ describe('registerDashboardPopoutHandlers', () => { expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') }) + it('relays only valid agent launches from the popout', () => { + const args = { worktreeId: 'worktree-1', agent: 'codex' } + handlers.get('dashboardPopout:spawnAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, { + ...args, + agent: 'unknown' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:spawnDashboardAgent', args) + }) + it('reveals an agent in only the trusted main window', () => { const main = makeWindow(mainSender) getTrustedWindowMock.mockReturnValue(main) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index c7a26421878..07dc6783dc6 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -14,7 +14,8 @@ import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRendere import { admitDashboardSnapshot, isDashboardPaneKey, - isDashboardRevealAgentArgs + isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs } from './dashboard-payload-validation' // The most recent snapshot the main renderer published, replayed to the popout @@ -36,6 +37,7 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') ipcMain.removeHandler('dashboardPopout:ackAgent') + ipcMain.removeHandler('dashboardPopout:spawnAgent') onDashboardPopoutOpenChanged((open) => { if (!open) { @@ -141,4 +143,15 @@ export function registerDashboardPopoutHandlers( // Best-effort; the per-window focus above may still bring it forward. } }) + + ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSpawnAgentArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:spawnDashboardAgent', args) + }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6e70b03033d..c586fd6fd8a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,7 +10,11 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2470,10 +2474,12 @@ export type PreloadApi = { onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise } terminalPreview: { connect: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index cb38c18e9af..5f14a98bf0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,11 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2290,6 +2294,12 @@ const api = { ipcRenderer.on('ui:ackDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, // ── Consumer side (pop-out window) ─────────────────────────────────── requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), @@ -2302,7 +2312,9 @@ const api = { revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args) }, terminalPreview: { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index c7c3c9711cc..6d8eb158566 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -41,6 +41,10 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' +import { + buildDashboardWorktreeLaunchOptions, + type DashboardLaunchDetectionState +} from './dashboard-worktree-launch-options' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -60,7 +64,7 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial + Partial function bucketForState(state: DashboardAgentRow['state']): DashboardBucket { switch (state) { @@ -330,6 +334,7 @@ export function buildDashboardSnapshot( cards, showIdle: state.settings?.experimentalAgentDashboardShowIdle === true, filterOptions, + launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions(state, cards), repoIconsByRepoId } } diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts new file mode 100644 index 00000000000..2e59af2afd4 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' + +type LaunchState = Parameters[0] + +function state(overrides: Partial = {}): LaunchState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + detectedAgentIds: [], + remoteDetectedAgentIds: {}, + runtimeDetectedAgentIds: {}, + settings: null, + ...overrides + } +} + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Ship it', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + startedAt: 1, + finishedAt: null, + stateChangedAt: 1, + unseen: false, + ...overrides + } +} + +describe('buildDashboardWorktreeLaunchOptions', () => { + it('combines local detection with proven providers, honoring defaults and disabled agents', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + detectedAgentIds: ['claude', 'codex'], + settings: { + defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'] + } as LaunchState['settings'] + }), + [card(), card({ paneKey: 'pane-2', agentType: 'gemini' })] + ) + + expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] }) + }) + + it('uses each git workspace execution host instead of local detection', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + repos: [ + { id: 'repo-ssh', connectionId: 'ssh-1' }, + { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + ] as LaunchState['repos'], + worktreesByRepo: { + 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], + 'repo-runtime': [{ id: 'runtime-worktree', repoId: 'repo-runtime' }] + } as unknown as LaunchState['worktreesByRepo'], + detectedAgentIds: ['claude'], + remoteDetectedAgentIds: { 'ssh-1': ['grok'] }, + runtimeDetectedAgentIds: { 'hub-1': ['aider'] } + }), + [ + card({ repoId: 'repo-ssh', worktreeId: 'ssh-worktree', agentType: 'grok' }), + card({ repoId: 'repo-runtime', worktreeId: 'runtime-worktree', agentType: 'aider' }) + ] + ) + + expect(options).toEqual({ 'ssh-worktree': ['grok'], 'runtime-worktree': ['aider'] }) + }) + + it('resolves folder workspace detection through its project host', () => { + const worktreeId = folderWorkspaceKey('folder-1') + const options = buildDashboardWorktreeLaunchOptions( + state({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', connectionId: 'ssh-folder' } + ] as LaunchState['folderWorkspaces'], + projectGroups: [{ id: 'group-1' }] as LaunchState['projectGroups'], + remoteDetectedAgentIds: { 'ssh-folder': ['goose'] } + }), + [card({ repoId: 'folder-workspace:group-1', worktreeId })] + ) + + expect(options).toEqual({ [worktreeId]: ['codex', 'goose'] }) + }) +}) diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts new file mode 100644 index 00000000000..18325beb556 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -0,0 +1,112 @@ +import type { AppState } from '@/store/types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + filterEnabledTuiAgents, + TUI_AGENT_AUTO_PICK_ORDER +} from '../../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../../shared/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type DashboardLaunchDetectionState = Pick< + AppState, + | 'detectedAgentIds' + | 'folderWorkspaces' + | 'projectGroups' + | 'remoteDetectedAgentIds' + | 'runtimeDetectedAgentIds' +> + +type DashboardLaunchOptionState = Pick & + Partial + +type DashboardLaunchCatalog = { + foldersById: Map + groupsById: Map + reposById: Map + worktreesByRepoAndId: Map> +} + +function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): DashboardLaunchCatalog { + return { + foldersById: new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])), + groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), + reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), + worktreesByRepoAndId: new Map( + Object.entries(state.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + new Map(worktrees.map((worktree) => [worktree.id, worktree])) + ]) + ) + } +} + +function detectedAgentsForWorktree( + state: DashboardLaunchOptionState, + worktreeId: string, + repoId: string, + catalog: DashboardLaunchCatalog +): readonly TuiAgent[] { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folder = catalog.foldersById.get(workspaceScope.folderWorkspaceId) + const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined + const host = parseExecutionHostId(group?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = folder?.connectionId ?? group?.connectionId + return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] + } + + const worktree = catalog.worktreesByRepoAndId.get(repoId)?.get(worktreeId) + const repo = catalog.reposById.get(worktree?.repoId ?? repoId) + const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId + return connectionId + ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) + : (state.detectedAgentIds ?? []) +} + +/** Host-detected choices plus providers already proven to run in the workspace. */ +export function buildDashboardWorktreeLaunchOptions( + state: DashboardLaunchOptionState, + cards: readonly DashboardCard[] +): Record { + const catalog = buildDashboardLaunchCatalog(state) + const cardsByWorktreeId = new Map() + for (const card of cards) { + const existing = cardsByWorktreeId.get(card.worktreeId) + if (existing) { + existing.push(card) + } else { + cardsByWorktreeId.set(card.worktreeId, [card]) + } + } + + const result: Record = {} + for (const [worktreeId, worktreeCards] of cardsByWorktreeId) { + const available = new Set( + detectedAgentsForWorktree(state, worktreeId, worktreeCards[0].repoId, catalog) + ) + for (const card of worktreeCards) { + if (isTuiAgent(card.agentType)) { + available.add(card.agentType) + } + } + const enabled = filterEnabledTuiAgents( + TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => available.has(agent)), + state.settings?.disabledTuiAgents + ) + const preferred = state.settings?.defaultTuiAgent + result[worktreeId] = + preferred && preferred !== 'blank' && enabled.includes(preferred) + ? [preferred, ...enabled.filter((agent) => agent !== preferred)] + : enabled + } + return result +} diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts new file mode 100644 index 00000000000..a207244cdb9 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnownWorktreeById: vi.fn(), + setActiveWorktree: vi.fn(), + launchAgentInNewTab: vi.fn(), + getExecutionHostIdForWorktree: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: null, + getKnownWorktreeById: mocks.getKnownWorktreeById, + setActiveWorktree: mocks.setActiveWorktree + }) + } +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree +})) + +import { launchDashboardAgent } from './launch-dashboard-agent' + +describe('launchDashboardAgent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') + mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) + mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + }) + + it('activates a folder or git workspace on its execution host before launching', () => { + expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true) + expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + agent: 'codex', + worktreeId: 'folder:docs', + launchSource: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts new file mode 100644 index 00000000000..be3b644e5a6 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -0,0 +1,23 @@ +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' + +/** Starts the requested agent through the same host-aware tab path as Quick Launch. */ +export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean { + const state = useAppStore.getState() + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const worktree = state.getKnownWorktreeById(worktreeId, executionHostId) + if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) { + return false + } + state.setActiveWorktree(worktreeId, executionHostId) + return ( + launchAgentInNewTab({ + agent, + worktreeId, + launchSource: 'unknown' + }) !== null + ) +} diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 0c0d26909cc..63f8f53e8b0 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -3,6 +3,7 @@ import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { launchDashboardAgent } from './launch-dashboard-agent' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree @@ -57,6 +58,9 @@ export function dashboardSnapshotInputsChanged( // Why: settings controls idle visibility and generated conversation names. state.settings !== previousState.settings || state.workspaceStatuses !== previousState.workspaceStatuses || + state.detectedAgentIds !== previousState.detectedAgentIds || + state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds || + state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds || // Why: freshness can change a bucket without replacing any backing map. state.agentStatusEpoch !== previousState.agentStatusEpoch || // Why: each card carries the host-input profile its preview terminal keys @@ -103,6 +107,13 @@ function watchSnapshotInputs(onChanged: () => void): () => void { * the agent's worktree and focus its pane in this (main) window. */ export function useDashboardPopoutBridge(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSpawnAgent?.(launchDashboardAgent) + }, [enabled]) + useEffect(() => { if (!enabled) { return diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 43933bfae62..9782f91c697 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -51,6 +51,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey) + const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) + const remoteDetectedAgentIds = useAppStore((s) => s.remoteDetectedAgentIds) + const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Why: freshness can flip a bucket without any backing map changing; the epoch // ticks on the freshness boundary so the memo re-derives stale-decayed cards. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) @@ -87,6 +90,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, // Why: read non-reactively — resolveWindowsShiftEnterEncoding takes // launch identity but never routes on it, so subscribing would only // rebuild the board. Matches the bridge's republish gate. @@ -122,6 +128,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, agentStatusEpoch ] ) diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 043786e57c2..5c5b6084392 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -1,5 +1,6 @@ import type { AgentType } from './agent-status-types' import type { RepoIcon } from './repo-icon' +import type { TuiAgent } from './types' /** * Serializable contract for the pop-out agent dashboard. The main renderer owns @@ -128,6 +129,8 @@ export type DashboardSnapshot = { /** Available filter dimensions are store-derived so zero-card projects and * statuses remain selectable. Optional for preload-version compatibility. */ filterOptions?: DashboardFilterOptions + /** Launch choices resolved on each workspace's execution host. */ + launchableAgentsByWorktreeId?: Record /** Icons for the repos the cards belong to. Keyed by repoId rather than * carried per card: image icons are data URLs up to 400KB, and the snapshot * is republished several times a second. Optional so a pop-out running @@ -139,6 +142,7 @@ export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = { generatedAt: 0, cards: [], filterOptions: { projects: [], workspaceStatuses: [] }, + launchableAgentsByWorktreeId: {}, repoIconsByRepoId: {} } @@ -151,3 +155,8 @@ export type DashboardRevealAgentArgs = { tabId: string leafId: string | null } + +export type DashboardSpawnAgentArgs = { + worktreeId: string + agent: TuiAgent +}