mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
preserve experimental dashboard agent launch
This commit is contained in:
@@ -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<string, unknown>
|
||||
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<string, unknown>)
|
||||
return (
|
||||
entries.length <= MAX_WORKTREES &&
|
||||
entries.every(
|
||||
([worktreeId, agents]) =>
|
||||
isBoundedId(worktreeId) &&
|
||||
Array.isArray(agents) &&
|
||||
agents.length <= MAX_AGENTS_PER_WORKTREE &&
|
||||
agents.every(isTuiAgent)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void
|
||||
revealAgent: (args: DashboardRevealAgentArgs) => Promise<void>
|
||||
ackAgent: (paneKey: string) => Promise<void>
|
||||
spawnAgent: (args: DashboardSpawnAgentArgs) => Promise<void>
|
||||
}
|
||||
terminalPreview: {
|
||||
connect: (
|
||||
|
||||
+14
-2
@@ -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<void> => ipcRenderer.invoke('dashboard:requestSnapshot'),
|
||||
@@ -2302,7 +2312,9 @@ const api = {
|
||||
revealAgent: (args: DashboardRevealAgentArgs): Promise<void> =>
|
||||
ipcRenderer.invoke('dashboardPopout:revealAgent', args),
|
||||
ackAgent: (paneKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey })
|
||||
ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }),
|
||||
spawnAgent: (args: DashboardSpawnAgentArgs): Promise<void> =>
|
||||
ipcRenderer.invoke('dashboardPopout:spawnAgent', args)
|
||||
},
|
||||
|
||||
terminalPreview: {
|
||||
|
||||
@@ -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<DashboardCardTerminalInputState>
|
||||
Partial<DashboardCardTerminalInputState & DashboardLaunchDetectionState>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof buildDashboardWorktreeLaunchOptions>[0]
|
||||
|
||||
function state(overrides: Partial<LaunchState> = {}): LaunchState {
|
||||
return {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
detectedAgentIds: [],
|
||||
remoteDetectedAgentIds: {},
|
||||
runtimeDetectedAgentIds: {},
|
||||
settings: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): 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'] })
|
||||
})
|
||||
})
|
||||
@@ -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<AppState, 'repos' | 'settings' | 'worktreesByRepo'> &
|
||||
Partial<DashboardLaunchDetectionState>
|
||||
|
||||
type DashboardLaunchCatalog = {
|
||||
foldersById: Map<string, AppState['folderWorkspaces'][number]>
|
||||
groupsById: Map<string, AppState['projectGroups'][number]>
|
||||
reposById: Map<string, AppState['repos'][number]>
|
||||
worktreesByRepoAndId: Map<string, Map<string, AppState['worktreesByRepo'][string][number]>>
|
||||
}
|
||||
|
||||
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<string, TuiAgent[]> {
|
||||
const catalog = buildDashboardLaunchCatalog(state)
|
||||
const cardsByWorktreeId = new Map<string, DashboardCard[]>()
|
||||
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<string, TuiAgent[]> = {}
|
||||
for (const [worktreeId, worktreeCards] of cardsByWorktreeId) {
|
||||
const available = new Set<TuiAgent>(
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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<string, TuiAgent[]>
|
||||
/** 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user