diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index cd0a9cb6c..4cfe020b5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Buffer } from "node:buffer"; import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; @@ -7839,15 +7840,17 @@ function automationsExampleText(): string { laneMode: "create", laneNamePreset: "issue-num-title", session: { - prompt: "Investigate and propose a fix for {{trigger.issue.title}}.", + title: "Issue fix", + codexFastMode: true, }, }, - actions: [ - { - type: "agent-session", - modelId: "claude-opus-4-7", + prompt: "Investigate and propose a fix for {{trigger.issue.title}}.", + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.5", + thinkingLevel: "xhigh", }, - ], + }, }, null, 2, @@ -10256,6 +10259,14 @@ function shouldReplaceMachineRuntimeVersion(runtimeVersion: string | null): bool return runtimeVersion == null || runtimeVersion !== VERSION; } +function computeRuntimeBuildHash(filePath: string): string | null { + try { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + } catch { + return null; + } +} + async function initializeMachineRuntimeDaemon( client: SocketJsonRpcClient, options: GlobalOptions, @@ -10291,6 +10302,7 @@ async function spawnMachineRuntimeDaemon( const { resolveAdeServeCommand } = await import("./serviceManager/common"); const serviceCommand = resolveAdeServeCommand(); const args = [...serviceCommand.args]; + let runtimeBuildHash: string | null = null; if ( serviceCommand.command === process.execPath && args.length === 1 && @@ -10298,19 +10310,27 @@ async function spawnMachineRuntimeDaemon( fs.existsSync(CLI_DIST_PATH) ) { args.splice(0, 1, CLI_DIST_PATH, "serve"); + runtimeBuildHash = computeRuntimeBuildHash(CLI_DIST_PATH); + } else if (serviceCommand.command === process.execPath && args[0]) { + runtimeBuildHash = computeRuntimeBuildHash(path.resolve(args[0])); } args.push("--socket", socketPath); + const env: NodeJS.ProcessEnv = { + ...process.env, + ...(serviceCommand.env ?? {}), + ADE_DEFAULT_ROLE: options.role, + ADE_RPC_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + }; + if (runtimeBuildHash) { + env.ADE_RUNTIME_BUILD_HASH = runtimeBuildHash; + } + const child = spawn(serviceCommand.command, args, { detached: true, stdio: "ignore", - env: { - ...process.env, - ...(serviceCommand.env ?? {}), - ADE_DEFAULT_ROLE: options.role, - ADE_RPC_SOCKET_PATH: socketPath, - ADE_RUNTIME_SOCKET_PATH: socketPath, - }, + env, }); child.once("error", () => {}); child.unref(); diff --git a/apps/ade-cli/src/services/projects/projectScope.test.ts b/apps/ade-cli/src/services/projects/projectScope.test.ts index 00334a181..ad76c4a92 100644 --- a/apps/ade-cli/src/services/projects/projectScope.test.ts +++ b/apps/ade-cli/src/services/projects/projectScope.test.ts @@ -140,14 +140,24 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.disposeAll(); }); - it("can switch the daemon sync host to a requested project", async () => { + it("switches the daemon sync host without disposing active project scopes", async () => { const { registry, first, second } = createRegistry(); const firstDispose = vi.fn(); const secondDispose = vi.fn(); const onDisposeProject = vi.fn(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; createAdeRuntimeMock - .mockResolvedValueOnce({ dispose: firstDispose }) - .mockResolvedValueOnce({ dispose: secondDispose }); + .mockResolvedValueOnce({ dispose: firstDispose, syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: secondDispose, syncService: secondSyncService }); const scopeRegistry = new ProjectScopeRegistry(registry, { onDisposeProject, syncRuntime: { @@ -162,8 +172,14 @@ describe("ProjectScopeRegistry", () => { await scopeRegistry.ensureSyncHost(first.projectId); await scopeRegistry.ensureSyncHost(second.projectId); - expect(firstDispose).toHaveBeenCalledTimes(1); - expect(onDisposeProject).toHaveBeenCalledWith(first.projectId); + expect(firstDispose).not.toHaveBeenCalled(); + expect(secondDispose).not.toHaveBeenCalled(); + expect(onDisposeProject).not.toHaveBeenCalled(); + expect(firstSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(false); + expect(firstSyncService.setHostStartupEnabled).toHaveBeenCalledWith(false); + expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + expect(secondSyncService.initialize).toHaveBeenCalled(); expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); expect(createAdeRuntimeMock.mock.calls[1]?.[0]).toMatchObject({ projectRoot: second.rootPath, @@ -176,9 +192,58 @@ describe("ProjectScopeRegistry", () => { }); await scopeRegistry.disposeAll(); + expect(firstDispose).toHaveBeenCalledTimes(1); expect(secondDispose).toHaveBeenCalledTimes(1); }); + it("promotes an existing warm project when selecting the default sync host", async () => { + const { registry, first, second } = createRegistry(); + const firstSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + const secondSyncService = { + initialize: vi.fn(async () => undefined), + setHostDiscoveryEnabled: vi.fn(), + setHostStartupEnabled: vi.fn(async () => undefined), + }; + createAdeRuntimeMock + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: firstSyncService }) + .mockResolvedValueOnce({ dispose: vi.fn(), syncService: secondSyncService }); + const scopeRegistry = new ProjectScopeRegistry(registry, { + syncRuntime: { + enabled: true, + hostStartupEnabled: true, + hostDiscoveryEnabled: true, + forceHostRole: true, + runtimeKind: "daemon", + }, + }); + + await scopeRegistry.ensureSyncHost(first.projectId); + await scopeRegistry.get(second.projectId); + const file = JSON.parse(fs.readFileSync(registry.path, "utf8")) as { + projects: Array<{ projectId: string; lastOpenedAt: number; addedAt: number }>; + }; + file.projects = file.projects.map((project) => ({ + ...project, + lastOpenedAt: project.projectId === second.projectId ? 2_000 : 1_000, + addedAt: project.projectId === second.projectId ? 2_000 : 1_000, + })); + fs.writeFileSync(registry.path, JSON.stringify(file, null, 2)); + await scopeRegistry.dispose(first.projectId); + const promoted = await scopeRegistry.ensureSyncHost(); + + expect(promoted?.registryProjectId).toBe(second.projectId); + expect(createAdeRuntimeMock).toHaveBeenCalledTimes(2); + expect(secondSyncService.setHostDiscoveryEnabled).toHaveBeenCalledWith(true); + expect(secondSyncService.setHostStartupEnabled).toHaveBeenCalledWith(true); + expect(secondSyncService.initialize).toHaveBeenCalled(); + + await scopeRegistry.disposeAll(); + }); + it("passes runtime capability options into project runtimes", async () => { const { registry, first } = createRegistry(); const scopeRegistry = new ProjectScopeRegistry(registry, { diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index c2a8d0f20..6ff7d5538 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -112,15 +112,21 @@ export class ProjectScopeRegistry { async ensureSyncHost(projectId?: ProjectId): Promise { if (!this.options.syncRuntime?.enabled) return null; if (projectId) { - if (this.scopes.has(projectId) && this.syncHostProjectId !== projectId) { - await this.dispose(projectId); - } const existingHostId = this.syncHostProjectId; if (existingHostId && existingHostId !== projectId) { - await this.dispose(existingHostId); + await this.configureCachedSyncHost(existingHostId, false); } this.syncHostProjectId = projectId; - return await this.get(projectId); + try { + const scope = await this.get(projectId); + await this.configureSyncHost(scope, true); + return scope; + } catch (error) { + if (this.syncHostProjectId === projectId) { + this.syncHostProjectId = null; + } + throw error; + } } const existingHostId = this.syncHostProjectId; @@ -139,7 +145,28 @@ export class ProjectScopeRegistry { const openedDelta = right.lastOpenedAt - left.lastOpenedAt; return openedDelta !== 0 ? openedDelta : right.addedAt - left.addedAt; })[0]; - return record ? this.get(record.projectId) : null; + return record ? this.ensureSyncHost(record.projectId) : null; + } + + private async configureCachedSyncHost( + projectId: ProjectId, + enabled: boolean, + ): Promise { + const cached = this.scopes.get(projectId); + if (!cached) return; + const scope = await cached.catch(() => null); + if (scope) await this.configureSyncHost(scope, enabled); + } + + private async configureSyncHost( + scope: ProjectScope, + enabled: boolean, + ): Promise { + const syncService = scope.runtime.syncService; + if (!syncService) return; + syncService.setHostDiscoveryEnabled?.(enabled); + await syncService.setHostStartupEnabled?.(enabled); + if (enabled) await syncService.initialize(); } private buildSyncRuntimeOptions(projectId: ProjectId): AdeRuntimeSyncOptions | null { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index a6cd53664..bc1ffdf67 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -551,176 +551,8 @@ function formatContextUsage(usage: AgentChatContextUsage | null): string { .join("\n"); } -function buildResolvedSubagentIdsByParent(events: AgentChatEventEnvelope[]): Map> { - const idsByParent = new Map>(); - for (const envelope of events) { - const event = envelope.event as Record; - const type = typeof event.type === "string" ? event.type : ""; - if (!type.startsWith("subagent")) continue; - const parent = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() - ? event.parentToolUseId.trim() - : null; - if (!parent) continue; - const taskId = typeof event.taskId === "string" && event.taskId.trim() ? event.taskId.trim() : null; - const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; - const id = agentId ?? taskId; - if (!id || id === parent) continue; - const ids = idsByParent.get(parent) ?? new Set(); - ids.add(id); - idsByParent.set(parent, ids); - } - return idsByParent; -} - -function isParentSubagentPlaceholder(snapshot: SubagentSnapshot | undefined, parentToolUseId: string): snapshot is SubagentSnapshot { - return Boolean( - snapshot - && snapshot.id === parentToolUseId - && snapshot.parentToolUseId === parentToolUseId, - ); -} - -export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): SubagentSnapshot[] { - const snapshots = new Map(); - const terminalTurnIds = new Set(); - const resolvedIdsByParent = buildResolvedSubagentIdsByParent(events); - - for (const envelope of events) { - const event = envelope.event as Record; - const turnId = typeof event.turnId === "string" ? event.turnId : null; - if (!turnId) continue; - const isDone = event.type === "done"; - const isTerminalStatus = event.type === "status" && event.turnStatus !== "started"; - if (isDone || isTerminalStatus) terminalTurnIds.add(turnId); - } - - for (const envelope of events) { - const event = envelope.event as Record; - const type = typeof event.type === "string" ? event.type : ""; - - if (type === "teammate.idle") { - const teamName = typeof event.teamName === "string" ? event.teamName : ""; - const teammateName = typeof event.teammateName === "string" ? event.teammateName : ""; - if (!teammateName) continue; - const id = `teammate:${teamName}:${teammateName}`; - const existing = snapshots.get(id); - snapshots.set(id, { - id, - name: teamName ? `${teamName}/${teammateName}` : teammateName, - kind: "teammate", - status: "running", - summary: existing?.summary ?? "idle", - turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId, - startedAt: existing?.startedAt ?? envelope.timestamp, - tokens: existing?.tokens, - durationMs: existing?.durationMs, - lastToolName: existing?.lastToolName, - }); - continue; - } - - if (type === "task.completed") { - const teamName = typeof event.teamName === "string" ? event.teamName : ""; - const teammateName = typeof event.teammateName === "string" ? event.teammateName : ""; - const subject = typeof event.subject === "string" ? event.subject : ""; - if (!teammateName) continue; - const id = `teammate:${teamName}:${teammateName}`; - const existing = snapshots.get(id); - snapshots.set(id, { - id, - name: existing?.name ?? (teamName ? `${teamName}/${teammateName}` : teammateName), - kind: "teammate", - status: "completed", - summary: subject || existing?.summary || "", - turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId, - startedAt: existing?.startedAt ?? envelope.timestamp, - endedAt: envelope.timestamp, - tokens: existing?.tokens, - durationMs: existing?.durationMs, - lastToolName: existing?.lastToolName, - }); - continue; - } - - if (!type.startsWith("subagent")) continue; - const taskId = typeof event.taskId === "string" && event.taskId.trim() ? event.taskId.trim() : null; - const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; - const id = agentId ?? taskId; - if (!id) continue; - const incomingParentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() - ? event.parentToolUseId.trim() - : null; - const parentPlaceholder = incomingParentToolUseId ? snapshots.get(incomingParentToolUseId) : undefined; - const parentResolvedIds = incomingParentToolUseId ? resolvedIdsByParent.get(incomingParentToolUseId) : undefined; - const parentIsPlaceholder = Boolean( - incomingParentToolUseId - && isParentSubagentPlaceholder(parentPlaceholder, incomingParentToolUseId), - ); - const canAdoptParentPlaceholder = parentIsPlaceholder - && parentResolvedIds?.size === 1 - && parentResolvedIds.has(id); - const taskAlias = taskId && taskId !== id ? snapshots.get(taskId) : undefined; - const existing = snapshots.get(id) ?? taskAlias ?? (canAdoptParentPlaceholder ? parentPlaceholder : undefined); - if (taskId && id !== taskId) snapshots.delete(taskId); - if ( - incomingParentToolUseId - && parentIsPlaceholder - && (canAdoptParentPlaceholder || (parentResolvedIds && parentResolvedIds.size > 1)) - ) { - snapshots.delete(incomingParentToolUseId); - } - const agentType = typeof event.agentType === "string" ? event.agentType : "subagent"; - const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : {}; - const parentToolUseId = incomingParentToolUseId ?? existing?.parentToolUseId ?? null; - const startedAt = existing?.startedAt ?? envelope.timestamp; - const endedAt = type === "subagent_result" || type === "subagent.completed" ? envelope.timestamp : existing?.endedAt; - const parsedDurationMs = endedAt && startedAt ? Date.parse(endedAt) - Date.parse(startedAt) : Number.NaN; - const fallbackDurationMs = Number.isFinite(parsedDurationMs) ? Math.max(0, parsedDurationMs) : existing?.durationMs; - const summaryFromEvent = [event.summary, event.finalSummary, event.text, event.description] - .find((value): value is string => typeof value === "string" && value.trim().length > 0); - const summary = summaryFromEvent ?? existing?.summary ?? ""; - const base: SubagentSnapshot = { - id, - name: typeof event.description === "string" ? event.description : existing?.name ?? agentType, - kind: "subagent", - status: existing?.status ?? "running", - summary, - parentToolUseId, - turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId ?? null, - background: event.background === true || existing?.background === true, - startedAt, - endedAt, - tokens: typeof usage.totalTokens === "number" ? usage.totalTokens : typeof event.tokens === "number" ? event.tokens : existing?.tokens, - durationMs: typeof usage.durationMs === "number" ? usage.durationMs : fallbackDurationMs, - lastToolName: typeof event.lastToolName === "string" ? event.lastToolName : existing?.lastToolName, - }; - if (type === "subagent_result" || type === "subagent.completed") { - const status = event.status === "failed" || event.status === "stopped" || event.status === "completed" ? event.status : "completed"; - snapshots.set(id, { ...base, status }); - } else { - snapshots.set(id, { ...base, status: "running" }); - } - } - - for (const [key, snapshot] of snapshots) { - if ( - snapshot.kind === "subagent" - && snapshot.status === "running" - && snapshot.background !== true - && snapshot.turnId - && terminalTurnIds.has(snapshot.turnId) - ) { - const terminalSummary = "Parent turn ended before ADE received a final subagent status"; - snapshots.set(key, { - ...snapshot, - status: "stopped", - summary: snapshot.summary && snapshot.summary !== snapshot.name ? snapshot.summary : terminalSummary, - }); - } - } - - return [...snapshots.values()]; -} +import { subagentSnapshotsFromEvents } from "../../../desktop/src/shared/chatSubagents"; +export { subagentSnapshotsFromEvents }; function isLaneWorktreeAvailable(lane: LaneSummary | null | undefined): boolean { const root = lane?.worktreePath?.trim(); diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts index 4bfe9a7b2..76cc9a780 100644 --- a/apps/ade-cli/src/tuiClient/chatInfo.ts +++ b/apps/ade-cli/src/tuiClient/chatInfo.ts @@ -1,11 +1,10 @@ import type { - AgentChatEvent, AgentChatEventEnvelope, AgentChatSessionSummary, } from "../../../desktop/src/shared/types/chat"; +import { latestPlan } from "../../../desktop/src/shared/chatSubagents"; import type { AdeCodeProvider, - ChatInfoPlan, ChatInfoSnapshot, SubagentSnapshot, } from "./types"; @@ -34,26 +33,6 @@ function tokenStatsSummary(stats: TokenStats | null): string | null { return parts.length ? parts.join(" ") : null; } -function planFromEvent(event: Extract): ChatInfoPlan { - const completed = event.steps.filter((step) => step.status === "completed").length; - const inProgress = event.steps.findIndex((step) => step.status === "in_progress"); - const current = inProgress >= 0 ? inProgress + 1 : completed; - return { - current, - total: event.steps.length, - steps: event.steps.map((step) => ({ text: step.text, status: step.status })), - live: event.steps.some((step) => step.status === "in_progress"), - }; -} - -function latestPlan(events: AgentChatEventEnvelope[]): ChatInfoPlan { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]?.event; - if (event?.type === "plan") return planFromEvent(event); - } - return null; -} - export function deriveChatInfoSnapshot(args: { events: AgentChatEventEnvelope[]; activeSession: AgentChatSessionSummary | null; diff --git a/apps/ade-cli/src/tuiClient/subagentPane.ts b/apps/ade-cli/src/tuiClient/subagentPane.ts index 5d4b77a5f..a6451cd5c 100644 --- a/apps/ade-cli/src/tuiClient/subagentPane.ts +++ b/apps/ade-cli/src/tuiClient/subagentPane.ts @@ -1,23 +1,20 @@ -import type { - AgentChatEvent, - AgentChatEventEnvelope, - AgentChatSessionSummary, -} from "../../../desktop/src/shared/types/chat"; import type { AdeCodeProvider, RightPaneContent, SubagentSnapshot } from "./types"; -import { workEventItemId, workEventParentItemId } from "./workEventIds"; - -export type SubagentPaneSection = "main" | "subagents" | "teammates" | "background"; - -export type SubagentPaneRow = - | { kind: "main"; key: "main"; section: "main"; label: string } - | { kind: "snapshot"; key: string; section: Exclude; snapshot: SubagentSnapshot }; - -// Vertical offset of the first selectable roster row in the rendered chat-info -// pane (header + status + plan + goal occupy the preceding lines). Used only by -// the mouse-click → row mapper. -const SUBAGENT_PANE_TABLE_START_LINE = 4; - -export type SubagentPaneContent = { +import type { SubagentPaneContent as SharedSubagentPaneContent } from "../../../desktop/src/shared/chatSubagents"; + +export { + buildSubagentPaneRows, + buildSubagentTranscriptEvents, + isLifecycleEventForSnapshot, + selectedSubagentSnapshot, + subagentIndexForPaneLine, + subagentPaneSelectableLineOffsets, +} from "../../../desktop/src/shared/chatSubagents"; +export type { + SubagentPaneRow, + SubagentPaneSection, +} from "../../../desktop/src/shared/chatSubagents"; + +export type SubagentPaneContent = SharedSubagentPaneContent & { provider: AdeCodeProvider; snapshots: SubagentSnapshot[]; }; @@ -31,237 +28,3 @@ export function subagentPaneContentFromRightPane(content: RightPaneContent): Sub } return null; } - -export function buildSubagentPaneRows(content: SubagentPaneContent): SubagentPaneRow[] { - const foregroundSubagents = content.snapshots.filter((snap) => ( - snap.kind === "subagent" - && snap.background !== true - )); - const runningWeight = (snap: SubagentSnapshot): number => (snap.status === "running" ? 0 : 1); - const sortedForegroundSubagents = [...foregroundSubagents].sort( - (left, right) => runningWeight(left) - runningWeight(right), - ); - const teammates = content.snapshots.filter((snap) => snap.kind === "teammate"); - const background = content.snapshots.filter((snap) => snap.kind === "subagent" && snap.background === true); - - return [ - { kind: "main", key: "main", section: "main", label: "main" }, - ...sortedForegroundSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), - ...teammates.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "teammates" as const, snapshot })), - ...background.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "background" as const, snapshot })), - ]; -} - -export function selectedSubagentSnapshot( - content: SubagentPaneContent, - selectedIndex: number, -): SubagentSnapshot | null { - const row = buildSubagentPaneRows(content)[selectedIndex] ?? null; - return row?.kind === "snapshot" ? row.snapshot : null; -} - -export function subagentPaneSelectableLineOffsets( - content: SubagentPaneContent, - selectedIndex = 0, -): number[] { - const rows = buildSubagentPaneRows(content); - const offsets: number[] = []; - let line = SUBAGENT_PANE_TABLE_START_LINE; - - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]!; - const previous = rows[index - 1]; - const showSection = row.section !== "main" && previous?.section !== row.section; - if (showSection) line += 2; - offsets.push(line); - line += 1; - const selectedSnapshotHasDetail = row.kind === "snapshot" - && index === selectedIndex - && (row.snapshot.lastToolName || row.snapshot.summary); - if (row.kind === "main" || selectedSnapshotHasDetail) { - line += 1; - } - } - - return offsets; -} - -export function subagentIndexForPaneLine( - content: SubagentPaneContent, - line: number, - selectedIndex = 0, -): number | null { - if (!Number.isFinite(line)) return null; - const offsets = subagentPaneSelectableLineOffsets(content, selectedIndex); - if (!offsets.length) return null; - const first = offsets[0]!; - const last = offsets[offsets.length - 1]!; - if (line < first - 1 || line > last + 1) return null; - - let bestIndex = 0; - let bestDistance = Number.POSITIVE_INFINITY; - for (let index = 0; index < offsets.length; index += 1) { - const distance = Math.abs(line - offsets[index]!); - if (distance < bestDistance) { - bestIndex = index; - bestDistance = distance; - } - } - return bestIndex; -} - -function textField(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function eventType(event: AgentChatEvent): string { - return String((event as { type?: unknown }).type ?? ""); -} - -function eventSubagentIds(event: AgentChatEvent): string[] { - const record = event as { taskId?: unknown; agentId?: unknown }; - const ids = [ - textField(record.taskId), - textField(record.agentId), - ].filter((value): value is string => value != null); - return [...new Set(ids)]; -} - -function eventParentToolUseId(event: AgentChatEvent): string | null { - return textField((event as { parentToolUseId?: unknown }).parentToolUseId); -} - -function isLifecycleEventForSnapshot(event: AgentChatEvent, snapshot: SubagentSnapshot): boolean { - const type = eventType(event); - if (snapshot.kind === "teammate") { - if (type === "teammate.idle" || type === "task.completed") { - const record = event as { teamName?: unknown; teammateName?: unknown }; - const teamName = textField(record.teamName) ?? ""; - const teammateName = textField(record.teammateName) ?? ""; - return snapshot.id === `teammate:${teamName}:${teammateName}`; - } - return false; - } - if ( - type !== "subagent_started" - && type !== "subagent_progress" - && type !== "subagent_result" - && type !== "subagent.started" - && type !== "subagent.progress" - && type !== "subagent.completed" - ) { - return false; - } - const explicitIds = eventSubagentIds(event); - if (explicitIds.length > 0) return explicitIds.includes(snapshot.id); - const parentToolUseId = eventParentToolUseId(event); - return Boolean(snapshot.parentToolUseId && parentToolUseId === snapshot.parentToolUseId); -} - -function lifecycleText(event: AgentChatEvent, snapshot: SubagentSnapshot): string | null { - const type = eventType(event); - const record = event as { - description?: unknown; - summary?: unknown; - finalSummary?: unknown; - text?: unknown; - subject?: unknown; - status?: unknown; - }; - if (type === "subagent_started" || type === "subagent.started") { - return "Started."; - } - if (type === "subagent_progress" || type === "subagent.progress") { - return textField(record.summary) ?? textField(record.text) ?? null; - } - if (type === "subagent_result" || type === "subagent.completed") { - const status = textField(record.status) ?? snapshot.status; - const summary = textField(record.finalSummary) ?? textField(record.summary) ?? snapshot.summary; - return `${status}: ${summary || snapshot.name}`; - } - if (type === "teammate.idle") { - return `Teammate idle: ${snapshot.name}`; - } - if (type === "task.completed") { - return `completed: ${textField(record.subject) ?? snapshot.summary ?? snapshot.name}`; - } - return null; -} - -function syntheticTextEvent( - sessionId: string, - timestamp: string, - sequence: number, - turnId: string | null | undefined, - text: string, -): AgentChatEventEnvelope { - return { - sessionId, - timestamp, - sequence, - event: { - type: "text", - text, - ...(turnId ? { turnId } : {}), - }, - }; -} - -export function buildSubagentTranscriptEvents(args: { - events: AgentChatEventEnvelope[]; - activeSession: AgentChatSessionSummary | null; - snapshot: SubagentSnapshot; -}): AgentChatEventEnvelope[] { - const sessionId = args.activeSession?.sessionId ?? args.events[0]?.sessionId ?? "subagent"; - const parentToolUseId = textField(args.snapshot.parentToolUseId); - const childItemIds = new Set(); - - if (parentToolUseId) { - for (const envelope of args.events) { - const event = envelope.event; - const parentItemId = workEventParentItemId(event); - const itemId = workEventItemId(event); - if (parentItemId === parentToolUseId && itemId) { - childItemIds.add(itemId); - } - } - } - - const transcript: AgentChatEventEnvelope[] = [ - syntheticTextEvent( - sessionId, - args.snapshot.startedAt ?? args.events[0]?.timestamp ?? new Date(0).toISOString(), - -2, - args.snapshot.turnId, - `Viewing ${args.snapshot.kind === "teammate" ? "teammate" : args.snapshot.background ? "background agent" : "agent"} transcript. Select Main chat in Chat Info to return.\nTask: ${args.snapshot.name}`, - ), - ]; - - for (const envelope of args.events) { - const event = envelope.event; - if (isLifecycleEventForSnapshot(event, args.snapshot)) { - const text = lifecycleText(event, args.snapshot); - if (text) { - transcript.push(syntheticTextEvent(sessionId, envelope.timestamp, envelope.sequence ?? 0, args.snapshot.turnId, text)); - } - continue; - } - const parentItemId = workEventParentItemId(event); - const itemId = workEventItemId(event); - if (parentToolUseId && (parentItemId === parentToolUseId || (itemId != null && childItemIds.has(itemId)))) { - transcript.push(envelope); - } - } - - if (transcript.length === 1) { - transcript.push(syntheticTextEvent( - sessionId, - args.snapshot.endedAt ?? args.events.at(-1)?.timestamp ?? new Date(0).toISOString(), - -1, - args.snapshot.turnId, - args.snapshot.summary || "No detailed transcript rows were recorded for this agent.", - )); - } - - return transcript; -} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 7d63213f8..b8ae77f70 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -107,33 +107,8 @@ export type SetupPaneRow = { cyclable?: boolean; }; -export type SubagentSnapshot = { - id: string; - name: string; - kind: "subagent" | "teammate"; - status: "running" | "completed" | "failed" | "stopped"; - summary: string; - parentToolUseId?: string | null; - turnId?: string | null; - background?: boolean; - startedAt?: string | null; - endedAt?: string | null; - tokens?: number; - durationMs?: number; - lastToolName?: string; -}; - -export type ChatInfoPlanStep = { - text: string; - status: "pending" | "in_progress" | "completed" | "failed"; -}; - -export type ChatInfoPlan = { - current: number; - total: number; - steps: ChatInfoPlanStep[]; - live: boolean; -} | null; +export type { SubagentSnapshot, ChatInfoPlan, ChatInfoPlanStep } from "../../../desktop/src/shared/chatSubagents"; +import type { SubagentSnapshot, ChatInfoPlan } from "../../../desktop/src/shared/chatSubagents"; export type ChatInfoSnapshot = { provider: AdeCodeProvider; diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index 5720f9eaf..cb3ab1026 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -207,7 +207,23 @@ async function ensureDevIcon() { } } +async function ensureAdeCliBuild() { + const builder = path.join(__dirname, "ensure-ade-cli-build.cjs"); + if (!fs.existsSync(builder)) return; + const result = cp.spawnSync(process.execPath, [builder], { + cwd: projectRoot, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`ADE CLI build check failed with exit code ${result.status ?? "unknown"}`); + } +} + async function main() { + await ensureAdeCliBuild(); await ensureDevIcon(); const devPort = await choosePort(5173, 32); const devServerUrl = `http://localhost:${devPort}`; diff --git a/apps/desktop/scripts/ensure-ade-cli-build.cjs b/apps/desktop/scripts/ensure-ade-cli-build.cjs new file mode 100644 index 000000000..dfb9c3f5f --- /dev/null +++ b/apps/desktop/scripts/ensure-ade-cli-build.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +const cp = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const desktopRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(desktopRoot, "..", ".."); +const cliRoot = path.join(repoRoot, "apps", "ade-cli"); + +const distFiles = [ + path.join(cliRoot, "dist", "cli.cjs"), + path.join(cliRoot, "dist", "bootstrap.cjs"), + path.join(cliRoot, "dist", "adeRpcServer.cjs"), + path.join(cliRoot, "dist", "tuiClient", "cli.mjs"), +]; + +const sourceEntries = [ + path.join(cliRoot, "src"), + path.join(cliRoot, "package.json"), + path.join(cliRoot, "package-lock.json"), + path.join(cliRoot, "tsconfig.json"), + path.join(cliRoot, "tsup.config.ts"), +]; + +function newestMtimeMs(entryPath) { + let newest = 0; + const stack = [entryPath]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + let stat; + try { + stat = fs.statSync(current); + } catch { + continue; + } + newest = Math.max(newest, stat.mtimeMs); + if (!stat.isDirectory()) continue; + for (const child of fs.readdirSync(current)) { + if (child === "node_modules" || child === "dist" || child === ".turbo") continue; + stack.push(path.join(current, child)); + } + } + return newest; +} + +function oldestDistMtimeMs() { + let oldest = Number.POSITIVE_INFINITY; + for (const filePath of distFiles) { + let stat; + try { + stat = fs.statSync(filePath); + } catch { + return 0; + } + oldest = Math.min(oldest, stat.mtimeMs); + } + return oldest; +} + +const newestSource = sourceEntries.reduce( + (newest, entryPath) => Math.max(newest, newestMtimeMs(entryPath)), + 0, +); +const oldestDist = oldestDistMtimeMs(); + +if (oldestDist >= newestSource) { + process.stdout.write("[ade] ADE CLI dist is up to date\n"); + process.exit(0); +} + +process.stdout.write("[ade] ADE CLI dist is stale; rebuilding before desktop dev launch\n"); +const result = cp.spawnSync( + "npm", + ["--prefix", path.join("..", "ade-cli"), "run", "build"], + { + cwd: desktopRoot, + stdio: "inherit", + shell: process.platform === "win32", + }, +); + +if (result.error) { + process.stderr.write(`[ade] failed to rebuild ADE CLI: ${result.error.message}\n`); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d046efc9d..21a728d18 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1081,6 +1081,7 @@ app.whenReady().then(async () => { const projectInitPromises = new Map>(); const closeContextPromises = new Map>(); const windowProjectRoots = new Map(); + const windowProjectTabRoots = new Map>(); const windowProjectBindings = new Map(); const ipcWindowScope = new AsyncLocalStorage(); const rpcSocketCleanupByRoot = new Map void>(); @@ -1115,7 +1116,13 @@ app.whenReady().then(async () => { } else if (process.env.NODE_ENV === "test") { localRuntimePool.noteServiceInstallSkipped("Background service installation is skipped in tests."); } - const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; + // Soft cap for project contexts that have NO user work at all (no chats, + // no live PTYs, no active sessions/missions/tests). Anything with work is + // protected by `hasActiveProjectWorkloads` and is never evicted regardless + // of this number. The cap exists only as a safety valve against opening + // hundreds of empty projects in a long-running session — well above any + // realistic working set, so users effectively never hit it. + const MAX_WARM_IDLE_PROJECT_CONTEXTS = 100; const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; let activeProjectRoot: string | null = null; let mobileSyncSelectedRoot: string | null = null; @@ -1137,6 +1144,34 @@ app.whenReady().then(async () => { return projectContexts.get(projectRoot)?.project ?? null; }; + const projectsForWindowTabs = (windowId: number | null): ProjectInfo[] => { + if (windowId == null) return []; + const roots = windowProjectTabRoots.get(windowId) ?? new Set(); + const projects: ProjectInfo[] = []; + for (const root of roots) { + const project = projectForRoot(root); + if (project) projects.push(project); + } + return projects; + }; + + const rememberWindowProjectTabs = ( + windowId: number | null, + rootPaths: string[], + ): ProjectInfo[] => { + if (windowId == null) return []; + const roots = new Set(); + for (const rootPath of rootPaths) { + const normalized = rootPath.trim() ? normalizeProjectRoot(rootPath) : ""; + if (normalized) roots.add(normalized); + } + const activeRoot = windowProjectRoots.get(windowId) ?? null; + if (activeRoot) roots.add(activeRoot); + windowProjectTabRoots.set(windowId, roots); + scheduleProjectContextRebalance(); + return projectsForWindowTabs(windowId); + }; + const bindingForLocalProject = (project: ProjectInfo | null): OpenProjectBinding | null => project && !shouldUseInProcessProjectRuntime() ? { @@ -1152,6 +1187,9 @@ app.whenReady().then(async () => { for (const root of windowProjectRoots.values()) { if (root) roots.add(root); } + for (const tabRoots of windowProjectTabRoots.values()) { + for (const root of tabRoots) roots.add(root); + } return roots; }; @@ -1268,6 +1306,11 @@ app.whenReady().then(async () => { if (windowId != null) { windowProjectRoots.set(windowId, normalizedRoot); windowProjectBindings.delete(windowId); + if (normalizedRoot) { + const tabRoots = windowProjectTabRoots.get(windowId) ?? new Set(); + tabRoots.add(normalizedRoot); + windowProjectTabRoots.set(windowId, tabRoots); + } } if (options.foreground ?? true) { setForegroundProject(normalizedRoot); @@ -1302,7 +1345,12 @@ app.whenReady().then(async () => { windowProjectRoots.set(windowId, null); windowProjectBindings.set(windowId, binding); } - setForegroundProject(null); + // Binding this window to a remote project must not tear down local + // foreground services that other windows depend on. Only drop the + // foreground if no other window is still working in a local project. + if (!activeProjectRoot || !rootsBoundToWindows().has(activeProjectRoot)) { + setForegroundProject(firstOpenWindowProjectRoot()); + } emitProjectChangedToWindow(windowId, null); emitProjectBindingChangedToWindow(windowId, binding); }; @@ -1333,7 +1381,9 @@ app.whenReady().then(async () => { ): void => { const normalizedRoot = normalizeProjectRoot(projectRoot); for (const win of BrowserWindow.getAllWindows()) { - if (windowProjectRoots.get(win.id) !== normalizedRoot) continue; + const isActiveInWindow = windowProjectRoots.get(win.id) === normalizedRoot; + const isOpenTabInWindow = windowProjectTabRoots.get(win.id)?.has(normalizedRoot) === true; + if (!isActiveInWindow && !isOpenTabInWindow) continue; try { win.webContents.send(channel, payload); } catch { @@ -1367,13 +1417,31 @@ app.whenReady().then(async () => { } try { - if (ctx.agentChatService?.hasActiveWorkloads()) { + // ANY chat session the user hasn't explicitly closed/deleted protects + // the project. The narrower hasActiveWorkloads check (mid-turn only) is + // not enough — a session sitting between turns still owns a live agent + // runtime that must survive a project switch so typing a new message + // does not cold-restart the agent. + if ( + ctx.agentChatService?.hasRetainableSessions?.() + ?? ctx.agentChatService?.hasActiveWorkloads() + ) { return true; } } catch (error) { return keepAliveOnProbeFailure("agent_chats", error); } + try { + // Any live PTY (running CLI/shell/agent) means the user has work that + // would be killed by eviction. Don't evict. + if (ctx.ptyService?.hasLiveSessions?.()) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("pty_sessions", error); + } + try { if (ctx.missionService?.list({ status: "active", limit: 1 }).length > 0) { return true; @@ -2784,8 +2852,21 @@ app.whenReady().then(async () => { getDirtyFileTextForPath: async (absPath: string) => { const trimmed = absPath.trim(); if (!trimmed) return undefined; + const normalizedProjectRoot = normalizeProjectRoot(projectRoot); + const candidateWindows = BrowserWindow.getAllWindows().filter( + (candidate) => + !candidate.isDestroyed() + && candidate.webContents + && !candidate.webContents.isDestroyed(), + ); const win = - BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + candidateWindows.find( + (candidate) => windowProjectRoots.get(candidate.id) === normalizedProjectRoot, + ) + ?? candidateWindows.find( + (candidate) => windowProjectTabRoots.get(candidate.id)?.has(normalizedProjectRoot) === true, + ) + ?? null; if (!win?.webContents || win.webContents.isDestroyed()) return undefined; try { @@ -4970,6 +5051,31 @@ app.whenReady().then(async () => { try { const resolveStartedAt = Date.now(); repoRoot = normalizeProjectRoot(await resolveRepoRoot(selectedPath)); // require a real git repo for onboarding. + // Kick off base-ref detection IN PARALLEL with the existing-context + // check and any recent-project bookkeeping. For a cold open this shaves + // 200-600ms off (git symbolic-ref + rev-parse run during work we'd be + // doing anyway). For the fast "context already warm" path we simply + // discard the in-flight promise. + const baseRefStartedAt = Date.now(); + const baseRefPromise = detectDefaultBaseRef(repoRoot) + .then((value) => { + projectOpenLogger.info("project.open.base_ref_detected", { + selectedPath, + repoRoot, + baseRef: value, + durationMs: Date.now() - baseRefStartedAt, + }); + return value; + }) + .catch((error) => { + projectOpenLogger.warn("project.open.base_ref_failed", { + selectedPath, + repoRoot, + durationMs: Date.now() - baseRefStartedAt, + error: error instanceof Error ? error.message : String(error), + }); + return "main"; + }); const isKnownRecentProject = (readGlobalState(globalStatePath).recentProjects ?? []).some((entry) => { if (typeof entry?.rootPath !== "string") return false; return normalizeProjectRoot(entry.rootPath) === repoRoot; @@ -4988,6 +5094,10 @@ app.whenReady().then(async () => { }); bindWindowToProject(currentIpcWindowId(), repoRoot, { emit: true, foreground: true }); scheduleProjectContextRebalance(); + // Drop the unused base-ref promise so it doesn't leak as an unhandled + // rejection if detectDefaultBaseRef threw between the .catch above + // and this point (already neutralized by .catch, but keep tidy). + void baseRefPromise; projectOpenLogger.info("project.open.done", { selectedPath, repoRoot, @@ -5000,14 +5110,7 @@ app.whenReady().then(async () => { let initPromise = projectInitPromises.get(repoRoot); if (!initPromise) { initPromise = (async () => { - const baseRefStartedAt = Date.now(); - const baseRef = await detectDefaultBaseRef(repoRoot!); - projectOpenLogger.info("project.open.base_ref_detected", { - selectedPath, - repoRoot, - baseRef, - durationMs: Date.now() - baseRefStartedAt, - }); + const baseRef = await baseRefPromise; const initStartedAt = Date.now(); const ctx = shouldUseInProcessProjectRuntime() ? await initContextForProjectRoot({ @@ -5072,11 +5175,15 @@ app.whenReady().then(async () => { const normalizedRoot = normalizeProjectRoot(projectRoot); const wasActive = activeProjectRoot === normalizedRoot; for (const [windowId, root] of windowProjectRoots) { + const tabRoots = windowProjectTabRoots.get(windowId); + tabRoots?.delete(normalizedRoot); if (root === normalizedRoot) { - windowProjectRoots.set(windowId, null); + const nextRoot = tabRoots?.values().next().value ?? null; + windowProjectRoots.set(windowId, nextRoot); windowProjectBindings.delete(windowId); - emitProjectChangedToWindow(windowId, null); - emitProjectBindingChangedToWindow(windowId, null); + const nextProject = projectForRoot(nextRoot); + emitProjectChangedToWindow(windowId, nextProject); + emitProjectBindingChangedToWindow(windowId, bindingForLocalProject(nextProject)); } } await closeProjectContext(normalizedRoot); @@ -5091,7 +5198,17 @@ app.whenReady().then(async () => { const previousRoot = current.project?.rootPath ?? ""; const windowId = currentIpcWindowId(); if (windowId != null) { - bindWindowToProject(windowId, null, { emit: true, foreground: true }); + // Unbind this window without clobbering the global foreground project — + // other open windows may still be working in their own projects, and + // background services keyed to `activeProjectRoot` (mobile sync host, + // artifact dir, etc.) must keep pointing at a live root if one exists. + const tabRoots = windowProjectTabRoots.get(windowId); + if (previousRoot) tabRoots?.delete(normalizeProjectRoot(previousRoot)); + const nextRoot = tabRoots?.values().next().value ?? null; + bindWindowToProject(windowId, nextRoot, { emit: true, foreground: false }); + if (nextRoot == null && (activeProjectRoot === previousRoot || activeProjectRoot == null)) { + setForegroundProject(firstOpenWindowProjectRoot()); + } dormantContext = createDormantProjectContext(previousRoot); scheduleProjectContextRebalance(); return; @@ -5099,7 +5216,7 @@ app.whenReady().then(async () => { if (activeProjectRoot) { await closeProjectContext(activeProjectRoot); } - setForegroundProject(null); + setForegroundProject(firstOpenWindowProjectRoot()); dormantContext = createDormantProjectContext(previousRoot); }; @@ -5438,15 +5555,27 @@ app.whenReady().then(async () => { }; const registerWindowSession = (win: BrowserWindow, projectRoot: string | null = null): void => { - windowProjectRoots.set(win.id, projectRoot ? normalizeProjectRoot(projectRoot) : null); + const normalizedRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; + windowProjectRoots.set(win.id, normalizedRoot); + windowProjectTabRoots.set(win.id, normalizedRoot ? new Set([normalizedRoot]) : new Set()); windowProjectBindings.delete(win.id); win.on("focus", () => { - setForegroundProject(windowProjectRoots.get(win.id) ?? null); + const focusedRoot = windowProjectRoots.get(win.id) ?? null; + if (focusedRoot != null) { + setForegroundProject(focusedRoot); + } else if (!activeProjectRoot || !rootsBoundToWindows().has(activeProjectRoot)) { + // Focusing an unscoped window (e.g. a brand-new File > New Window) must + // not clobber the foreground project — that would tear down background + // services and break running work in other windows. Only re-derive the + // foreground when the current one is no longer bound to any window. + setForegroundProject(firstOpenWindowProjectRoot()); + } builtInBrowserService.attachToWindow(win); }); win.on("closed", () => { const previousRoot = windowProjectRoots.get(win.id) ?? null; windowProjectRoots.delete(win.id); + windowProjectTabRoots.delete(win.id); windowProjectBindings.delete(win.id); if (activeProjectRoot === previousRoot) { setForegroundProject(firstOpenWindowProjectRoot()); @@ -5455,18 +5584,24 @@ app.whenReady().then(async () => { }); }; - const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null } => { + const getWindowSession = (windowId: number | null): { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; openProjectTabs: ProjectInfo[] } => { if (windowId == null) { const project = projectForRoot(activeProjectRoot); - return { windowId: null, project, binding: bindingForLocalProject(project) }; + return { + windowId: null, + project, + binding: bindingForLocalProject(project), + openProjectTabs: project ? [project] : [], + }; } const remoteBinding = windowProjectBindings.get(windowId) ?? null; - if (remoteBinding) return { windowId, project: null, binding: remoteBinding }; + if (remoteBinding) return { windowId, project: null, binding: remoteBinding, openProjectTabs: projectsForWindowTabs(windowId) }; const project = projectForRoot(windowProjectRoots.get(windowId) ?? null); return { windowId, project, binding: bindingForLocalProject(project), + openProjectTabs: projectsForWindowTabs(windowId), }; }; @@ -5615,6 +5750,7 @@ app.whenReady().then(async () => { runWithIpcWindow: (event, fn) => ipcWindowScope.run(BrowserWindow.fromWebContents(event.sender)?.id ?? null, fn), getWindowSession, + setWindowProjectTabs: rememberWindowProjectTabs, bindRemoteProject: bindWindowToRemoteProject, localRuntimeConnectionPool: shouldUseInProcessProjectRuntime() ? null diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b549cb507..4c9e2b9cb 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -769,7 +769,7 @@ export const ADE_ACTION_ALLOWLIST: Partial; preview(args?: unknown): Promise; - write(args?: unknown): unknown; + write(args?: unknown): Promise; resize(args?: unknown): unknown; signal(args?: unknown): unknown; activeForChat(args?: unknown): unknown; + reattachChatCli(args?: unknown): Promise; }; const RUNTIME_FILE_WATCH_CLIENT_ID_FIELD = "__adeRuntimeClientId"; @@ -3578,8 +3579,8 @@ function buildTerminalDomainService(runtime: AdeRuntime): TerminalDomainService preview(args) { return runtime.ptyService.previewTerminal(args as Parameters[0]); }, - write(args) { - return runtime.ptyService.writeTerminal(args as Parameters[0]); + async write(args) { + return await runtime.ptyService.writeTerminal(args as Parameters[0]); }, resize(args) { return runtime.ptyService.resizeTerminal(args as Parameters[0]); @@ -3590,6 +3591,9 @@ function buildTerminalDomainService(runtime: AdeRuntime): TerminalDomainService activeForChat(args) { return runtime.ptyService.activeForChat(args as Parameters[0]); }, + async reattachChatCli(args) { + return await runtime.ptyService.reattachChatCli(args as Parameters[0]); + }, }; } diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index eb065cbb4..4b26b8fd3 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -2015,10 +2015,10 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { }); }; - const writeTerminal = (terminalArgs: { data?: string | null }): { ok: true } => { + const writeTerminal = async (terminalArgs: { data?: string | null }): Promise<{ ok: true }> => { const terminalId = requireTerminalSessionId(); if (typeof terminalArgs?.data !== "string") throw new Error("App Control terminal write requires data."); - return args.ptyService!.writeTerminal({ terminalId, data: terminalArgs.data }); + return await args.ptyService!.writeTerminal({ terminalId, data: terminalArgs.data }); }; const signalTerminal = (terminalArgs: { signal?: "SIGINT" | "SIGTERM" | "SIGKILL" | null } = {}): { ok: true } => { diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index bb3d7bc6c..051da18d2 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -753,6 +753,7 @@ function normalizeDraft(args: { ...(prompt ? { prompt } : {}), ...(safeTrim(action?.sessionTitle) ? { sessionTitle: safeTrim(action?.sessionTitle) } : {}), ...(actionModelConfig ? { modelConfig: actionModelConfig } : {}), + ...(typeof action?.codexFastMode === "boolean" ? { codexFastMode: action.codexFastMode } : {}), ...(actionPermissionConfig ? { permissionConfig: actionPermissionConfig } : {}), }); continue; @@ -867,6 +868,9 @@ function normalizeDraft(args: { ...(safeTrim(requestedExecution.session?.reasoningEffort) ? { reasoningEffort: safeTrim(requestedExecution.session?.reasoningEffort) } : {}), + ...(typeof requestedExecution.session?.codexFastMode === "boolean" + ? { codexFastMode: requestedExecution.session.codexFastMode } + : {}), }, } : {}), diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index 826c90447..195b6d90f 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -1295,6 +1295,83 @@ describe("automationService integration", () => { } }); + it("passes Codex fast mode to rule-level agent-session automations", async () => { + const { db } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-fast-rule-")); + const createSession = vi.fn(async () => ({ id: "session-fast-rule" })); + const runSessionTurn = vi.fn(async () => ({ outputText: "ok" })); + + const rule = { + id: "agent-fast-rule", + name: "Agent fast rule", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + modelConfig: { + orchestratorModel: { modelId: "openai/gpt-5.5", thinkingLevel: "xhigh" as const }, + }, + permissionConfig: { providers: { codex: "default" as const } }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "agent-session" as const, + session: { codexFastMode: true, reasoningEffort: "xhigh" }, + }, + prompt: "Summarize the current state.", + }; + + const projectConfigService = { + get: () => ({ + trust: { requiresSharedTrust: false }, + effective: { automations: [rule], providerMode: "guest" } + }) + } as any; + + const laneService = { + list: async () => [{ id: "lane-1", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, + logger, + projectId, + projectRoot, + laneService, + projectConfigService, + agentChatService: { + createSession, + runSessionTurn, + } as any, + }); + + try { + const run = await service.triggerManually({ id: "agent-fast-rule" }); + expect(run.status).toBe("succeeded"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + modelId: "openai/gpt-5.5", + codexFastMode: true, + reasoningEffort: "xhigh", + })); + expect(runSessionTurn).toHaveBeenCalledWith(expect.objectContaining({ + reasoningEffort: "xhigh", + })); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("blocks agent-session automations when the budget cap rejects the run", async () => { const { db } = createInMemoryAdeDb(); const logger = createLogger(); @@ -1537,8 +1614,9 @@ describe("automationService integration", () => { type: "agent-session" as const, prompt: "Summarize", sessionTitle: "Summary", - modelConfig: { modelId: "opencode/openai/gpt-5.4", thinkingLevel: "high" as const }, - permissionConfig: { providers: { opencode: "full-auto" as const } }, + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" as const }, + codexFastMode: true, + permissionConfig: { providers: { codex: "full-auto" as const } }, }, ], }, @@ -1576,7 +1654,8 @@ describe("automationService integration", () => { const run = await service.triggerManually({ id: "action-model" }); expect(run.status).toBe("succeeded"); expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - modelId: "opencode/openai/gpt-5.4", + modelId: "openai/gpt-5.5", + codexFastMode: true, reasoningEffort: "high", permissionMode: "full-auto", })); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 1d7d72e31..bef3b08a7 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -46,7 +46,7 @@ import { buildClaudeReadOnlyWorkerAllowedTools } from "../orchestrator/providerO import type { createWorkerHeartbeatService } from "../cto/workerHeartbeatService"; import { isRecord, matchesGlob, normalizeSet, nowIso, resolvePathWithinRoot, safeJsonParse } from "../shared/utils"; import { terminateProcessTree } from "../shared/processExecution"; -import { getDefaultModelDescriptor, getModelById, resolveChatProviderForDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; +import { getDefaultModelDescriptor, getModelById, modelSupportsFastMode, resolveChatProviderForDescriptor, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; type CronTask = { stop: () => void; @@ -1876,6 +1876,9 @@ export function createAutomationService({ const promptText = typeof interpolated === "string" ? interpolated : rawPrompt; const { modelId, modelDescriptor, providerGroup } = resolveAutomationModelDescriptor(rule, action); const resolvedChat = resolveChatProviderForDescriptor(modelDescriptor); + const codexFastMode = providerGroup === "codex" + && action.codexFastMode === true + && modelSupportsFastMode(modelDescriptor); const permissionConfig = buildPermissionConfig(rule, { publishPhase: false }, action); const permissionMode = resolveProviderPermissionMode(providerGroup, permissionConfig); const reasoningEffort = action.modelConfig?.thinkingLevel @@ -1895,6 +1898,7 @@ export function createAutomationService({ sessionProfile: "workflow", reasoningEffort, permissionMode, + ...(codexFastMode ? { codexFastMode: true } : {}), ...(providerGroup === "cursor" ? { cursorModeId: cursorModeIdForPermissionMode(permissionMode) } : {}), ...(providerGroup === "codex" && permissionConfig.providers?.codexSandbox ? { codexSandbox: permissionConfig.providers.codexSandbox } @@ -2299,6 +2303,9 @@ export function createAutomationService({ ? "plan" : resolveProviderPermissionMode(providerGroup, permissionConfig); const reasoningEffort = args.rule.execution?.session?.reasoningEffort ?? args.rule.modelConfig?.orchestratorModel?.thinkingLevel ?? null; + const codexFastMode = providerGroup === "codex" + && args.rule.execution?.session?.codexFastMode === true + && modelSupportsFastMode(modelDescriptor); const timeoutMs = Math.max( 15_000, Math.floor((args.rule.guardrails.maxDurationMin ?? 10) * 60_000), @@ -2315,6 +2322,7 @@ export function createAutomationService({ sessionProfile: "workflow", reasoningEffort, permissionMode, + ...(codexFastMode ? { codexFastMode: true } : {}), ...(providerGroup === "cursor" ? { cursorModeId: cursorModeIdForPermissionMode(permissionMode) } : {}), ...(providerGroup === "codex" && !verificationRequired && !dryRun && permissionConfig.providers?.codexSandbox ? { codexSandbox: permissionConfig.providers.codexSandbox } diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index d063bb968..f3c9974d4 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -4,11 +4,27 @@ import { createBuiltInBrowserService } from "./builtInBrowserService"; const fakes = vi.hoisted(() => { type DebuggerHandler = (...args: unknown[]) => void; - type WindowOpenHandler = (details: { url: string }) => { action: string }; + type WindowOpenHandlerResponse = { + action: "allow" | "deny"; + createWindow?: (options: Record) => FakeWebContents; + }; + type WindowOpenHandler = (details: { url: string }) => WindowOpenHandlerResponse; type BeforeSendHeadersHandler = ( details: { requestHeaders: Record }, callback: (response: { requestHeaders: Record }) => void, ) => void; + type PermissionCheckHandler = ( + webContents: FakeWebContents | null, + permission: string, + requestingOrigin: string, + details: { requestingUrl?: string; embeddingOrigin?: string; securityOrigin?: string; isMainFrame: boolean }, + ) => boolean; + type PermissionRequestHandler = ( + webContents: FakeWebContents, + permission: string, + callback: (granted: boolean) => void, + details: { requestingUrl: string; isMainFrame: boolean; requestingOrigin?: string }, + ) => void; class FakeDebugger { attached = false; @@ -70,7 +86,7 @@ const fakes = vi.hoisted(() => { setWindowOpenHandler = (handler: WindowOpenHandler): void => { this.windowOpenHandler = handler; }; - openWindow = (url: string): { action: string } | null => this.windowOpenHandler?.({ url }) ?? null; + openWindow = (url: string): WindowOpenHandlerResponse | null => this.windowOpenHandler?.({ url }) ?? null; on = (event: string, fn: (...args: unknown[]) => void): void => { (this.listeners[event] ??= []).push(fn); }; @@ -92,6 +108,8 @@ const fakes = vi.hoisted(() => { const debuggerInstances: FakeDebugger[] = []; const webContentsInstances: FakeWebContents[] = []; const beforeSendHeadersHandlers: BeforeSendHeadersHandler[] = []; + let permissionCheckHandler: PermissionCheckHandler | null = null; + let permissionRequestHandler: PermissionRequestHandler | null = null; const OriginalFakeDebugger = FakeDebugger; class TrackedFakeDebugger extends OriginalFakeDebugger { constructor() { @@ -152,6 +170,44 @@ const fakes = vi.hoisted(() => { clearBeforeSendHeadersHandlers: () => { beforeSendHeadersHandlers.length = 0; }, + setPermissionCheckHandler: (handler: PermissionCheckHandler | null) => { + permissionCheckHandler = handler; + }, + setPermissionRequestHandler: (handler: PermissionRequestHandler | null) => { + permissionRequestHandler = handler; + }, + dispatchPermissionCheck: ( + permission: string, + requestingOrigin: string, + details: { requestingUrl?: string; embeddingOrigin?: string; securityOrigin?: string; isMainFrame?: boolean } = {}, + ): boolean | null => { + return permissionCheckHandler?.(webContentsInstances[0] ?? null, permission, requestingOrigin, { + isMainFrame: details.isMainFrame ?? true, + requestingUrl: details.requestingUrl, + embeddingOrigin: details.embeddingOrigin, + securityOrigin: details.securityOrigin, + }) ?? null; + }, + dispatchPermissionRequest: ( + permission: string, + details: { requestingUrl: string; isMainFrame?: boolean; requestingOrigin?: string }, + ): boolean | null => { + let granted: boolean | null = null; + const wc = webContentsInstances[0]; + if (!wc || !permissionRequestHandler) return null; + permissionRequestHandler(wc, permission, (nextGranted) => { + granted = nextGranted; + }, { + requestingUrl: details.requestingUrl, + isMainFrame: details.isMainFrame ?? true, + requestingOrigin: details.requestingOrigin, + }); + return granted; + }, + clearPermissionHandlers: () => { + permissionCheckHandler = null; + permissionRequestHandler = null; + }, }; }); @@ -165,8 +221,12 @@ vi.mock("electron", () => ({ fakes.beforeSendHeadersHandlers.push(handler as Parameters[0]); }, }, - setPermissionCheckHandler: () => {}, - setPermissionRequestHandler: () => {}, + setPermissionCheckHandler: (handler: unknown) => { + fakes.setPermissionCheckHandler(handler as Parameters[0]); + }, + setPermissionRequestHandler: (handler: unknown) => { + fakes.setPermissionRequestHandler(handler as Parameters[0]); + }, }), }, shell: { openExternal: fakes.openExternal }, @@ -212,6 +272,7 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { collector = captureStatusEvents(); fakes.clearWebContentsInstances(); fakes.clearBeforeSendHeadersHandlers(); + fakes.clearPermissionHandlers(); fakes.openExternal.mockClear(); }); @@ -339,27 +400,65 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(fakes.openExternal).not.toHaveBeenCalled(); }); - it("uses a desktop Chrome user agent for ADE browser requests", async () => { + it("does not impersonate Chrome or rewrite browser request headers", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); await service.createTab({ url: "https://example.test", activate: true }); const wc = fakes.webContentsInstances[0]; - expect(wc?.userAgentCalls.at(-1)).toMatch(/ Chrome\/\d+\.\d+\.\d+\.\d+ /); - expect(wc?.userAgentCalls.at(-1)).not.toMatch(/Electron|ADE/i); - - const response = fakes.dispatchBeforeSendHeaders({ - "User-Agent": "Electron/30", - "user-agent": "ADE/Electron", - "Sec-CH-UA": "\"Electron\";v=\"30\"", - "sec-ch-ua": "\"ADE\";v=\"1\"", - }); + expect(wc?.userAgentCalls).toEqual([]); + expect(fakes.beforeSendHeadersHandlers).toHaveLength(0); + expect(fakes.dispatchBeforeSendHeaders({ + "User-Agent": "Electron/41", + "Sec-CH-UA": "\"Chromium\";v=\"140\", \"Electron\";v=\"41\"", + })).toBeNull(); + }); + + it("allows only narrow Google account auth permissions", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.createTab({ url: "https://example.test", activate: true }); - expect(response?.requestHeaders["User-Agent"]).toMatch(/ Chrome\/\d+\.\d+\.\d+\.\d+ /); - expect(response?.requestHeaders["User-Agent"]).not.toMatch(/Electron|ADE/i); - expect(response?.requestHeaders["user-agent"]).toBeUndefined(); - expect(response?.requestHeaders["Sec-CH-UA"]).toContain("Google Chrome"); - expect(response?.requestHeaders["Sec-CH-UA"]).not.toContain("Electron"); - expect(response?.requestHeaders["sec-ch-ua"]).toBeUndefined(); + expect(fakes.dispatchPermissionCheck("storage-access", "https://accounts.google.com")).toBe(true); + expect(fakes.dispatchPermissionCheck("top-level-storage-access", "https://accounts.google.com")).toBe(true); + expect(fakes.dispatchPermissionCheck("hid", "https://accounts.google.com")).toBe(true); + expect(fakes.dispatchPermissionCheck("usb", "https://accounts.google.com")).toBe(true); + expect(fakes.dispatchPermissionCheck("serial", "https://accounts.google.com")).toBe(true); + + expect(fakes.dispatchPermissionCheck("storage-access", "https://example.test")).toBe(false); + expect(fakes.dispatchPermissionCheck("media", "https://accounts.google.com")).toBe(false); + + expect(fakes.dispatchPermissionRequest("storage-access", { + requestingUrl: "https://accounts.google.com/v3/signin/identifier", + })).toBe(true); + expect(fakes.dispatchPermissionRequest("top-level-storage-access", { + requestingUrl: "https://accounts.google.com/v3/signin/identifier", + })).toBe(true); + expect(fakes.dispatchPermissionRequest("media", { + requestingUrl: "https://accounts.google.com/v3/signin/identifier", + })).toBe(false); + expect(fakes.dispatchPermissionRequest("storage-access", { + requestingUrl: "https://example.test/login", + })).toBe(false); + }); + + it("opens popup requests as real ADE browser tabs", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.createTab({ url: "https://example.test", activate: true }); + const firstTabId = service.getStatus().activeTabId; + const firstWc = fakes.webContentsInstances[0]; + + const response = firstWc?.openWindow("https://accounts.google.com/gsi/select"); + + expect(response?.action).toBe("allow"); + expect(service.getStatus().tabs).toHaveLength(2); + expect(service.getStatus().activeTabId).not.toBe(firstTabId); + expect(response?.createWindow?.({})).toBe(fakes.webContentsInstances[1]); + + const openEvent = collector.events.findLast((event) => event.type === "open-request"); + expect(openEvent).toMatchObject({ + type: "open-request", + url: "https://accounts.google.com/gsi/select", + tabId: service.getStatus().activeTabId, + }); }); it("emits an open request so the Work sidebar can reveal the browser panel", async () => { @@ -416,6 +515,7 @@ describe("createBuiltInBrowserService — switchTab and navigate inspect/selecti fakes.clearDebuggerInstances(); fakes.clearWebContentsInstances(); fakes.clearBeforeSendHeadersHandlers(); + fakes.clearPermissionHandlers(); fakes.openExternal.mockClear(); }); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index 62f6c8ad0..3b055aaae 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -25,10 +25,17 @@ const SCREENSHOT_TIMEOUT_MS = 3_000; const ELEMENT_SCREENSHOT_TIMEOUT_MS = 2_000; const DEBUGGER_TIMEOUT_MS = 3_000; const MAX_BROWSER_TABS = 10; -const BROWSER_CHROME_VERSION = normalizeChromeVersion(process.versions.chrome); -const BROWSER_CHROME_MAJOR_VERSION = BROWSER_CHROME_VERSION.split(".")[0] || "120"; -const BROWSER_USER_AGENT = buildDesktopChromeUserAgent(BROWSER_CHROME_VERSION, process.platform); -const BROWSER_UA_PLATFORM = browserClientHintsPlatform(process.platform); +const GOOGLE_AUTH_PERMISSION_CHECK_ALLOWLIST = new Set([ + "hid", + "serial", + "storage-access", + "top-level-storage-access", + "usb", +]); +const GOOGLE_AUTH_PERMISSION_REQUEST_ALLOWLIST = new Set([ + "storage-access", + "top-level-storage-access", +]); type DebuggerMessageListener = ( event: Electron.Event, @@ -192,16 +199,13 @@ export function createBuiltInBrowserService(args: { const configureBrowserWebContents = (wc: WebContents): void => { if (configuredWebContents.has(wc)) return; configuredWebContents.add(wc); - try { - wc.setUserAgent(BROWSER_USER_AGENT); - } catch (error) { - logger()?.debug("built_in_browser.set_user_agent_failed", { - err: error instanceof Error ? error.message : String(error), - }); - } wc.setWindowOpenHandler(({ url }) => { - void navigate({ url, newTab: true, openPanel: true }).catch(emitError); - return { action: "deny" }; + const tab = createPopupTabState(url); + if (!tab) return { action: "deny" }; + return { + action: "allow", + createWindow: () => tab.webContents, + }; }); wc.on("will-navigate", (event, url) => { if (isAllowedNavigationUrl(url)) return; @@ -265,6 +269,26 @@ export function createBuiltInBrowserService(args: { }; }; + const createPopupTabState = (url: string): BrowserTabState | null => { + const popupUrl = stringOrNull(url) ?? "about:blank"; + if (!isAllowedNavigationUrl(popupUrl)) { + emitError(new Error(`Blocked unsupported browser popup protocol: ${url}`)); + return null; + } + if (tabs.length >= MAX_BROWSER_TABS) { + emitError(new Error(`ADE browser is limited to ${MAX_BROWSER_TABS} tabs. Close a tab before opening another.`)); + return null; + } + const tab = createTabState(); + tabs = [...tabs, tab]; + activeTabId = tab.id; + clearSelectionInternal(); + attachViewsToCurrentWindow(); + requestOpenPanel({ url: popupUrl, tabId: tab.id }); + emitStatus(); + return tab; + }; + const ensureActiveTab = (): BrowserTabState => { const existing = activeTab(); if (existing) return existing; @@ -308,20 +332,35 @@ export function createBuiltInBrowserService(args: { const configureBrowserSession = (): void => { if (browserSessionConfigured) return; const browserSession = session.fromPartition(BROWSER_PARTITION); - browserSession.webRequest.onBeforeSendHeaders((details, callback) => { - callback({ requestHeaders: normalizeBrowserRequestHeaders(details.requestHeaders) }); - }); - browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + if (shouldAllowGoogleAuthPermissionCheck(permission, requestingOrigin, details)) { + logger()?.debug("built_in_browser.permission_check_allowed", { + permission, + requestingOrigin, + requestingUrl: details.requestingUrl ?? null, + }); + return true; + } logger()?.debug("built_in_browser.permission_check_denied", { permission, requestingOrigin, + requestingUrl: details.requestingUrl ?? null, }); return false; }); browserSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { + if (shouldAllowGoogleAuthPermissionRequest(permission, details)) { + logger()?.debug("built_in_browser.permission_request_allowed", { + permission, + requestingUrl: stringOrNull(details.requestingUrl), + }); + callback(true); + return; + } logger()?.debug("built_in_browser.permission_request_denied", { permission, requestingOrigin: "requestingOrigin" in details ? details.requestingOrigin : null, + requestingUrl: stringOrNull(details.requestingUrl), }); callback(false); }); @@ -1070,58 +1109,41 @@ function emptyToNull(value: string): string | null { return trimmed.length ? trimmed : null; } -function normalizeChromeVersion(version: string | undefined): string { - const match = (version ?? "").match(/^\d+(?:\.\d+){0,3}/); - const parts = (match?.[0] ?? "120.0.0.0").split("."); - while (parts.length < 4) parts.push("0"); - return parts.slice(0, 4).join("."); -} - -function buildDesktopChromeUserAgent(chromeVersion: string, platform: NodeJS.Platform): string { - let platformToken: string; - if (platform === "darwin") { - platformToken = "Macintosh; Intel Mac OS X 10_15_7"; - } else if (platform === "win32") { - platformToken = "Windows NT 10.0; Win64; x64"; - } else { - platformToken = "X11; Linux x86_64"; - } - return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; -} - -function browserClientHintsPlatform(platform: NodeJS.Platform): string { - if (platform === "darwin") return "macOS"; - if (platform === "win32") return "Windows"; - return "Linux"; -} - -function browserClientHintsBrands(): string { - return `"Google Chrome";v="${BROWSER_CHROME_MAJOR_VERSION}", "Chromium";v="${BROWSER_CHROME_MAJOR_VERSION}", "Not:A-Brand";v="24"`; +function shouldAllowGoogleAuthPermissionCheck( + permission: string, + requestingOrigin: string, + details: Electron.PermissionCheckHandlerHandlerDetails, +): boolean { + if (!GOOGLE_AUTH_PERMISSION_CHECK_ALLOWLIST.has(permission)) return false; + return ( + isGoogleAccountsSurface(requestingOrigin) + || isGoogleAccountsSurface(details.requestingUrl) + || isGoogleAccountsSurface(details.embeddingOrigin) + || isGoogleAccountsSurface(details.securityOrigin) + ); } -type BrowserRequestHeaders = Record; - -function setRequestHeader(headers: BrowserRequestHeaders, name: string, value: string): void { - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === name.toLowerCase()) { - delete headers[key]; - } - } - headers[name] = value; +function shouldAllowGoogleAuthPermissionRequest(permission: string, details: unknown): boolean { + if (!GOOGLE_AUTH_PERMISSION_REQUEST_ALLOWLIST.has(permission)) return false; + if (!isRecord(details)) return false; + return ( + isGoogleAccountsSurface(details.requestingUrl) + || isGoogleAccountsSurface(details.requestingOrigin) + ); } -function normalizeBrowserRequestHeaders( - headers: Record, -): BrowserRequestHeaders { - const next: BrowserRequestHeaders = {}; - for (const [key, value] of Object.entries(headers)) { - if (value !== undefined) next[key] = value; +function isGoogleAccountsSurface(value: unknown): boolean { + const text = stringOrNull(value); + if (!text) return false; + try { + const parsed = new URL(text); + return parsed.protocol === "https:" && ( + parsed.hostname === "accounts.google.com" + || parsed.hostname.endsWith(".accounts.google.com") + ); + } catch { + return false; } - setRequestHeader(next, "User-Agent", BROWSER_USER_AGENT); - setRequestHeader(next, "Sec-CH-UA", browserClientHintsBrands()); - setRequestHeader(next, "Sec-CH-UA-Mobile", "?0"); - setRequestHeader(next, "Sec-CH-UA-Platform", `"${BROWSER_UA_PLATFORM}"`); - return next; } function tabStatus(tab: BrowserTabState): BuiltInBrowserTab { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8b0dddc60..c74437a5e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4098,6 +4098,227 @@ describe("createAgentChatService", () => { }); }); + // -------------------------------------------------------------------------- + // Claude subagent name capture (Task tool input -> task_started envelope) + // -------------------------------------------------------------------------- + + describe("claude subagent name capture", () => { + it("attaches agentType from the Task tool input to subagent_* envelopes", async () => { + const events: AgentChatEventEnvelope[] = []; + + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-name-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + // Assistant emits a Task tool_use block with subagent_type = "code-reviewer" + yield { + type: "assistant", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "toolu_task_1", + name: "Task", + input: { + subagent_type: "code-reviewer", + description: "Review the auth module", + prompt: "Please review auth.ts for security gaps.", + }, + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + // SDK emits the canonical task lifecycle system messages referencing + // the same tool_use id via parent_tool_use_id. + yield { + type: "system", + subtype: "task_started", + task_id: "task-1", + parent_tool_use_id: "toolu_task_1", + description: "Review the auth module", + }; + yield { + type: "system", + subtype: "task_progress", + task_id: "task-1", + parent_tool_use_id: "toolu_task_1", + summary: "Reading file…", + last_tool_name: "Read", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "task-1", + parent_tool_use_id: "toolu_task_1", + status: "completed", + summary: "Found no issues", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-name-1", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Spawn a code-reviewer subagent.", + }); + + await waitForEvent( + events, + (e): e is AgentChatEventEnvelope => + e.event.type === "subagent_result" && (e.event as any).taskId === "task-1", + ); + + const startEnvelope = events.find( + (e) => e.event.type === "subagent_started" && (e.event as any).taskId === "task-1", + ); + const progressEnvelope = events.find( + (e) => e.event.type === "subagent_progress" && (e.event as any).taskId === "task-1", + ); + const resultEnvelope = events.find( + (e) => e.event.type === "subagent_result" && (e.event as any).taskId === "task-1", + ); + + expect((startEnvelope?.event as any)?.agentType).toBe("code-reviewer"); + expect((progressEnvelope?.event as any)?.agentType).toBe("code-reviewer"); + expect((resultEnvelope?.event as any)?.agentType).toBe("code-reviewer"); + expect((startEnvelope?.event as any)?.parentToolUseId).toBe("toolu_task_1"); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("falls back gracefully when Task tool has no subagent_type", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-name-2", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + // Task tool input lacks subagent_type (older models) + yield { + type: "assistant", + message: { + id: "msg-2", + content: [ + { + type: "tool_use", + id: "toolu_task_2", + name: "Task", + input: { + description: "Audit something", + prompt: "Audit the change log.", + }, + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "system", + subtype: "task_started", + task_id: "task-2", + parent_tool_use_id: "toolu_task_2", + description: "Audit something", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "task-2", + parent_tool_use_id: "toolu_task_2", + status: "completed", + summary: "Done", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-name-2", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Run a subagent without an explicit type", + }); + + await waitForEvent( + events, + (e): e is AgentChatEventEnvelope => + e.event.type === "subagent_started" && (e.event as any).taskId === "task-2", + ); + + const startEnvelope = events.find( + (e) => e.event.type === "subagent_started" && (e.event as any).taskId === "task-2", + ); + expect(startEnvelope).toBeDefined(); + // No agentType is fine — the renderer falls back to description. + expect((startEnvelope?.event as any)?.agentType).toBeUndefined(); + expect((startEnvelope?.event as any)?.description).toBe("Audit something"); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + }); + // -------------------------------------------------------------------------- // getSlashCommands // -------------------------------------------------------------------------- @@ -5508,6 +5729,28 @@ describe("createAgentChatService", () => { }); }); + describe("hasRetainableSessions", () => { + it("is true while any chat session is open and false after it is closed", async () => { + const { service } = createService(); + expect(service.hasRetainableSessions()).toBe(false); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + // Idle session (no active turn, no pending input) — hasActiveWorkloads + // is narrow and returns false. hasRetainableSessions must still report + // true so project-context rebalancing keeps the agent runtime alive + // for an instant resume after a project switch. + expect(service.hasActiveWorkloads()).toBe(false); + expect(service.hasRetainableSessions()).toBe(true); + + await service.dispose({ sessionId: session.id }); + expect(service.hasRetainableSessions()).toBe(false); + }); + }); + describe("dispose", () => { it("only writes the persisted chat summary when the session is explicitly disposed", async () => { vi.mocked(streamText).mockReturnValue({ @@ -7443,6 +7686,173 @@ describe("createAgentChatService", () => { ).toBe("completed"); }); + it("assigns Agent #N labels to Codex collab agents in turn-spawn order", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Spawn two parallel agents.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-a"], + prompt: "Inspect the renderer", + agentsStates: {}, + }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-2", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-b"], + prompt: "Inspect the main process", + agentsStates: {}, + }, + }, + }); + + const startedEvents = events.filter((envelope) => + envelope.event.type === "subagent_started" + && (envelope.event.taskId === "agent-thread-a" || envelope.event.taskId === "agent-thread-b"), + ); + + expect(startedEvents).toHaveLength(2); + expect(startedEvents[0]!.event).toMatchObject({ + type: "subagent_started", + taskId: "agent-thread-a", + agentId: "agent-thread-a", + agentType: "Agent #1", + }); + expect(startedEvents[1]!.event).toMatchObject({ + type: "subagent_started", + taskId: "agent-thread-b", + agentId: "agent-thread-b", + agentType: "Agent #2", + }); + }); + + it("filters codex parent stream by threadId when fetching subagent transcript", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Run a parallel agent.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { + turnId: "turn-1", + item: { + id: "collab-1", + type: "collabAgentToolCall", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-filter"], + prompt: "Focused investigation", + agentsStates: {}, + }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "collab-2", + type: "collabAgentToolCall", + tool: "wait", + status: "completed", + senderThreadId: "thread-main", + receiverThreadIds: ["agent-thread-filter"], + agentsStates: { + "agent-thread-filter": { + status: "completed", + message: "Investigation complete.", + }, + }, + }, + }, + }); + + const transcript = await service.getSubagentTranscript({ + sessionId: session.id, + agentId: "agent-thread-filter", + }); + expect(transcript).not.toBeNull(); + expect(transcript!.length).toBeGreaterThanOrEqual(2); + const types = transcript!.map((m) => (m.message as { type: string }).type); + expect(types).toContain("subagent_started"); + expect(types).toContain("subagent_result"); + // Filter must reject envelopes that don't carry this threadId. + expect(transcript!.every((m) => { + const event = m.message as { taskId?: string }; + return event.taskId === "agent-thread-filter"; + })).toBe(true); + + // A different threadId returns an empty (but non-null) array. + const empty = await service.getSubagentTranscript({ + sessionId: session.id, + agentId: "some-other-thread", + }); + expect(empty).toEqual([]); + }); + it("coalesces Codex spawn placeholders when the app-server reveals the agent thread later", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index bf7213668..bf16132fd 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -8,6 +8,7 @@ import readline from "node:readline"; import { getSessionInfo as getClaudeSdkSessionInfo, getSessionMessages as getClaudeSdkSessionMessages, + getSubagentMessages as getClaudeSdkSubagentMessages, listSessions as listClaudeSdkSessions, query, renameSession as renameClaudeSession, @@ -152,6 +153,8 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatSubagentTranscriptArgs, + AgentChatSubagentTranscriptMessage, AgentChatSuggestLaneNameArgs, AgentChatCursorConfigOption, AgentChatCursorConfigValue, @@ -479,6 +482,13 @@ type CodexRuntime = { background: boolean; parentToolUseId: string | null; }>; + /** + * Per-turn 1-based index assigned to each new codex collab agent threadId + * the first time it is announced (`subagent_started`). Used to surface + * `Agent #N` labels in the desktop subagent panel; the raw threadId still + * lives on the snapshot via `agentId`. Cleared at turn boundaries. + */ + codexAgentIndexByTurn: Map>; interruptedTurnIds: Set; ignoredTurnIds: Set; terminalTurnIds: Set; @@ -538,6 +548,20 @@ type ClaudeRuntime = { parentToolUseId?: string | null; background?: boolean; finalSummary?: string; + agentType?: string; + agentId?: string; + }>; + /** + * Stash for Task-tool inputs captured at the assistant tool_use boundary, + * keyed by the Task tool_use_id. Lets the `system:task_*` system-message + * path enrich its envelopes with the `subagent_type` the model picked + * (e.g. "code-reviewer", "Explore") instead of falling back to "subagent". + */ + taskToolInputByToolUseId: Map; slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; busy: boolean; @@ -2515,6 +2539,43 @@ function taskParentToolUseId(item: Record): string | null { : null; } +/** + * Extract the `subagent_type` / `name` / `description` from a Task-tool input. + * Returns null when the block is not a Task tool or input is malformed. + * + * Why: the Claude Agent SDK's Task tool is the canonical way users spawn a + * subagent. Its `input.subagent_type` (e.g. "code-reviewer", "Explore") is + * the only place at spawn time where the chosen agent's name surfaces in the + * stream — the SubagentStart hook also has it, but downstream `task_started` + * system messages do not. Stashing it lets us enrich those envelopes. + */ +function extractTaskToolInput(input: unknown): { + subagentType?: string; + name?: string; + description?: string; + isBackground?: boolean; +} | null { + if (!input || typeof input !== "object") return null; + const record = input as Record; + const subagentType = typeof record.subagent_type === "string" && record.subagent_type.trim().length + ? record.subagent_type.trim() + : undefined; + const name = typeof record.name === "string" && record.name.trim().length + ? record.name.trim() + : undefined; + const description = typeof record.description === "string" && record.description.trim().length + ? record.description.trim() + : undefined; + const isBackground = isBackgroundTask(record); + if (!subagentType && !name && !description && !isBackground) return null; + return { + ...(subagentType ? { subagentType } : {}), + ...(name ? { name } : {}), + ...(description ? { description } : {}), + ...(isBackground ? { isBackground } : {}), + }; +} + function normalizePreview(text: string, maxChars = 220): string | null { const lines = text .split(/\r?\n/) @@ -8040,6 +8101,7 @@ export function createAgentChatService(args: { }); } runtime.approvals.clear(); + runtime.codexAgentIndexByTurn.clear(); if (shouldMarkInterrupted) { emitChatEvent(managed, { type: "system_notice", @@ -8081,6 +8143,7 @@ export function createAgentChatService(args: { managed.runtime.warmQuery = null; managed.runtime.warmupDone = null; managed.runtime.activeSubagents.clear(); + managed.runtime.taskToolInputByToolUseId.clear(); for (const pending of managed.runtime.approvals.values()) { pending.resolve({ decision: "cancel" }); } @@ -9172,7 +9235,7 @@ export function createAgentChatService(args: { }; const openClaudeToolUses = new Map(); const toolInputJsonByContentIndex = new Map(); - const toolUseMetaByContentIndex = new Map(); + const toolUseMetaByContentIndex = new Map(); const emittedClaudeTodoIds = new Set(); const emitClaudeToolCompletion = ( itemId: string, @@ -9585,16 +9648,24 @@ export function createAgentChatService(args: { const existing = runtime.activeSubagents.get(taskId); const description = String(taskMsg.description ?? existing?.description ?? ""); const parentToolUseId = taskParentToolUseId(taskMsg as Record) ?? existing?.parentToolUseId ?? null; + const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; + const agentType = existing?.agentType ?? stashed?.subagentType ?? stashed?.name; + const agentId = existing?.agentId + ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); runtime.activeSubagents.set(taskId, { taskId, description, parentToolUseId, background: existing?.background, finalSummary: existing?.finalSummary, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), }); emitChatEvent(managed, { type: "subagent_progress", taskId, + ...(agentId ? { agentId } : {}), + ...(agentType ? { agentType } : {}), parentToolUseId, description, summary: String(taskMsg.summary ?? ""), @@ -9614,18 +9685,33 @@ export function createAgentChatService(args: { const taskMsg = msg as any; const taskId = String(taskMsg.task_id ?? randomUUID()); const parentToolUseId = taskParentToolUseId(taskMsg as Record); + const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; + const description = String( + taskMsg.description + ?? stashed?.description + ?? "", + ); + const agentType = stashed?.subagentType ?? stashed?.name; + const agentId = typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length + ? taskMsg.agent_id.trim() + : undefined; + const background = isBackgroundTask(taskMsg as Record) || stashed?.isBackground === true; runtime.activeSubagents.set(taskId, { taskId, - description: String(taskMsg.description ?? ""), + description, parentToolUseId, - background: isBackgroundTask(taskMsg as Record), + background, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), }); emitChatEvent(managed, { type: "subagent_started", taskId, + ...(agentId ? { agentId } : {}), + ...(agentType ? { agentType } : {}), parentToolUseId, - description: String(taskMsg.description ?? ""), - background: isBackgroundTask(taskMsg as Record), + description, + background, turnId, }); continue; @@ -9639,10 +9725,17 @@ export function createAgentChatService(args: { const existing = runtime.activeSubagents.get(taskId); const parentToolUseId = taskParentToolUseId(taskMsg as Record) ?? existing?.parentToolUseId ?? null; const summary = String(taskMsg.summary ?? existing?.finalSummary ?? ""); + const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; + const agentType = existing?.agentType ?? stashed?.subagentType ?? stashed?.name; + const agentId = existing?.agentId + ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); runtime.activeSubagents.delete(taskId); + if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); emitChatEvent(managed, { type: "subagent_result", taskId, + ...(agentId ? { agentId } : {}), + ...(agentType ? { agentType } : {}), parentToolUseId, status: taskMsg.status === "completed" ? "completed" : taskMsg.status === "stopped" ? "stopped" : "failed", summary, @@ -9743,6 +9836,12 @@ export function createAgentChatService(args: { if (!emittedClaudeToolIds.has(itemId)) { emittedClaudeToolIds.add(itemId); openClaudeToolUses.set(itemId, { toolName }); + // Stash Task-tool input so the system:task_* envelopes that + // arrive later can be enriched with agentType. + if (toolName === "Task" && typeof block.id === "string" && block.id.length) { + const taskInput = extractTaskToolInput(block.input); + if (taskInput) runtime.taskToolInputByToolUseId.set(block.id, taskInput); + } emitChatEvent(managed, { type: "activity", activity: nextActivity.activity, @@ -9898,7 +9997,12 @@ export function createAgentChatService(args: { ? JSON.stringify(block.input) : ""; toolInputJsonByContentIndex.set(contentIndex, initial); - toolUseMetaByContentIndex.set(contentIndex, { toolName, itemId }); + const toolUseId = typeof block.id === "string" && block.id.length ? block.id : undefined; + toolUseMetaByContentIndex.set(contentIndex, { + toolName, + itemId, + ...(toolUseId ? { toolUseId } : {}), + }); } } } @@ -9918,6 +10022,13 @@ export function createAgentChatService(args: { parsed = {}; } } + // Stash Task-tool input so the system:task_* envelopes that + // arrive later can be enriched with agentType. Stream-event + // path: input arrives via input_json_delta and lands in `raw`. + if (meta.toolName === "Task" && meta.toolUseId) { + const taskInput = extractTaskToolInput(parsed); + if (taskInput) runtime.taskToolInputByToolUseId.set(meta.toolUseId, taskInput); + } const syntheticResult = maybeSyntheticToolResult(meta.toolName, parsed, meta.itemId, turnId); if (syntheticResult && !emittedSyntheticItemIds.has(meta.itemId)) { emittedSyntheticItemIds.add(meta.itemId); @@ -10972,12 +11083,15 @@ export function createAgentChatService(args: { const ensureSubagentStarted = (): void => { if (runtime.subagentSessionIds.has(childKey)) return; runtime.subagentSessionIds.add(childKey); + // We intentionally do NOT set `agentType` here. OpenCode encodes + // the human-readable identity in `session.title`, which we surface + // as `description`. The renderer prefers `agentType` when present, + // so omitting it lets the description drive the row label. emitChatEvent(managed, { type: "subagent_started", taskId: childKey, parentToolUseId: null, description: childDescription, - agentType: "opencode-subagent", turnId, }); }; @@ -11992,6 +12106,31 @@ export function createAgentChatService(args: { summary: string; }; + /** + * Assigns (and remembers) a 1-based codex collab-agent index for the given + * threadId within the given turn. The Codex app-server wire format does not + * carry a human-friendly name for parallel agents — we surface them as + * `Agent #N` to mirror the official codex desktop reference and keep the raw + * threadId in the snapshot's agentId for filtering / drill-in. + */ + const assignCodexAgentLabel = ( + runtime: CodexRuntime, + turnId: string | null | undefined, + threadId: string, + ): string => { + const key = typeof turnId === "string" && turnId.length ? turnId : "__no_turn__"; + let perTurn = runtime.codexAgentIndexByTurn.get(key); + if (!perTurn) { + perTurn = new Map(); + runtime.codexAgentIndexByTurn.set(key, perTurn); + } + const existing = perTurn.get(threadId); + if (existing !== undefined) return `Agent #${existing}`; + const next = perTurn.size + 1; + perTurn.set(threadId, next); + return `Agent #${next}`; + }; + const normalizeCodexCollabToolName = (value: unknown): string => { const raw = typeof value === "string" ? value.trim() : ""; const compact = raw.replace(/[_-]/g, "").toLowerCase(); @@ -12340,9 +12479,14 @@ export function createAgentChatService(args: { background, parentToolUseId: itemId, }); + const agentType = receiverIds.length + ? assignCodexAgentLabel(runtime, turnId, taskId) + : undefined; emitChatEvent(managed, { type: "subagent_started", taskId, + ...(receiverIds.length ? { agentId: taskId } : {}), + ...(agentType ? { agentType } : {}), parentToolUseId: itemId, description: prompt.slice(0, 120) || "Parallel agent", background, @@ -12393,9 +12537,12 @@ export function createAgentChatService(args: { taskId, parentToolUseId: placeholder.parentToolUseId ?? itemId, }); + const agentType = assignCodexAgentLabel(runtime, turnId, taskId); emitChatEvent(managed, { type: "subagent_started", taskId, + agentId: taskId, + agentType, parentToolUseId: placeholder.parentToolUseId ?? itemId, description: placeholder.description, background: placeholder.background, @@ -12782,6 +12929,7 @@ export function createAgentChatService(args: { runtime.webSearchActionsByItemId.clear(); runtime.itemTurnIdByItemId.clear(); runtime.agentMessageScopeByTurn.clear(); + runtime.codexAgentIndexByTurn.delete(turnId); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); @@ -13367,6 +13515,7 @@ export function createAgentChatService(args: { planningApprovalGuardByTurnId: new Map(), pendingTurnPlanningApprovalGuarded: null, activeSubagents: new Map(), + codexAgentIndexByTurn: new Map>(), interruptedTurnIds: new Set(), ignoredTurnIds: new Set(), terminalTurnIds: new Set(managed.codexTerminalTurnIds), @@ -14596,6 +14745,7 @@ export function createAgentChatService(args: { warmupCancel: null, warmupCancelled: false, activeSubagents: new Map(), + taskToolInputByToolUseId: new Map(), slashCommands: [], busy: false, activeTurnId: null, @@ -19394,6 +19544,19 @@ export function createAgentChatService(args: { return false; }; + // Broader than hasActiveWorkloads: a session that's between turns still + // owns a live agent runtime (Claude SDK client, Codex app-server, etc.) the + // user expects to keep using after switching away and back. Project context + // rebalancing must NOT evict a project that has any such session — losing + // the runtime forces a cold-start the next time the user types. + const hasRetainableSessions = (): boolean => { + for (const managed of managedSessions.values()) { + if (managed.closed || managed.deleted) continue; + return true; + } + return false; + }; + const ensureIdentitySession = async (args: { identityKey: AgentChatIdentityKey; laneId: string; @@ -21263,6 +21426,158 @@ export function createAgentChatService(args: { }); }; + /** + * Fetch the transcript of a subagent run within an existing chat session. + * + * Dispatch by runtime kind: + * + * - **Claude / ade-code**: SDK `getSubagentMessages` reads the per-subagent + * JSONL at `~/.claude/projects///subagents/agent-.jsonl`. + * - **OpenCode**: child session id is the agentId; we call + * `runtime.handle.client.session.messages({ path: { id }, query: { directory }})` + * and translate the returned `{info, parts}[]` rows into the renderer's + * transcript message shape. + * - **Codex**: codex's app-server never streams per-thread activity into the + * parent session, so the transcript is the subset of parent envelopes whose + * `subagent_*` event carries `taskId === threadId` (the codex agentId). + * - **Cursor**: SDK `task` events tag every lifecycle envelope with the + * subagent's `agentId`; we filter the parent stream by that value. + * - **Everything else (droid, lmstudio, …)**: `null`. + */ + const getSubagentTranscript = async ({ + sessionId, + agentId, + laneId, + limit, + offset, + }: AgentChatSubagentTranscriptArgs): Promise => { + const normalizedSessionId = sessionId.trim(); + const normalizedAgentId = agentId.trim(); + if (!normalizedSessionId.length) throw new Error("sessionId is required."); + if (!normalizedAgentId.length) throw new Error("agentId is required."); + const managed = managedSessions.get(normalizedSessionId); + const runtimeKind = managed?.runtime?.kind ?? null; + const provider = managed?.session.provider ?? null; + + const normalizedLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0 + ? Math.min(Math.trunc(limit), 500) + : undefined; + const normalizedOffset = typeof offset === "number" && Number.isFinite(offset) && offset > 0 + ? Math.trunc(offset) + : undefined; + + if (runtimeKind === "opencode" && managed?.runtime?.kind === "opencode") { + try { + const response = await managed.runtime.handle.client.session.messages({ + path: { id: normalizedAgentId }, + query: { directory: managed.runtime.handle.directory }, + }); + const rows = (response as { data?: Array<{ info: unknown; parts: unknown }> }).data + ?? (response as unknown as Array<{ info: unknown; parts: unknown }>); + if (!Array.isArray(rows)) return []; + const mapped = rows.map((row): AgentChatSubagentTranscriptMessage | null => { + const info = row?.info && typeof row.info === "object" + ? (row.info as Record) + : null; + if (!info) return null; + const role = typeof info.role === "string" ? info.role : ""; + const messageType: AgentChatSubagentTranscriptMessage["type"] = + role === "user" ? "user" : role === "assistant" ? "assistant" : "system"; + const id = typeof info.id === "string" ? info.id : ""; + const sId = typeof info.sessionID === "string" ? info.sessionID : normalizedAgentId; + const parts = Array.isArray(row.parts) ? row.parts : []; + const textBlocks: string[] = []; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const block = part as { type?: unknown; text?: unknown }; + if (typeof block.text === "string" && block.text.length + && (block.type === "text" || block.type === "reasoning")) { + textBlocks.push(block.text); + } + } + const text = textBlocks.join(""); + return { + type: messageType, + uuid: id, + sessionId: sId, + parentToolUseId: null, + message: { info, parts }, + ...(text.length ? { text } : {}), + }; + }).filter((entry): entry is AgentChatSubagentTranscriptMessage => entry !== null); + const sliced = normalizedOffset !== undefined ? mapped.slice(normalizedOffset) : mapped; + return normalizedLimit !== undefined ? sliced.slice(0, normalizedLimit) : sliced; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`OpenCode session.messages failed for ${normalizedAgentId}: ${message}`); + } + } + + if (runtimeKind === "codex" || runtimeKind === "cursor") { + const envelopes = eventHistoryBySession.get(normalizedSessionId) ?? []; + const matchKey = runtimeKind === "codex" ? "taskId" : "agentId"; + const matched: AgentChatSubagentTranscriptMessage[] = []; + for (const envelope of envelopes) { + const event = envelope.event as Record & { type: string }; + const candidate = event[matchKey]; + if (typeof candidate !== "string" || candidate !== normalizedAgentId) continue; + const text = (() => { + if (typeof event.summary === "string" && event.summary.length) return event.summary; + if (typeof event.description === "string" && event.description.length) return event.description; + if (typeof event.text === "string" && event.text.length) return event.text; + return ""; + })(); + const role: AgentChatSubagentTranscriptMessage["type"] = + event.type === "subagent_result" || event.type === "subagent.completed" + ? "assistant" + : "system"; + matched.push({ + type: role, + uuid: `${envelope.sequence ?? envelope.timestamp}:${event.type}`, + sessionId: normalizedSessionId, + parentToolUseId: typeof event.parentToolUseId === "string" ? event.parentToolUseId : null, + message: event, + ...(text.length ? { text } : {}), + }); + } + const sliced = normalizedOffset !== undefined ? matched.slice(normalizedOffset) : matched; + return normalizedLimit !== undefined ? sliced.slice(0, normalizedLimit) : sliced; + } + + if (provider !== "claude" && provider !== null) { + // Droid, LM Studio, and any provider without subagent semantics. + return null; + } + + // Resolve the on-disk Claude session id and project dir the same way + // getClaudeSessionMessages does — works whether the chat is live or + // resolved purely from the persisted pointer. + const claudeSessionId = managed?.runtime?.kind === "claude" + ? (managed.runtime.sdkSessionId ?? normalizedSessionId) + : normalizedSessionId; + const pointer = sessionService.getClaudeSessionPointer(claudeSessionId); + const laneFallback = await resolveClaudeSessionLaneFallback( + laneId ?? pointer?.laneId ?? managed?.session.laneId ?? null, + ); + const messages = await getClaudeSdkSubagentMessages(claudeSessionId, normalizedAgentId, { + ...(laneFallback.dir ? { dir: laneFallback.dir } : {}), + ...(normalizedLimit !== undefined ? { limit: normalizedLimit } : {}), + ...(normalizedOffset !== undefined ? { offset: normalizedOffset } : {}), + }); + return messages.map((message: ClaudeSdkSessionMessage) => { + const parentToolUseId = (message as unknown as { parent_tool_use_id?: unknown }).parent_tool_use_id; + const text = extractClaudeSessionMessageText(message.message); + return { + type: message.type, + uuid: message.uuid, + sessionId: message.session_id, + parentToolUseId: typeof parentToolUseId === "string" ? parentToolUseId : null, + message: message.message, + ...(text ? { text } : {}), + }; + }); + }; + const normalizeClaudeContextUsage = ( usage: SDKControlGetContextUsageResponse, ): AgentChatContextUsage => { @@ -21869,6 +22184,7 @@ export function createAgentChatService(args: { listSessions, getSessionSummary, hasActiveWorkloads, + hasRetainableSessions, getChatTranscript, getCodexResumeContext, getChatEventHistory, @@ -21886,6 +22202,7 @@ export function createAgentChatService(args: { listClaudeSessions, getClaudeSessionInfo, getClaudeSessionMessages, + getSubagentTranscript, getContextUsage, rewindFiles, codexFuzzyFileSearch, diff --git a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts index e9dba7be9..dcfef0fd2 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts @@ -71,6 +71,7 @@ describe("projectConfigService automation execution normalization", () => { laneMode: "nope", laneNamePreset: "issue-title", laneNameTemplate: "Should be dropped", + session: { codexFastMode: true }, }, }, { @@ -89,6 +90,24 @@ describe("projectConfigService automation execution normalization", () => { laneMode: "prompt-at-run", }, }, + { + id: "built-in-agent-rule", + trigger: { type: "manual" }, + execution: { + kind: "built-in", + builtIn: { + actions: [ + { + type: "agent-session", + prompt: "Summarize", + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, + codexFastMode: true, + permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, + }, + ], + }, + }, + }, ], }), "utf8", @@ -102,7 +121,7 @@ describe("projectConfigService automation execution normalization", () => { logger: makeLogger(), }); - const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule] = service.get().effective.automations; + const [customRule, presetRule, requireTriggerLaneRule, legacyPromptAtRunRule, builtInAgentRule] = service.get().effective.automations; expect(customRule.execution).toMatchObject({ kind: "mission", @@ -115,6 +134,7 @@ describe("projectConfigService automation execution normalization", () => { kind: "agent-session", laneMode: "reuse", laneNamePreset: "issue-title", + session: { codexFastMode: true }, }); expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); expect(requireTriggerLaneRule.execution).toMatchObject({ @@ -125,6 +145,19 @@ describe("projectConfigService automation execution normalization", () => { kind: "agent-session", laneMode: "require-on-trigger", }); + expect(builtInAgentRule.execution).toMatchObject({ + kind: "built-in", + builtIn: { + actions: [ + { + type: "agent-session", + modelConfig: { modelId: "openai/gpt-5.5", thinkingLevel: "high" }, + codexFastMode: true, + permissionConfig: { providers: { codex: "full-auto", codexSandbox: "danger-full-access" } }, + }, + ], + }, + }); }); it("flags fixed target lanes on require-on-trigger automation execution", () => { diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index e145c7762..13397306a 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -58,6 +58,7 @@ import type { ProviderMode, LinearAutoDispatchAction, LinearSyncConfig, + ModelConfig, MissionModelConfig, MissionPermissionConfig, NotificationsConfig, @@ -468,6 +469,17 @@ function coerceAutomationActiveHours(value: unknown): AutomationActiveHours | un return { start, end, timezone }; } +function coerceModelConfig(value: unknown): ModelConfig | undefined { + if (!isRecord(value)) return undefined; + const modelId = asString(value.modelId)?.trim(); + if (!modelId) return undefined; + return { + ...(asString(value.provider)?.trim() ? { provider: asString(value.provider)!.trim() as ModelConfig["provider"] } : {}), + modelId, + ...(asString(value.thinkingLevel)?.trim() ? { thinkingLevel: asString(value.thinkingLevel)!.trim() as ModelConfig["thinkingLevel"] } : {}), + }; +} + function coerceAutomationAction(value: unknown): AutomationAction | null { if (!isRecord(value)) return null; const typeRaw = asString(value.type)?.trim() ?? ""; @@ -488,6 +500,9 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { const adeAction = coerceRunAdeActionConfig(value.adeAction); const prompt = asString(value.prompt); const sessionTitle = asString(value.sessionTitle); + const modelConfig = coerceModelConfig(value.modelConfig); + const codexFastMode = asBool(value.codexFastMode); + const permissionConfig = coerceMissionPermissionConfig(value.permissionConfig); if (targetLaneId) out.targetLaneId = targetLaneId; if (suiteId != null) out.suiteId = suiteId; @@ -500,6 +515,9 @@ function coerceAutomationAction(value: unknown): AutomationAction | null { if (adeAction != null) out.adeAction = adeAction; if (prompt != null) out.prompt = prompt; if (sessionTitle != null) out.sessionTitle = sessionTitle; + if (modelConfig != null) out.modelConfig = modelConfig; + if (codexFastMode != null) out.codexFastMode = codexFastMode; + if (permissionConfig != null) out.permissionConfig = permissionConfig; return out; } @@ -557,6 +575,7 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi ...(asString(value.session.reasoningEffort)?.trim() ? { reasoningEffort: asString(value.session.reasoningEffort)!.trim() } : {}), + ...(asBool(value.session.codexFastMode) != null ? { codexFastMode: asBool(value.session.codexFastMode) } : {}), } : undefined; return { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 37ee8f84a..52ca5f9ae 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -237,6 +237,8 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatSubagentTranscriptArgs, + AgentChatSubagentTranscriptMessage, AgentChatClaudeOutputStyle, AgentChatClaudeOutputStylesArgs, AgentChatClaudePlugin, @@ -341,6 +343,7 @@ import type { ChatTerminalListArgs, ChatTerminalPreviewArgs, ChatTerminalReadArgs, + ChatTerminalReattachArgs, ChatTerminalSignalArgs, ChatTerminalWriteArgs, ReparentLaneArgs, @@ -1768,6 +1771,7 @@ export function registerIpc({ resolveSyncService, runWithIpcWindow, getWindowSession, + setWindowProjectTabs, bindRemoteProject, localRuntimeConnectionPool, createWindow, @@ -1782,7 +1786,8 @@ export function registerIpc({ getSyncService?: () => ReturnType | null | undefined; resolveSyncService?: () => Promise | null | undefined>; runWithIpcWindow?: (event: { sender: Electron.WebContents }, fn: () => T | Promise) => T | Promise; - getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null }; + getWindowSession?: (windowId: number | null) => { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; openProjectTabs?: ProjectInfo[] }; + setWindowProjectTabs?: (windowId: number | null, rootPaths: string[]) => ProjectInfo[]; bindRemoteProject?: (windowId: number | null, binding: OpenProjectBinding & { kind: "remote" }) => void; localRuntimeConnectionPool?: LocalRuntimeConnectionPool | null; createWindow?: (args?: { projectRoot?: string | null }) => Promise<{ windowId: number | null; project: ProjectInfo | null }>; @@ -3106,6 +3111,17 @@ export function registerIpc({ return { chatSessionId }; }; + const parseTerminalReattachArgs = (value: unknown): ChatTerminalReattachArgs => { + const record = terminalRecord(value); + const chatSessionId = optionalTerminalString(record, "chatSessionId", 128); + if (!chatSessionId) throw new Error("Invalid terminal payload: chatSessionId is required"); + const rawCols = record["cols"]; + const rawRows = record["rows"]; + const cols = typeof rawCols === "number" && Number.isFinite(rawCols) ? rawCols : null; + const rows = typeof rawRows === "number" && Number.isFinite(rawRows) ? rawRows : null; + return { chatSessionId, cols, rows }; + }; + const resolveComputerUseOwnerSnapshotArgs = async ( _ctx: AppContext, args: ComputerUseOwnerSnapshotArgs, @@ -3264,9 +3280,21 @@ export function registerIpc({ displayName: ctx.project.displayName, } : null, + openProjectTabs: ctx.hasUserSelectedProject ? [ctx.project] : [], }; }); + ipcMain.handle(IPC.appSetWindowProjectTabs, async (event, arg: { rootPaths?: string[] } = {}) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const rootPaths = Array.isArray(arg?.rootPaths) + ? arg.rootPaths.filter((rootPath): rootPath is string => typeof rootPath === "string") + : []; + const openProjectTabs = setWindowProjectTabs + ? setWindowProjectTabs(windowId, rootPaths) + : []; + return { openProjectTabs }; + }); + ipcMain.handle(IPC.appNewWindow, async () => { if (!createWindow) return { windowId: null }; const result = await createWindow({ projectRoot: null }); @@ -6584,7 +6612,7 @@ export function registerIpc({ const ctx = getCtx(); const session = ctx.sessionService.get(arg.sessionId); if (!session) return ""; - const maxBytes = typeof arg.maxBytes === "number" ? Math.max(1024, Math.min(2_000_000, arg.maxBytes)) : 160_000; + const maxBytes = typeof arg.maxBytes === "number" ? Math.max(1024, Math.min(16_000_000, arg.maxBytes)) : 160_000; const raw = arg.raw === true; return ctx.ptyService.readTranscriptTail({ sessionId: session.id, @@ -6763,6 +6791,11 @@ export function registerIpc({ return ctx.agentChatService.getClaudeSessionMessages(arg); }); + ipcMain.handle(IPC.agentChatGetSubagentTranscript, async (_event, arg: AgentChatSubagentTranscriptArgs): Promise => { + const ctx = getCtx(); + return ctx.agentChatService.getSubagentTranscript(arg); + }); + ipcMain.handle(IPC.agentChatGetContextUsage, async (_event, arg: AgentChatContextUsageArgs): Promise => { const ctx = getCtx(); return ctx.agentChatService.getContextUsage(arg); @@ -7565,7 +7598,7 @@ export function registerIpc({ ); ipcMain.handle(IPC.terminalWrite, async (_event, arg) => - getCtx().ptyService.writeTerminal(parseTerminalWriteArgs(arg)), + await getCtx().ptyService.writeTerminal(parseTerminalWriteArgs(arg)), ); ipcMain.handle(IPC.terminalSignal, async (_event, arg) => @@ -7576,6 +7609,10 @@ export function registerIpc({ getCtx().ptyService.activeForChat(parseTerminalActiveForChatArgs(arg)), ); + ipcMain.handle(IPC.terminalReattachChatCli, async (_event, arg) => + await getCtx().ptyService.reattachChatCli(parseTerminalReattachArgs(arg)), + ); + ipcMain.handle(IPC.diffGetChanges, async (_event, arg: GetDiffChangesArgs) => { const ctx = getCtx(); return await withIpcTiming(ctx, "diff.getChanges", async () => await ctx.diffService.getChanges(arg.laneId), { @@ -8241,32 +8278,43 @@ export function registerIpc({ const ensurePrPolling = () => { const ctx = getCtx(); + // PR services are only attached to fully-initialised project contexts. When + // the renderer fires PR queries for an unscoped window (e.g. a brand-new + // File > New Window before a project is chosen) or during a transition, + // ctx is dormant and these services are null. Return null so callers can + // hand back empty results instead of throwing into IPC. + if (!ctx.prPollingService || !ctx.prService) return null; ctx.prPollingService.start(); return ctx; }; ipcMain.handle(IPC.prsGetForLane, async (_event, arg: { laneId: string }): Promise => { const ctx = getCtx(); + if (!ctx.prService) return null; return ctx.prService.getForLane(arg.laneId); }); ipcMain.handle(IPC.prsListAll, async (): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; return ctx.prService.listAll(); }); ipcMain.handle(IPC.prsListOpenForRepo, async (): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; return await ctx.prService.listOpenPullRequests(); }); ipcMain.handle(IPC.prsRefresh, async (_event, arg: { prId?: string; prIds?: string[] } = {}): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; return await ctx.prService.refresh(arg); }); ipcMain.handle(IPC.prsGetStatus, async (_event, arg: { prId: string }): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return null; try { return await ctx.prService.getStatus(arg.prId); } catch (err) { @@ -8278,6 +8326,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsGetChecks, async (_event, arg: { prId: string }): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; try { return await ctx.prService.getChecks(arg.prId); } catch (err) { @@ -8288,6 +8337,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsGetComments, async (_event, arg: { prId: string }): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; try { return await ctx.prService.getComments(arg.prId); } catch (err) { @@ -8298,6 +8348,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsGetReviews, async (_event, arg: { prId: string }): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; try { return await ctx.prService.getReviews(arg.prId); } catch (err) { @@ -8308,6 +8359,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsGetReviewThreads, async (_event, arg: { prId: string }): Promise => { const ctx = ensurePrPolling(); + if (!ctx) return []; try { return await ctx.prService.getReviewThreads(arg.prId); } catch (err) { @@ -8381,22 +8433,34 @@ export function registerIpc({ getCtx().prService.getMergeContexts(Array.isArray(arg?.prIds) ? arg.prIds : []) ); - ipcMain.handle(IPC.prsListWithConflicts, async (_event, arg?: { includeConflictAnalysis?: boolean }) => - ensurePrPolling().prService.listWithConflicts({ + ipcMain.handle(IPC.prsListWithConflicts, async (_event, arg?: { includeConflictAnalysis?: boolean }) => { + const ctx = ensurePrPolling(); + if (!ctx) return []; + return ctx.prService.listWithConflicts({ includeConflictAnalysis: arg?.includeConflictAnalysis === true, - }) - ); + }); + }); ipcMain.handle(IPC.prsListSnapshots, async (_event, arg?: { prId?: string }) => getCtx().prService.listSnapshots({ prId: typeof arg?.prId === "string" ? arg.prId : undefined }) ); - ipcMain.handle(IPC.prsGetGitHubSnapshot, async (_event, arg?: { force?: boolean; includeExternalClosed?: boolean }): Promise => - await ensurePrPolling().prService.getGithubSnapshot({ + ipcMain.handle(IPC.prsGetGitHubSnapshot, async (_event, arg?: { force?: boolean; includeExternalClosed?: boolean }): Promise => { + const ctx = ensurePrPolling(); + if (!ctx) { + return { + repo: null, + viewerLogin: null, + repoPullRequests: [], + externalPullRequests: [], + syncedAt: new Date(0).toISOString(), + }; + } + return await ctx.prService.getGithubSnapshot({ force: arg?.force === true, includeExternalClosed: arg?.includeExternalClosed === true, - }) - ); + }); + }); ipcMain.handle(IPC.prsCreateQueue, async (_event, arg: CreateQueuePrsArgs): Promise => { const ctx = getCtx(); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 4e82cdaaf..39987c253 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -44,6 +44,7 @@ type RuntimeBridgeArgs = { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; + openProjectTabs?: ProjectInfo[]; }; bindRemoteProject?: ( windowId: number | null, diff --git a/apps/desktop/src/main/services/jobs/jobEngine.test.ts b/apps/desktop/src/main/services/jobs/jobEngine.test.ts index decc34c74..f5b128b95 100644 --- a/apps/desktop/src/main/services/jobs/jobEngine.test.ts +++ b/apps/desktop/src/main/services/jobs/jobEngine.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createJobEngine } from "./jobEngine"; -function flush(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - describe("jobEngine deterministic refresh", () => { it("queues lane refresh hooks without pack refresh side effects", async () => { const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any; @@ -14,9 +10,6 @@ describe("jobEngine deterministic refresh", () => { }); engine.onSessionEnded({ laneId: "lane-1", sessionId: "session-1" }); - await flush(); - await flush(); - await flush(); expect(logger.info).toHaveBeenCalledWith( "jobs.refresh_lane.begin", @@ -26,6 +19,7 @@ describe("jobEngine deterministic refresh", () => { "jobs.refresh_lane.done", expect.objectContaining({ laneId: "lane-1", sessionId: "session-1", reason: "session_end" }) ); + engine.dispose(); }); it("skips periodic full conflict prediction by default in dev stability mode", async () => { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 37638c68c..5d838063c 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -357,7 +357,7 @@ describe("local runtime connection pool", () => { } }, 45_000); - it("restarts a stale local daemon before attaching", async () => { + it("refuses a stale local daemon without shutting down active work", async () => { const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); const cliPath = path.join(adeCliRoot, "src", "cli.ts"); const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); @@ -366,7 +366,6 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); - const expectedProjectRoot = fs.realpathSync.native(projectRoot); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -405,10 +404,11 @@ describe("local runtime connection pool", () => { process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; pool = new LocalRuntimeConnectionPool("2.0.0", logger as never, { disableSync: true }); - const registered = await pool.ensureProject(projectRoot); + await expect(pool.ensureProject(projectRoot)).rejects.toThrow( + /does not match desktop version 2\.0\.0/, + ); - expect(registered.rootPath).toBe(expectedProjectRoot); - expect(logger.info).toHaveBeenCalledWith("local_runtime.version_mismatch_restart", expect.objectContaining({ + expect(logger.info).toHaveBeenCalledWith("local_runtime.version_mismatch_blocked", expect.objectContaining({ runtimeVersion: "1.0.0", appVersion: "2.0.0", })); @@ -423,7 +423,7 @@ describe("local runtime connection pool", () => { }); expect(initialized).toMatchObject({ runtimeInfo: { - version: "2.0.0", + version: "1.0.0", }, }); } finally { @@ -444,7 +444,79 @@ describe("local runtime connection pool", () => { } }, 45_000); - it("restarts a same-version local daemon when the packaged runtime build changed", async () => { + it("accepts a dev placeholder-version daemon when the runtime build hash matches", async () => { + const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); + const cliPath = path.join(adeCliRoot, "src", "cli.ts"); + const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); + expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(tsxLoaderPath)).toBe(true); + + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-dev-version-")); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-dev-version-project-")); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); + expect(expectedBuildHash).toBeTruthy(); + const originalEnv = { + ADE_CLI_JS: process.env.ADE_CLI_JS, + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + NODE_OPTIONS: process.env.NODE_OPTIONS, + }; + const baseEnv = { + ...process.env, + ADE_HOME: adeHome, + ADE_RUNTIME_SOCKET_PATH: socketPath, + NODE_OPTIONS: withTsxNodeOptions(originalEnv.NODE_OPTIONS, tsxLoaderPath), + }; + const devDaemon = startServeProcess({ + cliPath, + cwd: adeCliRoot, + env: { + ...baseEnv, + ADE_CLI_VERSION: "0.0.0", + ADE_RUNTIME_BUILD_HASH: expectedBuildHash!, + }, + socketPath, + }); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + let pool: LocalRuntimeConnectionPool | null = null; + + try { + await waitForRuntimeSocket(socketPath); + process.env.ADE_CLI_JS = cliPath; + process.env.ADE_HOME = adeHome; + process.env.ADE_RUNTIME_SOCKET_PATH = socketPath; + process.env.NODE_OPTIONS = baseEnv.NODE_OPTIONS; + + pool = new LocalRuntimeConnectionPool("1.0.0-beta.1", logger as never, { disableSync: true }); + const registered = await pool.ensureProject(projectRoot); + + expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + expect(logger.info).not.toHaveBeenCalledWith( + "local_runtime.version_mismatch_blocked", + expect.anything(), + ); + } finally { + pool?.dispose(); + await shutdownRuntime(socketPath); + if (!devDaemon.killed) devDaemon.kill(); + if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; + else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + if (originalEnv.NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalEnv.NODE_OPTIONS; + } + }, 45_000); + + it("refuses a same-version local daemon when the packaged runtime build changed", async () => { const adeCliRoot = path.resolve(process.cwd(), "../ade-cli"); const cliPath = path.join(adeCliRoot, "src", "cli.ts"); const tsxLoaderPath = path.join(adeCliRoot, "node_modules", "tsx", "dist", "loader.mjs"); @@ -453,7 +525,6 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); - const expectedProjectRoot = fs.realpathSync.native(projectRoot); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -495,10 +566,11 @@ describe("local runtime connection pool", () => { const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); expect(expectedBuildHash).toBeTruthy(); pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); - const registered = await pool.ensureProject(projectRoot); + await expect(pool.ensureProject(projectRoot)).rejects.toThrow( + /build does not match the packaged desktop runtime/, + ); - expect(registered.rootPath).toBe(expectedProjectRoot); - expect(logger.info).toHaveBeenCalledWith("local_runtime.build_mismatch_restart", expect.objectContaining({ + expect(logger.info).toHaveBeenCalledWith("local_runtime.build_mismatch_blocked", expect.objectContaining({ runtimeBuildHash: "old-build", expectedBuildHash, })); @@ -514,7 +586,7 @@ describe("local runtime connection pool", () => { expect(initialized).toMatchObject({ runtimeInfo: { version: "1.0.0", - buildHash: expectedBuildHash, + buildHash: "old-build", }, }); } finally { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 9aeef3ba7..21bd739f5 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -61,6 +61,7 @@ type LocalRuntimeNodePathOptions = { const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 3_000; const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; +const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; export function buildLocalRuntimeServeArgs( cliPath: string, @@ -217,17 +218,34 @@ export function computeLocalRuntimeBuildHash(cliPath = resolveCliScriptPath()): } } -async function shutdownRuntimeClient(client: RuntimeRpcClient): Promise { - try { - await client.call("shutdown", {}); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("socket closed")) throw error; - } finally { - try { client.close(); } catch {} +function isCompatibleRuntimeVersion(args: { + runtimeVersion: string | null; + appVersion: string; + runtimeBuildHash: string | null; + expectedBuildHash: string | null; +}): boolean { + if (!args.runtimeVersion) return true; + if (args.runtimeVersion === args.appVersion) return true; + return ( + args.runtimeVersion === PLACEHOLDER_RUNTIME_VERSION && + args.expectedBuildHash != null && + args.runtimeBuildHash === args.expectedBuildHash + ); +} + +class LocalRuntimeCompatibilityError extends Error { + constructor(message: string) { + super(message); + this.name = "LocalRuntimeCompatibilityError"; } } +function closeRuntimeClient(client: RuntimeRpcClient): void { + try { + client.close(); + } catch {} +} + async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { const startedAt = Date.now(); let lastError: Error | null = null; @@ -651,6 +669,9 @@ export class LocalRuntimeConnectionPool { try { return await this.connectClient(socketPath); } catch (error) { + if (error instanceof LocalRuntimeCompatibilityError) { + throw error; + } this.logger.debug("local_runtime.connect_existing_failed", { socketPath, error: error instanceof Error ? error.message : String(error), @@ -662,26 +683,43 @@ export class LocalRuntimeConnectionPool { private async connectClient(socketPath: string): Promise { const transport = await openSocketTransport(socketPath); const client = new RuntimeRpcClient(transport); - const initializeResult = await client.initialize("ade-desktop-local", this.appVersion); + let initializeResult: unknown; + try { + initializeResult = await client.initialize("ade-desktop-local", this.appVersion); + } catch (error) { + closeRuntimeClient(client); + throw error; + } const runtimeInfo = readRuntimeInfo(initializeResult); - if (runtimeInfo.version && runtimeInfo.version !== this.appVersion) { - this.logger.info("local_runtime.version_mismatch_restart", { + const expectedBuildHash = computeLocalRuntimeBuildHash(); + if (!isCompatibleRuntimeVersion({ + runtimeVersion: runtimeInfo.version, + appVersion: this.appVersion, + runtimeBuildHash: runtimeInfo.buildHash, + expectedBuildHash, + })) { + this.logger.info("local_runtime.version_mismatch_blocked", { socketPath, runtimeVersion: runtimeInfo.version, appVersion: this.appVersion, + runtimeBuildHash: runtimeInfo.buildHash, + expectedBuildHash, }); - await shutdownRuntimeClient(client); - throw new Error(`ADE service version ${runtimeInfo.version} does not match desktop version ${this.appVersion}.`); + closeRuntimeClient(client); + throw new LocalRuntimeCompatibilityError( + `ADE service version ${runtimeInfo.version} does not match desktop version ${this.appVersion}. The existing ADE service was left running to preserve active work.`, + ); } - const expectedBuildHash = computeLocalRuntimeBuildHash(); if (expectedBuildHash && runtimeInfo.buildHash !== expectedBuildHash) { - this.logger.info("local_runtime.build_mismatch_restart", { + this.logger.info("local_runtime.build_mismatch_blocked", { socketPath, runtimeBuildHash: runtimeInfo.buildHash, expectedBuildHash, }); - await shutdownRuntimeClient(client); - throw new Error("ADE service build does not match the packaged desktop runtime."); + closeRuntimeClient(client); + throw new LocalRuntimeCompatibilityError( + "ADE service build does not match the packaged desktop runtime. The existing ADE service was left running to preserve active work.", + ); } this.activeClient = client; client.onDisconnect((error) => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index beea9fd12..17b935069 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -2912,7 +2912,7 @@ describe("ptyService", () => { chatSessionId: "chat-write", }); - const result = service.writeTerminal({ chatSessionId: "chat-write", data: "y\n" }); + const result = await service.writeTerminal({ chatSessionId: "chat-write", data: "y\n" }); expect(result).toEqual({ ok: true }); expect(mockPty.write).toHaveBeenCalledWith("y\n"); }); @@ -2956,7 +2956,7 @@ describe("ptyService", () => { await expect(service.readTerminal({ chatSessionId: "no-such-chat" })).rejects.toThrow( /terminal\.read requires/, ); - expect(() => service.writeTerminal({ chatSessionId: "no-such-chat", data: "x" })).toThrow( + await expect(service.writeTerminal({ chatSessionId: "no-such-chat", data: "x" })).rejects.toThrow( /terminal\.write requires/, ); expect(() => service.signalTerminal({ chatSessionId: "no-such-chat", signal: "SIGINT" })).toThrow( @@ -2964,5 +2964,249 @@ describe("ptyService", () => { ); expect(service.activeForChat({ chatSessionId: "no-such-chat" })).toBeNull(); }); + + describe("reattachChatCli", () => { + it("returns the existing live PTY when one is already bound", async () => { + const { service } = createChatHarness(); + // Use the same sessionId for chat session and terminal session, mirroring chat-CLI tracking + const created = await service.create({ + sessionId: "chat-existing", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude Chat", + cols: 80, + rows: 24, + chatSessionId: "chat-existing", + tracked: true, + toolType: "claude-chat", + startupCommand: "claude --resume target", + }); + + const result = await service.reattachChatCli({ chatSessionId: "chat-existing" }); + + expect(result.relaunched).toBe(false); + expect(result.terminalId).toBe(created.sessionId); + expect(result.ptyId).toBe(created.ptyId); + }); + + it("relaunches a new PTY for a disposed chat-CLI session", async () => { + const { service, loadPty, sessionStore } = createChatHarness(); + const created = await service.create({ + sessionId: "chat-relaunch", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude Chat", + cols: 80, + rows: 24, + chatSessionId: "chat-relaunch", + tracked: true, + toolType: "claude-chat", + startupCommand: "claude --resume target", + }); + + // Persist a resume command on the session record so reattach can build a startup command. + const record = sessionStore.get("chat-relaunch"); + if (record) { + record.resumeCommand = "claude --resume target"; + } + + // Dispose the live PTY entry to simulate a dead session after ADE crash/restart. + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + // Wire a fresh mock PTY for the relaunch. + const freshMockPty = createMockPty(); + const freshSpawn = vi.fn(() => freshMockPty); + loadPty.mockImplementationOnce(() => ({ spawn: freshSpawn as any })); + + const result = await service.reattachChatCli({ chatSessionId: "chat-relaunch" }); + + expect(result.relaunched).toBe(true); + expect(result.terminalId).toBe(created.sessionId); + expect(result.ptyId).not.toBe(created.ptyId); + expect(freshSpawn).toHaveBeenCalled(); + }); + + it("throws when the chat-CLI session record is missing", async () => { + const { service } = createChatHarness(); + await expect(service.reattachChatCli({ chatSessionId: "missing" })).rejects.toThrow( + /was not found/, + ); + }); + + it("throws when the session is not a persisted chat tool type", async () => { + const { service } = createChatHarness(); + await service.create({ + sessionId: "plain-shell", + allowNewSessionId: true, + laneId: "lane-1", + title: "Shell", + cols: 80, + rows: 24, + tracked: true, + toolType: "shell", + }); + // The activeTerminalByChatSession bypass key is the chatSessionId, which is null for shell sessions, + // so reattachChatCli falls through to the session-lookup branch directly even with a live PTY. + + await expect(service.reattachChatCli({ chatSessionId: "plain-shell" })).rejects.toThrow( + /not a chat CLI session/, + ); + }); + + it("throws when the chat-CLI session is untracked", async () => { + const { service, sessionStore } = createChatHarness(); + const created = await service.create({ + sessionId: "untracked-chat", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude", + cols: 80, + rows: 24, + chatSessionId: "untracked-chat", + toolType: "claude-chat", + tracked: false, + }); + // Force tracked = false on the stored record (the chat harness defaults to tracked: true). + const record = sessionStore.get("untracked-chat"); + if (record) { + record.tracked = false; + } + // Dispose the live PTY so reattachChatCli must take the lookup-and-relaunch path. + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + await expect(service.reattachChatCli({ chatSessionId: "untracked-chat" })).rejects.toThrow( + /not tracked/, + ); + }); + + it("throws when the chat-CLI session has no resume command or metadata", async () => { + const { service, sessionStore } = createChatHarness(); + const created = await service.create({ + sessionId: "no-resume", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude", + cols: 80, + rows: 24, + chatSessionId: "no-resume", + tracked: true, + toolType: "claude-chat", + }); + + // Wipe out the resume hints so reattach has nothing to launch with. + const record = sessionStore.get("no-resume"); + if (record) { + record.resumeCommand = null; + record.resumeMetadata = null; + } + + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + await expect(service.reattachChatCli({ chatSessionId: "no-resume" })).rejects.toThrow( + /no resume command available/, + ); + }); + + it("throws when called with an empty chatSessionId", async () => { + const { service } = createChatHarness(); + await expect(service.reattachChatCli({ chatSessionId: "" })).rejects.toThrow( + /requires chatSessionId/, + ); + }); + + it("dedupes concurrent reattach calls for the same chatSessionId to a single relaunch", async () => { + const { service, loadPty, sessionStore } = createChatHarness(); + const created = await service.create({ + sessionId: "chat-dedup", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude Chat", + cols: 80, + rows: 24, + chatSessionId: "chat-dedup", + tracked: true, + toolType: "claude-chat", + startupCommand: "claude --resume target", + }); + const record = sessionStore.get("chat-dedup"); + if (record) record.resumeCommand = "claude --resume target"; + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + // Only one fresh spawn should happen even if multiple callers race. + const freshMockPty = createMockPty(); + const freshSpawn = vi.fn(() => freshMockPty); + loadPty.mockImplementation(() => ({ spawn: freshSpawn as any })); + + const [a, b, c] = await Promise.all([ + service.reattachChatCli({ chatSessionId: "chat-dedup" }), + service.reattachChatCli({ chatSessionId: "chat-dedup" }), + service.reattachChatCli({ chatSessionId: "chat-dedup" }), + ]); + + // All three resolve to the same new PTY; only one spawn happened. + expect(a.ptyId).toBe(b.ptyId); + expect(b.ptyId).toBe(c.ptyId); + expect(freshSpawn).toHaveBeenCalledTimes(1); + }); + }); + + describe("writeTerminal auto-reattach", () => { + it("auto-reattaches a dead chat-CLI session when writing by chatSessionId", async () => { + const { service, loadPty, sessionStore } = createChatHarness(); + const created = await service.create({ + sessionId: "chat-auto", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude Chat", + cols: 80, + rows: 24, + chatSessionId: "chat-auto", + tracked: true, + toolType: "claude-chat", + startupCommand: "claude --resume target", + }); + + // Seed a resume command so the auto-reattach can launch. + const record = sessionStore.get("chat-auto"); + if (record) { + record.resumeCommand = "claude --resume target"; + } + + // Simulate the PTY being dead (ADE crashed or quit). + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + // Wire a fresh PTY for the relaunch and capture its write calls. + const freshMockPty = createMockPty(); + const freshSpawn = vi.fn(() => freshMockPty); + loadPty.mockImplementationOnce(() => ({ spawn: freshSpawn as any })); + + const result = await service.writeTerminal({ chatSessionId: "chat-auto", data: "hello\n" }); + + expect(result).toEqual({ ok: true }); + expect(freshSpawn).toHaveBeenCalled(); + expect(freshMockPty.write).toHaveBeenCalledWith("hello\n"); + }); + + it("still throws when called with a stale explicit ptyId", async () => { + const { service } = createChatHarness(); + const created = await service.create({ + sessionId: "chat-stale-pty", + allowNewSessionId: true, + laneId: "lane-1", + title: "Claude Chat", + cols: 80, + rows: 24, + chatSessionId: "chat-stale-pty", + tracked: true, + toolType: "claude-chat", + }); + + service.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + + await expect( + service.writeTerminal({ ptyId: created.ptyId, data: "x" }), + ).rejects.toThrow(/Terminal PTY '.*' is not running/); + }); + }); }); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 44106863b..9c3a01261 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -27,6 +27,8 @@ import type { ChatTerminalListArgs, ChatTerminalReadArgs, ChatTerminalReadResult, + ChatTerminalReattachArgs, + ChatTerminalReattachResult, ChatTerminalResizeArgs, ChatTerminalSession, ChatTerminalSignalArgs, @@ -615,6 +617,9 @@ export function createPtyService({ const missingResumeTargetBackfillFailures = new Map(); const claudeTitleCaptureKeys = new Set(); const resumeRuntimeFlights = new Map>(); + // Dedup concurrent reattachChatCli calls for the same chatSessionId so we + // never spawn two PTYs racing to `claude --resume `. + const reattachChatCliFlights = new Map>(); /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ const toolAutoCloseTimers = new Map>(); let ptyDataSummaryTimer: ReturnType | null = null; @@ -3015,6 +3020,95 @@ export function createPtyService({ return session ? terminalSessionFromSummary(session) : null; }, + async reattachChatCli(args: ChatTerminalReattachArgs): Promise { + const chatSessionId = cleanOptionalId(args?.chatSessionId); + if (!chatSessionId) throw new Error("terminal.reattachChatCli requires chatSessionId."); + + // Fast path: an existing live PTY is already bound. Skip the dedup map to + // keep the no-op cost low. + const activeTerminalId = activeTerminalByChatSession.get(chatSessionId) ?? null; + if (activeTerminalId) { + const liveActive = liveEntryBySessionId(activeTerminalId); + if (liveActive) { + return { + terminalId: activeTerminalId, + ptyId: liveActive[0], + pid: liveActive[1].pty.pid ?? null, + relaunched: false, + }; + } + } + + // Single-flight dedup: concurrent callers (chat composer + App Control, + // rapid sends, etc.) must not each launch a fresh `claude --resume` PTY. + // Whoever wins the create wins; everyone else awaits the same Promise. + const existing = reattachChatCliFlights.get(chatSessionId); + if (existing) return existing; + + const flight = (async (): Promise => { + // For chat-CLI sessions the chat session id and terminal session id are the same. + const session = sessionService.get(chatSessionId); + if (!session) { + throw new Error(`Chat CLI session '${chatSessionId}' was not found.`); + } + if (!session.tracked) { + throw new Error(`Chat CLI session '${chatSessionId}' is not tracked and cannot be reattached.`); + } + if (!isPersistedChatToolType(session.toolType)) { + throw new Error(`Session '${chatSessionId}' is not a chat CLI session.`); + } + + const resumeCommand = session.resumeMetadata + ? buildTrackedCliResumeCommand(session.resumeMetadata, { + model: null, + reasoningEffort: null, + permissionMode: null, + }) + : normalizeResumeCommand(session.resumeCommand, session.toolType); + if (!resumeCommand) { + throw new Error(`Chat CLI session '${chatSessionId}' has no resume command available.`); + } + + const { cols, rows } = clampDims( + typeof args.cols === "number" ? args.cols : PTY_SEND_DEFAULT_COLS, + typeof args.rows === "number" ? args.rows : PTY_SEND_DEFAULT_ROWS, + ); + + const created = await service.create({ + sessionId: chatSessionId, + laneId: session.laneId, + chatSessionId, + cols, + rows, + title: session.title || session.goal || "Chat CLI", + tracked: true, + toolType: session.toolType, + startupCommand: resumeCommand, + }); + + logger.info("pty.reattach_chat_cli", { + chatSessionId, + toolType: session.toolType, + }); + + return { + terminalId: created.sessionId, + ptyId: created.ptyId, + pid: created.pid, + relaunched: true, + }; + })(); + + reattachChatCliFlights.set(chatSessionId, flight); + try { + return await flight; + } finally { + if (reattachChatCliFlights.get(chatSessionId) === flight) { + reattachChatCliFlights.delete(chatSessionId); + } + } + }, + async readTerminal(args: ChatTerminalReadArgs = {}): Promise { const terminalId = resolveTerminalId(args); if (!terminalId) throw new Error("terminal.read requires terminalId or an active chat terminal."); @@ -3092,21 +3186,45 @@ export function createPtyService({ }; }, - writeTerminal(args: ChatTerminalWriteArgs): { ok: true } { + async writeTerminal(args: ChatTerminalWriteArgs): Promise<{ ok: true }> { if (!args || typeof args.data !== "string") { throw new Error("terminal.write requires string data."); } const ptyId = cleanOptionalId(args.ptyId); let entry: PtyEntry | null = null; if (ptyId) { + // Explicit ptyId: never auto-reattach by ptyId; preserve the throw if the entry is missing. const candidate = ptys.get(ptyId); if (!candidate || candidate.disposed) throw new Error(`Terminal PTY '${ptyId}' is not running.`); entry = candidate; } else { const terminalId = resolveTerminalId(args); - if (!terminalId) throw new Error("terminal.write requires terminalId, ptyId, or an active chat terminal."); - const live = liveEntryBySessionId(terminalId); - if (!live) throw new Error(`Terminal session '${terminalId}' is not running.`); + const chatSessionId = cleanOptionalId(args.chatSessionId); + let live = terminalId ? liveEntryBySessionId(terminalId) : null; + if (!live) { + // The PTY is gone. If this is a chat-CLI session and we have a chatSessionId or a terminalId that + // matches a chat-CLI tracked session record, auto-reattach via reattachChatCli. + const reattachKey = chatSessionId ?? terminalId ?? null; + if (reattachKey) { + const session = sessionService.get(reattachKey); + if ( + session + && session.tracked + && isPersistedChatToolType(session.toolType) + ) { + const created = await service.reattachChatCli({ chatSessionId: reattachKey }); + live = liveEntryBySessionId(created.terminalId); + } + } + } + if (!live) { + if (!terminalId) { + // No live terminal could be resolved from args and no chat-CLI auto-reattach applies. + // Preserve the historical contract for callers that pass chatSessionId without a live target. + throw new Error("terminal.write requires terminalId, ptyId, or an active chat terminal."); + } + throw new Error(`Terminal session '${terminalId}' is not running.`); + } entry = live[1]; } entry.pty.write(args.data); @@ -3405,6 +3523,16 @@ export function createPtyService({ return count; }, + // Used by project-context rebalancing to decide whether a project still + // has user work that would be destroyed by eviction. ANY live PTY (running + // CLI, shell, agent process) protects the whole context. + hasLiveSessions(): boolean { + for (const entry of ptys.values()) { + if (!entry.disposed) return true; + } + return false; + }, + onData(listener: PtyDataListener): () => void { dataListeners.add(listener); return () => { diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.ts b/apps/desktop/src/main/services/updates/autoUpdateService.ts index 19160961e..0c0ab37ff 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import type { ProgressInfo } from "builder-util-runtime"; import { autoUpdater, type UpdateInfo } from "electron-updater"; import type { AutoUpdateSnapshot, RecentlyInstalledUpdate } from "../../../shared/types"; import type { Logger } from "../logging/logger"; @@ -34,6 +33,13 @@ type UpdateCheckResultLike = { downloadPromise?: Promise | null; }; +type ProgressInfo = { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number; +}; + export function createEmptyAutoUpdateSnapshot(): AutoUpdateSnapshot { return { status: "idle", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 48427074a..3f43f17dc 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -103,6 +103,8 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatSubagentTranscriptArgs, + AgentChatSubagentTranscriptMessage, AgentChatContextUsage, AgentChatContextUsageArgs, AgentChatRewindFilesArgs, @@ -735,6 +737,8 @@ import type { ChatTerminalPreviewResult, ChatTerminalReadArgs, ChatTerminalReadResult, + ChatTerminalReattachArgs, + ChatTerminalReattachResult, ChatTerminalSession, ChatTerminalSignalArgs, ChatTerminalWriteArgs, @@ -758,7 +762,11 @@ declare global { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; + openProjectTabs: ProjectInfo[]; }>; + setWindowProjectTabs: ( + rootPaths: string[], + ) => Promise<{ openProjectTabs: ProjectInfo[] }>; newWindow: () => Promise<{ windowId: number | null }>; openProjectInNewWindow: ( rootPath: string, @@ -1525,6 +1533,9 @@ declare global { getClaudeSessionMessages: ( args: AgentChatClaudeSessionMessagesArgs, ) => Promise; + getSubagentTranscript: ( + args: AgentChatSubagentTranscriptArgs, + ) => Promise; getContextUsage: ( args: AgentChatContextUsageArgs, ) => Promise; @@ -1762,6 +1773,9 @@ declare global { activeForChat: ( args: ChatTerminalActiveForChatArgs, ) => Promise; + reattachChatCli: ( + args: ChatTerminalReattachArgs, + ) => Promise; }; localhost: { probePort: (port: number) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 927625af1..ef71543ca 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -79,6 +79,53 @@ describe("preload OAuth bridge", () => { expect(removeListener).toHaveBeenCalledWith(IPC.lanesOAuthEvent, listener); }); + it("exposes per-window project tab session IPC", async () => { + const project = { rootPath: "/repo/a", displayName: "A", baseRef: "main" }; + const openProjectTabs = [ + project, + { rootPath: "/repo/b", displayName: "B", baseRef: "main" }, + ]; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 7, project, binding: null, openProjectTabs }; + } + if (channel === IPC.appSetWindowProjectTabs) { + return { openProjectTabs: [openProjectTabs[1]] }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.app.getWindowSession()).resolves.toEqual({ + windowId: 7, + project, + binding: null, + openProjectTabs, + }); + await expect(bridge.app.setWindowProjectTabs(["/repo/b"])).resolves.toEqual({ + openProjectTabs: [openProjectTabs[1]], + }); + expect(invoke).toHaveBeenCalledWith(IPC.appSetWindowProjectTabs, { rootPaths: ["/repo/b"] }); + }); + it("exposes review IPC methods and cleans up listeners", async () => { const invoke = vi.fn(async () => undefined); const on = vi.fn(); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8d4762e95..e4a85c98b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -312,6 +312,8 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatSubagentTranscriptArgs, + AgentChatSubagentTranscriptMessage, AgentChatContextUsage, AgentChatContextUsageArgs, AgentChatRewindFilesArgs, @@ -747,6 +749,8 @@ import type { ChatTerminalPreviewResult, ChatTerminalReadArgs, ChatTerminalReadResult, + ChatTerminalReattachArgs, + ChatTerminalReattachResult, ChatTerminalSession, ChatTerminalSignalArgs, ChatTerminalWriteArgs, @@ -2604,15 +2608,21 @@ contextBridge.exposeInMainWorld("ade", { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; + openProjectTabs: ProjectInfo[]; }> => { const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { windowId: number | null; project: ProjectInfo | null; binding: OpenProjectBinding | null; + openProjectTabs?: ProjectInfo[]; }; rememberProjectBinding(session.binding); - return session; + return { ...session, openProjectTabs: session.openProjectTabs ?? [] }; }, + setWindowProjectTabs: async ( + rootPaths: string[], + ): Promise<{ openProjectTabs: ProjectInfo[] }> => + ipcRenderer.invoke(IPC.appSetWindowProjectTabs, { rootPaths }), newWindow: async (): Promise<{ windowId: number | null }> => ipcRenderer.invoke(IPC.appNewWindow), openProjectInNewWindow: async ( @@ -5370,6 +5380,12 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("chat", "getClaudeSessionMessages", { args }, () => ipcRenderer.invoke(IPC.agentChatGetClaudeSessionMessages, args), ), + getSubagentTranscript: async ( + args: AgentChatSubagentTranscriptArgs, + ): Promise => + callProjectRuntimeActionOr("chat", "getSubagentTranscript", { args }, () => + ipcRenderer.invoke(IPC.agentChatGetSubagentTranscript, args), + ), getContextUsage: async ( args: AgentChatContextUsageArgs, ): Promise => @@ -6052,6 +6068,19 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.terminalActiveForChat, args); }, + reattachChatCli: async ( + args: ChatTerminalReattachArgs, + ): Promise => { + const runtime = + await callProjectRuntimeActionIfBound( + "terminal", + "reattachChatCli", + { args }, + ); + return runtime.handled + ? runtime.result + : ipcRenderer.invoke(IPC.terminalReattachChatCli, args); + }, }, localhost: { probePort: async (port: number): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 225833f4a..62ac0b616 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2818,7 +2818,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { rootPath: MOCK_PROJECT.rootPath, displayName: MOCK_PROJECT.name, }, + openProjectTabs: [MOCK_PROJECT], }), + setWindowProjectTabs: resolved({ openProjectTabs: [MOCK_PROJECT] }), newWindow: resolved({ windowId: 2 }), openProjectInNewWindow: resolvedArg({ windowId: 2, @@ -4504,6 +4506,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { listClaudeSessions: resolvedArg([]), getClaudeSessionInfo: resolvedArg(null), getClaudeSessionMessages: resolvedArg([]), + getSubagentTranscript: resolvedArg(null), getContextUsage: resolvedArg(null), rewindFiles: resolvedArg({ canRewind: false, diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 22423af5e..6dbe59001 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -3,12 +3,12 @@ import { BrowserRouter, HashRouter, Navigate, - Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { useShallow } from "zustand/react/shallow"; import { AppShell } from "./AppShell"; import { RunPage } from "../run/RunPage"; @@ -54,11 +54,17 @@ const CtoPage = React.lazy(() => import("../cto/CtoPage").then((m) => ({ default: m.CtoPage })) ); -import { useAppStore } from "../../state/appStore"; +import { + AppStoreProvider, + createProjectAppStore, + hydrateProjectAppStore, + useAppStore, + type AppStoreApi, +} from "../../state/appStore"; import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; -import type { AppNavigationRequest } from "../../../shared/types"; +import type { AppNavigationRequest, ProjectInfo } from "../../../shared/types"; // Use path-based routes on http(s) (Vite in Chrome, Cursor Simple Browser, etc.). // Use hash routes for non-http(s) surfaces (e.g. packaged Electron `file://`) where @@ -156,67 +162,210 @@ function PageErrorBoundary({ children }: { children: React.ReactNode }) { ); } -export function RequireProject({ children }: { children: React.ReactElement }): React.ReactElement { - const projectHydrated = useAppStore((s) => s.projectHydrated); - const showWelcome = useAppStore((s) => s.showWelcome); - const project = useAppStore((s) => s.project); - const location = useLocation(); +const LazyFallback = GuardLoadingFallback; - if (!projectHydrated) { - return GuardLoadingFallback; +function isWorkRoutePath(pathname: string): boolean { + return pathname === "/work" || pathname.startsWith("/work/"); +} + +const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; +const WARM_PROJECT_SURFACE_LIMIT = 8; +const EMPTY_PROJECT_TAB_ROOTS: string[] = []; +const EMPTY_PROJECT_INFO_BY_ROOT: Record = {}; + +function projectRouteStorageKey(projectRoot: string): string { + return `${PROJECT_ROUTE_STORAGE_PREFIX}${projectRoot}`; +} + +function serializeProjectRoute(location: ReturnType): string | null { + const pathname = location.pathname || "/work"; + const allowedRoots = [ + "/project", + "/lanes", + "/files", + "/work", + "/graph", + "/prs", + "/review", + "/history", + "/automations", + "/missions", + "/cto", + "/settings", + "/onboarding", + ]; + if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) { + return null; } + return `${pathname}${location.search ?? ""}${location.hash ?? ""}`; +} - const hasActiveProject = Boolean(project?.rootPath); - if ( - (!hasActiveProject || showWelcome) && - location.pathname !== "/work" && - location.pathname !== "/project" && - location.pathname !== "/onboarding" - ) { - return ; +function readStoredProjectRoute(projectRoot: string): string | null { + try { + const value = window.localStorage.getItem(projectRouteStorageKey(projectRoot)); + return value?.startsWith("/") ? value : null; + } catch { + return null; } +} - return children; +function writeStoredProjectRoute(projectRoot: string, route: string): void { + try { + window.localStorage.setItem(projectRouteStorageKey(projectRoot), route); + } catch { + // localStorage can be unavailable in private/test environments. + } } -const LazyFallback = GuardLoadingFallback; +function ProjectRouteContent({ active, route }: { active: boolean; route: string }) { + const workSurfaceRef = React.useRef(null); + const isWorkRoute = isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work"); + const [workRoute, setWorkRoute] = React.useState(() => isWorkRoute ? route : "/work"); + const [workMounted, setWorkMounted] = React.useState(isWorkRoute); + const routeProps = { active } as { active?: boolean }; + const shouldRenderWork = workMounted || isWorkRoute; + const visibleWorkRoute = isWorkRoute ? route : workRoute; -function guarded(element: React.ReactElement): React.ReactElement { - return ( - - {element} - - ); -} + React.useEffect(() => { + if (!isWorkRoute) return; + setWorkRoute(route); + setWorkMounted(true); + }, [isWorkRoute, route]); + + React.useEffect(() => { + const node = workSurfaceRef.current; + if (!node) return; + if (isWorkRoute) node.removeAttribute("inert"); + else node.setAttribute("inert", ""); + }, [isWorkRoute, shouldRenderWork]); + + const workSurface = shouldRenderWork ? ( + + + + + + + + + } /> + + ) : null; -function guardedLazy(element: React.ReactElement): React.ReactElement { return ( - - - {element} - - +
+ {workSurface} + {!isWorkRoute ? ( + + } /> + } /> + } /> + } /> + + {React.createElement(LanesPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(FilesPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(WorkspaceGraphPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(PRsPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(ReviewPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(HistoryPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(AutomationsPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(AutomationsTemplatesPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(MissionsPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(CtoPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + + {React.createElement(SettingsPage as React.ComponentType<{ active?: boolean }>, routeProps)} + + } /> + } /> + + ) : null} +
); } -function isWorkRoutePath(pathname: string): boolean { - return pathname === "/work" || pathname.startsWith("/work/"); -} +function ProjectSurface({ + active, + project, + route, + store, +}: { + active: boolean; + project: ProjectInfo; + route: string; + store: AppStoreApi; +}) { + const surfaceRef = React.useRef(null); -function PersistentWorkSurface({ active }: { active: boolean }) { - const projectHydrated = useAppStore((s) => s.projectHydrated); - const showWelcome = useAppStore((s) => s.showWelcome); - const project = useAppStore((s) => s.project); - const workSurfaceRef = React.useRef(null); + React.useEffect(() => { + hydrateProjectAppStore(store, { + project, + projectBinding: { + kind: "local", + key: `local:${project.rootPath}`, + rootPath: project.rootPath, + displayName: project.displayName, + }, + projectHydrated: true, + showWelcome: false, + }); + }, [project, store]); - // Only fire the reveal once the surface is *actually* mounted with a - // project. On a cold `/work` boot the route renders before `projectHydrated` - // / `project.rootPath` settle; firing the reveal here would notify - // listeners about a surface that's still showing the loading fallback. - const hasActiveProject = Boolean(project?.rootPath); - const shouldReveal = active && projectHydrated && hasActiveProject && !showWelcome; React.useEffect(() => { - if (!shouldReveal) return; + if (!active || !isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work")) return; const raf = window.requestAnimationFrame(() => { dispatchWorkSurfaceRevealed(); }); @@ -227,66 +376,208 @@ function PersistentWorkSurface({ active }: { active: boolean }) { window.cancelAnimationFrame(raf); window.clearTimeout(settleTimer); }; - }, [shouldReveal]); + }, [active, route]); React.useEffect(() => { - const node = workSurfaceRef.current; - // The `
` below is gated by the `projectHydrated` - // / `hasActiveProject` / `showWelcome` early returns, so on a cold `/work` - // boot the ref is null on the first run when `active` flips true. Re-run - // once those guards settle so the inert state lands on the real node. + if (!active) return; + const state = store.getState(); + if (state.lanes.length === 0 && !state.lanesLoading) { + void state.refreshLanes({ includeStatus: false }).catch(() => {}); + } + if (!state.keybindings) { + void state.refreshKeybindings().catch(() => {}); + } + void state.refreshProviderMode().catch(() => {}); + }, [active, store]); + + React.useEffect(() => { + const node = surfaceRef.current; if (!node) return; - if (active) { - node.removeAttribute("inert"); - } else { - node.setAttribute("inert", ""); + if (active) node.removeAttribute("inert"); + else node.setAttribute("inert", ""); + }, [active]); + + return ( + +
+ +
+
+ ); +} + +function ProjectTabHost() { + const location = useLocation(); + const navigate = useNavigate(); + const activeProject = useAppStore((s) => s.project); + const projectHydrated = useAppStore((s) => s.projectHydrated); + const showWelcome = useAppStore((s) => s.showWelcome); + const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots ?? EMPTY_PROJECT_TAB_ROOTS); + const projectInfoByRoot = useAppStore((s) => s.projectInfoByRoot ?? EMPTY_PROJECT_INFO_BY_ROOT); + const rootPrefs = useAppStore(useShallow((s) => ({ + theme: s.theme, + terminalPreferences: s.terminalPreferences, + codeBlockCopyButtonPosition: s.codeBlockCopyButtonPosition, + agentTurnCompletionSound: s.agentTurnCompletionSound, + agentTurnCompletionSoundVolume: s.agentTurnCompletionSoundVolume, + agentTurnCompletionSoundQuietWhenFocused: s.agentTurnCompletionSoundQuietWhenFocused, + chatFontSizePx: s.chatFontSizePx, + chatUserMinimapEnabled: s.chatUserMinimapEnabled, + chatTranscriptDensity: s.chatTranscriptDensity, + chatChromeTint: s.chatChromeTint, + chatShellGeometry: s.chatShellGeometry, + smartTooltipsEnabled: s.smartTooltipsEnabled, + onboardingEnabled: s.onboardingEnabled, + didYouKnowEnabled: s.didYouKnowEnabled, + }))); + const storesRef = React.useRef(new Map()); + const lruRef = React.useRef([]); + const [routesByRoot, setRoutesByRoot] = React.useState>({}); + const activeRoot = !showWelcome && activeProject?.rootPath ? activeProject.rootPath : null; + const previousActiveRootRef = React.useRef(null); + const pendingNavigationRef = React.useRef<{ root: string; route: string } | null>(null); + + React.useEffect(() => { + for (const store of storesRef.current.values()) { + hydrateProjectAppStore(store, rootPrefs); + } + }, [rootPrefs]); + + React.useEffect(() => { + if (!activeRoot) return; + lruRef.current = [activeRoot, ...lruRef.current.filter((root) => root !== activeRoot)]; + }, [activeRoot]); + + React.useEffect(() => { + const previousRoot = previousActiveRootRef.current; + if (previousRoot === activeRoot) return; + const currentRoute = serializeProjectRoute(location); + if (previousRoot && currentRoute) { + writeStoredProjectRoute(previousRoot, currentRoute); + setRoutesByRoot((prev) => ({ ...prev, [previousRoot]: currentRoute })); + } + previousActiveRootRef.current = activeRoot; + if (!activeRoot) return; + const shouldKeepInitialRoute = + currentRoute && + currentRoute !== "/project" && + currentRoute !== "/onboarding"; + if (!previousRoot && shouldKeepInitialRoute) { + writeStoredProjectRoute(activeRoot, currentRoute); + setRoutesByRoot((prev) => (prev[activeRoot] === currentRoute ? prev : { ...prev, [activeRoot]: currentRoute })); + return; + } + const nextRoute = routesByRoot[activeRoot] ?? readStoredProjectRoute(activeRoot) ?? "/work"; + pendingNavigationRef.current = { root: activeRoot, route: nextRoute }; + if (currentRoute !== nextRoute) { + navigate(nextRoute, { replace: true }); + } + }, [activeRoot, location, navigate, routesByRoot]); + + React.useEffect(() => { + if (!activeRoot) return; + const route = serializeProjectRoute(location); + if (!route) return; + const pending = pendingNavigationRef.current; + if (pending?.root === activeRoot && pending.route !== route) return; + if (pending?.root === activeRoot && pending.route === route) { + pendingNavigationRef.current = null; + } + writeStoredProjectRoute(activeRoot, route); + setRoutesByRoot((prev) => (prev[activeRoot] === route ? prev : { ...prev, [activeRoot]: route })); + }, [activeRoot, location]); + + const projects = React.useMemo(() => { + const roots = openProjectTabRoots.length > 0 + ? openProjectTabRoots + : activeProject?.rootPath + ? [activeProject.rootPath] + : []; + return roots + .map((root) => projectInfoByRoot[root] ?? (activeProject?.rootPath === root ? activeProject : null)) + .filter((project): project is ProjectInfo => project != null); + }, [activeProject, openProjectTabRoots, projectInfoByRoot]); + + const mountedProjects = React.useMemo(() => { + const lru = lruRef.current; + const openSet = new Set(projects.map((project) => project.rootPath)); + const ordered = [...projects].sort((left, right) => { + const leftIndex = lru.indexOf(left.rootPath); + const rightIndex = lru.indexOf(right.rootPath); + return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) + - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex); + }); + const warm = ordered.slice(0, WARM_PROJECT_SURFACE_LIMIT); + if (activeProject && openSet.has(activeProject.rootPath) && !warm.some((project) => project.rootPath === activeProject.rootPath)) { + warm.pop(); + warm.unshift(activeProject); + } + return warm; + }, [activeProject, projects]); + + for (const project of mountedProjects) { + if (!storesRef.current.has(project.rootPath)) { + storesRef.current.set(project.rootPath, createProjectAppStore(project)); } - }, [active, projectHydrated, hasActiveProject, showWelcome]); + } - if (!projectHydrated && !hasActiveProject) { - return active ? GuardLoadingFallback : null; + React.useEffect(() => { + const mountedRoots = new Set(mountedProjects.map((project) => project.rootPath)); + for (const root of storesRef.current.keys()) { + if (!mountedRoots.has(root)) storesRef.current.delete(root); + } + }, [mountedProjects]); + + if (!projectHydrated && !activeProject) { + return GuardLoadingFallback; } - if (!hasActiveProject || showWelcome) { - return active ? ( + if (!activeProject || showWelcome || mountedProjects.length === 0) { + return ( - ) : null; + ); } return ( -
- - - - - +
+ {mountedProjects.map((project) => { + const store = storesRef.current.get(project.rootPath); + if (!store) return null; + const route = routesByRoot[project.rootPath] ?? readStoredProjectRoute(project.rootPath) ?? "/work"; + return ( + + ); + })}
); } function ShellLayout() { - const location = useLocation(); - const isWorkRoute = isWorkRoutePath(location.pathname); - return ( - - {isWorkRoute ? null : } + ); } @@ -399,25 +690,7 @@ export function App() { {usesBrowserRouter ? : null} } /> - }> - } /> - )} /> - )} /> - } /> - )} /> - )} /> - - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - } /> - + } />
diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 755acdfbc..75312a158 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -16,11 +16,43 @@ const appStoreState = vi.hoisted(() => ({ showWelcome: false, project: { rootPath: "/fake/project" }, theme: "dark", + openProjectTabRoots: [] as string[], + projectInfoByRoot: {} as Record, })); -vi.mock("../../state/appStore", () => ({ - useAppStore: vi.fn((selector: (state: typeof appStoreState) => unknown) => selector(appStoreState)), -})); +vi.mock("../../state/appStore", async () => { + const ReactModule = await vi.importActual("react") as typeof ReactNamespace; + const createScopedState = (project = appStoreState.project) => ({ + ...appStoreState, + project, + lanes: [], + lanesLoading: false, + keybindings: null, + refreshLanes: vi.fn(async () => undefined), + refreshKeybindings: vi.fn(async () => undefined), + refreshProviderMode: vi.fn(async () => undefined), + }); + return { + useAppStore: vi.fn((selector: (state: typeof appStoreState) => unknown) => selector(appStoreState)), + createProjectAppStore: vi.fn((project) => { + let state = createScopedState(project); + return { + getState: () => state, + setState: (partial: unknown) => { + state = { + ...state, + ...(typeof partial === "function" ? (partial as (prev: typeof state) => Partial)(state) : partial as Partial), + }; + }, + subscribe: vi.fn(() => () => {}), + }; + }), + hydrateProjectAppStore: vi.fn((store, partial) => { + store.setState(partial); + }), + AppStoreProvider: ({ children }: { children: React.ReactNode }) => ReactModule.createElement(ReactModule.Fragment, null, children), + }; +}); vi.mock("../../lib/debugLog", () => ({ logRendererDebugEvent: vi.fn(), @@ -107,6 +139,9 @@ describe("App Work route keep-alive", () => { appStoreState.showWelcome = false; appStoreState.project = { rootPath: "/fake/project" }; appStoreState.theme = "dark"; + appStoreState.openProjectTabRoots = []; + appStoreState.projectInfoByRoot = {}; + window.localStorage.clear(); (window as Window & { __adeBrowserMock?: boolean }).__adeBrowserMock = true; window.history.replaceState({}, "", "/work"); }); @@ -196,6 +231,19 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.mounts).toBe(0); }); + it("enters the project surface instead of preserving the project picker route", async () => { + window.history.replaceState({}, "", "/project"); + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + await waitFor(() => { + expect(window.location.pathname).toBe("/work"); + }); + expect(screen.queryByTestId("project-page")).toBeNull(); + }); + it("does not render the Work surface on non-Work routes when the project is missing", async () => { appStoreState.project = { rootPath: "" }; window.history.replaceState({}, "", "/files"); @@ -203,8 +251,8 @@ describe("App Work route keep-alive", () => { render(); - // RequireProject on the /files route navigates to /project; the inactive - // PersistentWorkSurface must NOT also navigate (would race) or mount Work. + // The project host should route missing projects to the picker; the + // inactive Work surface must NOT also navigate (would race) or mount Work. await screen.findByTestId("project-page"); expect(screen.queryByTestId("work-page")).toBeNull(); expect(workLifecycle.mounts).toBe(0); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index d088b1b61..639fc532e 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -92,15 +92,6 @@ function serializeLocationRoute(location: ReturnType): strin return route; } -function readStoredProjectRoute(projectRoot: string): string | null { - try { - const value = window.localStorage.getItem(projectRouteStorageKey(projectRoot)); - return value?.startsWith("/") ? value : null; - } catch { - return null; - } -} - function writeStoredProjectRoute(projectRoot: string, route: string): void { try { window.localStorage.setItem(projectRouteStorageKey(projectRoot), route); @@ -345,7 +336,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { ); const [projectMissing, setProjectMissing] = useState(false); const [feedbackGenerating, setFeedbackGenerating] = useState(false); - const previousProjectRootRef = useRef(undefined); const lastRouteSaveProjectRootRef = useRef(undefined); const githubStatusProjectRootRef = useRef(null); const githubBannerDismissedRef = useRef(false); @@ -786,25 +776,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProjectMissing(false); }, [project?.rootPath]); - useEffect(() => { - const previousProjectRoot = previousProjectRootRef.current; - const nextProjectRoot = project?.rootPath ?? null; - previousProjectRootRef.current = nextProjectRoot; - - if (previousProjectRoot === undefined) return; - if (!nextProjectRoot || showWelcome) return; - if (previousProjectRoot === nextProjectRoot) return; - // First attach of a project (null → /path) must not restore localStorage, or we - // clobber a deep link / address-bar route (e.g. Vite, Cursor Simple Browser) with - // the last tab the user had for that project — often /graph. - if (previousProjectRoot == null) return; - if (previousProjectRoot) { - const previousRoute = serializeLocationRoute(location); - if (previousRoute) writeStoredProjectRoute(previousProjectRoot, previousRoute); - } - navigate(readStoredProjectRoute(nextProjectRoot) ?? "/work", { replace: true }); - }, [location, navigate, project?.rootPath, showWelcome]); - useEffect(() => { const projectRoot = project?.rootPath ?? null; if (!projectRoot || showWelcome) return; diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index a133ec802..65de47f0c 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect } from "react"; import { useSearchParams, useLocation } from "react-router-dom"; -import { Brain, GearSix, Stack, Database, FolderSimple, Plus, Plugs, Palette, DeviceMobile } from "@phosphor-icons/react"; +import { Brain, GearSix, Stack, Database, FolderSimple, Plugs, Palette, DeviceMobile } from "@phosphor-icons/react"; import { GeneralSection } from "../settings/GeneralSection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { LaneTemplatesSection } from "../settings/LaneTemplatesSection"; @@ -10,10 +10,7 @@ import { AiSettingsSection } from "../settings/AiSettingsSection"; import { WorkspaceSettingsSection } from "../settings/WorkspaceSettingsSection"; import { IntegrationsSettingsSection } from "../settings/IntegrationsSettingsSection"; import { MobilePushPanel } from "../settings/MobilePushPanel"; -import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, outlineButton, primaryButton, dangerButton } from "../lanes/laneDesignTokens"; -import { ConfirmDialog, PromptDialog, useConfirmDialog, usePromptDialog } from "../shared/InlineDialogs"; -import type { PhaseProfile, PhaseCard } from "../../../shared/types"; -import { PhaseCardEditor } from "../missions/PhaseCardEditor"; +import { COLORS, SANS_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { useAppStore } from "../../state/appStore"; const SECTIONS = [ @@ -50,402 +47,9 @@ function padIndex(i: number): string { return String(i + 1).padStart(2, "0"); } -/* ──────────────── Phase Profiles Section ──────────────── */ - -const SECTION_LABEL: React.CSSProperties = { - ...LABEL_STYLE, - fontFamily: SANS_FONT, - fontSize: 11, - marginBottom: 10, -}; - -const SETTINGS_INPUT: React.CSSProperties = { - height: 28, - width: "100%", - background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", - border: "1px solid color-mix(in srgb, var(--color-border) 65%, transparent)", - padding: "0 8px", - fontSize: 11, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - borderRadius: 8, - outline: "none", -}; - -const FIELD_LABEL: React.CSSProperties = { - fontSize: 9, - fontWeight: 700, - fontFamily: SANS_FONT, - textTransform: "uppercase" as const, - letterSpacing: "1px", - color: COLORS.textMuted, -}; - -function PhaseProfileSettingsCard({ - profile, - busy, - onAction, -}: { - profile: PhaseProfile; - busy: boolean; - onAction: (action: "clone" | "export" | "delete" | "save", payload?: { name: string; description: string; phases: PhaseCard[] }) => Promise; -}) { - const [expanded, setExpanded] = useState(false); - const [editName, setEditName] = useState(profile.name); - const [editDescription, setEditDescription] = useState(profile.description); - const [editPhases, setEditPhases] = useState(profile.phases); - const [dirty, setDirty] = useState(false); - const [expandedPhaseIds, setExpandedPhaseIds] = useState>({}); - - const isReadOnly = profile.isBuiltIn; - - return ( -
-
-
-
- {profile.isBuiltIn && {"\u25CF"}} - {profile.name} - {profile.isDefault && DEFAULT} -
-
- {profile.description || profile.phases.map((p) => p.name).join(" \u2192 ")} -
-
- {profile.phases.length} phase{profile.phases.length !== 1 ? "s" : ""} · {profile.isBuiltIn ? "Built-in (read-only)" : "Custom"} -
-
- - - - {!profile.isBuiltIn && ( - - )} -
- - {expanded && ( -
- {!isReadOnly && ( -
- - -
- )} - -
-
PHASES ({editPhases.length})
-
- - {editPhases.map((phase, idx) => ( -
- setExpandedPhaseIds((prev) => ({ ...prev, [phase.id]: !prev[phase.id] }))} - onUpdate={(updated) => { - setEditPhases((prev) => prev.map((p) => p.id === updated.id ? updated : p)); - setDirty(true); - }} - onMoveUp={() => { - if (idx === 0) return; - setEditPhases((prev) => { - const next = [...prev]; - const moved = next[idx]!; - next.splice(idx, 1); - next.splice(idx - 1, 0, moved); - return next.map((p, i) => ({ ...p, position: i })); - }); - setDirty(true); - }} - onMoveDown={() => { - setEditPhases((prev) => { - if (idx >= prev.length - 1) return prev; - const next = [...prev]; - const moved = next[idx]!; - next.splice(idx, 1); - next.splice(idx + 1, 0, moved); - return next.map((p, i) => ({ ...p, position: i })); - }); - setDirty(true); - }} - onRemove={phase.isCustom && !isReadOnly ? () => { - setEditPhases((prev) => prev.filter((p) => p.id !== phase.id).map((p, i) => ({ ...p, position: i }))); - setDirty(true); - } : undefined} - labelStyle={FIELD_LABEL} - inputStyle={SETTINGS_INPUT} - /> -
- ))} - - {!isReadOnly && ( - - )} - - {dirty && !isReadOnly && ( -
- - -
- )} -
- )} -
- ); -} - -function PhaseProfilesSection() { - const [profiles, setProfiles] = useState([]); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(false); - const [notice, setNotice] = useState(null); - const [error, setError] = useState(null); - const deleteConfirm = useConfirmDialog(); - const createPrompt = usePromptDialog(); - const importPrompt = usePromptDialog(); - - const refresh = useCallback(async () => { - try { - const result = await window.ade.missions.listPhaseProfiles({}); - setProfiles(result); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { void refresh(); }, [refresh]); - - const handleAction = useCallback(async ( - profileId: string, - action: "clone" | "export" | "delete" | "save", - payload?: { name: string; description: string; phases: PhaseCard[] } - ) => { - setNotice(null); - setError(null); - setBusy(true); - try { - if (action === "clone") { - await window.ade.missions.clonePhaseProfile({ profileId }); - await refresh(); - setNotice("Profile cloned."); - } else if (action === "export") { - const exported = await window.ade.missions.exportPhaseProfile({ profileId }); - setNotice(exported.savedPath ? `Exported: ${exported.savedPath}` : "Profile exported."); - } else if (action === "delete") { - const profile = profiles.find((p) => p.id === profileId); - const ok = await deleteConfirm.confirmAsync({ - title: "Delete Phase Profile", - message: `Delete phase profile "${profile?.name ?? profileId}"?`, - confirmLabel: "DELETE", - danger: true, - }); - if (!ok) { - setBusy(false); - return; - } - await window.ade.missions.deletePhaseProfile({ profileId }); - await refresh(); - setNotice("Profile deleted."); - } else if (action === "save" && payload) { - await window.ade.missions.savePhaseProfile({ - profile: { - id: profileId, - name: payload.name, - description: payload.description, - phases: payload.phases, - } - }); - await refresh(); - setNotice("Profile saved."); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }, [profiles, refresh]); - - if (loading) { - return ( -
- Loading phase profiles... -
- ); - } - - return ( -
- - - -
PHASE PROFILES
- -
- Phase profiles define the sequence of work phases for missions. Built-in profiles can be viewed but not edited. Clone or create custom profiles to customize phase descriptions, custom instructions, models, and Planning question behavior. -
- -
- - -
- - {notice && ( -
- {notice} -
- )} - {error && ( -
- {error} -
- )} - - {profiles.map((profile) => ( - handleAction(profile.id, action, payload)} - /> - ))} - - {profiles.length === 0 && !loading && ( -
- No phase profiles found. Create one or import from a JSON file. -
- )} -
- ); -} - /* ──────────────── Main Settings Page ──────────────── */ -export function SettingsPage() { +export function SettingsPage({ active = true }: { active?: boolean } = {}) { const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const projectBinding = useAppStore((s) => s.projectBinding); @@ -466,20 +70,22 @@ export function SettingsPage() { // Sync from URL when ?tab= changes useEffect(() => { + if (!active) return; if (validTab && validTab !== section) { setSection(validTab); } if (!memoryAvailable && section === "memory") { setSection("general"); } - }, [memoryAvailable, validTab, section]); + }, [active, memoryAvailable, validTab, section]); useEffect(() => { + if (!active) return; if (!tabParam || !canonicalTab || tabParam === canonicalTab) return; const nextParams = new URLSearchParams(searchParams); nextParams.set("tab", canonicalTab); setSearchParams(nextParams, { replace: true }); - }, [canonicalTab, searchParams, setSearchParams, tabParam]); + }, [active, canonicalTab, searchParams, setSearchParams, tabParam]); const navigateToSection = useCallback((next: SectionId) => { setSection(next); @@ -489,12 +95,13 @@ export function SettingsPage() { }, [searchParams, setSearchParams]); useEffect(() => { + if (!active) return; if (section !== "ai" || location.hash !== "#ai-providers") return; const id = window.requestAnimationFrame(() => { document.getElementById("ai-providers")?.scrollIntoView({ block: "start", behavior: "smooth" }); }); return () => window.cancelAnimationFrame(id); - }, [section, location.hash]); + }, [active, section, location.hash]); return (
diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index 1b9656f15..6704aebf8 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -33,16 +33,24 @@ describe("TabNav", () => { beforeEach(() => { resetStore(); - globalThis.window.ade = { - app: { - revealPath: async () => undefined, - getInfo: async () => ({ isPackaged: false }) as any, + Object.defineProperty(globalThis.window, "ade", { + configurable: true, + writable: true, + value: { + app: { + revealPath: async () => undefined, + getInfo: async () => ({ isPackaged: false }) as any, + }, }, - } as any; + }); }); afterEach(() => { - globalThis.window.ade = originalAde; + Object.defineProperty(globalThis.window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); }); it("places Review directly below PRs in the sidebar", () => { @@ -57,4 +65,3 @@ describe("TabNav", () => { expect(prs.nextElementSibling).toBe(review); }); }); - diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 8e72cf7f0..6b72d880f 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -163,7 +163,8 @@ describe("TopBar", () => { resetStore(); globalThis.window.ade = { app: { - getWindowSession: vi.fn(async () => ({ windowId: 1, project: useAppStore.getState().project })), + getWindowSession: vi.fn(async () => ({ windowId: 1, project: useAppStore.getState().project, openProjectTabs: [] })), + setWindowProjectTabs: vi.fn(async () => ({ openProjectTabs: [] })), newWindow: vi.fn(async () => ({ windowId: 2 })), openProjectInNewWindow: vi.fn(async (rootPath: string) => ({ windowId: 2, diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index fff9b6ea4..da9725197 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -646,7 +646,8 @@ export function TopBar() { useState(null); const [feedbackOpen, setFeedbackOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); - const [openProjectTabRoots, setOpenProjectTabRoots] = useState([]); + const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots); + const setOpenProjectTabRoots = useAppStore((s) => s.setOpenProjectTabRoots); const [openRemoteProjectTabs, setOpenRemoteProjectTabs] = useState< RemoteProjectTab[] >([]); @@ -699,6 +700,10 @@ export function TopBar() { openProjectTabRootsRef.current = openProjectTabRoots; }, [openProjectTabRoots]); + useEffect(() => { + window.ade.app.setWindowProjectTabs(openProjectTabRoots).catch(() => {}); + }, [openProjectTabRoots]); + useEffect(() => { openRemoteProjectTabsRef.current = openRemoteProjectTabs; }, [openRemoteProjectTabs]); @@ -800,7 +805,14 @@ export function TopBar() { window.ade.app .getWindowSession() .then((session) => { - if (!cancelled) setWindowId(session.windowId); + if (cancelled) return; + setWindowId(session.windowId); + if (session.openProjectTabs.length > 0) { + for (const tabProject of session.openProjectTabs) { + useAppStore.getState().rememberProjectInfo(tabProject); + } + setOpenProjectTabRoots(session.openProjectTabs.map((entry) => entry.rootPath)); + } }) .catch(() => { if (!cancelled) setWindowId(null); @@ -808,7 +820,7 @@ export function TopBar() { return () => { cancelled = true; }; - }, []); + }, [setOpenProjectTabRoots]); useEffect(() => { if (!phoneSyncOpen) return; diff --git a/apps/desktop/src/renderer/components/automations/ActionRow.tsx b/apps/desktop/src/renderer/components/automations/ActionRow.tsx index 90bb43507..f8e5119b0 100644 --- a/apps/desktop/src/renderer/components/automations/ActionRow.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionRow.tsx @@ -44,6 +44,7 @@ export type ActionRowValue = { prompt?: string; sessionTitle?: string; modelConfig?: ModelConfig; + codexFastMode?: boolean; permissionConfig?: MissionPermissionConfig; // Create lane laneNameTemplate?: string; @@ -114,6 +115,14 @@ export function ActionRow({ if ("continueOnFailure" in patch && !patch.continueOnFailure) delete next.continueOnFailure; onChange(next); }; + const setCodexFastMode = (enabled: boolean) => { + if (enabled) { + onChange({ ...value, codexFastMode: true }); + return; + } + const { codexFastMode: _codexFastMode, ...rest } = value; + onChange(rest); + }; return (
onChange({ ...value, modelConfig })} compact onOpenAiSettings={onOpenAiSettings} + fastModeActive={value.codexFastMode === true} + onFastModeToggle={setCodexFastMode} />