diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 299264d07..2028ccc5b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -229,6 +229,7 @@ ade init ade lanes list --text ade lanes create "fix-checkout-flow" --parent main ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' +ade lanes reparent lane-child --parent lane-parent --stack-base-branch main ade --role cto linear quick-view --text ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50 ade git commit --lane lane-id @@ -239,6 +240,7 @@ ade diff patch --lane lane-id --path src/file.ts --text ade prs create --lane lane-id --base main --title "Fix checkout flow" ade prs create --lane lane-id --base main --close-linear-issue-on-merge ade prs list-open --text +ade prs github-snapshot --include-external-closed ade prs path-to-merge --pr pr-id --model gpt-5.5 --max-rounds 3 --no-auto-merge ade prs path-to-merge --pr pr-id --model gpt-5.5 --conflict-strategy auto --force-finalize conditional ade prs pipeline pr-id save --conflict-strategy rebase --no-early-merge-on-green diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index a5962a85a..794401464 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -337,6 +337,7 @@ function createRuntime() { void data; return true; }), + readTranscriptTail: vi.fn(async () => ""), enrichSessions: vi.fn((sessions: unknown[]) => sessions), }, testService: { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index e560cbf98..858f60615 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -3592,9 +3592,11 @@ async function waitForSessionCompletion(args: { while (Date.now() <= deadline) { const session = runtime.sessionService.get(sessionId); if (session && session.status !== "running") { - const logTail = runtime.sessionService.readTranscriptTail(session.transcriptPath, maxLogBytes, { + const logTail = await runtime.ptyService.readTranscriptTail({ + sessionId, + maxBytes: maxLogBytes, raw: true, - alignToLineBoundary: true + alignToLineBoundary: true, }); return { session, @@ -3610,9 +3612,11 @@ async function waitForSessionCompletion(args: { session, timedOut: true, logTail: session - ? runtime.sessionService.readTranscriptTail(session.transcriptPath, maxLogBytes, { + ? await runtime.ptyService.readTranscriptTail({ + sessionId, + maxBytes: maxLogBytes, raw: true, - alignToLineBoundary: true + alignToLineBoundary: true, }) : "" }; diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 8d7026460..dcae04efc 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1502,6 +1502,74 @@ describe("ADE CLI", () => { }); }); + it("forwards lane reparent stack base branch override to the runtime action", () => { + const reparent = buildCliPlan([ + "lanes", + "reparent", + "lane-child", + "--parent", + "lane-parent", + "--stack-base-branch", + "develop", + ]); + expect(reparent.kind).toBe("execute"); + if (reparent.kind !== "execute") return; + expect(reparent.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "reparent", + args: { + laneId: "lane-child", + newParentLaneId: "lane-parent", + stackBaseBranchRef: "develop", + }, + }, + }); + + const reparentDefault = buildCliPlan([ + "lanes", + "reparent", + "lane-child", + "--parent", + "lane-parent", + ]); + expect(reparentDefault.kind).toBe("execute"); + if (reparentDefault.kind !== "execute") return; + expect(reparentDefault.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "lane", + action: "reparent", + args: { + laneId: "lane-child", + newParentLaneId: "lane-parent", + }, + }, + }); + }); + + it("forwards PR GitHub snapshot full-history flag to the runtime action", () => { + const snapshot = buildCliPlan([ + "prs", + "github-snapshot", + "--include-external-closed", + ]); + expect(snapshot.kind).toBe("execute"); + if (snapshot.kind !== "execute") return; + expect(snapshot.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "pr", + action: "getGithubSnapshot", + args: { + force: false, + includeExternalClosed: true, + }, + }, + }); + }); + it("maps discoverable git status, sync, and conflict helpers to existing actions", () => { const fullStatus = buildCliPlan([ "git", @@ -1854,6 +1922,16 @@ describe("ADE CLI", () => { // Regression: --text as output flag must not swallow --help. const lanesHelp = buildCliPlan(["lanes", "list", "--text", "--help"]); expect(lanesHelp.kind).toBe("help"); + + const reparentHelp = buildCliPlan([ + "lanes", + "reparent", + "lane-child", + "--stack-base-branch", + "develop", + "--help", + ]); + expect(reparentHelp.kind).toBe("help"); }); it("maps PR create Linear close flag to the typed RPC tool", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 3a5d99831..8c6c620c9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -882,6 +882,9 @@ const HELP_BY_COMMAND: Record = { $ ade lanes archive Archive a lane in ADE $ ade lanes unarchive Restore an archived lane $ ade lanes attach --path --name Attach an external worktree + $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) + $ ade lanes reparent --parent --stack-base-branch + Reparent and stack onto a specific branch (e.g. origin/main) $ ade lanes actions --text List callable lane service methods `, git: `${ADE_BANNER} @@ -947,6 +950,8 @@ const HELP_BY_COMMAND: Record = { $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work + $ ade prs github-snapshot --include-external-closed + Include closed external PR history in the GitHub snapshot $ ade prs inventory Refresh ADE issue inventory $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade prs path-to-merge --model --conflict-strategy auto --force-finalize conditional @@ -2478,6 +2483,23 @@ function buildLanePlan(args: string[]): CliPlan { readLaneId(args) ?? firstPositional(args), "laneId", ); + const reparentArgs: JsonObject = { + laneId, + newParentLaneId: + readValue(args, [ + "--parent", + "--parent-lane", + "--parent-lane-id", + ]) ?? firstPositional(args), + }; + const stackBaseBranchRef = readValue(args, [ + "--stack-base-branch", + "--stack-base", + "--base-branch-ref", + ]); + if (stackBaseBranchRef != null) { + reparentArgs.stackBaseBranchRef = stackBaseBranchRef; + } return { kind: "execute", label: "lane reparent", @@ -2486,15 +2508,7 @@ function buildLanePlan(args: string[]): CliPlan { "result", "lane", "reparent", - collectGenericObjectArgs(args, { - laneId, - newParentLaneId: - readValue(args, [ - "--parent", - "--parent-lane", - "--parent-lane-id", - ]) ?? firstPositional(args), - }), + collectGenericObjectArgs(args, reparentArgs), ), ], }; @@ -3628,7 +3642,13 @@ function buildPrPlan(args: string[]): CliPlan { label: "PR mobile snapshot", steps: [actionArgsListStep("result", "pr", "getMobileSnapshot", [])], }; - if (sub === "github-snapshot") + if (sub === "github-snapshot") { + const snapshotArgs: JsonObject = { + force: readFlag(args, ["--force"]), + }; + if (readFlag(args, ["--include-external-closed", "--include-closed-external"])) { + snapshotArgs.includeExternalClosed = true; + } return { kind: "execute", label: "PR GitHub snapshot", @@ -3637,12 +3657,11 @@ function buildPrPlan(args: string[]): CliPlan { "result", "pr", "getGithubSnapshot", - collectGenericObjectArgs(args, { - force: readFlag(args, ["--force"]), - }), + collectGenericObjectArgs(args, snapshotArgs), ), ], }; + } if (sub === "conflicts") { const mode = firstPositional(args) ?? "list"; if (mode === "list") @@ -8312,6 +8331,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--backend", "--base", "--base-branch", + "--base-branch-ref", "--base-ref", "--body", "--branch", @@ -8449,6 +8469,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--source", "--source-lane", "--stack", + "--stack-base", + "--stack-base-branch", "--stack-id", "--scheme", "--start-point", diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index b36f76db2..55b10a605 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -292,6 +292,7 @@ function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[ }, ptyService: { create: vi.fn(), + readTranscriptTail: vi.fn(async () => ""), enrichSessions: (rows: unknown[]) => rows, }, computerUseArtifactBrokerService: { diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 4bf1045ac..e11feddac 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -2830,11 +2830,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subscribedSessionIds.add(sessionId); const session = args.sessionService.get(sessionId); const transcript = session - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), - { raw: true, alignToLineBoundary: true }, - ) + ? await args.ptyService.readTranscriptTail({ + sessionId, + maxBytes: Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), + raw: true, + alignToLineBoundary: true, + }) : ""; const snapshot: SyncTerminalSnapshotPayload = { sessionId, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 6fb7ae3b3..1040e6130 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -404,9 +404,11 @@ function parseRenameLaneArgs(value: Record): RenameLaneArgs { } function parseReparentLaneArgs(value: Record): ReparentLaneArgs { + const stackBaseBranchRef = asTrimmedString(value.stackBaseBranchRef); return { laneId: requireString(value.laneId, "lanes.reparent requires laneId."), newParentLaneId: requireString(value.newParentLaneId, "lanes.reparent requires newParentLaneId."), + ...(stackBaseBranchRef ? { stackBaseBranchRef } : {}), }; } @@ -2333,7 +2335,10 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("prs.getComments", { viewerAllowed: true }, async (payload) => args.prService.getComments(requirePrId(payload, "prs.getComments"))); register("prs.getFiles", { viewerAllowed: true }, async (payload) => args.prService.getFiles(requirePrId(payload, "prs.getFiles"))); register("prs.getGitHubSnapshot", { viewerAllowed: true }, async (payload) => - args.prService.getGithubSnapshot({ force: payload.force === true })); + args.prService.getGithubSnapshot({ + force: payload.force === true, + includeExternalClosed: payload.includeExternalClosed === true, + })); register("prs.getReviewThreads", { viewerAllowed: true }, async (payload) => args.prService.getReviewThreads(requirePrId(payload, "prs.getReviewThreads"))); register("prs.getActionRuns", { viewerAllowed: true }, async (payload) => args.prService.getActionRuns(requirePrId(payload, "prs.getActionRuns"))); register("prs.getActivity", { viewerAllowed: true }, async (payload) => args.prService.getActivity(requirePrId(payload, "prs.getActivity"))); diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index a71a5b091..6594cc435 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -174,6 +174,85 @@ describe("subagentSnapshotsFromEvents", () => { summary: "done", }); }); + + it("adopts the resolved task id when a runtime placeholder has the same parent tool id", () => { + const snapshots = subagentSnapshotsFromEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "spawn-1", + parentToolUseId: "spawn-1", + description: "Parallel agent", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "subagent_progress", + taskId: "thread-1", + parentToolUseId: "spawn-1", + summary: "working", + }, + }, + ]); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "thread-1", + name: "Parallel agent", + parentToolUseId: "spawn-1", + status: "running", + summary: "working", + }); + }); + + it("stops foreground subagents when their parent turn has ended", () => { + const snapshots = subagentSnapshotsFromEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "agent-1", + description: "Foreground agent", + turnId: "turn-1", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { + type: "subagent_started", + taskId: "agent-bg", + description: "Background agent", + background: true, + turnId: "turn-1", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { type: "done", turnId: "turn-1", status: "completed" }, + }, + ]); + + expect(snapshots.find((snapshot) => snapshot.id === "agent-1")).toMatchObject({ + status: "stopped", + summary: "Parent turn ended before ADE received a final subagent status", + }); + expect(snapshots.find((snapshot) => snapshot.id === "agent-bg")).toMatchObject({ + status: "running", + background: true, + }); + }); }); describe("clampChatScrollOffsetRows", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 8efedb9fb..15e783656 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -28,6 +28,18 @@ describe("commands", () => { })); }); + it("routes lane reparent to the right pane", () => { + const parsed = parseCommand("/reparent lane-parent origin/main"); + expect(parsed?.name).toBe("/reparent"); + expect(parsed?.args).toBe("lane-parent origin/main"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(paletteCommands("/rep")).toContainEqual(expect.objectContaining({ + name: "/reparent", + source: "ade", + description: "Move the active lane under another lane", + })); + }); + it("routes /feedback to the ADE Code right pane", () => { const parsed = parseCommand("/feedback"); expect(parsed?.spec?.name).toBe("/feedback"); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 96d6ce63b..0b5dd51ad 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -524,8 +524,32 @@ function formatContextUsage(usage: AgentChatContextUsage | null): string { .join("\n"); } +function findSubagentSnapshotByParent( + snapshots: Map, + parentToolUseId: string | null | undefined, +): [string, SubagentSnapshot] | null { + if (!parentToolUseId) return null; + const direct = snapshots.get(parentToolUseId); + if (direct) return [parentToolUseId, direct]; + for (const [key, snapshot] of snapshots) { + if (snapshot.parentToolUseId === parentToolUseId) return [key, snapshot]; + } + return null; +} + export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): SubagentSnapshot[] { const snapshots = new Map(); + const terminalTurnIds = new Set(); + + 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 : ""; @@ -579,26 +603,23 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; const id = agentId ?? taskId; if (!id) continue; - const existing = snapshots.get(id) ?? (taskId ? snapshots.get(taskId) : undefined); + const incomingParentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() + ? event.parentToolUseId.trim() + : null; + const parentMatch = findSubagentSnapshotByParent(snapshots, incomingParentToolUseId); + const existing = snapshots.get(id) ?? (taskId ? snapshots.get(taskId) : undefined) ?? parentMatch?.[1]; if (taskId && id !== taskId) snapshots.delete(taskId); + if (parentMatch && parentMatch[0] !== id) snapshots.delete(parentMatch[0]); const agentType = typeof event.agentType === "string" ? event.agentType : "subagent"; const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : {}; - const parentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() - ? event.parentToolUseId.trim() - : existing?.parentToolUseId ?? null; + 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 summary = typeof event.summary === "string" - ? event.summary - : typeof event.finalSummary === "string" - ? event.finalSummary - : typeof event.text === "string" - ? event.text - : typeof event.description === "string" - ? event.description - : existing?.summary ?? ""; + 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, @@ -621,6 +642,24 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S 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()]; } @@ -641,6 +680,51 @@ function laneWorktreeUnavailableMessage(lane: LaneSummary | null | undefined): s return `Lane "${lane.name}" is missing its worktree at ${pathLabel}. Restore or recreate the lane before starting a chat.`; } +function collectDescendantLaneIds(rootId: string, lanes: LaneSummary[]): Set { + const childrenByParent = new Map(); + for (const lane of lanes) { + if (!lane.parentLaneId) continue; + const children = childrenByParent.get(lane.parentLaneId) ?? []; + children.push(lane); + childrenByParent.set(lane.parentLaneId, children); + } + const descendants = new Set(); + const stack = [...(childrenByParent.get(rootId) ?? [])]; + while (stack.length) { + const lane = stack.pop(); + if (!lane || descendants.has(lane.id)) continue; + descendants.add(lane.id); + stack.push(...(childrenByParent.get(lane.id) ?? [])); + } + return descendants; +} + +function reparentTargetsForLane(lane: LaneSummary, lanes: LaneSummary[]): LaneSummary[] { + const descendants = collectDescendantLaneIds(lane.id, lanes); + return lanes + .filter((candidate) => !candidate.archivedAt && candidate.id !== lane.id && !descendants.has(candidate.id)) + .sort((left, right) => { + const leftPrimary = left.laneType === "primary" ? 0 : 1; + const rightPrimary = right.laneType === "primary" ? 0 : 1; + if (leftPrimary !== rightPrimary) return leftPrimary - rightPrimary; + return left.name.localeCompare(right.name); + }); +} + +function resolveLaneReference(lanes: LaneSummary[], reference: string): LaneSummary | null { + const normalized = reference.trim().toLowerCase(); + if (!normalized) return null; + const exact = lanes.find((lane) => ( + lane.id.toLowerCase() === normalized || lane.name.toLowerCase() === normalized + )); + if (exact) return exact; + // Only accept partial-name matches when they resolve uniquely. The previous + // implementation picked the first `includes()` hit, which could silently + // pick the wrong lane (and target the wrong rebase) for `/reparent`. + const partialMatches = lanes.filter((lane) => lane.name.toLowerCase().includes(normalized)); + return partialMatches.length === 1 ? partialMatches[0] ?? null : null; +} + function seedLaneDetails( lane: LaneSummary, worktreeAvailable = isLaneWorktreeAvailable(lane), @@ -3083,6 +3167,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } newChatPreviewLaneIdRef.current = nextLaneId; } let nextEvents: AgentChatEventEnvelope[] | null = null; + let selectedSessionFound = true; if (nextSessionId && !nextTerminalSession) { const shouldHydrateHistory = shouldHydrateRefreshHistory({ hydrateHistory: options.hydrateHistory, @@ -3093,21 +3178,35 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (shouldHydrateHistory) { const history = await getChatHistory(conn, nextSessionId); if (!isCurrentRefresh()) return; - setCurrentGoal(latestGoal(history.events)); - nextEvents = clearedAt - ? history.events.filter((event) => event.timestamp > clearedAt) - : history.events; - const activeModelId = nextSession?.modelId ?? null; - const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; - const stats = latestTokenStats(history.events, fallbackContext); - setContextPercent(stats.percent); - setTokenSummary(formatTokenSummary(stats)); - setStatusLineStats(stats); - eventCountRef.current = history.events.length; - loadedSessionIdRef.current = nextSessionId; - } - setStreaming(nextSession?.status === "active"); - if (nextSession?.status === "active") setInterrupted(false); + if (history.sessionFound === false) { + selectedSessionFound = false; + setCurrentGoal(null); + setContextPercent(null); + setTokenSummary(null); + setStatusLineStats(null); + // The replacement view should not carry stale interrupted state from + // a previously-selected chat that we've now lost track of. + setInterrupted(false); + eventCountRef.current = 0; + loadedSessionIdRef.current = null; + nextEvents = []; + } else { + setCurrentGoal(latestGoal(history.events)); + nextEvents = clearedAt + ? history.events.filter((event) => event.timestamp > clearedAt) + : history.events; + const activeModelId = nextSession?.modelId ?? null; + const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; + const stats = latestTokenStats(history.events, fallbackContext); + setContextPercent(stats.percent); + setTokenSummary(formatTokenSummary(stats)); + setStatusLineStats(stats); + eventCountRef.current = history.events.length; + loadedSessionIdRef.current = nextSessionId; + } + } + setStreaming(selectedSessionFound && nextSession?.status === "active"); + if (selectedSessionFound && nextSession?.status === "active") setInterrupted(false); } else { setContextPercent(null); setTokenSummary(null); @@ -4181,6 +4280,58 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "list", title: "Recent commits", rows: routeRows(log), emptyText: "No commits." }); return; } + if (name === "/reparent") { + const showReparentDetails = (body: string): void => { + setRightPane({ kind: "details", title: "Reparent lane", body }); + }; + if (!laneId) { + showReparentDetails("No active lane is selected."); + return; + } + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + if (!lane) { + showReparentDetails(`Lane ${laneId} is not loaded.`); + return; + } + if (lane.laneType === "primary") { + showReparentDetails("Primary lane cannot be reparented."); + return; + } + const targets = reparentTargetsForLane(lane, lanes); + const parsed = splitFirstArg(args); + if (!parsed.first) { + const rows = targets.map((target) => { + const current = target.id === (lane.parentLaneId ?? "") ? "current" : target.laneType; + return `${target.id.padEnd(18)} ${target.name} · ${target.branchRef} · ${current}`; + }); + showReparentDetails([ + "Usage: /reparent [stack-base-ref]", + "", + "Moves the active lane under another parent and runs git rebase. The optional stack-base-ref overrides the parent branch, for example origin/main.", + "", + rows.length ? rows.join("\n") : "No valid parent lanes are available.", + ].join("\n")); + return; + } + const parent = resolveLaneReference(targets, parsed.first); + if (!parent) { + showReparentDetails(`No valid parent lane matched "${parsed.first}". Run /reparent to list targets.`); + return; + } + const stackBaseBranchRef = parsed.rest.trim(); + const result = await conn.action("lane", "reparent", { + laneId, + newParentLaneId: parent.id, + ...(stackBaseBranchRef ? { stackBaseBranchRef } : {}), + }); + showReparentDetails(renderObject(result, 20)); + addNotice( + `Reparented ${lane.name} under ${parent.name}${stackBaseBranchRef ? ` using ${stackBaseBranchRef}` : ""}.`, + "success", + ); + await refreshState(); + return; + } if (name.startsWith("/pr")) { if (!laneId) { setRightPane({ kind: "details", title: name.slice(1) || "PR", body: "No active lane is selected." }); diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 7bfc8380f..dedd6231e 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -45,6 +45,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/goal", description: "Set, clear, or inspect the active chat goal", placement: "chat", argumentHint: "[|clear|status active|paused|complete|budget |budget clear]", providers: ["claude", "codex"] }, { name: "/diff", description: "Show active lane diff", placement: "right" }, { name: "/log", description: "Show recent commits", placement: "right" }, + { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: " [stack-base-ref]" }, { name: "/pr", description: "Show pull request state", placement: "right" }, { name: "/pr open", description: "Create or open a PR for the active lane", placement: "right" }, { name: "/pr review", description: "Show PR reviews", placement: "right" }, diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 35abefb27..ee7a144d9 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -31,7 +31,7 @@ export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ { k: "c", label: "commit", slashCommand: "/commit", detail: "claude will draft message" }, { k: "p", label: "push", slashCommand: "/push" }, { k: "d", label: "diff", slashCommand: "/diff" }, - { k: "r", label: "rebase onto main", slashCommand: "/rebase" }, + { k: "r", label: "reparent", slashCommand: "/reparent", detail: "optional base ref" }, ]; // --------------------------------------------------------------------------- diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index cbee23cf9..da6c097c0 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -7,6 +7,7 @@ import type { AgentChatCursorConfigValue, AgentChatDroidPermissionMode, AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatInteractionMode, AgentChatModelInfo, AgentChatOpenCodePermissionMode, @@ -29,11 +30,7 @@ export type ProjectLaunchContext = { laneHint: string | null; }; -export type ChatHistorySnapshot = { - sessionId: string; - events: AgentChatEventEnvelope[]; - truncated: boolean; -}; +export type ChatHistorySnapshot = AgentChatEventHistorySnapshot; export type RunAdeActionResult = { domain: string; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 4d4e80b85..d046efc9d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1366,6 +1366,14 @@ app.whenReady().then(async () => { return keepAliveOnProbeFailure("sessions", error); } + try { + if (ctx.agentChatService?.hasActiveWorkloads()) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("agent_chats", error); + } + try { if (ctx.missionService?.list({ status: "active", limit: 1 }).length > 0) { return true; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index fdabfdbe0..66c0adcd7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -748,6 +748,15 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { } return undefined; }), + stopTask: vi.fn(async (taskId: string) => { + if (typeof session.stopTask === "function") { + return session.stopTask(taskId); + } + if (typeof session.query?.stopTask === "function") { + return session.query.stopTask(taskId); + } + return undefined; + }), setPermissionMode: vi.fn(async (mode: string) => { if (typeof session.setPermissionMode === "function") { return session.setPermissionMode(mode); @@ -1408,6 +1417,7 @@ describe("createAgentChatService", () => { expect(service.resumeSession).toBeTypeOf("function"); expect(service.listSessions).toBeTypeOf("function"); expect(service.getSessionSummary).toBeTypeOf("function"); + expect(service.hasActiveWorkloads).toBeTypeOf("function"); expect(service.getChatTranscript).toBeTypeOf("function"); expect(service.ensureIdentitySession).toBeTypeOf("function"); expect(service.approveToolUse).toBeTypeOf("function"); @@ -1493,6 +1503,93 @@ describe("createAgentChatService", () => { expect(session.model).toBe("sonnet"); }); + it("derives Claude Opus 1M from bracketed model aliases", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-7[1m]", + }); + + expect(session.modelId).toBe("anthropic/claude-opus-4-7-1m"); + expect(session.model).toBe("claude-opus-4-7[1m]"); + }); + + it("preserves Claude Opus 1M in done events when the SDK reports base Opus", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-opus-1m", + model: "claude-opus-4-7", + slash_commands: [], + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-opus-1m", + usage: { input_tokens: 1, output_tokens: 1 }, + modelUsage: { "claude-opus-4-7": { input_tokens: 1, output_tokens: 1 } }, + }; + return; + } + yield { + type: "system", + subtype: "init", + session_id: "sdk-opus-1m", + model: "claude-opus-4-7", + slash_commands: [], + }; + yield { + type: "assistant", + message: { + model: "claude-opus-4-7", + content: [{ type: "text", text: "Done" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-opus-1m", + usage: { input_tokens: 1, output_tokens: 1 }, + modelUsage: { "claude-opus-4-7": { input_tokens: 1, output_tokens: 1 } }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-opus-1m", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-7[1m]", + modelId: "anthropic/claude-opus-4-7-1m", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Report the selected model.", + }); + + const doneEvent = events.filter((event) => event.event.type === "done").at(-1); + expect(doneEvent?.event.type).toBe("done"); + expect((doneEvent!.event as any).model).toBe("claude-opus-4-7[1m]"); + expect((doneEvent!.event as any).modelId).toBe("anthropic/claude-opus-4-7-1m"); + }); + it("honors an explicit initial chat title", async () => { const { service, sessionService } = createService(); const session = await service.createSession({ @@ -5022,6 +5119,97 @@ describe("createAgentChatService", () => { // dispose and disposeAll // -------------------------------------------------------------------------- + describe("hasActiveWorkloads", () => { + it("reports active Codex app-server turns so project rebalancing keeps their context alive", 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", + }); + + expect(service.hasActiveWorkloads()).toBe(false); + + await service.sendMessage({ + sessionId: session.id, + text: "Keep this turn alive during a project switch.", + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + expect(service.hasActiveWorkloads()).toBe(true); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-1", status: "completed" } }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" + && event.event.status === "completed" + && event.event.turnId === "turn-1", + ); + + expect(service.hasActiveWorkloads()).toBe(false); + }); + + it("does not treat an idle reusable Claude query as an active workload", async () => { + const events: AgentChatEventEnvelope[] = []; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-claude", + slash_commands: [], + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-claude", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-idle-claude", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Complete a short turn.", + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" + && event.event.status === "completed", + ); + + expect(service.hasActiveWorkloads()).toBe(false); + }); + }); + describe("dispose", () => { it("only writes the persisted chat summary when the session is explicitly disposed", async () => { vi.mocked(streamText).mockReturnValue({ @@ -5242,6 +5430,57 @@ describe("createAgentChatService", () => { releaseStream(); } }); + + it("marks an active Codex app-server turn interrupted during shutdown", 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: "Start a turn that is still active during shutdown.", + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + service.forceDisposeAll(); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + message: expect.stringMatching(/stopped this Codex turn/i), + turnId: "turn-1", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "status", + turnStatus: "interrupted", + turnId: "turn-1", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "done", + status: "interrupted", + turnId: "turn-1", + }), + }), + ])); + }); }); describe("deleteSession", () => { @@ -6625,6 +6864,35 @@ describe("createAgentChatService", () => { ])); }); + it("starts a normal Codex turn when steering stale active UI state", 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", + }); + + const result = await service.steer({ + sessionId: session.id, + text: "Recover from a stale active marker.", + }); + + expect(result.queued).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "user_message", + text: "Recover from a stale active marker.", + }), + }), + ])); + }); + it("sends Codex image steer payloads as localImage input blocks", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -6877,6 +7145,171 @@ describe("createAgentChatService", () => { ).toBe("completed"); }); + it("coalesces Codex spawn placeholders when the app-server reveals the agent thread later", 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 repository scan.", + }, { 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: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + prompt: "Inspect the shared chat renderer", + }, + }, + }); + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + status: "running", + }), + ]); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + status: "completed", + newThreadId: "agent-thread-1", + prompt: "Inspect the shared chat renderer", + }, + }, + }); + + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "running", + }), + ]); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "call-wait-1", + type: "collabAgentToolCall", + tool: "wait", + status: "completed", + agentsStates: { + "agent-thread-1": { + status: "completed", + message: "Renderer path mapped.", + }, + }, + }, + }, + }); + + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Renderer path mapped.", + }), + ]); + }); + + it("stops foreground Codex subagents when the parent turn completes without a terminal subagent event", 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 repository scan.", + }, { 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: "call-spawn-1", + type: "collabAgentToolCall", + tool: "spawn_agent", + receiverThreadIds: ["agent-thread-1"], + prompt: "Inspect the shared chat renderer", + }, + }, + }); + + expect(service.hasActiveWorkloads()).toBe(true); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-1", status: "completed" } }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" + && event.event.status === "completed" + && event.event.turnId === "turn-1", + ); + + expect(service.listSubagents({ sessionId: session.id })).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "stopped", + summary: "Parent turn completed before ADE received a final subagent status", + }), + ]); + expect(service.hasActiveWorkloads()).toBe(false); + }); + it("does not add Codex cache breakdown tokens to derived totals", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -7441,6 +7874,7 @@ describe("createAgentChatService", () => { const history = service.getChatEventHistory("unknown-session"); expect(history.events).toEqual([]); expect(history.truncated).toBe(false); + expect(history.sessionFound).toBe(false); }); it("hydrates history from the on-disk transcript on first read", async () => { @@ -7476,6 +7910,7 @@ describe("createAgentChatService", () => { const history = service.getChatEventHistory(session.id); expect(history.sessionId).toBe(session.id); + expect(history.sessionFound).toBe(true); expect(history.events).toHaveLength(2); expect(history.events.map((envelope) => envelope.event.type === "text" ? envelope.event.text : "", @@ -7631,6 +8066,7 @@ describe("createAgentChatService", () => { const afterDelete = service.getChatEventHistory(session.id); expect(afterDelete.events).toEqual([]); expect(afterDelete.truncated).toBe(false); + expect(afterDelete.sessionFound).toBe(false); }); }); @@ -8027,7 +8463,7 @@ describe("createAgentChatService", () => { }, }); - await waitForEvent( + const completedPlanEvent = await waitForEvent( events, (event): event is AgentChatEventEnvelope & { event: Extract; @@ -8037,6 +8473,8 @@ describe("createAgentChatService", () => { && event.event.state === "complete" && (event.event.streamingText ?? "").includes("Inspect the streamed plan"), ); + expect(completedPlanEvent.event.state).toBe("complete"); + expect(completedPlanEvent.event.streamingText).toContain("Inspect the streamed plan"); }); it("emits a terminal event when a native Codex plan item completes without text", async () => { @@ -10356,6 +10794,7 @@ describe("createAgentChatService", () => { const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); const send = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stopTask = vi.fn().mockResolvedValue(undefined); const stream = vi.fn(() => (async function* () { streamCall += 1; if (streamCall === 1) { @@ -10394,6 +10833,7 @@ describe("createAgentChatService", () => { close: vi.fn(), sessionId: "sdk-interrupt-sub-1", setPermissionMode, + stopTask, } as any); const { service } = createService({ @@ -10433,6 +10873,8 @@ describe("createAgentChatService", () => { const stoppedTaskIds = stoppedEvents.map((e) => (e.event as any).taskId).sort(); expect(stoppedTaskIds).toEqual(["sub-task-1", "sub-task-2"]); + expect(stopTask).toHaveBeenCalledTimes(2); + expect(stopTask.mock.calls.map((call) => call[0]).sort()).toEqual(["sub-task-1", "sub-task-2"]); // After interrupt, listSubagents should reflect the stopped status const subagents = service.listSubagents({ sessionId: session.id }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4336103d2..ee0ab09f4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -111,6 +111,7 @@ import type { AgentChatExecutionMode, AgentChatEvent, AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatContextAttachment, AgentChatFileRef, AgentChatHandoffArgs, @@ -462,7 +463,12 @@ type CodexRuntime = { webSearchActionsByItemId: Map; planningApprovalGuardByTurnId: Map; pendingTurnPlanningApprovalGuarded: boolean | null; - activeSubagents: Map; + activeSubagents: Map; interruptedTurnIds: Set; ignoredTurnIds: Set; terminalTurnIds: Set; @@ -1007,6 +1013,57 @@ function hasLivePendingInput(managed: ManagedChatSession | null | undefined): bo return false; } +function hasRuntimeActiveWorkload(runtime: ChatRuntime | null): boolean { + if (!runtime) return false; + switch (runtime.kind) { + case "codex": + return Boolean( + runtime.activeTurnId + || runtime.startedTurnId + || runtime.awaitingTurnStart + || runtime.manualCompactionPending + || runtime.pending.size > 0 + || runtime.approvals.size > 0 + || runtime.activeSubagents.size > 0 + || runtime.pendingPlanFollowups.length > 0 + ); + case "claude": + return Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.pendingSteers.length > 0 + || runtime.approvals.size > 0 + || runtime.activeSubagents.size > 0 + ); + case "opencode": + return Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.eventAbortController + || runtime.pendingApprovals.size > 0 + || runtime.pendingSteers.length > 0 + ); + case "cursor": + return Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.activeCloudRunId + || runtime.cloudRuns.size > 0 + || runtime.pendingSteers.length > 0 + || runtime.permissionWaiters.size > 0 + ); + case "droid": + return Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.pendingSteers.length > 0 + || runtime.permissionWaiters.size > 0 + ); + default: + return false; + } +} + function isSignalPermissionError(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "EPERM"); } @@ -1574,6 +1631,7 @@ const MAX_INJECTED_PROJECT_COMMANDS = 20; const MAX_INJECTED_PROJECT_SKILLS = 20; const CURSOR_SDK_AGENT_PROTOCOL_VERSION = 2; const CLAUDE_WARMUP_WAIT_TIMEOUT_MS = 20_000; +const CLAUDE_STOP_TASK_TIMEOUT_MS = 2_000; const DEFAULT_CODEX_DESCRIPTOR = getDefaultModelDescriptor("codex"); const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); @@ -2609,21 +2667,26 @@ function resolveClaudeCliModelIdFromRuntimeValue(model: string): string | undefi .replace(/-api$/, ""); const inputs = [normalized, normalizedWithoutProvider]; + const descriptors = listModelDescriptorsForProvider("claude"); - return listModelDescriptorsForProvider("claude").find((descriptor) => { + const exactMatch = descriptors.find((descriptor) => { const descriptorShortId = descriptor.shortId.toLowerCase(); const candidates = new Set([ descriptor.id.toLowerCase(), descriptorShortId, descriptor.providerModelId.toLowerCase(), descriptor.id.toLowerCase().replace(/^anthropic\//, ""), + ...(descriptor.aliases ?? []).map((alias) => alias.toLowerCase()), ]); - if (inputs.some((input) => candidates.has(input))) return true; + return inputs.some((input) => candidates.has(input)); + }); + if (exactMatch) return exactMatch.id; + return descriptors.find((descriptor) => { + const descriptorShortId = descriptor.shortId.toLowerCase(); return normalizedWithoutProvider === `claude-${descriptorShortId}` - || normalizedWithoutProvider.startsWith(`claude-${descriptorShortId}-`) - || normalizedWithoutProvider.includes(descriptorShortId); + || normalizedWithoutProvider.startsWith(`claude-${descriptorShortId}-`); })?.id; } @@ -2690,6 +2753,28 @@ function resolveClaudeTurnModelPayload( session: Pick, candidates: Array, ): { model: string; modelId?: string } { + const sessionModelId = session.modelId ?? resolveModelIdFromStoredValue(session.model, "claude"); + const sessionPayload = { + model: session.model, + ...(sessionModelId ? { modelId: sessionModelId } : {}), + }; + let selectedDescriptor: ReturnType | null = null; + if (session.modelId) { + selectedDescriptor = getModelById(session.modelId) ?? resolveModelAlias(session.modelId); + } else if (sessionModelId) { + selectedDescriptor = getModelById(sessionModelId); + } + const selectedIsOpusOneMillion = + selectedDescriptor?.id === "anthropic/claude-opus-4-7-1m" + || selectedDescriptor?.shortId === "opus-1m" + || selectedDescriptor?.providerModelId.toLowerCase().includes("[1m]"); + const shouldPreserveSelectedModel = (reportedModelId: string | undefined): boolean => { + if (!reportedModelId || reportedModelId === session.modelId) return false; + if (!selectedIsOpusOneMillion) return false; + const reportedDescriptor = getModelById(reportedModelId) ?? resolveModelAlias(reportedModelId); + return reportedDescriptor?.id === "anthropic/claude-opus-4-7"; + }; + for (const candidate of candidates) { const normalized = normalizeReportedModelName(candidate); if (!normalized) continue; @@ -2698,21 +2783,20 @@ function resolveClaudeTurnModelPayload( resolveClaudeCliModelIdFromRuntimeValue(normalized) ?? resolveClaudeCliModelIdFromRuntimeValue(normalizedCliModel); if (resolvedCliModelId) { + if (shouldPreserveSelectedModel(resolvedCliModelId)) return sessionPayload; return { model: normalized, modelId: resolvedCliModelId }; } const resolvedModelId = resolveModelIdFromStoredValue(normalized, "claude") ?? resolveModelIdFromStoredValue(normalizedCliModel, "claude"); if (resolvedModelId) { + if (shouldPreserveSelectedModel(resolvedModelId)) return sessionPayload; return { model: normalized, modelId: resolvedModelId }; } return { model: normalized }; } - return { - model: session.model, - ...(session.modelId ? { modelId: session.modelId } : {}), - }; + return sessionPayload; } function fallbackModelForProvider(provider: AgentChatProvider): string { @@ -5149,13 +5233,28 @@ export function createAgentChatService(args: { subagentStates.delete(sessionId); }; + const findSubagentSnapshotByParent = ( + map: Map, + parentToolUseId: string | null | undefined, + ): [string, AgentChatSubagentSnapshot] | null => { + if (!parentToolUseId) return null; + const direct = map.get(parentToolUseId); + if (direct) return [parentToolUseId, direct]; + for (const [key, snapshot] of map) { + if (snapshot.parentToolUseId === parentToolUseId) return [key, snapshot]; + } + return null; + }; + const trackSubagentEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { if (event.type !== "subagent_started" && event.type !== "subagent_progress" && event.type !== "subagent_result") return; const map = ensureSubagentSnapshotMap(managed.session.id); if (event.type === "subagent_started") { const key = event.agentId ?? event.taskId; - const previous = map.get(key) ?? map.get(event.taskId); + const parentMatch = findSubagentSnapshotByParent(map, event.parentToolUseId); + const previous = map.get(key) ?? map.get(event.taskId) ?? parentMatch?.[1]; if (key !== event.taskId) map.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) map.delete(parentMatch[0]); map.set(key, { taskId: event.taskId, agentId: event.agentId ?? previous?.agentId, @@ -5171,10 +5270,13 @@ export function createAgentChatService(args: { } if (event.type === "subagent_progress") { const key = event.agentId ?? event.taskId; - const previous = map.get(key) ?? map.get(event.taskId); + const parentMatch = findSubagentSnapshotByParent(map, event.parentToolUseId); + const previous = map.get(key) ?? map.get(event.taskId) ?? parentMatch?.[1]; + const adoptEventTaskId = Boolean(parentMatch && parentMatch[0] !== key); if (key !== event.taskId) map.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) map.delete(parentMatch[0]); map.set(key, { - taskId: previous?.taskId ?? event.taskId, + taskId: adoptEventTaskId ? event.taskId : previous?.taskId ?? event.taskId, agentId: event.agentId ?? previous?.agentId, agentType: event.agentType ?? previous?.agentType, parentToolUseId: event.parentToolUseId ?? previous?.parentToolUseId ?? null, @@ -5190,15 +5292,18 @@ export function createAgentChatService(args: { return; } const key = event.agentId ?? event.taskId; - const previous = map.get(key) ?? map.get(event.taskId); + const parentMatch = findSubagentSnapshotByParent(map, event.parentToolUseId); + const previous = map.get(key) ?? map.get(event.taskId) ?? parentMatch?.[1]; + const adoptEventTaskId = Boolean(parentMatch && parentMatch[0] !== key); if (key !== event.taskId) map.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) map.delete(parentMatch[0]); const status = event.status === "failed" ? "failed" : event.status === "stopped" ? "stopped" : "completed"; map.set(key, { - taskId: previous?.taskId ?? event.taskId, + taskId: adoptEventTaskId ? event.taskId : previous?.taskId ?? event.taskId, agentId: event.agentId ?? previous?.agentId, agentType: event.agentType ?? previous?.agentType, parentToolUseId: event.parentToolUseId ?? previous?.parentToolUseId ?? null, @@ -5333,6 +5438,7 @@ export function createAgentChatService(args: { getContextUsage?: () => Promise; rewindFiles?: (userMessageId: string, options?: { dryRun?: boolean }) => Promise; interrupt?: () => Promise; + stopTask?: ClaudeQuery["stopTask"]; } => { return { setPermissionMode: typeof sessionQuery?.setPermissionMode === "function" @@ -5356,6 +5462,9 @@ export function createAgentChatService(args: { interrupt: typeof sessionQuery?.interrupt === "function" ? sessionQuery.interrupt.bind(sessionQuery) : undefined, + stopTask: typeof sessionQuery?.stopTask === "function" + ? sessionQuery.stopTask.bind(sessionQuery) + : undefined, }; }; @@ -5648,17 +5757,17 @@ export function createAgentChatService(args: { const getChatEventHistory = ( sessionId: string, options?: { maxEvents?: number }, - ): { sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean } => { + ): AgentChatEventHistorySnapshot => { const trimmedId = sessionId.trim(); if (!trimmedId.length) { - return { sessionId: trimmedId, events: [], truncated: false }; + return { sessionId: trimmedId, events: [], truncated: false, sessionFound: false }; } // Validate the session belongs to an agent chat before reading any // transcript path — this function is reachable via IPC and builds // filesystem paths from `trimmedId` downstream. const row = sessionService.get(trimmedId); if (!row || !isChatToolType(row.toolType)) { - return { sessionId: trimmedId, events: [], truncated: false }; + return { sessionId: trimmedId, events: [], truncated: false, sessionFound: false }; } const maxEvents = Math.max( 1, @@ -5681,7 +5790,7 @@ export function createAgentChatService(args: { const truncated = merged.length > maxEvents; const windowed = truncated ? merged.slice(-maxEvents) : merged; - return { sessionId: trimmedId, events: windowed, truncated }; + return { sessionId: trimmedId, events: windowed, truncated, sessionFound: true }; }; const deriveTranscriptTurnActive = (entries: AgentChatEventEnvelope[]): boolean => { @@ -7919,21 +8028,53 @@ export function createAgentChatService(args: { const preserveClaudeResumeState = managed.runtime.kind === "claude" && reasonAllowsPreservation; if (managed.runtime?.kind === "codex") { - managed.runtime.suppressExitError = true; - try { managed.runtime.reader.close(); } catch { /* ignore */ } - managed.runtime.killTimer = terminateChildProcessTree( - managed.runtime.process, - managed.runtime.killTimer, + const runtime = managed.runtime; + const interruptedTurnId = runtime.activeTurnId ?? runtime.startedTurnId ?? null; + const shouldMarkInterrupted = + reasonAllowsPreservation + && managed.session.status === "active" + && !managed.deleted; + runtime.suppressExitError = true; + try { runtime.reader.close(); } catch { /* ignore */ } + runtime.killTimer = terminateChildProcessTree( + runtime.process, + runtime.killTimer, ); - managed.runtime.pending.clear(); - for (const followup of managed.runtime.pendingPlanFollowups.splice(0)) { + runtime.pending.clear(); + for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { itemId: followup.itemId, decision: "cancel", turnId: followup.turnId, }); } - managed.runtime.approvals.clear(); + runtime.approvals.clear(); + if (shouldMarkInterrupted) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "ADE stopped this Codex turn while the app or project was closing. Send again to continue the saved thread.", + ...(interruptedTurnId ? { turnId: interruptedTurnId } : {}), + }); + emitChatEvent(managed, { + type: "status", + turnStatus: "interrupted", + message: "Stopped while ADE was closing.", + ...(interruptedTurnId ? { turnId: interruptedTurnId } : {}), + }); + if (interruptedTurnId) { + void emitTurnDiffSummaryIfChanged(managed, interruptedTurnId); + emitChatEvent(managed, { + type: "done", + turnId: interruptedTurnId, + status: "interrupted", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + } + markSessionIdleWithFreshCache(managed); + persistChatState(managed); + } managed.runtime = null; } if (managed.runtime?.kind === "claude") { @@ -9987,6 +10128,9 @@ export function createAgentChatService(args: { clearClaudeTurnTimers(); runtime.pauseIdleWatchdog = null; runtime.resumeIdleWatchdog = null; + if (runtime.interrupted) { + await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); + } flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); // Note: query is NOT closed here — it stays alive for the next turn. runtime.busy = false; @@ -10050,6 +10194,9 @@ export function createAgentChatService(args: { runtime.interrupted || isAbortRelatedError(effectiveError) ? "interrupted" : "failed"; + if (finalToolStatus === "interrupted") { + await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); + } flushOpenClaudeToolUses(finalToolStatus); // Only close the query on genuine errors. User interrupts close and @@ -11816,18 +11963,76 @@ export function createAgentChatService(args: { runtime: CodexRuntime, turnId: string | undefined, summary: string, + options?: { includeBackground?: boolean }, ): void => { if (runtime.activeSubagents.size === 0) return; - for (const { taskId } of runtime.activeSubagents.values()) { + const includeBackground = options?.includeBackground ?? true; + for (const { taskId, parentToolUseId, background } of [...runtime.activeSubagents.values()]) { + if (!includeBackground && background) continue; emitChatEvent(managed, { type: "subagent_result", taskId, + parentToolUseId, status: "stopped", summary, turnId, }); + runtime.activeSubagents.delete(taskId); + } + if (includeBackground) { + runtime.activeSubagents.clear(); + } + }; + + const stopActiveClaudeSubagents = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + turnId: string | undefined, + summary: string, + ): Promise => { + const activeSubagents = [...runtime.activeSubagents.values()]; + if (activeSubagents.length === 0) return; + + const control = getClaudeQueryControl(runtime.query); + for (const subagent of activeSubagents) { + if (!runtime.activeSubagents.has(subagent.taskId)) continue; + runtime.activeSubagents.delete(subagent.taskId); + if (typeof control.stopTask === "function") { + let timeoutHandle: ReturnType | null = null; + try { + const stopTaskPromise = Promise.resolve(control.stopTask(subagent.taskId)); + stopTaskPromise.catch(() => { + // The awaited race below handles timely rejections. This catch only + // prevents an unhandled rejection if the SDK rejects after our timeout. + }); + await Promise.race([ + stopTaskPromise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Timed out stopping Claude task after ${CLAUDE_STOP_TASK_TIMEOUT_MS}ms`)); + }, CLAUDE_STOP_TASK_TIMEOUT_MS); + }), + ]); + } catch (error) { + logger.warn("agent_chat.claude_stop_task_failed", { + sessionId: managed.session.id, + taskId: subagent.taskId, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } + } + emitChatEvent(managed, { + type: "subagent_result", + taskId: subagent.taskId, + parentToolUseId: subagent.parentToolUseId ?? undefined, + status: "stopped", + summary, + finalSummary: summary, + turnId, + }); } - runtime.activeSubagents.clear(); }; type CodexCollabAgentState = { @@ -12100,6 +12305,7 @@ export function createAgentChatService(args: { taskId: itemId, description: String(item.description ?? item.title ?? "Delegated task"), background: isBackgroundTask(item as Record), + parentToolUseId: null, }); emitChatEvent(managed, { type: "subagent_started", @@ -12132,6 +12338,7 @@ export function createAgentChatService(args: { if (tool === "spawn_agent" && eventKind === "started") { const taskIds = receiverIds.length ? receiverIds : [itemId]; + const background = isBackgroundTask(item as Record); emitChatEvent(managed, { type: "activity", activity: "spawning_agent", @@ -12142,13 +12349,15 @@ export function createAgentChatService(args: { runtime.activeSubagents.set(taskId, { taskId, description: prompt.slice(0, 120) || "Parallel agent", - background: isBackgroundTask(item as Record), + background, + parentToolUseId: itemId, }); emitChatEvent(managed, { type: "subagent_started", taskId, + parentToolUseId: itemId, description: prompt.slice(0, 120) || "Parallel agent", - background: isBackgroundTask(item as Record), + background, turnId, }); } @@ -12158,24 +12367,51 @@ export function createAgentChatService(args: { const spawnStatus = mapCodexCollabAgentStatus(item.status); if (spawnStatus === "failed") { for (const taskId of receiverIds.length ? receiverIds : [itemId]) { + const existing = runtime.activeSubagents.get(taskId) ?? runtime.activeSubagents.get(itemId); runtime.activeSubagents.delete(taskId); emitChatEvent(managed, { type: "subagent_result", taskId, + parentToolUseId: existing?.parentToolUseId ?? itemId, status: "failed", summary: String(item.error ?? item.result ?? "Agent spawn failed"), turnId, }); } + } else { + const resolvedTaskIds = receiverIds.length + ? receiverIds + : agentsStates.map((agentState) => agentState.threadId); + const placeholder = runtime.activeSubagents.get(itemId); + if (placeholder && resolvedTaskIds.length > 0) { + runtime.activeSubagents.delete(itemId); + for (const taskId of resolvedTaskIds) { + runtime.activeSubagents.set(taskId, { + ...placeholder, + taskId, + parentToolUseId: placeholder.parentToolUseId ?? itemId, + }); + emitChatEvent(managed, { + type: "subagent_started", + taskId, + parentToolUseId: placeholder.parentToolUseId ?? itemId, + description: placeholder.description, + background: placeholder.background, + turnId, + }); + } + } } } if ((tool === "send_input" || tool === "resume_agent") && eventKind === "completed") { const targetIds = receiverIds.length ? receiverIds : [itemId]; for (const targetId of targetIds) { + const existing = runtime.activeSubagents.get(targetId); emitChatEvent(managed, { type: "subagent_progress", taskId: targetId, + parentToolUseId: existing?.parentToolUseId ?? null, summary: prompt || "Agent received input", turnId, }); @@ -12185,11 +12421,13 @@ export function createAgentChatService(args: { if (tool === "wait" && eventKind === "completed") { for (const agentState of agentsStates) { const agentThreadId = agentState.threadId || itemId; + const existing = runtime.activeSubagents.get(agentThreadId); const subagentStatus = mapCodexCollabAgentStatus(agentState.status); if (!subagentStatus) { emitChatEvent(managed, { type: "subagent_progress", taskId: agentThreadId, + parentToolUseId: existing?.parentToolUseId ?? null, summary: agentState.summary || "Agent is still working", turnId, }); @@ -12199,6 +12437,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "subagent_result", taskId: agentThreadId, + parentToolUseId: existing?.parentToolUseId ?? null, status: subagentStatus, summary: agentState.summary, turnId, @@ -12209,10 +12448,12 @@ export function createAgentChatService(args: { if (tool === "close_agent" && eventKind === "completed") { const targetIds = receiverIds.length ? receiverIds : [itemId]; for (const targetId of targetIds) { + const existing = runtime.activeSubagents.get(targetId); runtime.activeSubagents.delete(targetId); emitChatEvent(managed, { type: "subagent_result", taskId: targetId, + parentToolUseId: existing?.parentToolUseId ?? null, status: "stopped", summary: "Agent closed", turnId, @@ -12568,6 +12809,16 @@ export function createAgentChatService(args: { : {}) }); + stopActiveCodexSubagents( + managed, + runtime, + turnId, + status === "failed" + ? "Parent turn failed before ADE received a final subagent status" + : "Parent turn completed before ADE received a final subagent status", + { includeBackground: false }, + ); + void emitTurnDiffSummaryIfChanged(managed, turnId); emitChatEvent(managed, { type: "done", @@ -13114,7 +13365,7 @@ export function createAgentChatService(args: { webSearchActionsByItemId: new Map(), planningApprovalGuardByTurnId: new Map(), pendingTurnPlanningApprovalGuarded: null, - activeSubagents: new Map(), + activeSubagents: new Map(), interruptedTurnIds: new Set(), ignoredTurnIds: new Set(), terminalTurnIds: new Set(managed.codexTerminalTurnIds), @@ -13676,6 +13927,7 @@ export function createAgentChatService(args: { const claudeExecutable = resolveClaudeCodeExecutable({ env: claudeEnv }); const outputStyle = resolveManagedClaudeOutputStyle(managed); const pluginPaths = discoverClaudePluginPaths(managed.laneWorktreePath); + const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, env: claudeEnv, @@ -13699,7 +13951,7 @@ export function createAgentChatService(args: { enableFileCheckpointing: true, skills: "all", maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, - model: resolveClaudeCliModel(managed.session.model), + model: resolveClaudeCliModel(claudeDescriptor?.providerModelId ?? managed.session.model ?? DEFAULT_CLAUDE_MODEL), spawnClaudeCodeProcess: (spawnOptions) => claudeSubprocessReaper.spawnClaudeCodeProcess(spawnOptions, { sessionId: managed.session.id, sdkSessionId: runtime.sdkSessionId, @@ -13797,7 +14049,6 @@ export function createAgentChatService(args: { ENABLE_TOOL_SEARCH: managed.session.identityKey === "cto" ? "0" : "auto", }; } - const claudeDescriptor = resolveSessionModelDescriptor(managed.session); const claudeSupportsReasoning = claudeDescriptor?.capabilities.reasoning ?? true; if (claudeSupportsReasoning) { const effort = managed.session.reasoningEffort; @@ -18627,9 +18878,6 @@ export function createAgentChatService(args: { if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); await runtime.collaborationModesReady?.catch(() => {}); - if (!managed.session.threadId || !runtime.activeTurnId) { - throw new Error("No active turn to steer."); - } const preparedSteer = prepareSendMessage({ sessionId, @@ -18641,6 +18889,10 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + if (!managed.session.threadId || !runtime.activeTurnId) { + await executePreparedSendMessage(preparedSteer); + return { steerId, queued: false }; + } const input: Array> = [ { @@ -18881,6 +19133,12 @@ export function createAgentChatService(args: { } runtime.interrupted = true; + await stopActiveClaudeSubagents( + managed, + runtime, + runtime.activeTurnId ?? undefined, + "Interrupted by queued message", + ); const control = getClaudeQueryControl(runtime.query); if (control.interrupt) { try { @@ -19085,6 +19343,7 @@ export function createAgentChatService(args: { }); } cancelClaudeWarmup(managed, runtime, "interrupt"); + await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); try { await runtime.query?.interrupt(); } catch { /* ignore */ } try { runtime.query?.close(); } catch { /* ignore */ } runtime.inputPump?.close(); @@ -19098,19 +19357,6 @@ export function createAgentChatService(args: { } runtime.approvals.clear(); - // Emit subagent_result "stopped" for every active subagent so the UI - // properly transitions them from "running" → "stopped" (matching Claude Code CLI behaviour). - const turnId = interruptedTurnId ?? undefined; - for (const { taskId } of runtime.activeSubagents.values()) { - emitChatEvent(managed, { - type: "subagent_result", - taskId, - status: "stopped", - summary: "Interrupted by user", - turnId, - }); - } - runtime.activeSubagents.clear(); persistChatState(managed); logger.info("agent_chat.turn_interrupt_completed", { sessionId, @@ -19400,6 +19646,16 @@ export function createAgentChatService(args: { return summarizeSessionRow(row); }; + const hasActiveWorkloads = (): boolean => { + for (const managed of managedSessions.values()) { + if (managed.closed || managed.deleted) continue; + if (managed.session.status === "active") return true; + if (hasLivePendingInput(managed)) return true; + if (hasRuntimeActiveWorkload(managed.runtime)) return true; + } + return false; + }; + const ensureIdentitySession = async (args: { identityKey: AgentChatIdentityKey; laneId: string; @@ -21650,6 +21906,7 @@ export function createAgentChatService(args: { resumeSession, listSessions, getSessionSummary, + hasActiveWorkloads, getChatTranscript, getCodexResumeContext, getChatEventHistory, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 51f640fca..e5bd5b37e 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -247,7 +247,7 @@ import type { AgentChatCreateArgs, AgentChatDeleteArgs, AgentChatGetSummaryArgs, - AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, @@ -3759,12 +3759,52 @@ export function registerIpc({ browseProjectDirectories(args) ); + const PROJECT_DETAIL_CACHE_TTL_MS = 10_000; + const projectDetailCache = new Map; + }>(); + const getCachedProjectDetail = (rootPath: string): Promise => { + const now = Date.now(); + const cached = projectDetailCache.get(rootPath); + // Cache hit when either the TTL is still valid (resolved entry) or the + // entry is still in flight (sentinel = Infinity). Without the in-flight + // arm, a slow `getProjectDetail` call can blow the start-time TTL while + // still pending, causing duplicate concurrent fetches for the same root. + if (cached && (cached.expiresAtMs > now || cached.expiresAtMs === Number.POSITIVE_INFINITY)) { + return cached.promise; + } + const promise = getProjectDetail(rootPath, { globalStatePath }); + projectDetailCache.set(rootPath, { + // Keep pending requests deduped; the TTL only starts once the promise + // settles successfully. + expiresAtMs: Number.POSITIVE_INFINITY, + promise, + }); + if (projectDetailCache.size > 64) { + const oldestKey = projectDetailCache.keys().next().value; + if (typeof oldestKey === "string") projectDetailCache.delete(oldestKey); + } + promise.then(() => { + const current = projectDetailCache.get(rootPath); + if (current?.promise === promise) { + current.expiresAtMs = Date.now() + PROJECT_DETAIL_CACHE_TTL_MS; + } + }); + promise.catch(() => { + if (projectDetailCache.get(rootPath)?.promise === promise) { + projectDetailCache.delete(rootPath); + } + }); + return promise; + }; + ipcMain.handle( IPC.projectGetDetail, async (_event, args: { rootPath: string }): Promise => { const rootPath = typeof args?.rootPath === "string" ? args.rootPath.trim() : ""; if (!rootPath) throw new Error("rootPath is required"); - return getProjectDetail(rootPath, { globalStatePath }); + return getCachedProjectDetail(rootPath); } ); @@ -3910,10 +3950,47 @@ export function registerIpc({ return { deletedPaths, clearedAt }; }); - ipcMain.handle(IPC.projectListRecent, async (): Promise => { + const RECENT_PROJECT_SUMMARY_CACHE_TTL_MS = 5_000; + let recentProjectSummaryCache: { + signature: string; + rows: RecentProjectSummary[]; + expiresAtMs: number; + } | null = null; + const recentProjectSignature = ( + entries: Array<{ rootPath: string; displayName: string; lastOpenedAt: string }>, + ): string => JSON.stringify(entries.map((entry) => [ + entry.rootPath, + entry.displayName, + entry.lastOpenedAt, + ])); + const listRecentProjectSummaries = (options?: { force?: boolean }): RecentProjectSummary[] => { const state = readGlobalState(globalStatePath); - return (state.recentProjects ?? []).map(toRecentProjectSummary); - }); + const entries = state.recentProjects ?? []; + const signature = recentProjectSignature(entries); + const now = Date.now(); + if ( + !options?.force + && recentProjectSummaryCache + && recentProjectSummaryCache.signature === signature + && recentProjectSummaryCache.expiresAtMs > now + ) { + return recentProjectSummaryCache.rows; + } + const rows = entries.map(toRecentProjectSummary); + recentProjectSummaryCache = { + signature, + rows, + expiresAtMs: now + RECENT_PROJECT_SUMMARY_CACHE_TTL_MS, + }; + return rows; + }; + const clearRecentProjectSummaryCache = (): void => { + recentProjectSummaryCache = null; + }; + + ipcMain.handle(IPC.projectListRecent, async (): Promise => + listRecentProjectSummaries() + ); registerRuntimeBridge({ appVersion: app.getVersion(), @@ -3968,10 +4045,8 @@ export function registerIpc({ ); ipcMain.handle(IPC.projectGetDefaultParentDir, async (): Promise => { - const state = readGlobalState(globalStatePath); - const recents = (state.recentProjects ?? []).map(toRecentProjectSummary); const ctx = getCtx(); - return ctx.projectScaffoldService.getDefaultParentDir(recents); + return ctx.projectScaffoldService.getDefaultParentDir(listRecentProjectSummaries()); }); ipcMain.handle(IPC.projectCloseCurrent, async (): Promise => { @@ -3982,7 +4057,7 @@ export function registerIpc({ const rootPath = typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; const state = readGlobalState(globalStatePath); if (!rootPath) { - return (state.recentProjects ?? []).map(toRecentProjectSummary); + return listRecentProjectSummaries(); } const filtered = (state.recentProjects ?? []).filter((entry) => entry.rootPath !== rootPath); const next = { ...state, recentProjects: filtered }; @@ -3990,24 +4065,25 @@ export function registerIpc({ delete next.lastProjectRoot; } writeGlobalState(globalStatePath, next); + clearRecentProjectSummaryCache(); try { await closeProjectByPath(rootPath); } catch { // Best effort; forgetting a project should still update recents even if teardown fails. } - return filtered.map(toRecentProjectSummary); + return listRecentProjectSummaries({ force: true }); }); ipcMain.handle(IPC.projectReorderRecent, async (_event, arg: { orderedPaths: string[] }): Promise => { const orderedPaths = Array.isArray(arg?.orderedPaths) ? arg.orderedPaths.filter((p): p is string => typeof p === "string" && p.length > 0) : []; if (orderedPaths.length === 0) { - const state = readGlobalState(globalStatePath); - return (state.recentProjects ?? []).map(toRecentProjectSummary); + return listRecentProjectSummaries(); } const state = readGlobalState(globalStatePath); const next = reorderRecentProjects(state, orderedPaths); writeGlobalState(globalStatePath, next); - return (next.recentProjects ?? []).map(toRecentProjectSummary); + clearRecentProjectSummaryCache(); + return listRecentProjectSummaries({ force: true }); }); ipcMain.handle(IPC.projectSwitchToPath, async (_event, arg: { rootPath: string }): Promise => { @@ -6501,9 +6577,11 @@ export function registerIpc({ if (!session) return ""; const maxBytes = typeof arg.maxBytes === "number" ? Math.max(1024, Math.min(2_000_000, arg.maxBytes)) : 160_000; const raw = arg.raw === true; - return ctx.sessionService.readTranscriptTail(session.transcriptPath, maxBytes, { + return ctx.ptyService.readTranscriptTail({ + sessionId: session.id, + maxBytes, raw, - alignToLineBoundary: raw + alignToLineBoundary: raw, }); }); @@ -6752,10 +6830,10 @@ export function registerIpc({ ipcMain.handle(IPC.agentChatGetEventHistory, async ( _event, arg: { sessionId?: string; maxEvents?: number }, - ): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => { + ): Promise => { const ctx = getCtx(); const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; - if (!sessionId) return { sessionId: "", events: [], truncated: false }; + if (!sessionId) return { sessionId: "", events: [], truncated: false, sessionFound: false }; // Only forward maxEvents when it is a finite positive number; the service // layer applies its own clamp but guarding here avoids ambiguous NaN/0 // inputs from untrusted renderer IPC. diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 13d4178e6..e6c7f8be8 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2052,6 +2052,151 @@ describe("laneService reparent", () => { ["rebase", "sha-origin-main"], expect.objectContaining({ cwd: path.join(repoRoot, "child") }), ); + expect( + db.get<{ base_ref: string; parent_lane_id: string | null }>( + "select base_ref, parent_lane_id from lane_branch_profiles where lane_id = ? and branch_ref = ?", + ["lane-child", "feature/child"], + ), + ).toEqual({ base_ref: "main", parent_lane_id: null }); + }); + + it("reparents onto stackBaseBranchRef when provided", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-stack-base-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-reparent-stack-base", repoRoot }); + + let childHeadReads = 0; + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/child")) { + childHeadReads += 1; + return childHeadReads === 1 ? "sha-child-pre" : "sha-child-post"; + } + return "sha-unused"; + }); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/develop") { + return { exitCode: 0, stdout: "sha-origin-develop\n", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-develop"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reparent-stack-base", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.reparent({ + laneId: "lane-child", + newParentLaneId: "lane-main", + stackBaseBranchRef: "develop", + }); + + expect(result.newBaseRef).toBe("develop"); + expect(result.newParentLaneId).toBe("lane-main"); + expect(runGitOrThrow).toHaveBeenCalledWith( + ["rebase", "sha-origin-develop"], + expect.objectContaining({ cwd: path.join(repoRoot, "child") }), + ); + expect( + db.get<{ base_ref: string; parent_lane_id: string | null }>( + "select base_ref, parent_lane_id from lane_branch_profiles where lane_id = ? and branch_ref = ?", + ["lane-child", "feature/child"], + ), + ).toEqual({ base_ref: "develop", parent_lane_id: null }); + }); + + it("restores the active branch profile when reparent rebase fails", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-rollback-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-reparent-rollback", repoRoot }); + + vi.mocked(getHeadSha).mockResolvedValue("sha-child"); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if ( + args[0] === "rev-parse" + && args[1] === "--abbrev-ref" + && args[2] === "--symbolic-full-name" + && args[3] === "@{upstream}" + ) { + return { exitCode: 0, stdout: "origin/main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + if (args[0] === "rebase" && args[1] === "--abort") { + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "rebase") throw new Error("rebase failed"); + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reparent-rollback", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await expect(service.reparent({ laneId: "lane-child", newParentLaneId: "lane-main" })).rejects.toThrow("rebase failed"); + expect( + db.get<{ base_ref: string; parent_lane_id: string | null }>( + "select base_ref, parent_lane_id from lanes where id = ?", + ["lane-child"], + ), + ).toEqual({ base_ref: "feature/parent", parent_lane_id: "lane-parent" }); + expect( + db.get<{ base_ref: string; parent_lane_id: string | null }>( + "select base_ref, parent_lane_id from lane_branch_profiles where lane_id = ? and branch_ref = ?", + ["lane-child", "feature/child"], + ), + ).toEqual({ base_ref: "feature/parent", parent_lane_id: "lane-parent" }); + }); + + it("skips git rebase when parent and base ref are unchanged", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-reparent-noop-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-reparent-noop", repoRoot }); + + vi.mocked(getHeadSha).mockResolvedValue("sha-stable"); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + vi.mocked(runGitOrThrow).mockReset(); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-reparent-noop", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await service.reparent({ laneId: "lane-child", newParentLaneId: "lane-parent" }); + + expect(runGitOrThrow).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 1b6b1a149..61f33cf9a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3361,7 +3361,7 @@ export function createLaneService({ return run ? cloneRebaseRun(run) : null; }, - async reparent({ laneId, newParentLaneId }: ReparentLaneArgs): Promise { + async reparent({ laneId, newParentLaneId, stackBaseBranchRef }: ReparentLaneArgs): Promise { const lane = getLaneRow(laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); if (lane.lane_type === "primary") throw new Error("Primary lane cannot be reparented"); @@ -3378,11 +3378,26 @@ export function createLaneService({ const previousParentLaneId = lane.parent_lane_id; const previousBaseRef = lane.base_ref; - const newBaseRef = newParent.branch_ref; const persistedParentLaneId = newParent.lane_type === "primary" ? null : newParent.id; - const preHeadSha = await getHeadSha(lane.worktree_path); - const newParentTarget = await resolveParentRebaseTarget({ projectRoot, parent: newParent }); + const stackBaseOverride = stackBaseBranchRef ? normalizeBranchName(stackBaseBranchRef).trim() : ""; + const newBaseRef = stackBaseOverride || newParent.branch_ref; + if (lane.parent_lane_id === persistedParentLaneId && newBaseRef === previousBaseRef) { + const headSha = await getHeadSha(lane.worktree_path); + return { + laneId: lane.id, + previousParentLaneId, + newParentLaneId: newParent.id, + previousBaseRef, + newBaseRef: previousBaseRef, + preHeadSha: headSha, + postHeadSha: headSha, + }; + } + const newParentTarget = stackBaseOverride + ? await resolveBranchRebaseTarget({ projectRoot, branchRef: stackBaseOverride, preferRemote: true }) + : await resolveParentRebaseTarget({ projectRoot, parent: newParent }); const newParentHead = newParentTarget.headSha; + const preHeadSha = await getHeadSha(lane.worktree_path); const operation = operationService?.start({ laneId: lane.id, @@ -3401,6 +3416,11 @@ export function createLaneService({ "update lanes set parent_lane_id = ?, base_ref = ? where id = ? and project_id = ?", [persistedParentLaneId, newBaseRef, lane.id, projectId] ); + upsertBranchProfileForRow(lane, { + branchRef: lane.branch_ref, + baseRef: newBaseRef, + parentLaneId: persistedParentLaneId, + }); invalidateLaneListCache(); try { @@ -3415,6 +3435,11 @@ export function createLaneService({ "update lanes set parent_lane_id = ?, base_ref = ? where id = ? and project_id = ?", [previousParentLaneId, previousBaseRef, lane.id, projectId] ); + upsertBranchProfileForRow(lane, { + branchRef: lane.branch_ref, + baseRef: previousBaseRef, + parentLaneId: previousParentLaneId, + }); invalidateLaneListCache(); const message = error instanceof Error ? error.message : String(error); if (operation?.operationId) { diff --git a/apps/desktop/src/main/services/projects/projectBrowserService.ts b/apps/desktop/src/main/services/projects/projectBrowserService.ts index f46244c83..d7178efe4 100644 --- a/apps/desktop/src/main/services/projects/projectBrowserService.ts +++ b/apps/desktop/src/main/services/projects/projectBrowserService.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { ProjectBrowseEntry, ProjectBrowseInput, ProjectBrowseResult } from "../../../shared/types"; -import { resolveRepoRoot } from "./projectService"; +import { findAdeManagedWorktreeRoot } from "../../../../../ade-cli/src/services/projects/projectRoots"; function expandHomePath(input: string): string { if (input === "~") return os.homedir(); @@ -27,10 +27,23 @@ function parentPathOf(input: string): string | null { async function resolveOpenableProjectRoot(candidatePath: string | null): Promise { if (!candidatePath) return null; - try { - return path.resolve(await resolveRepoRoot(candidatePath)); - } catch { - return null; + const managedWorktree = findAdeManagedWorktreeRoot(candidatePath); + if (managedWorktree) return path.resolve(managedWorktree.projectRoot); + + let current = path.resolve(candidatePath); + while (true) { + try { + const stat = await fs.stat(path.join(current, ".git")); + if (stat.isDirectory() || stat.isFile()) { + return current; + } + } catch { + // Keep walking up. The actual open flow still performs full Git + // validation; the browser path stays cheap enough to run while typing. + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; } } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index ab186faea..4d6d95d72 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -860,6 +860,117 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("echo hello\r"); }); + it("hydrates transcript reads from recent live output before the file stream flushes", async () => { + const { service, mockPty, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen", "ADE session guidance"], + }); + sessionService.readTranscriptTail.mockResolvedValueOnce(""); + + mockPty._emitter.emit("data", "\u001b[2J\u001b[HReady for input\n> "); + + await expect(service.readTranscriptTail({ + sessionId: created.sessionId, + maxBytes: 160_000, + raw: true, + })).resolves.toBe("\u001b[2J\u001b[HReady for input\n> "); + expect(sessionService.readTranscriptTail).toHaveBeenLastCalledWith( + "/tmp/transcripts/uuid-2.log", + 160_000, + expect.objectContaining({ raw: true }), + ); + }); + + it("deduplicates recent live output that has already reached the transcript file", async () => { + const { service, mockPty, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + }); + mockPty._emitter.emit("data", "Ready for input\n> "); + sessionService.readTranscriptTail.mockResolvedValueOnce("Booting\nReady for input"); + + await expect(service.readTranscriptTail({ + sessionId: created.sessionId, + maxBytes: 160_000, + raw: true, + })).resolves.toBe("Booting\nReady for input\n> "); + }); + + it("deduplicates live output overlaps larger than the default scan window", async () => { + const { service, mockPty, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + }); + const overlap = "x".repeat(13_000); + mockPty._emitter.emit("data", `${overlap} live`); + sessionService.readTranscriptTail.mockResolvedValueOnce(`disk\n${overlap}`); + + await expect(service.readTranscriptTail({ + sessionId: created.sessionId, + maxBytes: 20_000, + raw: true, + })).resolves.toBe(`disk\n${overlap} live`); + }); + + it("returns the disk tail when the live entry has been disposed", async () => { + const { service, mockPty, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + }); + // Dispose the live pty so `liveEntryBySessionId` returns null and the + // merge path falls through to disk-only. + mockPty._emitter.emit("exit", { exitCode: 0 }); + sessionService.readTranscriptTail.mockResolvedValueOnce("only on disk\n"); + + await expect(service.readTranscriptTail({ + sessionId: created.sessionId, + maxBytes: 20_000, + raw: true, + })).resolves.toBe("only on disk\n"); + }); + + it("runs the merged tail through stripAnsi when raw is not set", async () => { + const { service, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + }); + sessionService.readTranscriptTail.mockResolvedValueOnce("disk tail"); + // Reset before the call so we can detect the new invocation specifically. + mocks.stripAnsi.mockClear(); + + await service.readTranscriptTail({ + sessionId: created.sessionId, + maxBytes: 20_000, + }); + + // Without raw: true, the service must pass the merged tail through + // stripAnsi before returning. The fixture mocks stripAnsi to an identity + // function, so we assert by invocation rather than by output content. + expect(mocks.stripAnsi).toHaveBeenCalledWith(expect.stringContaining("disk tail")); + }); + it("stores structured resume metadata for Claude launches", async () => { const { service, sessionService } = createHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 89fd7c077..9a70c1b51 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -96,6 +96,7 @@ const PTY_DATA_BATCH_INTERVAL_MS = 16; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; const DEFAULT_TERMINAL_READ_MAX_BYTES = 220_000; +const LIVE_TRANSCRIPT_TAIL_BUFFER_CHARS = 2_000_000; const TERMINAL_SNAPSHOT_DEBOUNCE_MS = 500; const TERMINAL_SNAPSHOT_SCROLLBACK = 2_000; const TERMINAL_SNAPSHOT_TRANSCRIPT_FALLBACK_BYTES = 220_000; @@ -329,6 +330,7 @@ type PtyEntry = { pendingDataChars: number; pendingDataTimer: ReturnType | null; terminalSnapshot: TerminalSnapshotMirror | null; + recentOutputTail: string; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ aiTitleTimer: ReturnType | null; cliUserTitleLineBuffer: string; @@ -397,6 +399,47 @@ function statusFromExit(exitCode: number | null): TerminalSessionStatus { return "failed"; } +function tailString(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return value.slice(value.length - maxChars); +} + +function computeSuffixPrefixOverlap(left: string, right: string, maxChars = 12_000): number { + if (!left.length || !right.length) return 0; + const cap = Math.min(maxChars, left.length, right.length); + if (cap <= 0) return 0; + + const rightHead = right.slice(0, cap); + const leftTail = left.slice(left.length - cap); + const prefixLengths = new Array(rightHead.length).fill(0); + for (let index = 1, matched = 0; index < rightHead.length; index += 1) { + while (matched > 0 && rightHead[index] !== rightHead[matched]) { + matched = prefixLengths[matched - 1] ?? 0; + } + if (rightHead[index] === rightHead[matched]) matched += 1; + prefixLengths[index] = matched; + } + + let matched = 0; + for (let index = 0; index < leftTail.length; index += 1) { + while (matched > 0 && leftTail[index] !== rightHead[matched]) { + matched = prefixLengths[matched - 1] ?? 0; + } + if (leftTail[index] === rightHead[matched]) matched += 1; + if (matched === rightHead.length && index < leftTail.length - 1) { + matched = prefixLengths[matched - 1] ?? 0; + } + } + return matched; +} + +function mergeTranscriptTailWithLiveOutput(transcriptTail: string, liveOutputTail: string, maxChars: number): string { + if (!liveOutputTail) return tailString(transcriptTail, maxChars); + if (!transcriptTail) return tailString(liveOutputTail, maxChars); + const overlap = computeSuffixPrefixOverlap(transcriptTail, liveOutputTail, maxChars); + return tailString(`${transcriptTail}${liveOutputTail.slice(overlap)}`, maxChars); +} + function runtimeFromStatus(status: TerminalSessionStatus): TerminalRuntimeState { if (status === "running") return "running"; if (status === "disposed") return "killed"; @@ -1965,6 +2008,11 @@ export function createPtyService({ clearToolAutoCloseTimer(ptyId); cleanupEntryPaths(entry); flushPreview(entry); + // Release the live-tail buffer (up to LIVE_TRANSCRIPT_TAIL_BUFFER_CHARS + // per session). Disposed entries linger in the `ptys` map for replacement + // lookups; without this, every ended terminal would keep its 2 MB tail + // pinned indefinitely. + entry.recentOutputTail = ""; const endedAt = new Date().toISOString(); const status = statusFromExit(exitCode); @@ -2047,6 +2095,11 @@ export function createPtyService({ } }; + const appendRecentOutput = (entry: PtyEntry, data: string) => { + if (!data) return; + entry.recentOutputTail = tailString(`${entry.recentOutputTail}${data}`, LIVE_TRANSCRIPT_TAIL_BUFFER_CHARS); + }; + const flushPreview = (entry: PtyEntry) => { const candidate = (entry.latestPreviewLine ?? "").trim(); if (!candidate) return; @@ -2549,6 +2602,7 @@ export function createPtyService({ pendingDataChars: 0, pendingDataTimer: null, terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null, + recentOutputTail: "", aiTitleTimer: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, @@ -2573,6 +2627,7 @@ export function createPtyService({ // emit ptyData after ptyExit while transcript summarization is in // flight. if (entry.disposed) return; + appendRecentOutput(entry, data); writeTranscript(entry, data); feedTerminalSnapshot(entry, data); updatePreviewThrottled(entry, data); @@ -3171,6 +3226,31 @@ export function createPtyService({ return computeRuntimeState(sessionId, fallbackStatus); }, + async readTranscriptTail(args: { + sessionId: string; + maxBytes: number; + raw?: boolean; + alignToLineBoundary?: boolean; + }): Promise { + const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!sessionId) return ""; + const session = sessionService.get(sessionId); + if (!session) return ""; + const maxBytes = Number.isFinite(args.maxBytes) + ? Math.max(1024, Math.min(MAX_TRANSCRIPT_BYTES, Math.floor(args.maxBytes))) + : DEFAULT_TERMINAL_READ_MAX_BYTES; + const diskTail = await sessionService.readTranscriptTail(session.transcriptPath, maxBytes, { + raw: true, + alignToLineBoundary: args.alignToLineBoundary, + }); + // A Work terminal can mount after the CLI has already drawn its first TUI + // frame, while the transcript WriteStream is still buffered. Merge the + // live tail so hydration can replay that initial screen state. + const live = liveEntryBySessionId(sessionId)?.[1].recentOutputTail ?? ""; + const merged = mergeTranscriptTailWithLiveOutput(diskTail, live, maxBytes); + return args.raw ? merged : stripAnsi(merged); + }, + enrichSessions(rows: T[]): T[] { return rows.map((row) => { const live = liveEntryBySessionId(row.id); @@ -3239,6 +3319,8 @@ export function createPtyService({ clearToolAutoCloseTimer(ptyId); flushQueuedPtyData(entry, { ptyId, sessionId: entry.sessionId }); cleanupEntryPaths(entry); + // Release the live-tail buffer; see closeEntry for rationale. + entry.recentOutputTail = ""; try { entry.pty.kill(); } catch { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 485f449a1..bf142e2be 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1399,6 +1399,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const createSpy = vi.fn().mockResolvedValue({ ptyId: "pty-1", sessionId: "session-1" }); const writeBySessionId = vi.fn().mockReturnValue(true); const resizeBySessionId = vi.fn().mockReturnValue(true); + const readTranscriptTail = vi.fn(async () => "prior output\n"); const host = createSyncHostService({ db: brainDb, @@ -1534,6 +1535,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { } as any, ptyService: { create: createSpy, + readTranscriptTail, writeBySessionId, resizeBySessionId, enrichSessions: (rows: any[]) => rows, @@ -1568,6 +1570,12 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const snapshot = await client.queue.next("terminal_snapshot"); expect(snapshot.requestId).toBe("sub-1"); expect((snapshot.payload as { transcript: string }).transcript).toContain("prior output"); + expect(readTranscriptTail).toHaveBeenCalledWith({ + sessionId: "session-1", + maxBytes: 32_000, + raw: true, + alignToLineBoundary: true, + }); client.ws.send(encodeSyncEnvelope({ type: "terminal_input", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 97eeab375..51ee6404d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -74,6 +74,7 @@ import type { AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatGetSummaryArgs, AgentChatHandoffArgs, AgentChatHandoffResult, @@ -1536,11 +1537,7 @@ declare global { getEventHistory: (args: { sessionId: string; maxEvents?: number; - }) => Promise<{ - sessionId: string; - events: AgentChatEventEnvelope[]; - truncated: boolean; - }>; + }) => Promise; codex: { openInCli: ( args: AgentChatCodexOpenInCliArgs, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 9fde8f037..85048d262 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -288,6 +288,65 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); }); + it("does not let stale window-session refreshes overwrite a newer project binding", async () => { + const oldLocalBinding = { + kind: "local", + key: "local:/old", + rootPath: "/old", + displayName: "Old", + }; + const newerRemoteBinding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + let resolveSession: (value: unknown) => void = () => {}; + const sessionPromise = new Promise((resolve) => { + resolveSession = resolve; + }); + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) return sessionPromise; + if (channel === IPC.lanesOpenFolder) throw new Error("stale local IPC should not run"); + 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; + bridge.app.onProjectBindingChanged(vi.fn()); + const openFolder = bridge.lanes.openFolder({ laneId: "lane-1" }); + + expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); + + const bindingListener = on.mock.calls.find(([channel]) => channel === IPC.appProjectBindingChanged)?.[1]; + expect(typeof bindingListener).toBe("function"); + bindingListener({}, newerRemoteBinding); + resolveSession({ windowId: 1, project: { rootPath: "/old", displayName: "Old" }, binding: oldLocalBinding }); + + await expect(openFolder).rejects.toThrow(/remote lane folders/i); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesOpenFolder, { laneId: "lane-1" }); + }); + it("keeps lane folder opens on local project bindings routed to local lane IPC", async () => { const binding = { kind: "local", @@ -2200,4 +2259,120 @@ describe("preload OAuth bridge", () => { expect(removeListener).toHaveBeenCalledWith(IPC.sessionsChanged, sessionListener); expect(removeListener).toHaveBeenCalledWith(IPC.prsEvent, prListener); }); + + it("blocks chat actions while a project switch is in flight", async () => { + let resolveSwitch!: (project: unknown) => void; + const switchPromise = new Promise((resolve) => { + resolveSwitch = resolve; + }); + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.projectSwitchToPath) { + return switchPromise; + } + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { rootPath: "/old", displayName: "Old", baseRef: "main" }, + binding: { + kind: "local", + key: "local:/old", + rootPath: "/old", + displayName: "Old", + }, + }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + 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; + const pendingSwitch = bridge.project.switchToPath("/next"); + await expect( + bridge.agentChat.send({ sessionId: "session-1", text: "hello" }), + ).rejects.toThrow(/Project is switching/i); + + expect(invoke).toHaveBeenCalledWith(IPC.projectSwitchToPath, { rootPath: "/next" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.appGetWindowSession); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSend, expect.anything()); + + resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); + await pendingSwitch; + }); + + it("falls through read-only chat actions to IPC while a project switch is in flight", async () => { + let resolveSwitch!: (project: unknown) => void; + const switchPromise = new Promise((resolve) => { + resolveSwitch = resolve; + }); + const listResult = [{ id: "summary-1" }]; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.projectSwitchToPath) { + return switchPromise; + } + if (channel === IPC.agentChatList) { + return listResult; + } + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { rootPath: "/old", displayName: "Old", baseRef: "main" }, + binding: { + kind: "local", + key: "local:/old", + rootPath: "/old", + displayName: "Old", + }, + }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + 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; + const pendingSwitch = bridge.project.switchToPath("/next"); + // Read-only chat call must fall through to the IPC-backed read API + // instead of rejecting because a project transition is in flight. + await expect( + bridge.agentChat.list({ laneId: "lane-1" }), + ).resolves.toEqual(listResult); + + expect(invoke).toHaveBeenCalledWith(IPC.agentChatList, { laneId: "lane-1" }); + + resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); + await pendingSwitch; + }); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bd249847d..58b0310d1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -283,6 +283,7 @@ import type { AgentChatDeleteArgs, AgentChatSuggestLaneNameArgs, AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatGetSummaryArgs, AgentChatHandoffArgs, AgentChatHandoffResult, @@ -1088,10 +1089,14 @@ function isSafeLocalRuntimeFallbackError(error: unknown): boolean { let currentProjectBinding: OpenProjectBinding | null = null; let projectBindingGeneration = 0; +let projectBindingVersion = 0; +let projectBindingRefreshPromise: Promise | null = null; +let projectRuntimeTransitionDepth = 0; function rememberProjectBinding(binding: OpenProjectBinding | null): void { const previousKey = currentProjectBinding?.key ?? null; const nextKey = binding?.key ?? null; + projectBindingVersion += 1; currentProjectBinding = binding; if (previousKey !== nextKey) { projectBindingGeneration += 1; @@ -1107,53 +1112,51 @@ function rememberProjectBinding(binding: OpenProjectBinding | null): void { } } -async function getRemoteProjectBinding(): Promise { + if (projectBindingRefreshPromise) return projectBindingRefreshPromise; + const refreshVersion = projectBindingVersion; + projectBindingRefreshPromise = ipcRenderer + .invoke(IPC.appGetWindowSession) + .then((session: { binding?: OpenProjectBinding | null } | null) => { + const binding = session?.binding ?? null; + if (projectBindingVersion !== refreshVersion) return currentProjectBinding; + rememberProjectBinding(binding); + return binding; + }) + .finally(() => { + projectBindingRefreshPromise = null; + }); + return projectBindingRefreshPromise; +} + +async function getRemoteProjectBinding(options?: { fresh?: boolean }): Promise | null> { - if (currentProjectBinding) { - return currentProjectBinding.kind === "remote" - ? currentProjectBinding - : null; - } - const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { - binding?: OpenProjectBinding | null; - } | null; - rememberProjectBinding(session?.binding ?? null); - return session?.binding?.kind === "remote" ? session.binding : null; + const binding = await getProjectRuntimeBinding(options); + return binding?.kind === "remote" ? binding : null; } -async function getLocalProjectBinding(): Promise | null> { - if (currentProjectBinding) { - return currentProjectBinding.kind === "local" - ? currentProjectBinding - : null; - } - const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { - binding?: OpenProjectBinding | null; - } | null; - rememberProjectBinding(session?.binding ?? null); - return session?.binding?.kind === "local" ? session.binding : null; + const binding = await getProjectRuntimeBinding(options); + return binding?.kind === "local" ? binding : null; } -async function getProjectRuntimeBinding(): Promise { - if (currentProjectBinding) return currentProjectBinding; - const session = (await ipcRenderer.invoke(IPC.appGetWindowSession)) as { - binding?: OpenProjectBinding | null; - } | null; - rememberProjectBinding(session?.binding ?? null); - return session?.binding ?? null; +async function getProjectRuntimeBinding(options?: { fresh?: boolean }): Promise { + if (!options?.fresh && currentProjectBinding) return currentProjectBinding; + return refreshProjectBinding(); } async function callRemoteProjectActionIfBound( domain: string, action: string, request: Omit = {}, + options?: { freshBinding?: boolean }, ): Promise<{ handled: true; result: T } | { handled: false }> { - const binding = await getRemoteProjectBinding(); + const binding = await getRemoteProjectBinding(options?.freshBinding ? { fresh: true } : undefined); if (!binding) return { handled: false }; const response = (await ipcRenderer.invoke(IPC.remoteRuntimeCallAction, { id: binding.targetId, @@ -1167,9 +1170,10 @@ async function callLocalProjectActionIfBound( domain: string, action: string, request: Omit = {}, + options?: { freshBinding?: boolean }, ): Promise<{ handled: true; result: T } | { handled: false }> { if (localRuntimeDaemonDisabled) return { handled: false }; - const binding = await getLocalProjectBinding(); + const binding = await getLocalProjectBinding(options?.freshBinding ? { fresh: true } : undefined); if (!binding) return { handled: false }; try { const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { @@ -1202,18 +1206,61 @@ async function callLocalProjectActionStrictIfBound( return { handled: true, result: response.result as T }; } +// Chat actions that mutate runtime state. Only these are gated by the +// project-transition guard: read-only chat queries (e.g. `listSessions`, +// `getSessionSummary`, `getAvailableModels`, `getChatEventHistory`) must be +// allowed to fall through to IPC while a project switch is in flight, so the +// UI can render summaries and history during the transition. +const MUTATING_CHAT_ACTIONS = new Set([ + "sendMessage", + "respondToInput", + "approveToolUse", + "interrupt", + "steer", + "cancelSteer", + "editSteer", + "dispatchSteer", + "cancelDispatchedSteer", + "createSession", + "archiveSession", + "unarchiveSession", + "deleteSession", + "updateSession", + "handoffSession", + "setClaudeOutputStyle", + "reloadClaudePlugins", + "setParallelLaunchState", + "ensureCtoSession", + "ensureAgentIdentitySession", + "warmupModel", + "rewindFiles", + "saveTempAttachment", +]); + async function callProjectRuntimeActionIfBound( domain: string, action: string, request: Omit = {}, ): Promise<{ handled: true; result: T } | { handled: false }> { + const freshBinding = domain === "chat"; + const isMutatingChatAction = + freshBinding && MUTATING_CHAT_ACTIONS.has(action); + if (isMutatingChatAction && projectRuntimeTransitionDepth > 0) { + throw new Error("Project is switching. Wait for the current project to finish loading before sending chat messages."); + } + // During a project transition, let read-only chat calls fall through to + // their IPC fallback instead of binding to a possibly-stale runtime. + if (freshBinding && !isMutatingChatAction && projectRuntimeTransitionDepth > 0) { + return { handled: false }; + } const remote = await callRemoteProjectActionIfBound( domain, action, request, + { freshBinding }, ); if (remote.handled) return remote; - return callLocalProjectActionIfBound(domain, action, request); + return callLocalProjectActionIfBound(domain, action, request, { freshBinding }); } async function callProjectRuntimeActionOr( @@ -2453,6 +2500,17 @@ async function clearAround( } } +async function runProjectRuntimeTransition( + action: () => Promise, +): Promise { + projectRuntimeTransitionDepth += 1; + try { + return await action(); + } finally { + projectRuntimeTransitionDepth = Math.max(0, projectRuntimeTransitionDepth - 1); + } +} + function createIpcEventFanout( channel: string, beforeDispatch?: (payload: T) => void, @@ -2622,11 +2680,23 @@ contextBridge.exposeInMainWorld("ade", { ): void => ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), }, project: { - openRepo: async (args?: { rootPath?: string }): Promise => - clearAround( - () => clearProjectScopedReadCaches(), - () => ipcRenderer.invoke(IPC.projectOpenRepo, args ?? {}), - ), + openRepo: async (args?: { rootPath?: string }): Promise => { + // `clearAround` runs its cleanup callback both before AND after the + // action. Nulling the binding inside that callback meant a successful + // open clobbered the freshly-published binding (set by the + // appProjectBindingChanged listener) and disabled runtime routing / + // event pumping until another refresh restored it. Null once up front; + // the listener handles the post-action update. + rememberProjectBinding(null); + return clearAround( + () => { + clearProjectScopedReadCaches(); + }, + () => runProjectRuntimeTransition(() => + ipcRenderer.invoke(IPC.projectOpenRepo, args ?? {}), + ), + ); + }, chooseDirectory: async ( args: { title?: string; defaultPath?: string } = {}, ): Promise => @@ -2685,16 +2755,24 @@ contextBridge.exposeInMainWorld("ade", { rememberProjectBinding(null); clearProjectScopedReadCaches(); }, - () => ipcRenderer.invoke(IPC.projectCloseCurrent), + () => runProjectRuntimeTransition(() => + ipcRenderer.invoke(IPC.projectCloseCurrent), + ), ), - switchToPath: async (rootPath: string): Promise => - clearAround( + switchToPath: async (rootPath: string): Promise => { + // See openRepo above: `clearAround` runs cleanup twice, so nulling the + // binding inside it would clobber the new one set by the + // appProjectBindingChanged listener. + rememberProjectBinding(null); + return clearAround( () => { - rememberProjectBinding(null); clearProjectScopedReadCaches(); }, - () => ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), - ), + () => runProjectRuntimeTransition(() => + ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), + ), + ); + }, forgetRecent: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectForgetRecent, { rootPath }), reorderRecent: async ( @@ -5296,16 +5374,8 @@ contextBridge.exposeInMainWorld("ade", { getEventHistory: async (args: { sessionId: string; maxEvents?: number; - }): Promise<{ - sessionId: string; - events: AgentChatEventEnvelope[]; - truncated: boolean; - }> => { - const runtime = await callProjectRuntimeActionIfBound<{ - sessionId: string; - events: AgentChatEventEnvelope[]; - truncated: boolean; - }>("chat", "getChatEventHistory", { + }): Promise => { + const runtime = await callProjectRuntimeActionIfBound("chat", "getChatEventHistory", { argsList: [args.sessionId, { maxEvents: args.maxEvents }], }); return runtime.handled diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 146e4167c..d088b1b61 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -541,7 +541,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { }); const disposeProjectBindingChanged = window.ade.app.onProjectBindingChanged((binding) => { const state = useAppStore.getState(); - if (state.projectTransition) return; + if (state.projectTransition) { + setProjectBinding(binding); + return; + } setProjectHydrated(false); applyProjectState(binding?.kind === "local" ? state.project : null, binding); setProjectHydrated(true); diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index bba09ebae..8e72cf7f0 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -143,11 +143,16 @@ function fireProjectTabDragEnd( fireEvent(element, event); } +async function flushMicrotasks(count = 1) { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => queueMicrotask(resolve)); + } +} + async function advancePhoneSyncStartupDelay() { await act(async () => { vi.advanceTimersByTime(5_000); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(2); }); } @@ -367,6 +372,37 @@ describe("TopBar", () => { expect(globalThis.window.ade.app.openProjectInNewWindow).toHaveBeenCalledWith("/Users/arul/ADE"); }); + it("keeps the source project tab active until the detached window is bound", async () => { + let resolveOpen!: (value: { windowId: number; project: { rootPath: string; name: string } }) => void; + const openPromise = new Promise<{ windowId: number; project: { rootPath: string; name: string } }>((resolve) => { + resolveOpen = resolve; + }); + globalThis.window.ade.app.openProjectInNewWindow = vi.fn(() => openPromise) as any; + const closeProject = useAppStore.getState().closeProject; + render(); + + const tab = await screen.findByTitle("/Users/arul/ADE"); + fireProjectTabDragEnd(tab, makeDataTransfer({}, "none")); + + expect(globalThis.window.ade.app.openProjectInNewWindow).toHaveBeenCalledWith("/Users/arul/ADE"); + await act(async () => { + await flushMicrotasks(); + }); + expect(closeProject).not.toHaveBeenCalled(); + + await act(async () => { + resolveOpen({ + windowId: 2, + project: { rootPath: "/Users/arul/ADE", name: "ADE" }, + }); + await openPromise; + }); + + await waitFor(() => { + expect(closeProject).toHaveBeenCalled(); + }); + }); + it("opens the phone sync drawer from the host status control", async () => { vi.useFakeTimers(); try { @@ -462,14 +498,14 @@ describe("TopBar", () => { render(); await act(async () => { - await Promise.resolve(); + await flushMicrotasks(); }); expect(getStatus).not.toHaveBeenCalled(); await act(async () => { vi.advanceTimersByTime(15_000); - await Promise.resolve(); + await flushMicrotasks(); }); expect(getStatus).toHaveBeenCalledTimes(1); @@ -490,16 +526,14 @@ describe("TopBar", () => { await act(async () => { window.dispatchEvent(new Event("focus")); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(2); }); expect(screen.getByText("Phone sync ready")).toBeTruthy(); await act(async () => { window.dispatchEvent(new Event("focus")); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(2); }); expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); @@ -608,7 +642,7 @@ describe("TopBar", () => { await act(async () => { window.dispatchEvent(new Event("focus")); - await Promise.resolve(); + await flushMicrotasks(); }); fireEvent.click(await screen.findByRole("button", { name: /linear quick view/i })); @@ -667,7 +701,7 @@ describe("TopBar", () => { await act(async () => { window.dispatchEvent(new Event("focus")); - await Promise.resolve(); + await flushMicrotasks(); }); await waitFor(() => { expect(getLinearConnectionStatus).toHaveBeenCalledTimes(1); @@ -676,7 +710,7 @@ describe("TopBar", () => { await act(async () => { window.dispatchEvent(new Event("focus")); - await Promise.resolve(); + await flushMicrotasks(); }); expect(await screen.findByRole("button", { name: /linear quick view/i })).toBeTruthy(); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 7227922c7..fff9b6ea4 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -650,6 +650,8 @@ export function TopBar() { const [openRemoteProjectTabs, setOpenRemoteProjectTabs] = useState< RemoteProjectTab[] >([]); + const openProjectTabRootsRef = useRef(openProjectTabRoots); + const openRemoteProjectTabsRef = useRef(openRemoteProjectTabs); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); const [windowId, setWindowId] = useState(null); @@ -693,6 +695,14 @@ export function TopBar() { connectedRemoteCount > 0 ? `Remote ${connectedRemoteCount}` : "Remote"; const showSyncControl = workspaceProjectOpen; + useEffect(() => { + openProjectTabRootsRef.current = openProjectTabRoots; + }, [openProjectTabRoots]); + + useEffect(() => { + openRemoteProjectTabsRef.current = openRemoteProjectTabs; + }, [openRemoteProjectTabs]); + const applyZoom = useCallback((pct: number) => { const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); window.ade.zoom.setLevel(displayZoomToLevel(clamped)); @@ -1045,39 +1055,45 @@ export function TopBar() { const shouldClose = await checkForActiveWorkloads(rootPath); if (!shouldClose) return; - const currentIndex = openProjectTabRoots.indexOf(rootPath); - const nextTabRoots = openProjectTabRoots.filter( + const latestTabRoots = openProjectTabRootsRef.current; + const currentIndex = latestTabRoots.indexOf(rootPath); + if (currentIndex === -1) return; + const nextTabRoots = latestTabRoots.filter( (entry) => entry !== rootPath, ); - setOpenProjectTabRoots(nextTabRoots); - if (!remoteBinding && project?.rootPath === rootPath) { + openProjectTabRootsRef.current = nextTabRoots; + setOpenProjectTabRoots((prev) => + prev.includes(rootPath) ? prev.filter((entry) => entry !== rootPath) : prev, + ); + + const latestState = useAppStore.getState(); + const latestProjectRoot = latestState.project?.rootPath ?? null; + const latestRemoteBinding = + latestState.projectBinding?.kind === "remote" + ? latestState.projectBinding + : null; + const latestRemoteTabs = openRemoteProjectTabsRef.current; + if (!latestRemoteBinding && latestProjectRoot === rootPath) { const nextRoot = nextTabRoots[currentIndex] ?? nextTabRoots[currentIndex - 1] ?? null; if (nextRoot) { - switchProjectToPath(nextRoot).catch(() => {}); - } else if (openRemoteProjectTabs[0]) { - switchRemoteProject( - openRemoteProjectTabs[0].targetId, - openRemoteProjectTabs[0].projectId, + latestState.switchProjectToPath(nextRoot).catch(() => {}); + } else if (latestRemoteTabs[0]) { + latestState.switchRemoteProject( + latestRemoteTabs[0].targetId, + latestRemoteTabs[0].projectId, ).catch(() => {}); } else { - closeProject().catch(() => {}); + latestState.closeProject().catch(() => {}); } } })().catch(() => {}); }, [ checkForActiveWorkloads, - closeProject, - openProjectTabRoots, - openRemoteProjectTabs, - project?.rootPath, projectTabs, - remoteBinding, - switchProjectToPath, - switchRemoteProject, ], ); @@ -1238,43 +1254,52 @@ export function TopBar() { setDropIdx(null); if (!draggedOutside || droppedOnAdeTarget || !rootPath) return; - // Fire IPC immediately so the new window starts spawning while we - // optimistically clean up the source window's tab state. - window.ade.app.openProjectInNewWindow(rootPath).catch(() => {}); - - // Detach skips the confirmation + active workload checks intentionally: - // the user already committed to detaching by dragging the tab out, and - // the work is moving to a new window rather than terminating. - const currentIndex = openProjectTabRoots.indexOf(rootPath); - if (currentIndex === -1) return; - const nextTabRoots = openProjectTabRoots.filter( - (entry) => entry !== rootPath, - ); - setOpenProjectTabRoots(nextTabRoots); - if (!remoteBinding && project?.rootPath === rootPath) { - const nextRoot = - nextTabRoots[currentIndex] ?? nextTabRoots[currentIndex - 1] ?? null; - if (nextRoot) { - switchProjectToPath(nextRoot).catch(() => {}); - } else if (openRemoteProjectTabs[0]) { - switchRemoteProject( - openRemoteProjectTabs[0].targetId, - openRemoteProjectTabs[0].projectId, - ).catch(() => {}); - } else { - closeProject().catch(() => {}); + void (async () => { + try { + await window.ade.app.openProjectInNewWindow(rootPath); + } catch { + return; } - } + + // Detach skips the confirmation + active workload checks intentionally: + // the user already committed to detaching by dragging the tab out, and + // the work is moving to a new window rather than terminating. Only remove + // the source tab after the destination window has a bound project. + const latestTabRoots = openProjectTabRootsRef.current; + const currentIndex = latestTabRoots.indexOf(rootPath); + if (currentIndex === -1) return; + const nextTabRoots = latestTabRoots.filter( + (entry) => entry !== rootPath, + ); + openProjectTabRootsRef.current = nextTabRoots; + setOpenProjectTabRoots((prev) => + prev.includes(rootPath) ? prev.filter((entry) => entry !== rootPath) : prev, + ); + + const latestState = useAppStore.getState(); + const latestProjectRoot = latestState.project?.rootPath ?? null; + const latestRemoteBinding = + latestState.projectBinding?.kind === "remote" + ? latestState.projectBinding + : null; + const latestRemoteTabs = openRemoteProjectTabsRef.current; + if (!latestRemoteBinding && latestProjectRoot === rootPath) { + const nextRoot = + nextTabRoots[currentIndex] ?? nextTabRoots[currentIndex - 1] ?? null; + if (nextRoot) { + await latestState.switchProjectToPath(nextRoot).catch(() => {}); + } else if (latestRemoteTabs[0]) { + await latestState.switchRemoteProject( + latestRemoteTabs[0].targetId, + latestRemoteTabs[0].projectId, + ).catch(() => {}); + } else { + await latestState.closeProject().catch(() => {}); + } + } + })(); }, - [ - closeProject, - openProjectTabRoots, - openRemoteProjectTabs, - project?.rootPath, - remoteBinding, - switchProjectToPath, - switchRemoteProject, - ], + [], ); const handleProjectAccentColorChange = useCallback( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index c40b21b2a..e2297e299 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -49,6 +49,7 @@ import { calculateVirtualWindow, deriveTurnModelState, reconcileMeasuredScrollTop, + shouldAbsorbProgrammaticScrollEvent, } from "./AgentChatMessageList"; function findButtonByTextContent(matcher: RegExp): HTMLButtonElement { @@ -1128,6 +1129,21 @@ describe("AgentChatMessageList transcript rendering", () => { expect(unchanged).toBe(400); }); + it("only absorbs the exact programmatic scroll target", () => { + expect(shouldAbsorbProgrammaticScrollEvent({ + scrollTop: 800, + programmaticTarget: 800, + })).toBe(true); + expect(shouldAbsorbProgrammaticScrollEvent({ + scrollTop: 400, + programmaticTarget: 800, + })).toBe(false); + expect(shouldAbsorbProgrammaticScrollEvent({ + scrollTop: 400, + programmaticTarget: null, + })).toBe(false); + }); + it("keeps activity rows in the streaming indicator instead of the transcript", () => { const sharedEvents: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 094940a23..093b02ac5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3555,6 +3555,16 @@ const VIRTUALIZATION_THRESHOLD = 60; */ const STICK_THRESHOLD_PX = 160; +export function shouldAbsorbProgrammaticScrollEvent({ + scrollTop, + programmaticTarget, +}: { + scrollTop: number; + programmaticTarget: number | null; +}): boolean { + return programmaticTarget != null && Math.abs(scrollTop - programmaticTarget) < 1; +} + export function calculateVirtualWindow({ rowCount, scrollTop, @@ -3702,10 +3712,10 @@ export function AgentChatMessageList({ // into at most one scrollTop assignment per frame. const scrollRafRef = useRef(null); const scrollFollowFramesRef = useRef(0); - // Each programmatic scroll write increments this counter; the matching - // scroll event then decrements it and skips stick-state updates. Keeps - // user gestures as the only thing that toggles auto-follow off. - const programmaticScrollCountRef = useRef(0); + // Programmatic scroll writes can be coalesced by the browser. Track the + // latest ADE-authored scrollTop target instead of using a counter, so a real + // user scroll never gets swallowed by stale "programmatic" credits. + const programmaticScrollTargetRef = useRef(null); const onApprovalRef = useRef(onApproval); const resolvedInputStates = useMemo(() => { const resolved = new Map(); @@ -3855,11 +3865,10 @@ export function AgentChatMessageList({ setScrollTop(el.scrollTop); // Only register a pending programmatic scroll event if the assignment // actually moved the element. Otherwise (clamped to the same value, - // hidden element, etc.) no scroll event will fire and the counter - // would stay positive forever, misclassifying the next real user - // scroll as programmatic. + // hidden element, etc.) no scroll event will fire and the next real + // user scroll must still be allowed to update sticky state. if (el.scrollTop !== before) { - programmaticScrollCountRef.current += 1; + programmaticScrollTargetRef.current = el.scrollTop; } } const remaining = scrollFollowFramesRef.current; @@ -3953,6 +3962,7 @@ export function AgentChatMessageList({ }); if (adjustedScrollTop !== scrollEl.scrollTop) { scrollEl.scrollTop = adjustedScrollTop; + programmaticScrollTargetRef.current = adjustedScrollTop; setScrollTop(adjustedScrollTop); } } @@ -3999,11 +4009,16 @@ export function AgentChatMessageList({ // Absorb scroll events produced by our own programmatic scroll-to-bottom // writes so we never flip sticky state based on them — only the user's // own gesture (wheel / trackpad / keyboard) should break auto-follow. - if (programmaticScrollCountRef.current > 0) { - programmaticScrollCountRef.current -= 1; + const programmaticTarget = programmaticScrollTargetRef.current; + if (shouldAbsorbProgrammaticScrollEvent({ + scrollTop: target.scrollTop, + programmaticTarget, + })) { + programmaticScrollTargetRef.current = null; setScrollTop(target.scrollTop); return; } + programmaticScrollTargetRef.current = null; const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; // Wider threshold (~1 row of assistant text) so a small wheel nudge // while the turn is streaming actually breaks free instead of snapping @@ -4060,7 +4075,7 @@ export function AgentChatMessageList({ const before = el.scrollTop; el.scrollTop = clamped; if (el.scrollTop !== before) { - programmaticScrollCountRef.current += 1; + programmaticScrollTargetRef.current = el.scrollTop; } setScrollTop(el.scrollTop); }, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx deleted file mode 100644 index e3c8a9d99..000000000 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.companionDrawers.test.tsx +++ /dev/null @@ -1,454 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import type { ComponentProps } from "react"; -import type { AgentChatSessionSummary } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; -import { AgentChatPane } from "./AgentChatPane"; - -vi.mock("../terminals/TerminalView", () => { - const ReactMod = require("react") as typeof import("react"); - return { - TerminalView: (props: { sessionId: string; ptyId: string }) => - ReactMod.createElement("div", { "data-testid": "terminal-view" }, `${props.sessionId}:${props.ptyId}`), - }; -}); - -vi.mock("lottie-react", () => ({ - useLottie: () => ({ - View: null, - play: () => {}, - stop: () => {}, - pause: () => {}, - setSpeed: () => {}, - goToAndStop: () => {}, - goToAndPlay: () => {}, - setDirection: () => {}, - getDuration: () => 0, - destroy: () => {}, - animationItem: null, - }), - default: () => null, -})); - -vi.mock("@lobehub/icons", () => { - const brand = () => { - const Component = () => null; - Object.assign(Component, { - Avatar: () => null, - Color: () => null, - Combine: () => null, - Text: () => null, - colorPrimary: "#888", - title: "stub", - }); - return Component; - }; - return { - Claude: brand(), - Codex: brand(), - Cursor: brand(), - OpenCode: brand(), - }; -}); - -vi.mock("./ChatIosSimulatorPanel", () => { - const ReactMod = require("react") as typeof import("react"); - return { - ChatIosSimulatorPanel: () => ReactMod.createElement("div", { "data-testid": "ios-panel" }, "iOS panel mounted"), - }; -}); - -vi.mock("./ChatAppControlPanel", () => { - const ReactMod = require("react") as typeof import("react"); - return { - ChatAppControlPanel: () => ReactMod.createElement("div", { "data-testid": "app-control-panel" }, "App Control panel mounted"), - }; -}); - -const originalAde = globalThis.window.ade; -const originalNavigatorPlatform = window.navigator.platform; -let iosEventListener: ((event: { type: string; chatSessionId?: string; laneId?: string; mode?: string }) => void) | null = null; - -function buildSession(overrides: Partial = {}): AgentChatSessionSummary { - return { - sessionId: "session-1", - laneId: "lane-1", - provider: "codex", - model: "gpt-5.4", - modelId: "openai/gpt-5.4", - endedAt: null, - lastOutputPreview: null, - summary: null, - startedAt: "2026-03-24T05:57:45.700Z", - lastActivityAt: "2026-03-24T05:57:45.700Z", - status: "active", - sessionProfile: "workflow", - title: "Drawer audit chat", - goal: null, - completion: null, - reasoningEffort: "xhigh", - executionMode: "focused", - interactionMode: null, - ...overrides, - }; -} - -function installAdeMocks( - sessionOrSessions: AgentChatSessionSummary | AgentChatSessionSummary[], - options?: { transcript?: string }, -) { - const sessions = Array.isArray(sessionOrSessions) ? sessionOrSessions : [sessionOrSessions]; - const unarchive = vi.fn().mockResolvedValue(undefined); - globalThis.window.ade = { - projectConfig: { - get: vi.fn().mockResolvedValue({ - effective: { - ai: { - chat: { - sendOnEnter: true, - }, - }, - }, - }), - }, - ai: { - getStatus: vi.fn().mockRejectedValue(new Error("no ai status")), - }, - agentChat: { - models: vi.fn().mockResolvedValue([{ id: "gpt-5.4" }]), - slashCommands: vi.fn().mockResolvedValue([]), - list: vi.fn().mockResolvedValue(sessions), - getSummary: vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => - sessions.find((session) => session.sessionId === sessionId) ?? null, - ), - onEvent: vi.fn().mockImplementation(() => () => undefined), - send: vi.fn().mockResolvedValue(undefined), - steer: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockResolvedValue({ id: "created-session", laneId: "lane-1" }), - handoff: vi.fn().mockResolvedValue({ session: { id: "handoff-session", laneId: "lane-1" }, usedFallbackSummary: false }), - suggestLaneName: vi.fn().mockResolvedValue("drawer-task"), - parallelLaunchState: { - get: vi.fn().mockResolvedValue(null), - set: vi.fn().mockResolvedValue(undefined), - }, - editSteer: vi.fn().mockResolvedValue(undefined), - updateSession: vi.fn().mockResolvedValue(undefined), - archive: vi.fn().mockResolvedValue(undefined), - unarchive, - delete: vi.fn().mockResolvedValue(undefined), - interrupt: vi.fn().mockResolvedValue(undefined), - approve: vi.fn().mockResolvedValue(undefined), - respondToInput: vi.fn().mockResolvedValue(undefined), - warmupModel: vi.fn().mockResolvedValue(undefined), - fileSearch: vi.fn().mockResolvedValue([]), - dispose: vi.fn().mockResolvedValue(undefined), - }, - sessions: { - get: vi.fn().mockResolvedValue({ toolType: "codex-chat" }), - readTranscriptTail: vi.fn().mockResolvedValue(options?.transcript ?? ""), - getDelta: vi.fn().mockResolvedValue(null), - onChanged: vi.fn().mockImplementation(() => () => undefined), - }, - computerUse: { - getOwnerSnapshot: vi.fn().mockResolvedValue({ artifacts: [] }), - onEvent: vi.fn().mockImplementation(() => () => undefined), - }, - files: { - listWorkspaces: vi.fn().mockResolvedValue([]), - }, - lanes: { - list: vi.fn().mockResolvedValue([]), - listSnapshots: vi.fn().mockResolvedValue([]), - }, - git: { - listBranches: vi.fn().mockResolvedValue([]), - getActionRuntime: vi.fn().mockResolvedValue(null), - onActionRuntimeEvent: vi.fn().mockImplementation(() => () => undefined), - }, - diff: { - getChanges: vi.fn().mockResolvedValue({ staged: [], unstaged: [] }), - }, - prs: { - getForLane: vi.fn().mockResolvedValue(null), - onEvent: vi.fn().mockImplementation(() => () => undefined), - getChecks: vi.fn().mockResolvedValue([]), - openInGitHub: vi.fn().mockResolvedValue(undefined), - }, - pty: { - create: vi.fn().mockResolvedValue({ ptyId: "pty-created", sessionId: "terminal-created", pid: 1234 }), - onExit: vi.fn().mockImplementation(() => () => undefined), - dispose: vi.fn().mockResolvedValue(undefined), - resize: vi.fn().mockResolvedValue(undefined), - write: vi.fn().mockResolvedValue(undefined), - onData: vi.fn().mockImplementation(() => () => undefined), - }, - terminal: { - list: vi.fn().mockResolvedValue([]), - read: vi.fn().mockResolvedValue({ terminalId: "term-1", data: "", nextSince: 0 }), - write: vi.fn().mockResolvedValue({ ok: true }), - signal: vi.fn().mockResolvedValue({ ok: true }), - activeForChat: vi.fn().mockResolvedValue(null), - }, - iosSimulator: { - getStatus: vi.fn().mockResolvedValue({ platform: "darwin" }), - onEvent: vi.fn().mockImplementation((listener) => { - iosEventListener = listener; - return () => { - if (iosEventListener === listener) iosEventListener = null; - }; - }), - }, - appControl: { - getStatus: vi.fn().mockResolvedValue({ supported: true }), - onEvent: vi.fn().mockImplementation(() => () => undefined), - }, - } as any; - return { unarchive }; -} - -function seedStore() { - useAppStore.setState({ - project: { rootPath: "/tmp/project-under-test" } as any, - lanes: [{ - id: "lane-1", - name: "drawer lane", - branchRef: "refs/heads/drawer-lane", - laneType: "worktree", - worktreePath: "/tmp/project-under-test/drawer-lane", - } as any], - selectedLaneId: "lane-1", - }); -} - -function renderPane() { - const session = buildSession(); - installAdeMocks(session); - seedStore(); - - return render( - - - , - ); -} - -function renderSessionPane( - sessions: AgentChatSessionSummary[], - options?: { - transcript?: string; - presentation?: ComponentProps["presentation"]; - }, -) { - const active = sessions[0]!; - const mocks = installAdeMocks(sessions, { transcript: options?.transcript }); - seedStore(); - const view = render( - - - , - ); - return { ...mocks, view }; -} - -beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - iosEventListener = null; - Object.defineProperty(window.navigator, "platform", { - configurable: true, - value: "MacIntel", - }); - useAppStore.setState({ - project: null, - laneSnapshots: [], - lanes: [], - selectedLaneId: null, - focusedSessionId: null, - laneInspectorTabs: {}, - workViewByProject: {}, - laneWorkViewByScope: {}, - }); -}); - -afterEach(() => { - cleanup(); - Object.defineProperty(window.navigator, "platform", { - configurable: true, - value: originalNavigatorPlatform, - }); - if (originalAde === undefined) { - delete (globalThis.window as any).ade; - } else { - globalThis.window.ade = originalAde; - } -}); - -describe("AgentChatPane companion drawers", () => { - it("opens and closes the iOS simulator and App Control drawers from chat chrome", async () => { - renderPane(); - - await waitFor(() => { - expect(iosEventListener).toBeTruthy(); - }); - act(() => { - iosEventListener?.({ - type: "drawer-open-requested", - chatSessionId: "session-1", - laneId: "lane-1", - mode: "control", - }); - }); - - expect(screen.getByTestId("ios-panel").textContent).toBe("iOS panel mounted"); - fireEvent.click(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!); - await waitFor(() => { - expect(screen.queryByTestId("ios-panel")).toBeNull(); - }); - - const iosButton = screen.getAllByRole("button", { name: "Open iOS simulator drawer" })[0]!; - fireEvent.click(iosButton); - - expect(screen.getByTestId("ios-panel").textContent).toBe("iOS panel mounted"); - expect(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!.getAttribute("aria-pressed")).toBe("true"); - - fireEvent.click(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!); - await waitFor(() => { - expect(screen.queryByTestId("ios-panel")).toBeNull(); - }); - - await waitFor(() => { - expect(screen.getAllByRole("button", { name: "Open App Control drawer" }).length).toBeGreaterThan(0); - }); - const appControlButton = screen.getAllByRole("button", { name: "Open App Control drawer" })[0]!; - fireEvent.click(appControlButton); - - expect(screen.getByTestId("app-control-panel").textContent).toBe("App Control panel mounted"); - expect(screen.getAllByRole("button", { name: "Close App Control drawer" })[0]!.getAttribute("aria-pressed")).toBe("true"); - - fireEvent.click(screen.getAllByRole("button", { name: "Close App Control drawer" })[0]!); - await waitFor(() => { - expect(screen.queryByTestId("app-control-panel")).toBeNull(); - }); - }); - - it("opens the proof drawer and persists split resize from the real divider", async () => { - renderPane(); - - fireEvent.click(await screen.findByRole("button", { name: "Open proof drawer" })); - expect(screen.getByText("Artifacts")).toBeTruthy(); - - const divider = screen.getByRole("separator", { name: "" }); - const splitParent = divider.parentElement; - expect(splitParent).toBeTruthy(); - Object.defineProperty(splitParent, "getBoundingClientRect", { - configurable: true, - value: () => ({ - x: 0, - y: 0, - width: 1000, - height: 600, - top: 0, - right: 1000, - bottom: 600, - left: 0, - toJSON: () => ({}), - }), - }); - - fireEvent.mouseDown(divider, { clientX: 500 }); - fireEvent.mouseMove(document, { clientX: 600 }); - fireEvent.mouseUp(document); - - await waitFor(() => { - expect(window.sessionStorage.getItem("ade.chat.rightPaneSplit")).toBe("40"); - }); - - fireEvent.click(screen.getByRole("button", { name: "Close proof drawer" })); - await waitFor(() => { - expect(screen.queryByText("Artifacts")).toBeNull(); - }); - }); - - it("does not mount the terminal drawer when lane tool drawers are hidden", async () => { - const session = buildSession(); - installAdeMocks(session); - seedStore(); - - render( - - - , - ); - - await screen.findByRole("textbox"); - - expect(screen.queryByRole("button", { name: /open terminal/i })).toBeNull(); - expect(globalThis.window.ade.terminal.list).not.toHaveBeenCalled(); - expect(globalThis.window.ade.appControl.getStatus).not.toHaveBeenCalled(); - }); - - it("restores an archived chat from the archived selector", async () => { - const active = buildSession({ sessionId: "active-session", title: "Active chat" }); - const archived = buildSession({ - sessionId: "archived-session", - title: "Archived chat", - archivedAt: "2026-05-12T00:00:00.000Z", - }); - const { unarchive } = renderSessionPane([active, archived]); - - const restoreSelect = await screen.findByTitle("Restore archived chat"); - fireEvent.change(restoreSelect, { target: { value: "archived-session" } }); - - await waitFor(() => { - expect(unarchive).toHaveBeenCalledWith({ sessionId: "archived-session" }); - }); - }); - - it("clears a persistent identity chat view without deleting the session", async () => { - const transcript = `${JSON.stringify({ - sessionId: "persistent-session", - timestamp: "2026-05-12T00:00:00.000Z", - event: { - type: "text", - text: "Persistent memory view text", - itemId: "persistent-text", - turnId: "turn-1", - }, - })}\n`; - - renderSessionPane( - [buildSession({ sessionId: "persistent-session", title: "Persistent identity" })], - { - transcript, - presentation: { mode: "standard", profile: "persistent_identity" }, - }, - ); - - expect(await screen.findByText("Persistent memory view text")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Clear view" })); - - await waitFor(() => { - expect(screen.queryByText("Persistent memory view text")).toBeNull(); - }); - expect(globalThis.window.ade.agentChat.delete).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index ff17e2b52..b705c1168 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -6,6 +6,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import type { AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, AgentChatParallelLaunchState, AgentChatSession, AgentChatSessionSummary, @@ -30,6 +31,20 @@ vi.mock("../terminals/TerminalView", () => { }; }); +vi.mock("./ChatIosSimulatorPanel", () => { + const ReactMod = require("react") as typeof import("react"); + return { + ChatIosSimulatorPanel: () => ReactMod.createElement("div", { "data-testid": "ios-panel" }, "iOS panel mounted"), + }; +}); + +vi.mock("./ChatAppControlPanel", () => { + const ReactMod = require("react") as typeof import("react"); + return { + ChatAppControlPanel: () => ReactMod.createElement("div", { "data-testid": "app-control-panel" }, "App Control panel mounted"), + }; +}); + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -138,6 +153,7 @@ function installAdeMocks(options?: { createError?: Error; handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean }; sessions?: AgentChatSessionSummary[]; + eventHistory?: AgentChatEventHistorySnapshot | ((args: { sessionId: string; maxEvents?: number }) => Promise | AgentChatEventHistorySnapshot); includeClaudeModel?: boolean; parallelLaunchState?: AgentChatParallelLaunchState | null; linkedPr?: PrSummary | null; @@ -179,6 +195,8 @@ function installAdeMocks(options?: { const parallelLaunchStateGet = vi.fn().mockResolvedValue(options?.parallelLaunchState ?? null); const parallelLaunchStateSet = vi.fn().mockResolvedValue(undefined); const deleteChat = vi.fn().mockResolvedValue(undefined); + const archive = vi.fn().mockResolvedValue(undefined); + const unarchive = vi.fn().mockResolvedValue(undefined); const deleteLane = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); @@ -216,6 +234,14 @@ function installAdeMocks(options?: { send, steer, list, + ...(options?.eventHistory !== undefined + ? { + getEventHistory: vi.fn().mockImplementation(async (args: { sessionId: string; maxEvents?: number }) => { + if (typeof options.eventHistory === "function") return options.eventHistory(args); + return options.eventHistory; + }), + } + : {}), suggestLaneName, parallelLaunchState: { get: parallelLaunchStateGet, @@ -227,6 +253,8 @@ function installAdeMocks(options?: { }), editSteer: vi.fn().mockResolvedValue(undefined), updateSession: vi.fn().mockResolvedValue(undefined), + archive, + unarchive, interrupt: vi.fn().mockResolvedValue(undefined), approve: vi.fn().mockResolvedValue(undefined), respondToInput: vi.fn().mockResolvedValue(undefined), @@ -248,7 +276,7 @@ function installAdeMocks(options?: { }), }, computerUse: { - getOwnerSnapshot: vi.fn().mockResolvedValue(null), + getOwnerSnapshot: vi.fn().mockResolvedValue({ artifacts: [] }), onEvent: vi.fn().mockImplementation(() => () => undefined), }, files: { @@ -290,6 +318,19 @@ function installAdeMocks(options?: { signal: vi.fn().mockResolvedValue({ ok: true }), activeForChat: vi.fn().mockResolvedValue(null), }, + iosSimulator: { + getStatus: vi.fn().mockResolvedValue({ platform: "darwin" }), + onEvent: vi.fn().mockImplementation((listener: (event: { type: string; chatSessionId?: string; laneId?: string; mode?: string }) => void) => { + iosEventListener = listener; + return () => { + if (iosEventListener === listener) iosEventListener = null; + }; + }), + }, + appControl: { + getStatus: vi.fn().mockResolvedValue({ supported: true }), + onEvent: vi.fn().mockImplementation(() => () => undefined), + }, } as any; return { @@ -299,6 +340,8 @@ function installAdeMocks(options?: { create, createLane, deleteChat, + archive, + unarchive, deleteLane, suggestLaneName, parallelLaunchStateGet, @@ -324,6 +367,7 @@ function resetChatTestStore() { lanes: [], selectedLaneId: null, focusedSessionId: null, + projectTransition: null, laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, @@ -336,16 +380,28 @@ function LocationProbe() { } const originalAde = globalThis.window.ade; +const originalNavigatorPlatform = window.navigator.platform; +let iosEventListener: ((event: { type: string; chatSessionId?: string; laneId?: string; mode?: string }) => void) | null = null; beforeEach(() => { invalidateAiDiscoveryCache(); window.localStorage.clear(); + window.sessionStorage.clear(); + iosEventListener = null; + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); resetChatTestStore(); }); afterEach(() => { cleanup(); invalidateAiDiscoveryCache(); + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: originalNavigatorPlatform, + }); if (originalAde === undefined) { delete (globalThis.window as any).ade; } else { @@ -393,6 +449,50 @@ function renderTabbedPane(session: AgentChatSessionSummary) { ); } +function seedDrawerStore() { + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes: [{ + id: "lane-1", + name: "drawer lane", + branchRef: "refs/heads/drawer-lane", + laneType: "worktree", + worktreePath: "/tmp/project-under-test/drawer-lane", + } as any], + selectedLaneId: "lane-1", + }); +} + +function renderDrawerPane() { + const session = buildSession("session-1", { title: "Drawer audit chat" }); + installAdeMocks({ sessions: [session] }); + seedDrawerStore(); + return renderPane(session); +} + +function renderDrawerSessionPane( + sessions: AgentChatSessionSummary[], + options?: { + transcript?: string; + presentation?: React.ComponentProps["presentation"]; + }, +) { + const active = sessions[0]!; + const mocks = installAdeMocks({ sessions, transcript: options?.transcript }); + seedDrawerStore(); + const view = render( + + + , + ); + return { ...mocks, view }; +} + function renderParallelDraftPane(args?: { laneId?: string; availableModelIdsOverride?: string[]; @@ -492,12 +592,144 @@ async function clickEnabledModelOption(name: RegExp | string) { fireEvent.click(enabledOption!); } -function expectSessionTabOrder(expectedTitles: string[]) { +function sessionTabTitles(expectedTitles: string[]) { const tabs = screen.getAllByRole("button") .filter((button) => expectedTitles.includes(button.textContent?.trim() ?? "")); - expect(tabs.map((button) => button.textContent?.trim())).toEqual(expectedTitles); + return tabs.map((button) => button.textContent?.trim()); } +describe("AgentChatPane companion drawers", () => { + it("opens and closes the iOS simulator and App Control drawers from chat chrome", async () => { + renderDrawerPane(); + + await waitFor(() => { + expect(typeof iosEventListener).toBe("function"); + }); + act(() => { + iosEventListener?.({ + type: "drawer-open-requested", + chatSessionId: "session-1", + laneId: "lane-1", + mode: "control", + }); + }); + + expect(screen.getByTestId("ios-panel").textContent).toBe("iOS panel mounted"); + fireEvent.click(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!); + await waitFor(() => { + expect(screen.queryByTestId("ios-panel")).toBeNull(); + }); + + const iosButton = screen.getAllByRole("button", { name: "Open iOS simulator drawer" })[0]!; + fireEvent.click(iosButton); + + expect(screen.getByTestId("ios-panel").textContent).toBe("iOS panel mounted"); + expect(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!.getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(screen.getAllByRole("button", { name: "Close iOS simulator drawer" })[0]!); + await waitFor(() => { + expect(screen.queryByTestId("ios-panel")).toBeNull(); + }); + + await waitFor(() => { + expect(screen.getAllByRole("button", { name: "Open App Control drawer" }).length).toBeGreaterThan(0); + }); + const appControlButton = screen.getAllByRole("button", { name: "Open App Control drawer" })[0]!; + fireEvent.click(appControlButton); + + expect(screen.getByTestId("app-control-panel").textContent).toBe("App Control panel mounted"); + expect(screen.getAllByRole("button", { name: "Close App Control drawer" })[0]!.getAttribute("aria-pressed")).toBe("true"); + + fireEvent.click(screen.getAllByRole("button", { name: "Close App Control drawer" })[0]!); + await waitFor(() => { + expect(screen.queryByTestId("app-control-panel")).toBeNull(); + }); + }); + + it("opens the proof drawer and persists split resize from the real divider", async () => { + renderDrawerPane(); + + fireEvent.click(await screen.findByRole("button", { name: "Open proof drawer" })); + expect(screen.getByText("Artifacts")).toBeTruthy(); + + const divider = screen.getByRole("separator", { name: "" }); + const splitParent = divider.parentElement; + expect(splitParent).toBeInstanceOf(HTMLElement); + Object.defineProperty(splitParent as HTMLElement, "getBoundingClientRect", { + configurable: true, + value: () => ({ + x: 0, + y: 0, + width: 1000, + height: 600, + top: 0, + right: 1000, + bottom: 600, + left: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.mouseDown(divider, { clientX: 500 }); + fireEvent.mouseMove(document, { clientX: 600 }); + fireEvent.mouseUp(document); + + await waitFor(() => { + expect(window.sessionStorage.getItem("ade.chat.rightPaneSplit")).toBe("40"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close proof drawer" })); + await waitFor(() => { + expect(screen.queryByText("Artifacts")).toBeNull(); + }); + }); + + it("restores an archived chat from the archived selector", async () => { + const active = buildSession("active-session", { title: "Active chat" }); + const archived = buildSession("archived-session", { + title: "Archived chat", + archivedAt: "2026-05-12T00:00:00.000Z", + }); + const { unarchive } = renderDrawerSessionPane([active, archived]); + + const restoreSelect = await screen.findByTitle("Restore archived chat"); + fireEvent.change(restoreSelect, { target: { value: "archived-session" } }); + + await waitFor(() => { + expect(unarchive).toHaveBeenCalledWith({ sessionId: "archived-session" }); + }); + }); + + it("clears a persistent identity chat view without deleting the session", async () => { + const transcript = `${JSON.stringify({ + sessionId: "persistent-session", + timestamp: "2026-05-12T00:00:00.000Z", + event: { + type: "text", + text: "Persistent memory view text", + itemId: "persistent-text", + turnId: "turn-1", + }, + })}\n`; + + renderDrawerSessionPane( + [buildSession("persistent-session", { title: "Persistent identity" })], + { + transcript, + presentation: { mode: "standard", profile: "persistent_identity" }, + }, + ); + + expect(await screen.findByText("Persistent memory view text")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Clear view" })); + + await waitFor(() => { + expect(screen.queryByText("Persistent memory view text")).toBeNull(); + }); + expect(globalThis.window.ade.agentChat.delete).not.toHaveBeenCalled(); + }); +}); + describe("AgentChatPane submit recovery", () => { it("loads Claude slash commands for a draft chat before session creation", async () => { installAdeMocks({ sessions: [], includeClaudeModel: true }); @@ -713,6 +945,30 @@ describe("AgentChatPane submit recovery", () => { expect(steer).not.toHaveBeenCalled(); }); + it("disables chat sending while the project is switching", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { send, steer } = installAdeMocks({ sessions: [session] }); + useAppStore.setState({ + projectTransition: { + kind: "switching", + rootPath: "/tmp/next", + startedAtMs: Date.now(), + }, + } as any); + + renderPane(session); + + const textbox = await screen.findByRole("textbox"); + expect((textbox as HTMLTextAreaElement).placeholder).toBe("Project is switching..."); + fireEvent.change(textbox, { target: { value: "This should wait." } }); + const sendButton = await screen.findByRole("button", { name: "Send" }); + expect((sendButton as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(sendButton); + + expect(send).not.toHaveBeenCalled(); + expect(steer).not.toHaveBeenCalled(); + }); + it("does not keep showing a working indicator when the session summary is idle", async () => { const session = buildSession("session-1", { status: "idle", @@ -1035,6 +1291,56 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("falls back to a normal send when the active-turn marker is stale", async () => { + const session = buildSession("session-1"); + const { send, steer } = installAdeMocks({ + transcript: buildStatusStartedTranscript(session.sessionId), + steerError: new Error("No active turn to steer."), + }); + + renderPane(session); + + const textbox = await screen.findByPlaceholderText("Steer the active turn..."); + fireEvent.change(textbox, { target: { value: "Recover by starting a new turn." } }); + fireEvent.click(screen.getByLabelText("Send steer message")); + + await waitFor(() => { + expect(steer).toHaveBeenCalledWith({ + sessionId: session.sessionId, + text: "Recover by starting a new turn.", + }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + text: "Recover by starting a new turn.", + })); + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(""); + }); + }); + + it("retries a send when the turn ends between send-active and steer", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { send, steer } = installAdeMocks({ + sessions: [session], + steerError: new Error("No active turn to steer."), + }); + send.mockRejectedValueOnce(new Error("turn is already active")); + + renderPane(session); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Please keep going." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(send).toHaveBeenCalledTimes(2); + expect(steer).toHaveBeenCalledWith({ + sessionId: session.sessionId, + text: "Please keep going.", + }); + expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(""); + }); + }); + it("restores the draft when the send itself fails", async () => { const session = buildSession("session-1", { status: "idle" }); const { send } = installAdeMocks({ @@ -1305,13 +1611,13 @@ describe("AgentChatPane submit recovery", () => { renderTabbedPane(newerSession); await waitFor(() => { - expectSessionTabOrder(["Newer chat", "Older chat"]); + expect(sessionTabTitles(["Newer chat", "Older chat"])).toEqual(["Newer chat", "Older chat"]); }); fireEvent.click(screen.getByRole("button", { name: /Older chat/i })); await waitFor(() => { - expectSessionTabOrder(["Older chat", "Newer chat"]); + expect(sessionTabTitles(["Older chat", "Newer chat"])).toEqual(["Older chat", "Newer chat"]); }); }); @@ -1549,7 +1855,7 @@ describe("AgentChatPane submit recovery", () => { renderTabbedPane(newerSession); await waitFor(() => { - expectSessionTabOrder(["Newer chat", "Older chat"]); + expect(sessionTabTitles(["Newer chat", "Older chat"])).toEqual(["Newer chat", "Older chat"]); }); emitChatEvent({ @@ -1563,7 +1869,7 @@ describe("AgentChatPane submit recovery", () => { }); await waitFor(() => { - expectSessionTabOrder(["Older chat", "Newer chat"]); + expect(sessionTabTitles(["Older chat", "Newer chat"])).toEqual(["Older chat", "Newer chat"]); }); }); @@ -2205,6 +2511,50 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByText("Background output kept streaming")).toBeTruthy(); }); + it("validates empty legacy event-history snapshots before treating them as loaded", async () => { + const session = buildSession("session-1", { title: "Possibly foreign chat" }); + installAdeMocks({ + sessions: [], + eventHistory: { + sessionId: session.sessionId, + events: [], + truncated: false, + }, + }); + + renderPane(session); + + await waitFor(() => { + expect(window.ade.agentChat.getEventHistory).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + })); + expect(window.ade.agentChat.getSummary).toHaveBeenCalledWith({ sessionId: session.sessionId }); + expect(window.ade.sessions.readTranscriptTail).not.toHaveBeenCalled(); + }); + }); + + it("rejects explicit foreign-session event-history snapshots", async () => { + const session = buildSession("session-1", { title: "Foreign chat" }); + installAdeMocks({ + sessions: [session], + eventHistory: { + sessionId: session.sessionId, + events: [], + truncated: false, + sessionFound: false, + }, + }); + + renderPane(session); + + await waitFor(() => { + expect(window.ade.agentChat.getEventHistory).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + })); + expect(window.ade.sessions.readTranscriptTail).not.toHaveBeenCalled(); + }); + }); + it("reloads a previously viewed chat transcript when switching back to recover missed background output", async () => { const primarySession = buildSession("session-1", { title: "Primary chat", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 284935276..2b10908e6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -14,6 +14,7 @@ import { type AgentChatDroidPermissionMode, type AgentChatExecutionMode, type AgentChatEventEnvelope, + type AgentChatEventHistorySnapshot, type AgentChatContextAttachment, type AgentChatFileRef, type AgentChatInteractionMode, @@ -1224,6 +1225,18 @@ function isMissingParallelLaunchLaneError(error: unknown): boolean { return /not found|no such lane|does not exist/i.test(message); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isTurnAlreadyActiveError(error: unknown): boolean { + return /turn is already active|already active/i.test(errorMessage(error)); +} + +function isNoActiveTurnToSteerError(error: unknown): boolean { + return /no active turn to steer/i.test(errorMessage(error)); +} + export function formatParallelLaunchFailureMessage(args: { launchError: string; cleanupIssues: ParallelLaunchCleanupIssue[]; @@ -1480,6 +1493,7 @@ export function AgentChatPane({ onLaneChange?: (laneId: string) => void; }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectTransition = useAppStore((s) => s.projectTransition); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); @@ -1739,6 +1753,7 @@ export function AgentChatPane({ const sessionsRef = useRef(sessions); const completionSoundPrevTurnActiveRef = useRef(false); const completionSoundArmedRef = useRef(true); + const projectTransitionBlocksChat = projectTransition != null; const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); const loadedHistoryRef = useRef>(new Set()); @@ -1993,24 +2008,42 @@ export function AgentChatPane({ const sendCodexControlMessage = useCallback(async (sessionId: string, text: string) => { setError(null); try { - if (turnActiveBySession[sessionId]) { + const steerControlMessage = async () => { await window.ade.agentChat.steer({ sessionId, text }); - return; - } + }; + const sendOrSteerIfBusy = async (retryOnStaleSteer = true) => { + try { + await window.ade.agentChat.send({ sessionId, text }); + } catch (sendError) { + if (isTurnAlreadyActiveError(sendError)) { + setError(null); + try { + await steerControlMessage(); + } catch (steerError) { + if (!isNoActiveTurnToSteerError(steerError) || !retryOnStaleSteer) throw steerError; + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + await sendOrSteerIfBusy(false); + } + return; + } + throw sendError; + } + }; - try { - await window.ade.agentChat.send({ sessionId, text }); - } catch (sendError) { - const message = sendError instanceof Error ? sendError.message : String(sendError); - if (/turn is already active|already active/i.test(message)) { - setError(null); - await window.ade.agentChat.steer({ sessionId, text }); - return; + if (turnActiveBySession[sessionId]) { + try { + await steerControlMessage(); + } catch (steerError) { + if (!isNoActiveTurnToSteerError(steerError)) throw steerError; + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + await sendOrSteerIfBusy(); } - throw sendError; + return; } + + await sendOrSteerIfBusy(); } catch (controlError) { - setError(controlError instanceof Error ? controlError.message : String(controlError)); + setError(errorMessage(controlError)); } }, [turnActiveBySession]); // Per-session memo of which sessions have already triggered the auto-open @@ -2529,6 +2562,9 @@ export function AgentChatPane({ const assistantLabel = presentation?.assistantLabel?.trim() || resolveAssistantLabel(selectedModelDesc, selectedSession?.provider); const messagePlaceholder = presentation?.messagePlaceholder?.trim() || "Type to vibecode..."; + const effectiveMessagePlaceholder = projectTransitionBlocksChat + ? "Project is switching..." + : messagePlaceholder; const chipsJson = JSON.stringify(presentation?.chips ?? []); const resolvedChips = useMemo(() => JSON.parse(chipsJson) as ChatSurfaceChip[], [chipsJson]); @@ -3040,6 +3076,14 @@ export function AgentChatPane({ } }, []); + const clearSessionView = useCallback((sessionId: string) => { + eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; + setEventsBySession((prev) => ({ ...prev, [sessionId]: [] })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: [] })); + setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: [] })); + }, []); + const loadHistory = useCallback(async (sessionId: string, options?: { force?: boolean }) => { if (options?.force) { loadedHistoryRef.current.delete(sessionId); @@ -3060,10 +3104,23 @@ export function AgentChatPane({ let usedSnapshotPath = false; try { if (typeof window.ade.agentChat.getEventHistory === "function") { - const snapshot = await window.ade.agentChat.getEventHistory({ + const snapshot: AgentChatEventHistorySnapshot = await window.ade.agentChat.getEventHistory({ sessionId, maxEvents: MAX_SELECTED_CHAT_SESSION_EVENTS, }); + if (snapshot?.sessionId === sessionId && snapshot.sessionFound === false) { + clearSessionView(sessionId); + loadedHistoryRef.current.delete(sessionId); + return; + } + if (snapshot?.sessionId === sessionId && !snapshot.events?.length && snapshot.sessionFound !== true) { + const summary = await window.ade.agentChat.getSummary({ sessionId }).catch(() => null); + if (!summary) { + clearSessionView(sessionId); + loadedHistoryRef.current.delete(sessionId); + return; + } + } if (snapshot?.events?.length || snapshot?.sessionId === sessionId) { parsed = (snapshot.events ?? []).filter((entry) => entry.sessionId === sessionId); usedSnapshotPath = true; @@ -3120,15 +3177,7 @@ export function AgentChatPane({ // permanently blocked re-entry until the chat received a new event. loadedHistoryRef.current.delete(sessionId); } - }, [initialSessionSummary, lockSessionId]); - - const clearSessionView = useCallback((sessionId: string) => { - eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; - setEventsBySession((prev) => ({ ...prev, [sessionId]: [] })); - setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); - setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: [] })); - setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: [] })); - }, []); + }, [clearSessionView, initialSessionSummary, lockSessionId]); useEffect(() => { if (lockSessionId) { @@ -4645,7 +4694,7 @@ export function AgentChatPane({ }, [preferencesReady, laneId, modelId, selectedSessionId, lockSessionId, initialSessionId, forceDraft, createSession]); const submit = useCallback(async () => { - if (submitInFlightRef.current || busy || parallelLaunchBusy) return; + if (submitInFlightRef.current || busy || parallelLaunchBusy || projectTransitionBlocksChat) return; if (selectedSessionId) { const sessionPending = pendingInputsBySession[selectedSessionId] ?? []; const hasBlockingPending = sessionPending.some((entry) => entry.request.blocking); @@ -4766,27 +4815,31 @@ export function AgentChatPane({ if (!sessionId) continue; const desc = getModelById(slot.modelId); const provider = resolveChatRuntimeProvider(desc); + const sendPayload = { + sessionId, + text: sendText, + displayText: displayForSend, + attachments: attachmentsSnapshot, + contextAttachments: contextAttachmentsSnapshot, + reasoningEffort: slot.reasoningEffort, + executionMode: slot.executionMode, + interactionMode: provider === "claude" ? slot.interactionMode : null, + }; try { - await window.ade.agentChat.send({ - sessionId, - text: sendText, - displayText: displayForSend, - attachments: attachmentsSnapshot, - contextAttachments: contextAttachmentsSnapshot, - reasoningEffort: slot.reasoningEffort, - executionMode: slot.executionMode, - interactionMode: provider === "claude" ? slot.interactionMode : null, - }); + await window.ade.agentChat.send(sendPayload); } catch (sendError) { - const sendMsg = sendError instanceof Error ? sendError.message : String(sendError); - const isBusyErr = /turn is already active|already active/i.test(sendMsg); - if (isBusyErr) { - await window.ade.agentChat.steer({ - sessionId, - text: sendText, - ...(attachmentsSnapshot.length ? { attachments: attachmentsSnapshot } : {}), - ...(contextAttachmentsSnapshot.length ? { contextAttachments: contextAttachmentsSnapshot } : {}), - }); + if (isTurnAlreadyActiveError(sendError)) { + try { + await window.ade.agentChat.steer({ + sessionId, + text: sendText, + ...(attachmentsSnapshot.length ? { attachments: attachmentsSnapshot } : {}), + ...(contextAttachmentsSnapshot.length ? { contextAttachments: contextAttachmentsSnapshot } : {}), + }); + } catch (steerError) { + if (!isNoActiveTurnToSteerError(steerError)) throw steerError; + await window.ade.agentChat.send(sendPayload); + } } else { throw sendError; } @@ -5121,15 +5174,16 @@ export function AgentChatPane({ lastActivityAt: new Date().toISOString(), }); - if (turnActiveBySession[sessionId]) { - setOptimisticOutgoingMessageSynced(null); + const steerMessage = async () => { await window.ade.agentChat.steer({ sessionId, text: finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), }); - } else { + }; + + const sendMessageOrSteerIfBusy = async (retryOnStaleSteer = true) => { try { setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); await window.ade.agentChat.send({ @@ -5147,19 +5201,31 @@ export function AgentChatPane({ // Race condition: the turn may have started between our state check // and the backend call. If so, automatically fall back to steer // instead of surfacing a confusing error to the user. - const sendMsg = sendError instanceof Error ? sendError.message : String(sendError); - const isBusy = /turn is already active|already active/i.test(sendMsg); - if (isBusy) { - await window.ade.agentChat.steer({ - sessionId, - text: finalText, - ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), - ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), - }); + if (isTurnAlreadyActiveError(sendError)) { + try { + await steerMessage(); + } catch (steerError) { + if (!isNoActiveTurnToSteerError(steerError) || !retryOnStaleSteer) throw steerError; + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + await sendMessageOrSteerIfBusy(false); + } } else { throw sendError; } } + }; + + if (turnActiveBySession[sessionId]) { + setOptimisticOutgoingMessageSynced(null); + try { + await steerMessage(); + } catch (steerError) { + if (!isNoActiveTurnToSteerError(steerError)) throw steerError; + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: false })); + await sendMessageOrSteerIfBusy(); + } + } else { + await sendMessageOrSteerIfBusy(); } // Skip refresh when we just created the session — createSession already triggered one. // A redundant refresh here causes flicker as it re-resolves session selection. @@ -5210,6 +5276,7 @@ export function AgentChatPane({ launchModeEditable, modelId, patchSessionSummary, + projectTransitionBlocksChat, reasoningEffort, pendingInputsBySession, refreshAvailableModels, @@ -6311,7 +6378,7 @@ export function AgentChatPane({ approvalResponding={pendingInput ? respondingApprovalIds.has(pendingInput.itemId) : false} turnActive={turnActive} sendOnEnter={sendOnEnter} - busy={busy} + busy={busy || projectTransitionBlocksChat} sessionProvider={sessionProvider} interactionMode={interactionMode} claudePermissionMode={claudePermissionMode} @@ -6328,10 +6395,10 @@ export function AgentChatPane({ builtInBrowserContextItems={builtInBrowserContextItems} macosVmContextItems={macosVmContextItems} executionModeOptions={launchModeEditable ? executionModeOptions : []} - modelSelectionLocked={modelSelectionLocked || sessionMutationKind === "model" || turnActive} - permissionModeLocked={permissionModeLocked || identitySessionSettingsBusy} + modelSelectionLocked={modelSelectionLocked || sessionMutationKind === "model" || turnActive || projectTransitionBlocksChat} + permissionModeLocked={permissionModeLocked || identitySessionSettingsBusy || projectTransitionBlocksChat} hideNativeControls={hideNativeControls} - messagePlaceholder={messagePlaceholder} + messagePlaceholder={effectiveMessagePlaceholder} onExecutionModeChange={setExecutionMode} onModelCatalogOpen={refreshCursorModelInventory} onInteractionModeChange={(value) => { void updateNativeControls({ interactionMode: value }); }} diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index eb493c4f6..6815a149c 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -55,6 +55,85 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); + it("coalesces Codex spawn placeholders with later agent-thread snapshots", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + description: "Inspect desktop IPC path", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:01.000Z", + event: { + type: "subagent_started", + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + description: "Inspect desktop IPC path", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:02.000Z", + event: { + type: "subagent_result", + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Mapped the IPC path.", + }, + }, + ]; + + expect(deriveChatSubagentSnapshots(events)).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-1", + parentToolUseId: "call-spawn-1", + status: "completed", + summary: "Mapped the IPC path.", + }), + ]); + }); + + it("marks non-background running snapshots stopped when their parent turn has already ended", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + description: "Inspect desktop IPC path", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:03.000Z", + event: { + type: "done", + turnId: "turn-1", + status: "completed", + model: "gpt-5.4", + }, + }, + ]; + + expect(deriveChatSubagentSnapshots(events)).toEqual([ + expect.objectContaining({ + taskId: "call-spawn-1", + status: "stopped", + summary: "Parent turn ended before ADE received a final subagent status", + }), + ]); + }); + it("reuses prior description and summary when result payload is sparse", () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index 330272a9f..9d77a4fa1 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -7,6 +7,7 @@ export type ChatSubagentSnapshot = { parentToolUseId?: string | null; description: string; status: "running" | "completed" | "failed" | "stopped"; + turnId?: string; startedAt: string; updatedAt: string; summary: string | null; @@ -24,22 +25,49 @@ function compareIsoDesc(left: string, right: string): number { return Date.parse(right) - Date.parse(left); } +function findSnapshotByParent( + snapshots: Map, + parentToolUseId: string | null | undefined, +): [string, ChatSubagentSnapshot] | null { + if (!parentToolUseId) return null; + const direct = snapshots.get(parentToolUseId); + if (direct) return [parentToolUseId, direct]; + for (const entry of snapshots.entries()) { + const [, snapshot] = entry; + if (snapshot.parentToolUseId === parentToolUseId) return entry; + } + return null; +} + export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): ChatSubagentSnapshot[] { const snapshots = new Map(); + const terminalTurnIds = new Set(); + + for (const envelope of events) { + const event = envelope.event; + if (event.type === "done") { + terminalTurnIds.add(event.turnId); + } else if (event.type === "status" && event.turnStatus !== "started" && event.turnId) { + terminalTurnIds.add(event.turnId); + } + } for (const envelope of events) { const event = envelope.event; if (event.type === "subagent_started") { const key = event.agentId ?? event.taskId; - const existing = snapshots.get(key) ?? snapshots.get(event.taskId); + const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); + const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; if (key !== event.taskId) snapshots.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); snapshots.set(key, { - taskId: existing?.taskId ?? event.taskId, + taskId: event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, description: event.description.trim() || existing?.description || "Subagent task", status: "running", + turnId: event.turnId ?? existing?.turnId, startedAt: existing?.startedAt ?? envelope.timestamp, updatedAt: envelope.timestamp, summary: existing?.summary ?? null, @@ -53,15 +81,22 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C if (event.type === "subagent_progress") { const key = event.agentId ?? event.taskId; - const existing = snapshots.get(key) ?? snapshots.get(event.taskId); + const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); + const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; if (key !== event.taskId) snapshots.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); snapshots.set(key, { + // Preserve the stable merged identity. Overwriting taskId with the + // current event's taskId for a parent-based match would split the + // agent's history in `deriveSubagentTimeline`, which keys off + // `snapshot.taskId`. taskId: existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, description: event.description?.trim() || existing?.description || "Subagent task", status: "running", + turnId: event.turnId ?? existing?.turnId, startedAt: existing?.startedAt ?? envelope.timestamp, updatedAt: envelope.timestamp, summary: event.summary?.trim() || existing?.summary || null, @@ -75,15 +110,19 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C if (event.type === "subagent_result") { const key = event.agentId ?? event.taskId; - const existing = snapshots.get(key) ?? snapshots.get(event.taskId); + const parentMatch = findSnapshotByParent(snapshots, event.parentToolUseId); + const existing = snapshots.get(key) ?? snapshots.get(event.taskId) ?? parentMatch?.[1]; if (key !== event.taskId) snapshots.delete(event.taskId); + if (parentMatch && parentMatch[0] !== key) snapshots.delete(parentMatch[0]); snapshots.set(key, { - taskId: event.taskId, + // Preserve the merged taskId (see subagent_progress branch above). + taskId: existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, description: existing?.description ?? "Subagent task", status: event.status, + turnId: event.turnId ?? existing?.turnId, startedAt: existing?.startedAt ?? envelope.timestamp, updatedAt: envelope.timestamp, summary: event.summary?.trim() || existing?.summary || null, @@ -95,6 +134,22 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C } } + for (const [key, snapshot] of snapshots) { + if ( + snapshot.status === "running" + && snapshot.background !== true + && snapshot.turnId + && terminalTurnIds.has(snapshot.turnId) + ) { + snapshots.set(key, { + ...snapshot, + status: "stopped", + summary: snapshot.summary ?? "Parent turn ended before ADE received a final subagent status", + finalSummary: snapshot.finalSummary ?? "Parent turn ended before ADE received a final subagent status", + }); + } + } + return [...snapshots.values()].sort((left, right) => { if (left.status === "running" && right.status !== "running") return -1; if (right.status === "running" && left.status !== "running") return 1; diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index 7905f3ecf..9188a9983 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -24,47 +24,56 @@ export function LaneDialogShell({ onCloseAutoFocus?: (event: Event) => void; children: ReactNode; }) { + const width = widthClassName ?? "w-[min(720px,calc(100vw-1rem))]"; + const maxHeight = "max-h-[min(92dvh,calc(100vh-1rem))]"; + return ( { if (!busy || next) onOpenChange(next); }}> - +
-
-
-
- - {Icon ? ( - - - - ) : null} - {title} - - {description ? ( - - {description} - - ) : ( - - {title} - - )} +
+
+
+
+
+ + {Icon ? ( + + + + ) : null} + {title} + + {description ? ( + + {description} + + ) : ( + + {title} + + )} +
+ + + +
+
+
+ {children}
- - -
- {children}
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b3c02328e..0fdbac1c9 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -3636,6 +3636,7 @@ export function LanesPage() { onArchive={() => { archiveManagedLanes().catch(() => {}); }} onDelete={() => { deleteManagedLanes().catch(() => {}); }} onAppearanceChanged={() => refreshLanes({ includeStatus: false }).catch(() => {})} + onStackReorganized={() => { refreshLanes().catch(() => {}); }} /> diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 9c1555be6..61f11a838 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -13,7 +13,9 @@ import { Cube, CheckCircle, X, - Minus + Minus, + TreeStructure, + Info } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; import type { @@ -24,7 +26,14 @@ import type { LaneSummary } from "../../../shared/types"; import { LaneDialogShell } from "./LaneDialogShell"; -import { SECTION_CLASS_NAME, LABEL_CLASS_NAME, INPUT_CLASS_NAME } from "./laneDialogTokens"; +import { + SECTION_CLASS_NAME, + SECTION_ACCENT_CLASS_NAME, + SECTION_HERO_CLASS_NAME, + LABEL_CLASS_NAME, + INPUT_CLASS_NAME, + SELECT_CLASS_NAME +} from "./laneDialogTokens"; import { LaneColorPicker } from "./LaneColorPicker"; import { colorsInUse, laneColorName } from "./laneColorPalette"; @@ -42,6 +51,12 @@ const STEP_LABELS: Record = { database_cleanup: "Updating database" }; +const SHELL_DESCRIPTION_BATCH = + "Archive or delete the selected lanes. Stack position, color, and adopt are only available when you manage one lane at a time."; + +const SHELL_DESCRIPTION_SINGLE = + "Review lane details, then use the sections below. Stack and appearance only change this lane; archive and delete are separate flows with their own confirmations."; + export function ManageLaneDialog({ open, onOpenChange, @@ -64,7 +79,8 @@ export function ManageLaneDialog({ onAdoptAttached, onArchive, onDelete, - onAppearanceChanged + onAppearanceChanged, + onStackReorganized }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -88,6 +104,7 @@ export function ManageLaneDialog({ onArchive: () => void; onDelete: () => void; onAppearanceChanged?: () => void | Promise; + onStackReorganized?: () => void | Promise; }) { const lanes = managedLanes?.length ? managedLanes : managedLane ? [managedLane] : []; const isBatch = lanes.length > 1; @@ -169,28 +186,84 @@ export function ManageLaneDialog({ const confirmMatch = !requiresTypeConfirm || deleteConfirmText.trim().toLowerCase() === deletePhrase.toLowerCase(); const showStaticBusy = laneActionBusy && !deleteProgress; + let shellDescription: string | undefined; + if (lanes.length > 0 && !allPrimary) { + shellDescription = isBatch ? SHELL_DESCRIPTION_BATCH : SHELL_DESCRIPTION_SINGLE; + } + return ( {lanes.length === 0 ? (
Select a lane first.
) : allPrimary ? ( -
Primary lane cannot be archived or deleted.
+
+ Primary lane cannot be archived or deleted. Close this dialog or pick another lane. +
) : ( -
+
+ {!isBatch ? ( +
+
+ +
+
What each section does
+
    +
  • + Stack position + {" — "} + Change which lane is above you in the stack and optionally which branch name to stack onto. Applying updates ADE and runs{" "} + git rebase in this worktree (blocked while dirty or mid-rebase). +
  • +
  • + Appearance + {" — "} + Lane color for tabs and lists only; does not change branches or git state. +
  • +
  • + Archive + {" — "} + Hides the lane from ADE; worktree and branches stay on disk until you delete them elsewhere. +
  • +
  • + Delete + {" — "} + Destructive: remove worktree folder and optionally local and/or remote branches, with typed confirmation when risk is high. +
  • +
+
+
+
+ ) : ( +
+
Batch mode
+

+ You are managing {lanes.length} lanes together. Only archive and{" "} + delete apply to the whole selection. Open Manage Lane on one lane for stack position, color, or adopt. +

+
+ )} + {/* Lane info */} -
+
{isBatch ? "Selected lanes" : "Lane"} {isBatch ? ( -
+
{lanes.map((lane) => ( -
+
{lane.name} {lane.branchRef} @@ -202,13 +275,19 @@ export function ManageLaneDialog({
) : (
-
+
{lanes[0].name} - {lanes[0].laneType} + + {lanes[0].laneType} +
-
-
Branch: {lanes[0].branchRef}
-
Path: {lanes[0].worktreePath}
+
+
+ Branch: {lanes[0].branchRef} +
+
+ Path: {lanes[0].worktreePath} +
)} @@ -216,18 +295,19 @@ export function ManageLaneDialog({ {/* Adopt attached — single lane only */} {!isBatch && isAttached && ( -
-
-
-
- - Move to ADE-Managed Worktree +
+
+
+
+ + Move to ADE-managed worktree
-
- Move this attached worktree into .ade/worktrees for full lifecycle management. +
+ Copies registration into .ade/worktrees so ADE can manage lifecycle + (open, env, delete) the same way as other lanes. Does not rewrite git history.
-
@@ -235,36 +315,48 @@ export function ManageLaneDialog({ )} {/* Appearance — single lane only */} - {!isBatch && lanes[0] ? ( - + {singleLane ? ( + + ) : null} + + {singleLane && singleLane.laneType !== "primary" ? ( + ) : null} {/* Archive */}
-
-
+
+
- + Archive
-
+
{isBatch - ? `Hide ${lanes.length} lanes from ADE without deleting worktrees or branches.` - : "Hide from ADE without deleting worktree or branches."} + ? `Hides all ${lanes.length} lanes from ADE. Worktrees and branches stay on disk until you delete them with the section below or outside ADE.` + : "Hides this lane from ADE lists and the stack view. Worktrees and branches stay on disk until you delete them here or outside ADE."}
-
{/* Delete */} -
-
- - {hasAttached && !isBatch ? "Detach / Delete" : "Delete"} +
+
+ + {hasAttached && !isBatch ? "Detach / delete" : "Delete"}
+

+ ADE stops processes, terminals, and watchers on this lane, then removes the worktree folder and optionally deletes branches locally and/or on the remote you name. This cannot be undone from ADE. +

{hasAnyDirty && (
@@ -607,8 +699,11 @@ function AppearanceSection({ Appearance
-
- {currentName ? `Color: ${currentName}` : "Pick a color to identify this lane across the app."} +
+ Pick a color for this lane in tabs, the stack strip, and headers. This is visual only and does not rename branches or run git. +
+
+ {currentName ? `Current: ${currentName}` : "No color set yet."}
); } + +// Mirror the backend's normalization (apps/desktop/src/main/services/shared/utils.ts +// `normalizeBranchName`) so the frontend `baseChanged` check matches what the +// IPC handler will actually compare against. Without this, typing +// `refs/heads/main` or `origin/main` when the stored ref is already `main` would +// flip `baseChanged` to true but the backend would no-op the rebase. +function normalizeBranchRefForCompare(ref: string): string { + return ref + .trim() + .replace(/^refs\/heads\//, "") + .replace(/^origin\//, ""); +} + +function collectDescendantLaneIds(rootId: string, all: LaneSummary[]): Set { + const childrenByParent = new Map(); + for (const row of all) { + if (!row.parentLaneId) continue; + const list = childrenByParent.get(row.parentLaneId) ?? []; + list.push(row); + childrenByParent.set(row.parentLaneId, list); + } + const out = new Set(); + const stack = [...(childrenByParent.get(rootId) ?? [])]; + while (stack.length) { + const row = stack.pop()!; + if (out.has(row.id)) continue; + out.add(row.id); + const kids = childrenByParent.get(row.id); + if (kids) stack.push(...kids); + } + return out; +} + +function StackPositionSection({ + lane, + allLanes, + disabled, + onDone, +}: { + lane: LaneSummary; + allLanes: LaneSummary[]; + disabled: boolean; + onDone?: () => void | Promise; +}) { + const primaryLane = React.useMemo( + () => allLanes.find((l) => l.laneType === "primary" && !l.archivedAt) ?? null, + [allLanes], + ); + + const effectiveCurrentParentId = lane.parentLaneId ?? primaryLane?.id ?? ""; + + const candidates = React.useMemo(() => { + const descendants = collectDescendantLaneIds(lane.id, allLanes); + const list = allLanes.filter( + (l) => !l.archivedAt && l.id !== lane.id && !descendants.has(l.id), + ); + list.sort((a, b) => { + const ap = a.laneType === "primary" ? 0 : 1; + const bp = b.laneType === "primary" ? 0 : 1; + if (ap !== bp) return ap - bp; + return a.name.localeCompare(b.name); + }); + return list; + }, [allLanes, lane.id]); + + const [stackParentId, setStackParentId] = React.useState(effectiveCurrentParentId); + const [baseBranchInput, setBaseBranchInput] = React.useState(""); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(null); + + React.useEffect(() => { + setStackParentId(effectiveCurrentParentId); + setBaseBranchInput(""); + setError(null); + setSuccess(null); + }, [lane.id, lane.parentLaneId, lane.baseRef, effectiveCurrentParentId]); + + const defaultBaseBranch = candidates.find((c) => c.id === stackParentId)?.branchRef ?? ""; + + const baseOverrideTrim = baseBranchInput.trim(); + const normalizedOverride = normalizeBranchRefForCompare(baseOverrideTrim); + const normalizedExistingBase = normalizeBranchRefForCompare(lane.baseRef ?? ""); + const normalizedDefaultBase = normalizeBranchRefForCompare(defaultBaseBranch); + const parentChanged = stackParentId !== effectiveCurrentParentId; + // baseChanged covers two cases: + // 1. User typed a non-empty override that resolves to a different effective + // branch than what is currently stored (after normalizing refs/heads/ + // and origin/ prefixes consistent with the backend). + // 2. User cleared the field while the lane's stored base actually diverges + // from the selected parent's current branch — clearing then removes the + // override and the backend will rebase onto the parent's branch. + // `lane.baseRef` is a non-nullable string so we can't gate on its mere + // presence; we must compare it against the effective default to avoid + // enabling Apply on initial open when nothing has actually changed. + const baseChanged = normalizedOverride.length > 0 + ? normalizedOverride !== normalizedExistingBase + : normalizedExistingBase.length > 0 && + normalizedExistingBase !== normalizedDefaultBase; + const canApply = + !lane.status.dirty && + !lane.status.rebaseInProgress && + !busy && + !disabled && + Boolean(stackParentId) && + (parentChanged || baseChanged); + + const apply = async () => { + if (!canApply || !stackParentId) return; + setError(null); + setSuccess(null); + setBusy(true); + try { + await window.ade.lanes.reparent({ + laneId: lane.id, + newParentLaneId: stackParentId, + stackBaseBranchRef: baseOverrideTrim || null, + }); + setSuccess("Stack position updated."); + await onDone?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not update stack position."); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + Stack position +
+
+ Parent lane is where this lane sits in the stack (primary is the root). Base branch is the ref ADE uses for ahead/behind. Leave it blank to use the parent lane's current branch. +
+ +
+ Runs git rebase. When you apply, ADE updates stack metadata then runs{" "} + git rebase in this lane's worktree onto the resolved base commit. If rebase fails, ADE aborts the rebase and restores the previous parent and base branch; the error appears below. +
+ + {!primaryLane ? ( +
No primary lane found; stack changes may be limited.
+ ) : null} + + {lane.status.dirty ? ( +
+ + Commit or stash changes before changing stack position. +
+ ) : null} + + {lane.status.rebaseInProgress ? ( +
+ + Finish or abort the in-progress rebase before changing stack position. +
+ ) : null} + + Parent lane + + + Base branch (optional) + { + setBaseBranchInput(e.target.value); + setSuccess(null); + }} + placeholder={defaultBaseBranch ? `Default: ${defaultBaseBranch}` : "branch-name"} + /> + {defaultBaseBranch ? ( +
+ Selected parent is on {defaultBaseBranch} right now; that is used when the field above is empty. +
+ ) : null} + + {error ?
{error}
: null} + {success ?
{success}
: null} + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/lanes/laneDialogTokens.ts b/apps/desktop/src/renderer/components/lanes/laneDialogTokens.ts index 76c943dbc..bf5732d6a 100644 --- a/apps/desktop/src/renderer/components/lanes/laneDialogTokens.ts +++ b/apps/desktop/src/renderer/components/lanes/laneDialogTokens.ts @@ -1,6 +1,12 @@ /** Shared Tailwind class-name tokens used across lane dialog components. */ export const SECTION_CLASS_NAME = "rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-card"; +/** Softer accent wash — stack / integration style callouts */ +export const SECTION_ACCENT_CLASS_NAME = + "rounded-xl border border-accent/20 bg-gradient-to-br from-accent/[0.07] to-white/[0.02] p-4 shadow-card"; +/** Lane info hero strip at top of manage / multi-step flows */ +export const SECTION_HERO_CLASS_NAME = + "rounded-xl border border-white/[0.08] bg-gradient-to-br from-white/[0.06] via-white/[0.02] to-accent/[0.05] p-4 shadow-card"; export const LABEL_CLASS_NAME = "text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg/70"; export const INPUT_CLASS_NAME = "mt-2 h-11 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] px-3 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/60 focus:border-accent/40"; diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 05e5c3857..cae0e60a6 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -45,6 +45,7 @@ import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES, DEFAULT_CHAT_FONT function resetStore() { useAppStore.setState({ project: null, + projectBinding: null, projectHydrated: false, showWelcome: true, projectTransition: null, @@ -739,6 +740,14 @@ describe("appStore", () => { it("tracks project switching progress and clears it on success", async () => { const nextProject = { rootPath: "/tmp/next", displayName: "Next", baseRef: "main" } as any; (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); + useAppStore.setState({ + projectBinding: { + kind: "local", + key: "local:/tmp/old", + rootPath: "/tmp/old", + displayName: "Old", + }, + } as any); const pending = useAppStore.getState().switchProjectToPath("/tmp/next"); expect(useAppStore.getState().projectTransition).toEqual( @@ -747,6 +756,7 @@ describe("appStore", () => { rootPath: "/tmp/next", }), ); + expect(useAppStore.getState().projectBinding).toBeNull(); await pending; diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 21b3c00f1..b256c7df0 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1174,6 +1174,7 @@ export const useAppStore = create((set, get) => ({ startedAtMs: Date.now(), }, projectTransitionError: null, + projectBinding: null, }); try { const project = await window.ade.project.openRepo(); @@ -1229,6 +1230,7 @@ export const useAppStore = create((set, get) => ({ startedAtMs: Date.now(), }, projectTransitionError: null, + projectBinding: null, }); try { const project = await window.ade.project.switchToPath(rootPath); @@ -1313,6 +1315,7 @@ export const useAppStore = create((set, get) => ({ startedAtMs: Date.now(), }, projectTransitionError: null, + projectBinding: null, }); try { const binding = await window.ade.remoteRuntime.openProject(targetId, projectId); @@ -1361,6 +1364,7 @@ export const useAppStore = create((set, get) => ({ startedAtMs: Date.now(), }, projectTransitionError: null, + projectBinding: null, }); try { await window.ade.project.closeCurrent(); diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index f45e95f30..6d2601d55 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -640,6 +640,17 @@ export type AgentChatEventEnvelope = { }; }; +export type AgentChatEventHistorySnapshot = { + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + /** + * Explicitly false means the session id did not resolve in this project + * runtime. Optional for compatibility with older desktop/runtime pairs. + */ + sessionFound?: boolean; +}; + export type AgentChatPermissionMode = "default" | "auto" | "plan" | "edit" | "full-auto" | "config-toml"; export type AgentChatExecutionMode = "focused" | "parallel" | "subagents" | "teams"; export type AgentChatInteractionMode = "default" | "plan"; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index bbf72684d..f1d960e8e 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -252,6 +252,12 @@ export type RenameLaneArgs = { export type ReparentLaneArgs = { laneId: string; newParentLaneId: string; + /** + * Git branch name to stack onto (resolved in the project repo, prefers `origin/`). + * When omitted, uses the new parent lane's current branch, or for the primary lane the same + * upstream / origin resolution as graph reparent. + */ + stackBaseBranchRef?: string | null; }; export type ReparentLaneResult = { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 6648f789a..75c921324 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -797,6 +797,35 @@ func syncCommandEnvelopePayload( return payload } +/// Builds the args dictionary for the `lanes.reparent` sync command. +/// +/// Behavior mirrors the host contract in `apps/desktop/src/main/services/lanes/laneService.ts`: +/// - `newParentLaneId` is always emitted. Use the primary lane id when moving a lane to +/// the root of the stack. +/// - `stackBaseBranchRef` is only emitted when non-empty after trimming, so the host falls +/// back to the new parent lane's current branch when the caller leaves it blank. +func makeLanesReparentArgs( + laneId: String, + newParentLaneId: String, + stackBaseBranchRef: String? +) -> [String: Any] { + var args: [String: Any] = ["laneId": laneId] + args["newParentLaneId"] = newParentLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed = stackBaseBranchRef?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty { + args["stackBaseBranchRef"] = trimmed + } + return args +} + +func makePrGithubSnapshotArgs(force: Bool = false, includeExternalClosed: Bool = false) -> [String: Any] { + var args: [String: Any] = ["force": force] + if includeExternalClosed { + args["includeExternalClosed"] = true + } + return args +} + func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> String? { let projectScopedTypes: Set = [ "changeset_batch", @@ -2862,8 +2891,12 @@ final class SyncService: ObservableObject { try await sendDecodableCommand(action: "prs.getMobileSnapshot", as: PrMobileSnapshot.self) } - func fetchGitHubPullRequestSnapshot(force: Bool = false) async throws -> GitHubPrSnapshot { - try await sendDecodableCommand(action: "prs.getGitHubSnapshot", args: ["force": force], as: GitHubPrSnapshot.self) + func fetchGitHubPullRequestSnapshot(force: Bool = false, includeExternalClosed: Bool = false) async throws -> GitHubPrSnapshot { + try await sendDecodableCommand( + action: "prs.getGitHubSnapshot", + args: makePrGithubSnapshotArgs(force: force, includeExternalClosed: includeExternalClosed), + as: GitHubPrSnapshot.self + ) } func fetchPullRequestReviewThreads(prId: String) async throws -> [PrReviewThread] { @@ -3300,11 +3333,16 @@ final class SyncService: ObservableObject { _ = try await sendCommand(action: "lanes.rename", args: ["laneId": laneId, "name": name]) } - func reparentLane(_ laneId: String, newParentLaneId: String?) async throws { - var args: [String: Any] = ["laneId": laneId] - // Always include the key so the server receives a defined value. - // "ROOT" signals detachment from any parent lane. - args["newParentLaneId"] = (newParentLaneId?.isEmpty == false) ? newParentLaneId! : "ROOT" + func reparentLane( + _ laneId: String, + newParentLaneId: String, + stackBaseBranchRef: String? = nil + ) async throws { + let args = makeLanesReparentArgs( + laneId: laneId, + newParentLaneId: newParentLaneId, + stackBaseBranchRef: stackBaseBranchRef + ) _ = try await sendCommand(action: "lanes.reparent", args: args) } diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 5498bc6b7..4f48773cf 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -10,6 +10,7 @@ struct LaneManageSheet: View { @State private var renameText: String @State private var selectedParentLaneId: String + @State private var baseBranchOverride: String = "" @State private var colorText: String @State private var iconText: String @State private var tagsText: String @@ -27,8 +28,9 @@ struct LaneManageSheet: View { self.snapshot = snapshot self.allLaneSnapshots = allLaneSnapshots self.onComplete = onComplete + let primaryLaneId = allLaneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id ?? "" _renameText = State(initialValue: snapshot.lane.name) - _selectedParentLaneId = State(initialValue: snapshot.lane.parentLaneId ?? "") + _selectedParentLaneId = State(initialValue: snapshot.lane.parentLaneId ?? primaryLaneId) _colorText = State(initialValue: snapshot.lane.color ?? "") _iconText = State(initialValue: snapshot.lane.icon?.rawValue ?? "") _tagsText = State(initialValue: snapshot.lane.tags.joined(separator: ", ")) @@ -64,6 +66,64 @@ struct LaneManageSheet: View { snapshot.lane.laneType != "primary" } + private var primaryLaneId: String { + allLaneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id ?? "" + } + + private var effectiveCurrentParentId: String { + snapshot.lane.parentLaneId ?? primaryLaneId + } + + /// Branch ref the host will stack onto when the override field is empty — + /// matches desktop placeholder copy and mirrors the lanes.reparent fallback. + private var defaultStackBaseBranch: String { + reparentCandidates.first(where: { $0.id == selectedParentLaneId })?.branchRef ?? "" + } + + private var trimmedBaseOverride: String { + baseBranchOverride.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var reparentParentChanged: Bool { + selectedParentLaneId != effectiveCurrentParentId + } + + private var reparentBaseChanged: Bool { + // Empty input means "use the selected parent's current branch". That is + // only a real change when the lane's stored base actually diverges from + // the selected parent's effective branch — `snapshot.lane.baseRef` is a + // non-optional string and is essentially always populated, so gating on + // its mere presence enables Apply on initial sheet open and risks an + // unintended rebase. Compare normalized values against the parent's + // default to mirror the backend's normalizeBranchName (strips + // refs/heads/ and origin/ prefixes). + let normalizedOverride = LaneManageSheet.normalizeBranchRefForCompare(trimmedBaseOverride) + let normalizedExisting = LaneManageSheet.normalizeBranchRefForCompare(snapshot.lane.baseRef) + let normalizedDefault = LaneManageSheet.normalizeBranchRefForCompare(defaultStackBaseBranch) + if normalizedOverride.isEmpty { + return !normalizedExisting.isEmpty && normalizedExisting != normalizedDefault + } + return normalizedOverride != normalizedExisting + } + + private static func normalizeBranchRefForCompare(_ ref: String) -> String { + var value = ref.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("refs/heads/") { + value = String(value.dropFirst("refs/heads/".count)) + } + if value.hasPrefix("origin/") { + value = String(value.dropFirst("origin/".count)) + } + return value + } + + private var canApplyReparent: Bool { + guard canRunLiveActions else { return false } + if snapshot.lane.status.dirty || snapshot.lane.status.rebaseInProgress { return false } + guard !selectedParentLaneId.isEmpty else { return false } + return reparentParentChanged || reparentBaseChanged + } + private var canRunLiveActions: Bool { laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: syncService.status(for: .lanes)) } @@ -154,24 +214,96 @@ struct LaneManageSheet: View { } if snapshot.lane.laneType != "primary" { - GlassSection(title: "Reparent") { + GlassSection(title: "Stack position") { VStack(alignment: .leading, spacing: 12) { + Text("Parent lane is where this lane sits in the stack; the primary lane is the root. Base branch is the ref ADE uses for ahead/behind. Leave it blank to use the parent lane's current branch.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.bubble.fill") + .foregroundStyle(ADEColor.warning) + .accessibilityHidden(true) + Text("Runs git rebase. Applying updates ADE then runs git rebase in this lane's worktree onto the resolved base commit. If rebase fails, ADE aborts and restores the previous parent and base.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(ADEColor.warning.opacity(0.08)) + ) + + if snapshot.lane.status.dirty { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.warning) + .accessibilityHidden(true) + Text("Commit or stash changes before changing stack position.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + } + } + + if snapshot.lane.status.rebaseInProgress { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.warning) + .accessibilityHidden(true) + Text("Finish or abort the in-progress rebase before changing stack position.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + } + } + Picker("Parent lane", selection: $selectedParentLaneId) { - Text("No parent").tag("") - ForEach(reparentCandidates) { lane in - Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) + if reparentCandidates.isEmpty { + Text("No valid parent").tag("") + } else { + ForEach(reparentCandidates) { lane in + Text(lane.laneType == "primary" ? "\(lane.name) (primary)" : "\(lane.name) (\(lane.branchRef))").tag(lane.id) + } } } .pickerStyle(.menu) + .onChange(of: selectedParentLaneId) { _, _ in + // Clear the override so the new parent's branch is used as + // the default — matches desktop behavior on parent change. + baseBranchOverride = "" + } - LaneActionButton(title: "Save parent", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { + VStack(alignment: .leading, spacing: 4) { + LaneTextField( + defaultStackBaseBranch.isEmpty + ? "Base branch (optional)" + : "Base branch (default: \(defaultStackBaseBranch))", + text: $baseBranchOverride + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("Base branch override") + if !defaultStackBaseBranch.isEmpty { + Text("Selected parent is on \(defaultStackBaseBranch) right now; that is used when this is empty.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + + LaneActionButton(title: "Apply stack change", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { Task { await performAction("reparent lane") { - try await syncService.reparentLane(snapshot.lane.id, newParentLaneId: selectedParentLaneId.isEmpty ? nil : selectedParentLaneId) + try await syncService.reparentLane( + snapshot.lane.id, + newParentLaneId: selectedParentLaneId, + stackBaseBranchRef: trimmedBaseOverride.isEmpty ? nil : trimmedBaseOverride + ) } } } - .disabled(!canRunLiveActions || selectedParentLaneId == (snapshot.lane.parentLaneId ?? "")) + .disabled(!canApplyReparent) } } } diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 1d9a32b2f..3b2f4f2df 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -15,6 +15,13 @@ struct PRsTabView: View { @State private var queueStates: [QueueLandingState] = [] @State private var mobileSnapshot: PrMobileSnapshot? @State private var githubSnapshot: GitHubPrSnapshot? + @State private var githubExternalHistoryLoaded = false + // Set synchronously on the @MainActor before any awaited fetch so concurrent + // callers (the `onChange` filter handler and the projection-reload `task`) + // do not both pass the `!githubExternalHistoryLoaded` guard and issue + // duplicate `fetchGitHubPullRequestSnapshot(includeExternalClosed: true)` + // requests. + @State private var isLoadingExternalHistory = false @State private var errorMessage: String? @State private var actionMessage: String? @State private var busyAction: String? @@ -124,6 +131,14 @@ struct PRsTabView: View { repoScopedGitHubPullRequests(from: githubSnapshot) } + private var githubSnapshotNeedsExternalHistory: Bool { + selectedGitHubStatusFilter.wrappedValue != .open + } + + private var githubSnapshotShouldIncludeExternalClosed: Bool { + githubExternalHistoryLoaded || githubSnapshotNeedsExternalHistory + } + private var filteredGitHubPrs: [GitHubPrListItem] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let status = selectedGitHubStatusFilter.wrappedValue @@ -385,6 +400,10 @@ struct PRsTabView: View { guard prNavigationRequestKey != nil else { return } await handleRequestedPrNavigation() } + .onChange(of: githubStatusFilterRawValue) { _, _ in + guard selectedGitHubStatusFilter.wrappedValue != .open else { return } + Task { await loadGitHubExternalHistoryIfNeeded() } + } .refreshable { await refreshFromPullGesture() } @@ -1171,12 +1190,32 @@ struct PRsTabView: View { var nextMobileSnapshot: PrMobileSnapshot? if shouldAttemptLiveSnapshots { lastPrsLiveSnapshotAttempt = now + let includeExternalClosed = githubSnapshotShouldIncludeExternalClosed + // If `loadGitHubExternalHistoryIfNeeded` is already fetching external + // history, skip the external-closed arm here so we don't issue a + // duplicate concurrent request. The other task will populate the + // snapshot. + let suppressExternalClosed = includeExternalClosed && isLoadingExternalHistory + let effectiveIncludeExternalClosed = includeExternalClosed && !suppressExternalClosed + let markLoadingExternal = effectiveIncludeExternalClosed + if markLoadingExternal { isLoadingExternalHistory = true } let mobileSnapshotTask = Task { try? await syncService.fetchPrMobileSnapshot() } - let githubSnapshotTask = Task { try? await syncService.fetchGitHubPullRequestSnapshot(force: refreshRemote) } + let githubSnapshotTask = Task { + try? await syncService.fetchGitHubPullRequestSnapshot( + force: refreshRemote, + includeExternalClosed: effectiveIncludeExternalClosed + ) + } nextMobileSnapshot = await mobileSnapshotTask.value - if let nextGithubSnapshot = await githubSnapshotTask.value, githubSnapshot != nextGithubSnapshot { - githubSnapshot = nextGithubSnapshot + if let nextGithubSnapshot = await githubSnapshotTask.value { + if effectiveIncludeExternalClosed { + githubExternalHistoryLoaded = true + } + if githubSnapshot != nextGithubSnapshot { + githubSnapshot = nextGithubSnapshot + } } + if markLoadingExternal { isLoadingExternalHistory = false } } if !isLive { @@ -1186,6 +1225,8 @@ struct PRsTabView: View { if githubSnapshot != nil { githubSnapshot = nil } + githubExternalHistoryLoaded = false + isLoadingExternalHistory = false } if let nextMobileSnapshot { if mobileSnapshot != nextMobileSnapshot { @@ -1231,6 +1272,23 @@ struct PRsTabView: View { } } + @MainActor + private func loadGitHubExternalHistoryIfNeeded() async { + guard isLive, githubSnapshotNeedsExternalHistory, !githubExternalHistoryLoaded else { return } + // Atomic guard against concurrent fetches: the filter `onChange` and the + // projection-reload `task` can race here. Setting this synchronously + // before the first await ensures only one fetch is in flight at a time. + if isLoadingExternalHistory { return } + isLoadingExternalHistory = true + defer { isLoadingExternalHistory = false } + if let nextGithubSnapshot = try? await syncService.fetchGitHubPullRequestSnapshot(includeExternalClosed: true) { + githubExternalHistoryLoaded = true + if githubSnapshot != nextGithubSnapshot { + githubSnapshot = nextGithubSnapshot + } + } + } + @MainActor private func handleRequestedPrNavigation() async { guard let request = syncService.requestedPrNavigation else { return } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c53b5c543..3b5cce0b5 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -144,6 +144,51 @@ final class ADETests: XCTestCase { XCTAssertNil(payload["projectRootPath"]) } + func testMakeLanesReparentArgsUsesTrimmedParentLaneIdAndOmitsBaseOverride() throws { + let args = makeLanesReparentArgs( + laneId: "lane-1", + newParentLaneId: " lane-primary ", + stackBaseBranchRef: nil + ) + + XCTAssertEqual(args["laneId"] as? String, "lane-1") + XCTAssertEqual(args["newParentLaneId"] as? String, "lane-primary") + // No stackBaseBranchRef in the payload -- host falls back to the parent's current branch. + XCTAssertNil(args["stackBaseBranchRef"]) + } + + func testMakeLanesReparentArgsIncludesTrimmedStackBaseBranchOverride() throws { + let args = makeLanesReparentArgs( + laneId: "lane-1", + newParentLaneId: "lane-parent", + stackBaseBranchRef: " feature/integration " + ) + + XCTAssertEqual(args["newParentLaneId"] as? String, "lane-parent") + XCTAssertEqual(args["stackBaseBranchRef"] as? String, "feature/integration") + } + + func testMakeLanesReparentArgsOmitsWhitespaceOnlyStackBaseBranchOverride() throws { + let args = makeLanesReparentArgs( + laneId: "lane-1", + newParentLaneId: "lane-parent", + stackBaseBranchRef: " " + ) + + XCTAssertEqual(args["newParentLaneId"] as? String, "lane-parent") + XCTAssertNil(args["stackBaseBranchRef"]) + } + + func testMakePrGithubSnapshotArgsIncludesExternalClosedOnlyWhenRequested() throws { + let defaultArgs = makePrGithubSnapshotArgs(force: false, includeExternalClosed: false) + XCTAssertEqual(defaultArgs["force"] as? Bool, false) + XCTAssertNil(defaultArgs["includeExternalClosed"]) + + let historyArgs = makePrGithubSnapshotArgs(force: true, includeExternalClosed: true) + XCTAssertEqual(historyArgs["force"] as? Bool, true) + XCTAssertEqual(historyArgs["includeExternalClosed"] as? Bool, true) + } + func testProjectScopedOutboundEnvelopeTypesIncludeActiveProjectId() { let projectScopedTypes = [ "changeset_batch", @@ -4625,7 +4670,7 @@ final class ADETests: XCTestCase { func testFilesSearchEmptyMessageReflectsLiveAndQueryState() { XCTAssertEqual( filesSearchEmptyMessage(kind: .quickOpen, isLive: false, needsRepairing: false, query: ""), - "Quick open needs a live host connection." + "Quick open needs a live machine connection." ) XCTAssertEqual( filesSearchEmptyMessage(kind: .textSearch, isLive: true, needsRepairing: false, query: "needle"), @@ -4664,7 +4709,7 @@ final class ADETests: XCTestCase { filesHistoryFallback(laneId: "lane-1", entries: [], errorMessage: nil), FilesSectionFallback( title: "No recent history", - message: "The host did not return recent commits for this file yet. Reconnect or refresh to try again." + message: "The machine did not return recent commits for this file yet. Reconnect or refresh to try again." ) ) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cace14a3e..a9b256e25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -410,7 +410,7 @@ ade.updates.* `apps/desktop/src/main/services/ipc/registerIpc.ts` (~6,400 lines) is the single registration point: - `ipcMain.handle(IPC.channelName, async (event, args) => { ... })` for invoke channels. -- Every handler is wrapped with a timeout — 30 seconds by default, with explicit longer budgets for known long operations such as lane delete, iOS Simulator launch/control, macOS VM provisioning, App Control, and built-in browser actions. Runtime-dispatched `lane.delete` calls get the same 4-minute budget as the direct `ade.lanes.delete` IPC. +- Every handler is wrapped with a timeout — 30 seconds by default, with explicit longer budgets for known long operations such as direct lane delete, iOS Simulator launch/control, macOS VM provisioning/control, App Control, and built-in browser actions. Runtime-dispatched actions use the runtime-call channel budget; the timeout wrapper no longer inspects the action payload to give `lane.delete` a special runtime-dispatch override. - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. - **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its project context before routing into services. @@ -465,7 +465,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, IOSurface/Indigo primary streaming and input with idb/simctl/window-capture fallbacks, recovery-only H.264+ffmpeg after idb MJPEG failure, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / action dispatch / event stream, local-work checks, LAN discovery) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. `ipcTimeouts.ts` carries the default 30-second handler timeout plus long-operation overrides; it inspects runtime action payloads so `localRuntimeCallAction` / `remoteRuntimeCallAction` lane deletes receive the lane-delete budget. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / action dispatch / event stream, local-work checks, LAN discovery) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | @@ -497,7 +497,7 @@ Startup sequencing: every background service goes through `scheduleBackgroundPro Project-init step timing goes through `measureProjectInitStep(step, task)` — a wrapper that logs `project.init_step { projectRoot, step, durationMs }` around each hot-path operation (`db_open`, `lane.ensure_primary`, `ade_rpc.socket_server_start`, `memory.files.initial_sync`, `sync.initialize`, etc.) so cold-start latency shows up in the logs by phase. The memory-file mirror sync and sync-service initialization are now scheduled through `scheduleBackgroundProjectTask` rather than awaited inline, gated by `ADE_ENABLE_MEMORY_FILE_SYNC` and `ADE_ENABLE_SYNC_INIT` respectively (both default-on). -Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. `runImmediateProcessCleanup()` disposes the orchestrator, automations, tests, processes, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that closing will stop OpenCode servers, terminal sessions, and test runs. +Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. `runImmediateProcessCleanup()` disposes the orchestrator, automations, tests, processes, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that quitting will end agents and background processes owned by the desktop session, including OpenCode servers, terminal sessions, and test runs. On startup the main process also invokes `recoverManagedOpenCodeOrphans({ force: true })` (see `services/opencode/openCodeServerManager.ts`) to reap previous-run OpenCode processes left behind after a crash. Orphan detection matches processes by the managed marker env (`ADE_OPENCODE_MANAGED=1`) and/or the shared XDG config root, and confirms orphaning either by dead owner PID (`ADE_OPENCODE_OWNER_PID`) or reparent-to-init. Each acquire of a shared OpenCode server also invokes `pruneIdleSharedEntries()` which compacts idle entries from older configs (`pool_compaction` reason). @@ -649,7 +649,7 @@ webPreferences: { **CSP**: `default-src 'self'`; `script-src 'self'` (no eval, no inline scripts); `style-src 'self' 'unsafe-inline'` (required for Tailwind); `connect-src 'self'`; `img-src 'self' data:`. -Every IPC handler **validates** its arguments; invalid args return structured errors, never crash. Every handler has a bounded timeout: 30 seconds by default, with named longer budgets for long-running lifecycle and control-plane calls. Every handler emits structured tracing. +Every IPC handler **validates** its arguments; invalid args return structured errors, never crash. Every handler has a bounded timeout: 30 seconds by default, with named longer budgets for direct long-running lifecycle and control-plane calls. Every handler emits structured tracing. ### 8.3 ADE CLI auth + API-key storage diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 26794a77e..c1ed0baa9 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,7 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for auto-created / parallel lanes. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so bundled Agent Skills roots are lane-scoped in persistent system/developer prompts and any provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks. Large orchestrator file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so bundled Agent Skills roots are lane-scoped in persistent system/developer prompts and any provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Large orchestrator file. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -30,9 +30,9 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | -| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. | +| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, macOS VM, and attachment context into prompt text. Automatic macOS VM capability context is prompt-intent gated (`ADE VM`, `macOS VM`, Lume, isolated macOS GUI, etc.) so ordinary sends do not query or inject VM state. | | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Claude file-rewind confirmation surface. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the SDK `rewindFiles` call without using browser-native confirm UI. | @@ -255,6 +255,14 @@ shutdown" error so IPC callers don't hang, resolves local pending-input promises with a `cancel` decision, and tears down every managed runtime with reason `"shutdown"`. +`hasActiveWorkloads()` is the close/quit guard used by `main.ts`: a +chat counts as active when its session is active, it has a live pending +input, or its runtime still has work in flight (turn ids, approvals, +queued steers, subagents, Codex plan follow-ups, Cursor cloud runs, +etc.). Project/window close probes fail closed: if the chat workload +probe throws, ADE keeps the project alive instead of closing over a +possibly running agent. + ## IPC surface All channel constants live in `apps/desktop/src/shared/ipc.ts`; service @@ -264,6 +272,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. |---|---|---| | `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`. | | `ade.agentChat.getSummary` | invoke | Fetch `AgentChatSessionSummary` for a single session. | +| `ade.agentChat.getEventHistory` | invoke | Return `AgentChatEventHistorySnapshot` for a session. `sessionFound: false` is the explicit stale-session signal used by renderer surfaces to clear dead locked panes. | | `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. Accepts `codexFastMode?: boolean` for Codex sessions to start with the `serviceTier: "fast"` default. | | `ade.agentChat.suggestLaneName` | invoke | Derive a slug-safe lane name from a Work launch prompt using the session-intelligence title prompt, with a prompt-slug + optional unique temporary fallback. | | `ade.agentChat.parallelLaunchState.get` / `.set` | invoke | Read/write crash-recovery state for renderer-orchestrated parallel launches. State is scoped by project root and parent lane id. | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 311b06aa0..977c057e1 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -59,7 +59,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | @@ -89,13 +89,14 @@ Renderer components: | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | -| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | +| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | +| `renderer/components/lanes/laneDialogTokens.ts` | Shared Tailwind class-name tokens for lane dialog sections: `SECTION_CLASS_NAME` (neutral), `SECTION_ACCENT_CLASS_NAME` (accent wash used by stack/integration callouts like the Stack position panel), `SECTION_HERO_CLASS_NAME` (the hero strip at the top of Manage Lane), `LABEL_CLASS_NAME`, `INPUT_CLASS_NAME`, `SELECT_CLASS_NAME`. | | `renderer/components/lanes/BranchPickerView.tsx` | Filterable virtualized branch list rendered inside `CreateLaneDialog`. Each row shows branch name, last-commit author + relative date, and an inline PR pill (`#NNN`, dim for drafts) when the branch has an open PR. Loading/empty/error states are handled inline. Backed by `branchPickerSearch.ts`. | | `renderer/components/lanes/branchPickerSearch.ts` | Pure parser + matcher. Tokens AND together: `pr:open` / `pr:none` / `pr:draft`, `author:NAME` (or `author:me` / `mine` resolved against the local git user), `stale:Nd` (older than N days), `#PRNUMBER` (exact match), and free text fuzzy-matched across branch name / PR title / author. Also exposes `formatRelativeTime` for the row subtitle. | | `renderer/components/lanes/LinearIssuePicker.tsx` | Filterable Linear issue picker rendered inside `CreateLaneDialog`. Loads project / state / assignee filters from `ade.cto.getLinearIssuePickerData` and pages issues through `ade.cto.searchLinearIssues`. Shared row + label helpers (`LinearIssueRow`, `linearPriorityLabel`, `issueProjectLabel`, `issueUpdatedLabel`, `toLaneLinearIssue`, `branchExistsForLinearIssue`) are reused by `LinearIssueBrowser` (top-bar quick view) and the chat composer's Linear context dialog. Also exports a `LinearIssueSummaryCard` used by the dialog's "currently connected" state. | | `renderer/components/lanes/LinearIssueBadge.tsx` | Compact lane-list badge that surfaces the lane's connected Linear issue (identifier + state + priority); clicking opens the issue in a new chat with the issue pre-attached as context, falling back to opening the issue in Linear when chat is unavailable. | | `renderer/components/lanes/linearBrand.tsx` | Linear brand tokens (`LINEAR_BRAND` colour palette) plus the icon family used everywhere ADE references Linear: `LinearMark`, `LinearStateIcon`, `LinearPriorityIcon`. | -| `renderer/components/lanes/ManageLaneDialog.tsx` | Unified delete / archive / adopt-attached dialog. Supports single-lane and batch (multi-select) modes, three delete scopes (`worktree`, `local_branch`, `remote_branch`), a typed confirmation phrase, remote-branch name input, dirty-state warnings, and a live multi-step progress strip wired to `lanes.delete.event` (`stop_processes` / `stop_ptys` / `stop_watchers` / `cancel_auto_rebase` / `cleanup_env` / `git_status` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). Optional branch cleanup steps can finish as warnings, allowing lane-owned worktree/database cleanup to complete while still showing the branch cleanup error inline. The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; running deletes are shown as non-cancellable because teardown runs to completion once started. | +| `renderer/components/lanes/ManageLaneDialog.tsx` | Unified manage dialog covering stack position, appearance, adopt-attached, archive, and delete in both single-lane and batch (multi-select) modes. Single-lane mode opens with a "What each section does" info panel and a hero lane-info strip; batch mode swaps in a callout explaining that only archive/delete apply to multiple lanes (stack, color, and adopt are single-lane only). The `StackPositionSection` is single-lane and non-primary only: it shows a parent-lane select (filtered to exclude the lane itself and its descendants), an optional base-branch override input, and an inline "Runs git rebase" disclosure. Apply calls `lanes.reparent({ laneId, newParentLaneId, stackBaseBranchRef })`; the button is disabled while the lane is dirty or has a rebase in progress and while nothing has actually changed, and a parent-callback (`onStackReorganized`) refreshes the lane list. Delete still supports the three scopes (`worktree`, `local_branch`, `remote_branch`), the typed confirmation phrase, remote-branch name input, dirty-state warnings, and the live multi-step progress strip wired to `lanes.delete.event` (`git_status` when a worktree exists, then `cancel_auto_rebase` / `stop_processes` / `stop_ptys` / `stop_watchers` / `cleanup_env` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). Optional branch cleanup steps can finish as warnings, allowing lane-owned worktree/database cleanup to complete while still showing the branch cleanup error inline. The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; running deletes are shown as non-cancellable because teardown runs to completion once started. | | `renderer/components/lanes/MonacoDiffView.tsx` | Monaco diff editor used for editable working-tree views (invoked from `AdeDiffViewer`) | | `renderer/components/run/LaneRuntimeBar.tsx` | Compact lane runtime status bar (health, preview, port, proxy, oauth) | | `renderer/components/run/RunPage.tsx`, `RunNetworkPanel.tsx` | Runtime dashboards that consume lane runtime services | @@ -138,6 +139,11 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): `LaneChatLaunchSheet.swift`, `LaneTreeView.swift`, `LaneFileTreeComponents.swift` — mobile detail, git, rebase, diff, stash, sync, manage, chat-launch, and file-tree parity surfaces. + `LaneManageSheet.swift` mirrors desktop's single-lane Stack position + section: parent-lane picker, optional base-branch override, "Runs git + rebase" disclosure, dirty/rebase-in-progress guards, and + `lanes.reparent` payloads that omit `stackBaseBranchRef` when the + override is blank. `LaneCommitSheet.swift` is now a "review & commit" sheet: staged and unstaged files render with per-file stage / unstage / discard / restore / open-diff / open-files affordances, plus a "Suggest" @@ -275,24 +281,31 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in 5. **Attach** — `attach` links an external worktree path (pre-existing outside ADE). `lane_type = 'attached'`. 6. **Rename / update appearance / reparent** — `rename`, `updateAppearance`, - `reparent` edit the lane row. `reparent` refuses to move a lane - under one of its own descendants and refuses to reparent the - primary lane. + `reparent` edit the lane row. `reparent({ laneId, newParentLaneId, + stackBaseBranchRef? })` refuses to move a lane under one of its own + descendants and refuses to reparent the primary lane. When + `stackBaseBranchRef` is supplied the service resolves it in the project + repo (preferring `origin/`) and uses that as the rebase target + and persisted base ref; otherwise it falls back to the new parent's + current branch. When both the parent link and the resolved base ref are + unchanged, reparent short-circuits without touching git so a redundant + apply is a no-op rather than a stack rebase. 7. **Archive** — `archive` sets `archived_at` and `status = 'archived'` but keeps the worktree on disk. `unarchive` reverses it. 8. **Delete** — `delete({ laneId, deleteBranch?, deleteRemoteBranch?, remoteBranchName?, force? })` runs an explicit teardown pipeline and emits `lanes.delete.event` per step. Steps execute in order: + `git_status` (when a worktree exists) → `cancel_auto_rebase` → `stop_processes` → `stop_ptys` → `stop_watchers` → - `cancel_auto_rebase` → `cleanup_env` → `git_status` → - `git_worktree_remove` → `git_branch_delete` (only when - `deleteBranch`) → `git_remote_branch_delete` (only when - `deleteRemoteBranch`) → `pack_dir_remove` → `database_cleanup`. + `cleanup_env` → `git_worktree_remove` (when a worktree exists) → + `git_branch_delete` (only when `deleteBranch`) → + `git_remote_branch_delete` (only when `deleteRemoteBranch`) → + `pack_dir_remove` → `database_cleanup`. `getDeleteRisk(laneId)` returns the preflight `LaneDeleteRisk` the dialog renders before confirmation. `cancelDelete(laneId)` is - honored cooperatively until the first irreversible filesystem step - (`git_worktree_remove`) starts; after that the call no-ops with - `{ cancelled: false, reason: "uncancellable_step_in_progress" }`. + retained for contract compatibility but always returns + `{ cancelled: false, reason }`; once a delete starts, teardown runs + to completion. Teardown depends on optional injected services (`processService`, `ptyService`, `autoRebaseService`, `rebaseSuggestionService`, `fileWatcherService`); when one is not diff --git a/docs/features/lanes/stacking.md b/docs/features/lanes/stacking.md index e27201fdb..b5b750cbb 100644 --- a/docs/features/lanes/stacking.md +++ b/docs/features/lanes/stacking.md @@ -77,19 +77,37 @@ children, `base_ref` otherwise) and memoized per-call. ## Reparenting -`laneService.reparent({ laneId, newParentLaneId })`: +`laneService.reparent({ laneId, newParentLaneId, stackBaseBranchRef? })`: - Refuses to reparent the primary lane (`lane_type === "primary"`). - Refuses to reparent a lane under one of its own descendants (detected by walking up from `newParentLaneId`). - Refuses to reparent a lane to itself. -- Updates `parent_lane_id` and records a `lane_reparent` operation in - the history timeline with the reason `reparent`. +- Resolves the new base ref: when `stackBaseBranchRef` is provided it is + resolved in the project repo through `resolveBranchRebaseTarget` with + `preferRemote: true` so a name like `develop` picks `origin/develop` + when it exists; otherwise the new parent's current `branch_ref` is + used (with the primary-lane upstream fallback handled by + `resolveParentRebaseTarget`). +- No-op fast path: when the persisted parent link and the resolved base + ref both match the lane's current state, `reparent` returns the + current head sha for both pre/post and does not run git. This keeps + redundant "Apply" clicks from the Manage Lane dialog cheap. +- Otherwise updates `parent_lane_id`, persists the new `base_ref`, + records a `lane_reparent` operation in the history timeline with the + reason `reparent`, and rebases the lane's worktree onto the resolved + base commit. - Triggers downstream refresh events (rebase suggestion service re-evaluates, stack graph re-renders). -`ReparentLaneResult` carries the before/after parent ids so the UI -can update state without a full list refresh. +`ReparentLaneResult` carries the before/after parent ids and base refs +plus pre/post head shas so the UI can update state without a full list +refresh. + +`syncRemoteCommandService` (ade-cli) parses `stackBaseBranchRef` off the +`lanes.reparent` payload as an optional trimmed string, so headless +controllers driving stack edits over sync see the same surface as the +desktop renderer. ## Rebase runs diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index ba69a54cc..4c082285c 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -100,9 +100,14 @@ main process because they execute before a runtime binding exists. expands `~`, handles platform-appropriate relative / absolute paths, lists matching subdirectories with `.git` detection (concurrency-limited, capped at - `limit` with 500 max), and resolves any exact-directory match up to - an openable repo root via `resolveRepoRoot()`. Windows-style paths - are rejected on non-Windows hosts. + `limit` with 500 max), and resolves any exact-directory match to an + openable project root without shelling out while the user types. If + the candidate path is inside an ADE-managed worktree, it resolves + back to that lane's owning project root via + `findAdeManagedWorktreeRoot`; otherwise it walks ancestors until it + finds a `.git` file or directory. The eventual open flow still + performs full git validation. Windows-style paths are rejected on + non-Windows hosts. - `apps/desktop/src/main/services/projects/projectScaffoldService.ts` — backs the "Add project → Create" and "Add project → Clone" flows. `createLocalProject({ name, parentDir })` makes a new directory, runs @@ -126,6 +131,10 @@ main process because they execute before a runtime binding exists. depth-2 walk capped at 2,000 files), subdirectory count, and — when the path matches a recent-projects row in the global state file — lane count and last-opened timestamp. + `registerIpc.ts` wraps `project.getDetail(rootPath)` in a short + per-root promise cache (10 s, capped at 64 entries) so moving through + the project browser does not recompute git/README/language metadata + for the same highlighted path on every render. - `apps/desktop/src/main/services/projects/projectIconResolver.ts` — best-effort icon discovery and user-overridable selection for a project root. Discovery walks a fixed list of base directories @@ -210,7 +219,10 @@ a prior session. Shows: - "ADD PROJECT" primary button → opens the Command Palette in `intent="project-add"` mode (see the next subsection) - recent projects list from `window.ade.project.listRecent()`, with - display name, host path, lane count, and last-opened timestamp + display name, host path, lane count, and last-opened timestamp. + `registerIpc.ts` caches the converted summaries for 5 seconds keyed + by the recent-project signature, and clears the cache after forget / + reorder writes. Clicking a recent project calls `appStore.switchProjectToPath(path)` which goes through the project open flow @@ -256,7 +268,9 @@ Project-browse behavior: 3. A debounced `window.ade.project.getDetail(target)` populates a preview pane alongside the list — branch, dirty/ahead/behind, last commit, README excerpt (rendered through `react-markdown` + - `remark-gfm`), language swatches, lane count, last-opened. + `remark-gfm`), language swatches, lane count, last-opened. The + main process dedupes repeated detail reads for the same root for a + short window. 4. Enter activates the highlighted directory (walks into it). ⌘/Ctrl+ Enter opens the openable project root (the first ancestor with a `.git` entry). diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 99909155e..06e921ade 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -169,6 +169,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): matching `projectId` (see *Scope enforcement* below). Mobile / controller CLI launches resolve the target lane worktree before building provider argv/env so ADE Agent Skills roots stay lane-aware. + Lane reparent commands parse the optional `stackBaseBranchRef` + override and forward it to the host lane service so controllers can + pick a specific branch to stack onto instead of always using the + selected parent lane's branch. - `deviceRegistryService.ts` (~670 lines) — synced `devices` table and `sync_cluster_state` singleton. - `syncPairingStore.ts` — validates `pairing_request` envelopes @@ -226,9 +230,11 @@ iOS service files (`apps/ios/ADE/Services/`): command routing, keychain integration, PIN-based pairing, lane presence announcements, terminal subscribe/unsubscribe tracking, terminal input/resize senders, mobile CLI launch/continuation, - PR mobile snapshot fetch, live chat-event push listener, project - home/catalog state, active-project scoping, unregistered-worktree - discovery, and APNs push-token registration to the host. + PR mobile snapshot fetch, live chat-event push listener, lane + reparent payload building with the optional stack base-branch + override, project home/catalog state, active-project scoping, + unregistered-worktree discovery, and APNs push-token registration + to the host. - `KeychainService.swift` — iOS Keychain Services for paired device secrets (per-host token shelf included). - `LiveActivityCoordinator.swift` — owns the single workspace diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 5019f1ce5..03160450f 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -49,6 +49,7 @@ apps/ios/ │ │ # PIN pairing, lane presence, terminal │ │ # subscribe/unsubscribe + input/resize, │ │ # CLI launcher (startCliSession), chat push, +│ │ # lane reparent stack-base override payloads, │ │ # push-token registration, worktree discovery │ ├── Shared/ │ │ ├── ADESharedContainer.swift # App Group UserDefaults + WorkspaceSnapshot helpers @@ -319,7 +320,7 @@ turns a raw response dict into either the `result` value or throws an ### Timeouts `SyncRequestTimeout.defaultTimeoutNanoseconds = 30_000_000_000` (30s). -Timed-out requests throw with the message *"The host took too long to +Timed-out requests throw with the message *"The machine took too long to respond. Reconnecting now."* Chat send commands (`chat.send` and the mobile CLI launchers) use an extended budget `SyncRequestTimeout.chatSendTimeoutNanoseconds = 120_000_000_000` (120s) @@ -721,6 +722,11 @@ The iOS PRs tab consumes a single aggregate command, - `live: boolean` — false signals the phone should render a "host offline" banner. +The PR list's GitHub browser uses the same GitHub snapshot shape as +desktop: `repoPullRequests` and `externalPullRequests` are combined so +external PRs involving the viewer can populate list/detail fallback +cards instead of collapsing to unknown placeholders. + ## Command policy from the host The host exposes command-policy metadata @@ -741,7 +747,7 @@ reflected in the phone's UI on the next descriptor read. | PIN pairing flow | Implemented | | QR pairing payload (v2, address candidates + port) | Implemented | | Project home + machine project switching | Implemented | -| Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, and template environment progress) | +| Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, stack-position/base-branch editing in Manage Lane, and template environment progress) | | Files tab | Implemented with `mobileReadOnly` workspace gate and capped search/quick-open result rendering | | Work tab | Implemented; live chat-event push from host, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), message-to-continue on ended agent CLI rows | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index e513f56de..32344a171 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -128,6 +128,13 @@ Listed in order of appearance in the registry: `LaneSummary.devicesOpen` with a 60 s TTL and fans out updates via `brain_status`. +`lanes.reparent` accepts `{ laneId, newParentLaneId, +stackBaseBranchRef? }`. The optional base ref is trimmed before +dispatch; when present, the host resolves it in the project repo +preferring `origin/`, persists it as the lane's `base_ref`, +and rebases the lane onto that resolved branch. When omitted, the host +uses the selected parent lane's current branch. + **Work** (`work.*`) - `listSessions`, `updateSessionMeta`, `runQuickCommand`, `startCliSession`, `sendToSession`, `stopRuntime` diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index bd56ea384..d3b3ffd60 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -38,9 +38,12 @@ desktop fallback IPC path. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), runtime - state, AI auto-titles, tool-type routing, continuation-target backfill, and + state, AI auto-titles, tool-type routing, continuation-target backfill, session-id based write/resize entry points used by mobile sync - terminal control. ~1,500 lines. Branch rewrite. + terminal control, and `readTranscriptTail({ sessionId, ... })`, which + merges the on-disk transcript tail with the live PTY output tail so + Work/TUI terminal hydration can replay output that is still buffered + in the transcript write stream. ~1,500 lines. Branch rewrite. - `apps/desktop/src/main/services/pty/ptyService.test.ts` — PTY behavior tests. Branch updated. - `apps/desktop/src/main/services/sessions/sessionService.ts` — persistence @@ -121,8 +124,9 @@ IPC registration: `ptyWrite`, `ptyResize`, `ptyDispose`, the `processes.*` handlers, and the chat-scoped `terminalList` / `terminalRead` / `terminalWrite` / `terminalSignal` / `terminalActiveForChat` - handlers (which delegate to the new `ptyService` chat-terminal - helpers). + handlers. `terminalRead` delegates transcript-tail reads to + `ptyService` so chat-owned terminal drawers and `ade code` get the + same live-tail merge as the Work tab. Renderer surfaces: