diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 541d3a7ec..1b010b0dd 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2414,6 +2414,8 @@ describe("ADE CLI", () => { "--personal", "--title", "Trip planning", + "--tag", + "review-ready", "--reasoning-effort", "high", "--fast", @@ -2424,12 +2426,32 @@ describe("ADE CLI", () => { args: { sessionId: "personal-1", title: "Trip planning", + tag: "review-ready", reasoningEffort: "high", fastMode: true, }, }, }); + // `--tag ""` clears the Claude session tag (empty string is forwarded, not dropped). + const clearTag = expectExecutePlan(buildCliPlan([ + "chat", + "update", + "personal-1", + "--personal", + "--tag", + "", + ])); + expect(clearTag.steps[0]).toMatchObject({ + params: { + action: "updateSession", + args: { + sessionId: "personal-1", + tag: "", + }, + }, + }); + expect(() => buildCliPlan([ "chat", "list", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b1af49791..322e2423c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1522,6 +1522,8 @@ const HELP_BY_COMMAND: Record = { $ ade chat steer --personal --text "focus on the tradeoffs" $ ade chat models --personal --provider codex $ ade chat update --personal --title "Trip planning" + $ ade chat update --personal --tag "review-ready" + Claude-only; --tag "" clears it $ ade chat message --kind auto --text "status" Deliver via auto | queue | wake | interrupt-replace $ ade chat steer --text "context" Steer/queue context into an active turn @@ -7295,6 +7297,9 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { if (sub === "update" || sub === "configure") { const model = readValue(args, ["--model", "--model-id"]); const title = readValue(args, ["--title"]); + // Claude-only session tag mirrored to the SDK. Pass `--tag ""` to clear it. + // Rejected at runtime for non-Claude sessions or before a Claude turn exists. + const tag = readValue(args, ["--tag"]); const provider = readValue(args, ["--provider"]); const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort"]); const permissionMode = readValue(args, ["--permission-mode", "--permissions"]); @@ -7305,6 +7310,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { steps: [personalChatStep("updateSession", collectGenericObjectArgs(args, { sessionId, ...(title !== null ? { title } : {}), + ...(tag !== null ? { tag } : {}), ...(provider ? { provider } : {}), ...(model ? { model, modelId: model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 22396150d..c0eca4372 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -742,6 +742,26 @@ describe("createSyncRemoteCommandService", () => { ]); }); + it("routes main transcript fetches to the chat service", async () => { + const transcript = [ + { type: "assistant", uuid: "msg-1", sessionId: "sdk-1", parentToolUseId: null, message: {}, text: "main" }, + ]; + const getMainTranscript = vi.fn().mockResolvedValue(transcript); + const { service } = createService({ agentChatService: { getMainTranscript } }); + + expect(service.getDescriptor("chat.getMainTranscript")).toEqual({ + action: "chat.getMainTranscript", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + await expect(service.execute(makePayload("chat.getMainTranscript", { + sessionId: "chat-1", + limit: 50, + offset: 2, + }))).resolves.toEqual(transcript); + expect(getMainTranscript).toHaveBeenCalledWith({ sessionId: "chat-1", limit: 50, offset: 2 }); + }); + it("routes subagent roster fetches to the chat service", async () => { const listSubagents = vi.fn().mockReturnValue([ { taskId: "agent-1", agentId: "agent-1", agentType: "Sagan", description: "Read files", status: "stopped" }, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index dc3ee598e..6b9501953 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -24,6 +24,7 @@ import type { AgentChatHandoffArgs, AgentChatLaunchArgs, AgentChatListArgs, + AgentChatMainTranscriptArgs, AgentChatModelCatalogArgs, AgentChatSuggestLaneNameArgs, AgentChatModelCatalogMode, @@ -2077,6 +2078,17 @@ function parseAgentChatSubagentTranscriptArgs(value: Record): A return parsed; } +function parseAgentChatMainTranscriptArgs(value: Record): AgentChatMainTranscriptArgs { + const parsed: AgentChatMainTranscriptArgs = { + sessionId: requireString(value.sessionId, "chat.getMainTranscript requires sessionId."), + }; + const limit = asOptionalNumber(value.limit); + const offset = asOptionalNumber(value.offset); + if (limit !== undefined) parsed.limit = limit; + if (offset !== undefined) parsed.offset = offset; + return parsed; +} + function parseAgentChatSubagentListArgs(value: Record): AgentChatSubagentListArgs { return { sessionId: requireString(value.sessionId, "chat.listSubagents requires sessionId."), @@ -3619,6 +3631,10 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio requireService(args.agentChatService, "Agent chat service not available.").getSubagentTranscript( parseAgentChatSubagentTranscriptArgs(payload), )); + register("chat.getMainTranscript", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getMainTranscript( + parseAgentChatMainTranscriptArgs(payload), + )); register("chat.listSubagents", { viewerAllowed: true, queueable: false }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").listSubagents( parseAgentChatSubagentListArgs(payload), diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 8002767f7..86921c426 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -124,6 +124,21 @@ describe("RightPane chat info", () => { expect(frame).not.toContain("GOAL"); }); + it("renders the Claude session tag in the chat info header", () => { + const result = render( + , + ); + + expect(stripAnsi(result.lastFrame() ?? "")).toContain("tag:review-ready"); + }); + it("shows the main row + a 'no subagents yet' hint when the roster is empty", () => { const result = render( { expect(frame).toContain("agent-07"); }); + it("windows grouped roster rows while keeping Show all and Earlier affordances reachable", () => { + const snapshots: ChatInfoSnapshot["snapshots"] = [ + ...Array.from({ length: 13 }, (_, index) => ({ + id: `run-${index}`, + name: `running-${index}`, + kind: "subagent" as const, + status: "running" as const, + summary: "working", + })), + { id: "done-1", name: "completed-agent", kind: "subagent", status: "completed", summary: "done" }, + ]; + const collapsed = render( + , + ); + const collapsedFrame = stripAnsi(collapsed.lastFrame() ?? ""); + + expect(collapsedFrame).toContain("+ show all (1)"); + expect(collapsedFrame).toContain("▸ earlier (1)"); + expect(collapsedFrame).toMatch(/↑\s+\d+\s+earlier/); + expect(collapsedFrame).not.toContain("completed-agent"); + + const expanded = render( + , + ); + expect(stripAnsi(expanded.lastFrame() ?? "")).toContain("completed-agent"); + }); + it("separates foreground subagents from background tasks with section headers", () => { const result = render( { }); it("caps scheduled work rows in narrow chat info panes", () => { - const scheduledWork: ChatInfoSnapshot["scheduledWork"] = Array.from({ length: 7 }, (_, index) => { + const scheduledWork: ChatInfoSnapshot["scheduledWork"] = Array.from({ length: 12 }, (_, index) => { const ordinal = String(index + 1).padStart(2, "0"); return { id: `wake-${ordinal}`, @@ -355,9 +409,9 @@ describe("RightPane chat info", () => { expect(frame).toContain("SCHEDULE"); expect(frame).toContain("Wakeup 01"); - expect(frame).toContain("Wakeup 05"); - expect(frame).not.toContain("Wakeup 06"); - expect(frame).toContain("↓ 2 more"); + expect(frame).toContain("Wakeup 10"); + expect(frame).not.toContain("Wakeup 11"); + expect(frame).toContain("+ show all (2)"); expect(longestLine).toBeLessThanOrEqual(44); }); @@ -391,6 +445,7 @@ describe("RightPane chat info", () => { ], }), }} + subagentPaneViewState={{ earlierExpanded: { schedule: true } }} focused width={80} />, diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 3834ee026..fcdd63ffd 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; @@ -57,6 +57,21 @@ describe("listLaneDiffStats", () => { }); }); +describe("getMainTranscript", () => { + it("calls the main transcript chat action with paging arguments", async () => { + const action = vi.fn().mockResolvedValue([]); + const connection = { action } as unknown as AdeCodeConnection; + + await expect(getMainTranscript(connection, { sessionId: "chat-1", limit: 50, offset: 2 })) + .resolves.toEqual([]); + expect(action).toHaveBeenCalledWith("chat", "getMainTranscript", { + sessionId: "chat-1", + limit: 50, + offset: 2, + }); + }); +}); + describe("runDefaultLaneSetup", () => { it("applies the configured default template when it still exists", async () => { const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts index 4b1c5ae17..b54725633 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts @@ -193,6 +193,22 @@ describe("deriveChatInfoSnapshot", () => { expect(snapshot.nextWakeAt).toBe("2026-07-09T12:12:00.000Z"); }); + it("carries the Claude session tag into the chat info header", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [], + activeSession: session({ claudeTag: "review-ready" }), + provider: "claude", + modelLabel: "claude-opus-4-8", + laneLabel: "lane", + snapshots: [], + tokenStats: null, + goal: null, + streaming: false, + }); + + expect(snapshot.claudeTag).toBe("review-ready"); + }); + it("derives todos, plan explanation, and the lane PR rollup", () => { const snapshot = deriveChatInfoSnapshot({ events: [ diff --git a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts index 9c66c681f..a38eb4375 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts @@ -3,6 +3,7 @@ import { buildSubagentPaneRows, buildSubagentTranscriptEvents, selectedSubagentSnapshot, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneSelectableLineOffsets, subagentTranscriptMessagesToEvents, @@ -12,6 +13,7 @@ import { resolveSubagentCapability } from "../../../../desktop/src/shared/subage import { renderChatLines } from "../format"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { RightPaneContent } from "../types"; +import type { SubagentSnapshot } from "../types"; const session: AgentChatSessionSummary = { sessionId: "s1", @@ -82,17 +84,69 @@ describe("subagent pane helpers", () => { const offsets = subagentPaneSelectableLineOffsets(content, 1); expect(offsets.length).toBe(5); - expect(subagentIndexForPaneLine(content, offsets[0]!, 1)).toBe(0); - expect(subagentIndexForPaneLine(content, offsets[1]!, 1)).toBe(1); - expect(subagentIndexForPaneLine(content, offsets[3]!, 1)).toBe(3); - expect(subagentIndexForPaneLine(content, offsets[4]! + 1, 1)).toBe(4); + expect(subagentIndexForPaneLine(content, offsets[0]!, 1)).toEqual({ type: "snapshot", index: 0 }); + expect(subagentIndexForPaneLine(content, offsets[1]!, 1)).toEqual({ type: "snapshot", index: 1 }); + expect(subagentIndexForPaneLine(content, offsets[3]!, 1)).toEqual({ type: "snapshot", index: 3 }); + expect(subagentIndexForPaneLine(content, offsets[4]!, 1)).toEqual({ type: "snapshot", index: 4 }); expect(subagentIndexForPaneLine(content, offsets[0]! - 2, 1)).toBeNull(); }); it("only accounts for detail lines on the selected subagent row", () => { + // Offsets are anchored at the calibrated preamble baseline (4) that + // app.tsx's subagentPaneTop formula assumes — see + // SUBAGENT_PANE_TABLE_START_LINE in shared/chatSubagents.ts. const content = rosterPaneContent(); - expect(subagentPaneSelectableLineOffsets(content, 1)).toEqual([4, 8, 10, 13, 16]); - expect(subagentPaneSelectableLineOffsets(content, 2)).toEqual([4, 8, 9, 13, 16]); + expect(subagentPaneSelectableLineOffsets(content, 1)).toEqual([4, 6, 8, 9, 10]); + expect(subagentPaneSelectableLineOffsets(content, 2)).toEqual([4, 6, 7, 9, 10]); + }); + + it("snaps near-miss clicks to the nearest interactive row", () => { + const content = rosterPaneContent(); + const offsets = subagentPaneSelectableLineOffsets(content, 1); + // One line above/below a row (inside a non-interactive span or gap) still + // resolves — the TUI's constant paneTop drifts when variable blocks render + // above the roster, and dead-dropping every near miss made clicks brittle. + expect(subagentIndexForPaneLine(content, offsets[0]! + 1, 1)).toEqual({ type: "snapshot", index: 0 }); + // Far outside the roster stays null. + expect(subagentIndexForPaneLine(content, 0, 1)).toBeNull(); + expect(subagentIndexForPaneLine(content, offsets[4]! + 4, 1)).toBeNull(); + }); + + it("emits grouped section, show-all, Earlier, and tagged action rows", () => { + const snapshots: SubagentSnapshot[] = Array.from({ length: 13 }, (_, index) => ({ + id: `run-${index}`, + name: `run ${index}`, + kind: "subagent" as const, + status: "running" as const, + summary: "working", + })); + snapshots.push({ id: "done", name: "done", kind: "subagent", status: "completed", summary: "done" }); + const content = { snapshots }; + const rows = buildSubagentPaneRows(content, {}); + + expect(rows.find((row) => row.kind === "section-header")).toMatchObject({ + section: "subagents", + activeCount: 13, + earlierCount: 1, + collapsible: true, + }); + expect(rows.find((row) => row.kind === "show-all")).toMatchObject({ hiddenCount: 1 }); + expect(rows.find((row) => row.kind === "earlier-toggle")).toMatchObject({ count: 1, expanded: false }); + + const actionTargets = Array.from({ length: 40 }, (_, line) => subagentIndexForPaneLine(content, line, 0, {})) + .filter((target) => target && target.type !== "snapshot"); + expect(actionTargets).toContainEqual({ type: "show-all", section: "subagents" }); + expect(actionTargets).toContainEqual({ type: "toggle-earlier", section: "subagents" }); + + const windowedTargets = Array.from({ length: 20 }, (_, line) => subagentIndexForPaneLine( + content, + line, + 12, + {}, + SUBAGENT_PANE_ROSTER_CAPACITY, + )).filter((target) => target && target.type !== "snapshot"); + expect(windowedTargets).toContainEqual({ type: "show-all", section: "subagents" }); + expect(windowedTargets).toContainEqual({ type: "toggle-earlier", section: "subagents" }); }); it("builds a focused transcript without unrelated subagent output", () => { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index cdad12748..83d4a7f1d 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -21,6 +21,7 @@ import type { AgentChatDispatchSteerResult, AgentChatDroidPermissionMode, AgentChatEventEnvelope, + AgentChatMainTranscriptArgs, AgentChatEventHistoryPage, AgentChatFileRef, AgentChatInteractionMode, @@ -702,6 +703,21 @@ export async function getSubagentTranscript( ); } +export async function getMainTranscript( + connection: AdeCodeConnection, + args: AgentChatMainTranscriptArgs, +): Promise { + return await connection.action( + "chat", + "getMainTranscript", + { + sessionId: args.sessionId, + ...(typeof args.limit === "number" ? { limit: args.limit } : {}), + ...(typeof args.offset === "number" ? { offset: args.offset } : {}), + }, + ); +} + /** Daemon-backed roster of subagents for a session (richer than event-derived snapshots). */ export async function listSubagents( connection: AdeCodeConnection, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 61b1966a4..8855c86d8 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -56,9 +56,10 @@ import { getAiSettingsStatus, getChatHistory, getChatHistoryPage, - getContextUsage, - getModelCatalog, - getModelPickerFavorites, + getMainTranscript, + getContextUsage, + getModelCatalog, + getModelPickerFavorites, getModelPickerRecents, pushModelPickerRecent, toggleModelPickerFavorite, @@ -309,10 +310,14 @@ import { copyToClipboard } from "../lib/clipboard"; import { buildSubagentPaneRows, buildSubagentTranscriptEvents, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneContentFromRightPane, subagentTranscriptMessagesToEvents, + type SubagentPaneDisclosureSection, type SubagentPaneRow, + type SubagentPaneTarget, + type SubagentPaneViewState, } from "./subagentPane"; import { readClaudeStatusLineConfig, runClaudeStatusLineCommand } from "./statusline"; import { @@ -2893,6 +2898,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); + const [subagentPaneViewStateBySessionId, setSubagentPaneViewStateBySessionId] = useState>({}); + const subagentPaneViewState = activeSessionId ? (subagentPaneViewStateBySessionId[activeSessionId] ?? {}) : {}; + const updateSubagentPaneViewState = useCallback((update: (current: SubagentPaneViewState) => SubagentPaneViewState) => { + if (!activeSessionId) return; + setSubagentPaneViewStateBySessionId((current) => ({ + ...current, + [activeSessionId]: update(current[activeSessionId] ?? {}), + })); + }, [activeSessionId]); + const activateSubagentPaneTarget = useCallback((target: SubagentPaneTarget, resumeOffset: number) => { + if (target.type === "snapshot") { + setRightSelectionIndex(target.index + resumeOffset); + return; + } + if (target.type === "toggle-section") { + updateSubagentPaneViewState((current) => ({ + ...current, + collapsed: { ...current.collapsed, [target.section]: current.collapsed?.[target.section] !== true }, + })); + return; + } + if (target.type === "toggle-earlier") { + updateSubagentPaneViewState((current) => ({ + ...current, + earlierExpanded: { ...current.earlierExpanded, [target.section]: current.earlierExpanded?.[target.section] !== true }, + })); + return; + } + if (target.type === "show-all") { + updateSubagentPaneViewState((current) => ({ + ...current, + showAll: { ...current.showAll, [target.section]: true }, + })); + return; + } + updateSubagentPaneViewState((current) => ({ + ...current, + cleared: { ...current.cleared, [target.section]: [] }, + })); + }, [updateSubagentPaneViewState]); const [rightChatsClosedExpanded, setRightChatsClosedExpanded] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); @@ -2937,6 +2982,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // keyed by subagent id so a stale fetch never bleeds into a different agent. Null // ⇒ fall back to the locally-reconstructed transcript. const [realSubagentTranscript, setRealSubagentTranscript] = useState<{ id: string; status: SubagentSnapshot["status"]; envelopes: AgentChatEventEnvelope[] } | null>(null); + const [realMainTranscript, setRealMainTranscript] = useState<{ sessionId: string; envelopes: AgentChatEventEnvelope[] } | null>(null); const unavailableSubagentTranscriptKeysRef = useRef>(new Set()); const [mentionSuggestions, setMentionSuggestions] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); @@ -3917,9 +3963,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // visible (0 = resume, 1 = main, …). Clamp prior selection back into range // when the roster shrinks (e.g., a subagent finishes and is reaped). const resumeOffset = rightPane.kind === "chat-info" ? chatInfoSelectionOffset(rightPane.info) : 0; - const rowCount = buildSubagentPaneRows(content).filter((row) => row.kind === "snapshot").length + resumeOffset; + const rowCount = buildSubagentPaneRows(content, subagentPaneViewState).filter((row) => row.kind === "snapshot").length + resumeOffset; setRightSelectionIndex((index) => Math.max(0, Math.min(Number.isFinite(index) ? Math.floor(index) : 0, rowCount))); - }, [rightPane]); + }, [rightPane, subagentPaneViewState]); useEffect(() => { if (!inspectedSubagentId) return; if (rightPane.kind !== "chat-info" || !rightOpen || !subagentSnapshots.some((snap) => snap.id === inspectedSubagentId)) { @@ -3928,6 +3974,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }, [inspectedSubagentId, rightOpen, rightPane.kind, subagentSnapshots]); useEffect(() => { setInspectedSubagentId(null); + setRealMainTranscript(null); }, [activeSessionId]); const openSubagentsPane = useCallback((): boolean => { if (!subagentPaneCommandAvailable) return false; @@ -4508,6 +4555,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return subagentSnapshots.find((snapshot) => snapshot.id === inspectedSubagentId) ?? null; }, [inspectedSubagentId, rightOpen, rightPane.kind, subagentSnapshots]); const displayEvents = useMemo(() => { + const mainTranscript = realMainTranscript; + if (mainTranscript && mainTranscript.sessionId === activeSession?.sessionId) { + return mainTranscript.envelopes; + } if (!selectedAgentSnapshot) return events; // Prefer the real daemon-backed child transcript when we've fetched it for // THIS subagent (Codex/OpenCode); otherwise reconstruct locally from the @@ -4516,7 +4567,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return realSubagentTranscript.envelopes; } return buildSubagentTranscriptEvents({ events, activeSession, snapshot: selectedAgentSnapshot }); - }, [activeSession, events, realSubagentTranscript, selectedAgentSnapshot]); + }, [activeSession, events, realMainTranscript, realSubagentTranscript, selectedAgentSnapshot]); const displayPendingSteers = useMemo( () => displayEvents === events ? pendingSteers : derivePendingSteers(displayEvents), [displayEvents, events, pendingSteers], @@ -4528,6 +4579,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, ]); }, []); const inspectSubagentWithTranscriptProbe = useCallback((snapshot: SubagentSnapshot | null) => { + setRealMainTranscript(null); if (!snapshot) { setInspectedSubagentId(null); setChatScrollOffset(0); @@ -4569,6 +4621,49 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setChatScrollOffset(0); }); }, [addTranscriptProbeNotice, chatInfo.capability.canViewFullTranscript, setChatScrollOffset]); + const inspectMainTranscript = useCallback(() => { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + const chatSession = activeSessionRef.current; + if ( + !conn + || !sessionId + || chatInfoRef.current.provider !== "claude" + || chatSession?.sessionId !== sessionId + || chatSession.provider !== "claude" + ) { + setRealMainTranscript(null); + setInspectedSubagentId(null); + setChatScrollOffset(0); + return; + } + void getMainTranscript(conn, { sessionId }) + .then((messages) => { + const snapshot: SubagentSnapshot = { + id: "main", + name: "Full session transcript (SDK)", + kind: "subagent", + status: "completed", + summary: "Provider-fidelity view; ADE-only events are not shown.", + }; + const envelopes = messages && messages.length > 0 + ? subagentTranscriptMessagesToEvents({ messages, snapshot, sessionId }) + : []; + if (!envelopes.length) { + addTranscriptProbeNotice("Full session transcript unavailable."); + setRealMainTranscript(null); + return; + } + setInspectedSubagentId(null); + setRealSubagentTranscript(null); + setRealMainTranscript({ sessionId, envelopes }); + setChatScrollOffset(0); + }) + .catch((err) => { + addTranscriptProbeNotice(`Full session transcript unavailable. ${err instanceof Error ? err.message : String(err)}`); + setRealMainTranscript(null); + }); + }, [addTranscriptProbeNotice, setChatScrollOffset]); // Fetch the real child transcript for the inspected subagent when the runtime // can produce one (Codex app-server threads / OpenCode child sessions). Falls // back silently to local reconstruction on null/empty/error. @@ -7518,6 +7613,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }; }); } + // A backend self-heal/splice-repair rewrote persisted envelope history for + // the active chat (session_meta_updated · historyInvalidated). Our in-memory + // event buffer is now stale relative to the repaired turns and stays that + // way until a reconnect/gap, so refetch history — mirroring desktop + // AgentChatPane's loadHistory(force) on the same signal. + if ( + envelope.event.type === "session_meta_updated" + && envelope.event.historyInvalidated === true + && isActiveSessionEvent + ) { + void refreshStateRef.current({ hydrateHistory: true }).catch(() => undefined); + } // A cross-client mode change (iOS/desktop re-moding the session the TUI is // viewing) arrives as a transient session_meta_updated carrying the new // permission/interaction fields. The composer footer reads modelState, not @@ -12157,10 +12264,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeOffset = chatInfoSelectionOffset(rightPane.info); const subagentPaneTop = 4 + goalBannerRows + addModeRows + (resumeOffset ? CHAT_INFO_RESUME_ROW_LINES : 0); const subagentContent = subagentPaneContentFromRightPane(rightPane); - const nextIndex = subagentContent - ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset) + const target = subagentContent + ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY) : null; - if (nextIndex != null) setRightSelectionIndex(nextIndex + resumeOffset); + if (target) activateSubagentPaneTarget(target, resumeOffset); } return; } @@ -12292,12 +12399,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeOffset = chatInfoSelectionOffset(rightPane.info); const subagentPaneTop = 4 + goalBannerRows + addModeRows + (resumeOffset ? CHAT_INFO_RESUME_ROW_LINES : 0); const subagentContent = subagentPaneContentFromRightPane(rightPane); - const nextIndex = subagentContent - ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset) + const target = subagentContent + ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY) : null; - if (nextIndex != null) { - setRightSelectionIndex(nextIndex + resumeOffset); - } + if (target) activateSubagentPaneTarget(target, resumeOffset); setRightOpen(true); setPaneFocus("details"); } @@ -13213,6 +13318,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // First Esc unwinds a subagent transcript back to the main chat; the // right pane stays focused on the main agent's info, so a second Esc // would close the pane normally. + if (realMainTranscript) { + setRealMainTranscript(null); + setChatScrollOffset(0); + return; + } if ( pane === "details" && rightOpen @@ -13486,15 +13596,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } const killWorkerKey = key.ctrl && !key.meta && input.toLowerCase() === "k"; + const chatInfoDisclosureKey = !key.ctrl && !key.meta && !key.shift + ? input.toLowerCase() + : ""; if ( pane === "details" && rightOpen && rightPane.kind === "chat-info" - && (key.upArrow || key.downArrow || key.return || killWorkerKey) + && (key.upArrow || key.downArrow || key.return || killWorkerKey || ["c", "e", "a", "x"].includes(chatInfoDisclosureKey)) ) { const subagentContent = subagentPaneContentFromRightPane(rightPane); if (!subagentContent) return; - const snapshotRows = buildSubagentPaneRows(subagentContent) + const paneRows = buildSubagentPaneRows(subagentContent, subagentPaneViewState); + const snapshotRows = paneRows .filter((row): row is Extract => row.kind === "snapshot"); // Selection: 0 = main row; 1..N = subagent rows — shifted down by one // when the resume row is visible (0 = resume, 1 = main, …). @@ -13503,6 +13617,48 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeRowSelected = resumeOffset === 1 && rightSelectionIndex === 0; const selectedRow = rightSelectionIndex > resumeOffset ? snapshotRows[rightSelectionIndex - 1 - resumeOffset] : null; const selectedSnapshot: SubagentSnapshot | null = selectedRow ? selectedRow.snapshot : null; + const focusedSection: SubagentPaneDisclosureSection = selectedRow?.section + ?? (paneRows.find((row): row is Extract => row.kind === "section-header")?.section ?? "subagents"); + const focusedHeader = paneRows.find((row): row is Extract => ( + row.kind === "section-header" && row.section === focusedSection + )); + if (chatInfoDisclosureKey === "c") { + if (focusedHeader?.collapsible) { + activateSubagentPaneTarget({ type: "toggle-section", section: focusedSection }, resumeOffset); + if (!focusedHeader.collapsed) setRightSelectionIndex(resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "e") { + if (focusedHeader && (focusedHeader.earlierCount > 0 || focusedHeader.clearedCount > 0)) { + activateSubagentPaneTarget({ type: "toggle-earlier", section: focusedSection }, resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "a") { + if (paneRows.some((row) => row.kind === "show-all" && row.section === focusedSection)) { + activateSubagentPaneTarget({ type: "show-all", section: focusedSection }, resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "x") { + const clearIds = paneRows + .filter((row): row is Extract => ( + row.kind === "snapshot" && row.section === focusedSection && row.group === "earlier" + )) + .map((row) => row.snapshot.id); + if (clearIds.length) { + updateSubagentPaneViewState((current) => ({ + ...current, + cleared: { + ...current.cleared, + [focusedSection]: [...new Set([...(current.cleared?.[focusedSection] ?? []), ...clearIds])], + }, + })); + setRightSelectionIndex(resumeOffset); + } + return; + } // ^k — stop the selected Droid AGI worker. The Droid worker subagent id IS // its workerSessionId (see droidSdkEventMapper.mission_worker_started). if (killWorkerKey) { @@ -13546,11 +13702,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } // Capability gate: only runtimes with a real child transcript // (Codex/OpenCode) take over the main chat. Cursor/Droid keep the row - // selected with its inline detail shown; selecting "main" always - // returns to the parent chat. + // selected with its inline detail shown. Claude's main row opens the + // alternate provider-fidelity transcript; other main rows return home. if (selectedSnapshot && !chatInfoRef.current.capability.canViewFullTranscript) { return; } + if ( + !selectedSnapshot + && rightSelectionIndex === resumeOffset + && chatInfoRef.current.provider === "claude" + && chatInfoRef.current.capability.canViewFullTranscript + ) { + inspectMainTranscript(); + return; + } inspectSubagentWithTranscriptProbe(selectedSnapshot); return; } @@ -14949,13 +15114,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } if (subagentContent) { for (let y = rightBodyTop; y <= Math.max(rightBodyTop, rows - 2); y += 1) { - const index = subagentIndexForPaneLine(subagentContent, y - subagentPaneTop, rightSelectionIndex - resumeOffset); - if (index == null) continue; + const target = subagentIndexForPaneLine(subagentContent, y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY); + if (!target) continue; + const targetKey = target.type === "snapshot" ? `${target.type}:${target.index}` : `${target.type}:${target.section}`; addTarget({ - id: `right:chat-info:${index + resumeOffset}:${y}`, + id: `right:chat-info:${targetKey}:${y}`, rect: { x: rightStartColumn, y, w: rightPaneWidth, h: 1 }, onClick: () => { - setRightSelectionIndex(index + resumeOffset); + activateSubagentPaneTarget(target, resumeOffset); setRightOpen(true); setPaneFocus("details"); }, @@ -15504,6 +15670,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, activeProvider={activeCommandProvider as AdeCodeProvider} width={rightPaneWidth} scrollOffsetRows={rightPaneScrollOffsetRows} + subagentPaneViewState={subagentPaneViewState} modelPickerInputs={rightPaneModelPickerInputs} onModelPickerMeasureOrigin={handlePickerMeasureOrigin} /> diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts index 0e522a896..66775c365 100644 --- a/apps/ade-cli/src/tuiClient/chatInfo.ts +++ b/apps/ade-cli/src/tuiClient/chatInfo.ts @@ -72,6 +72,7 @@ export function deriveChatInfoSnapshot(args: { provider, modelLabel: args.modelLabel, laneLabel: args.laneLabel, + claudeTag: args.activeSession?.claudeTag ?? null, contextPercent: args.tokenStats?.percent ?? null, tokenSummary: tokenStatsSummary(args.tokenStats), goal: args.goal, diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index cbd7f0bb5..c220612ff 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -630,9 +630,14 @@ function ChatRow({ // NextWakeChip parity); reserve its width so the title never collides with it. const wake = chatNextWakeLabel(session); const wakeReserve = wake ? wake.length + 3 : 0; + // A tagged Claude chat shows a compact trailing "#tag" marker (desktop + // SessionCard claudeTag pill parity); reserve its width like the wake chip so + // the title truncates ahead of it instead of colliding. + const tag = session.claudeTag?.trim() ? `#${truncate(session.claudeTag.trim(), 16)}` : null; + const tagReserve = tag ? tag.length + 1 : 0; // The age used to reserve trailing room; without it the title can run wider, // keeping just a little space for the spinner + active dot. - const label = truncate(formatSessionLabel(session), drawerChatLabelWidth(max, wakeReserve)); + const label = truncate(formatSessionLabel(session), drawerChatLabelWidth(max, wakeReserve + tagReserve)); // Selection/hover wins with violet; awaiting-input tints amber as a calm // "needs you" signal; otherwise the title sits a touch dimmer under a // non-selected lane (dimTitle) than under the focused one. @@ -647,6 +652,7 @@ function ChatRow({ {running ? {" "} : {dot.glyph} } {exec.glyph} {label} + {tag ? {` ${tag}`} : null} {wake ? {` ⏰${wake}`} : null} {running ? : null} {session.sessionId === activeSessionId ? ( diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 6da0e76e2..cb34104cd 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -20,10 +20,24 @@ import { } from "../externalSessionBrowser"; import { formatRelativePastTime } from "../relativeTime"; import { + isEarlierBackgroundItem, + isEarlierScheduleItem, backgroundCommandLabel, compactRelativeDuration, } from "../../../../desktop/src/shared/chatScheduledWork"; -import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; +import { + BACKGROUND_ACTIVE_CAP, + SCHEDULE_ACTIVE_CAP, + capPaneSectionItems, + groupPaneSectionItems, +} from "../../../../desktop/src/shared/chatSubagents"; +import { + buildSubagentPaneRows, + SUBAGENT_PANE_ROSTER_CAPACITY, + type SubagentPaneRow, + type SubagentPaneViewState, + windowSubagentPaneRows, +} from "../subagentPane"; import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; import { TokenBar } from "./FooterControls"; @@ -696,11 +710,16 @@ function ChatInfoHeader({ info, width }: { info: ChatInfoSnapshot; width: number {brand.glyph} {` ${endTruncate(info.modelLabel, inner - 2)}`} - {info.laneLabel ? ( + {info.laneLabel || info.claudeTag ? ( - lane - · - {endTruncate(info.laneLabel, Math.max(6, inner - 7))} + {info.laneLabel ? ( + <> + lane + · + {endTruncate(info.laneLabel, Math.max(6, inner - 7))} + + ) : null} + {info.claudeTag ? {`${info.laneLabel ? " " : ""}tag:${endTruncate(info.claudeTag, 24)}`} : null} ) : null} @@ -794,36 +813,29 @@ export function ChatInfoResumeRow({ selected }: { selected: boolean }) { ); } -function rosterWindow(rowCount: number, selected: number, capacity: number): { start: number; end: number } { - if (rowCount <= capacity) return { start: 0, end: rowCount }; - const half = Math.floor(capacity / 2); - let start = Math.max(0, selected - half); - let end = start + capacity; - if (end > rowCount) { - end = rowCount; - start = end - capacity; - } - return { start, end }; -} - function ChatInfoRoster({ info, selectedIndex, brandColor, width, + viewState, }: { info: ChatInfoSnapshot; selectedIndex: number; brandColor: string; width: number; + viewState: SubagentPaneViewState; }) { const inner = Math.max(10, width - 4); - const snapshotRows = buildSubagentPaneRows(info) + const paneRows = buildSubagentPaneRows(info, viewState); + const snapshotRows = paneRows .filter((row): row is Extract => row.kind === "snapshot"); - const runCount = snapshotRows.filter((row) => row.snapshot.status === "running").length; - const doneCount = snapshotRows.filter((row) => row.snapshot.status === "completed").length; - const failedCount = snapshotRows.filter((row) => row.snapshot.status === "failed").length; - const bgCount = snapshotRows.filter((row) => row.section === "background").length; + const clearedIds = new Set(Object.values(viewState.cleared ?? {}).flatMap((ids) => [...(ids ?? [])])); + const countedSnapshots = info.snapshots.filter((snapshot) => !clearedIds.has(snapshot.id)); + const runCount = countedSnapshots.filter((snapshot) => snapshot.status === "running").length; + const doneCount = countedSnapshots.filter((snapshot) => snapshot.status === "completed").length; + const failedCount = countedSnapshots.filter((snapshot) => snapshot.status === "failed").length; + const bgCount = countedSnapshots.filter((snapshot) => snapshot.background === true).length; // Selection convention: 0 = main row; 1..N = subagent rows (1-indexed). // A negative index means the selection sits ABOVE the roster (the resume // row) — nothing in the roster highlights. @@ -831,7 +843,9 @@ function ChatInfoRoster({ const selected = Math.max(-1, Math.min(selectedIndex, totalSelectable - 1)); const mainSelected = selected === 0; const showingMain = !info.inspectedSubagentId; - const hint = snapshotRows.length === 0 + // Gate on the full uncleared snapshot list, not the visible rows — a + // collapsed section empties snapshotRows while agents are still running. + const hint = countedSnapshots.length === 0 ? "0 live" : [ `${runCount} live`, @@ -840,13 +854,23 @@ function ChatInfoRoster({ bgCount ? `${bgCount} bg` : null, ].filter((value): value is string => value !== null).join(" · "); - const ROSTER_CAPACITY = 5; const subagentSelectedIndex = mainSelected ? -1 : selected - 1; const selectedSnapshot = !mainSelected ? (snapshotRows[subagentSelectedIndex]?.snapshot ?? null) : null; - const window = rosterWindow(snapshotRows.length, Math.max(0, subagentSelectedIndex), ROSTER_CAPACITY); - const visibleSlice = snapshotRows.slice(window.start, window.end); - const hiddenBefore = window.start; - const hiddenAfter = snapshotRows.length - window.end; + const selectedSection = !mainSelected ? snapshotRows[subagentSelectedIndex]?.section ?? null : null; + const selectedHeader = selectedSection + ? paneRows.find((row): row is Extract => row.kind === "section-header" && row.section === selectedSection) + : null; + const disclosureHints = selectedHeader ? [ + ...(selectedHeader.collapsible ? ["c section"] : []), + ...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e earlier"] : []), + ...(paneRows.some((row) => row.kind === "show-all" && row.section === selectedSection) ? ["a all"] : []), + ] : []; + const { visibleRows: visibleSlice, hiddenBefore, hiddenAfter } = windowSubagentPaneRows( + paneRows, + selected, + SUBAGENT_PANE_ROSTER_CAPACITY, + ); + const rosterIndexByKey = new Map(snapshotRows.map((row, index) => [row.key, index])); return ( @@ -861,19 +885,31 @@ function ChatInfoRoster({ {showingMain ? "viewing" : "return ↵"} - {snapshotRows.length === 0 ? ( + {info.snapshots.length === 0 ? ( {" "}no subagents yet ) : ( <> {hiddenBefore > 0 ? ( {` ↑ ${hiddenBefore} earlier`} ) : null} - {visibleSlice.map((row, sliceIndex) => { - const rosterIndex = window.start + sliceIndex; - const previousSection = rosterIndex === 0 - ? "main" - : snapshotRows[rosterIndex - 1]?.section; - const showSection = row.section !== previousSection; + {visibleSlice.map((row) => { + if (row.kind === "section-header") { + return ; + } + if (row.kind === "earlier-toggle") { + return ( + + {` ${row.expanded ? "▾" : "▸"} earlier (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`} + + ); + } + if (row.kind === "show-all") { + return {` + show all (${row.hiddenCount})`}; + } + if (row.kind === "restore-cleared") { + return {` restore (${row.count})`}; + } + const rosterIndex = rosterIndexByKey.get(row.key) ?? -1; const isSelected = !mainSelected && subagentSelectedIndex === rosterIndex; const kind = subagentAgentKind(row.snapshot.status); // Background rows get a cyan glyph tint so the eye can sort them out @@ -889,7 +925,6 @@ function ChatInfoRoster({ const detail = isSelected ? selectedRosterDetail(row.snapshot, info.capability) : null; return ( - {showSection ? : null} {isSelected ? theme.rail : " "} {` ${theme.agentStatusGlyph(kind)}`} @@ -912,7 +947,7 @@ function ChatInfoRoster({ )} - {rosterFooterHint(info, mainSelected, selectedSnapshot)} + {rosterFooterHint(info, mainSelected, selectedSnapshot, disclosureHints)} {info.mission ? : null} @@ -927,6 +962,7 @@ function rosterFooterHint( info: ChatInfoSnapshot, mainSelected: boolean, selectedSnapshot: SubagentSnapshot | null, + disclosureHints: string[], ): string { const parts = ["↑↓ focus"]; if (mainSelected) { @@ -942,6 +978,7 @@ function rosterFooterHint( ) { parts.push("^k kill"); } + parts.push(...disclosureHints); parts.push("esc → main"); return parts.join(" · "); } @@ -949,14 +986,16 @@ function rosterFooterHint( // Section heading for the roster — matches the 2-line allowance built into // `subagentPaneSelectableLineOffsets` (one blank-margin line + one title line) // so the mouse-click line-math stays accurate. -function RosterSectionHead({ section }: { section: SubagentPaneRow["section"] }) { - let label = "subagents"; - if (section === "background") label = "background"; - else if (section === "teammates") label = "teammates"; - const color = section === "background" ? theme.color.tool : theme.color.t4; +function RosterSectionHead({ row }: { row: Extract }) { + const color = row.section === "background" ? theme.color.tool : theme.color.t4; + const count = row.earlierCount + ? `${row.activeCount} · ${row.earlierCount} earlier` + : `${row.activeCount}`; return ( - {label} + + {row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {count}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""} + ); } @@ -1019,7 +1058,6 @@ function ChatInfoMissionBlock({ mission, width, brandColor }: { mission: Mission // Desktop ChatTasksPanel parity: latest todo_update snapshot. Rendered BELOW // the roster (like Mission) so the roster's click line-math stays intact. const TASKS_VISIBLE_CAP = 6; -const SCHEDULE_VISIBLE_CAP = 5; function scheduleStatusColor(status: ChatScheduledWorkSnapshot["status"]): string { if (status === "running" || status === "fired") return theme.color.running; @@ -1058,65 +1096,89 @@ function nextWakeCountdown(value: string | null | undefined, nowMs: number): str return compactRelativeDuration(Math.max(60_000, timestampMs - nowMs)); } -function ChatInfoScheduleBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { +function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: ChatInfoSnapshot; brandColor: string; width: number; viewState: SubagentPaneViewState }) { const nextWake = nextWakeCountdown(info.nextWakeAt, Date.now()); if (!info.scheduledWork.length && !nextWake) return null; const inner = Math.max(10, width - 4); - const visible = info.scheduledWork.slice(0, SCHEDULE_VISIBLE_CAP); - const hiddenAfter = info.scheduledWork.length - visible.length; + const clearedIds = new Set(viewState.cleared?.schedule ?? []); + const grouped = groupPaneSectionItems(info.scheduledWork, { + isEarlier: isEarlierScheduleItem, + isCleared: (item) => clearedIds.has(item.id), + isPinned: () => false, + }); + const capped = viewState.showAll?.schedule + ? { visible: grouped.active, hiddenCount: 0 } + : capPaneSectionItems(grouped.active, SCHEDULE_ACTIVE_CAP, (item) => item.status === "failed"); + const earlierExpanded = viewState.earlierExpanded?.schedule === true; + const renderItem = (item: ChatScheduledWorkSnapshot, earlier: boolean) => { + const detail = scheduleLineDetail(item); + const label = `${scheduleKindLabel(item.kind)} · ${item.status}${item.late ? " · late" : ""}`; + const titleBudget = Math.max(6, inner - label.length - 4); + return ( + + + {scheduleStatusGlyph(item.status)} {endTruncate(item.title, titleBudget)} {label} + + {detail ? ( + + {" "}{endTruncate(detail, inner - 2)} + + ) : null} + + ); + }; return ( - + {nextWake ? ( {` ⏰ next wake ${nextWake}`} ) : null} - {visible.map((item) => { - const detail = scheduleLineDetail(item); - // Mirror desktop's history row: a fired-behind-schedule wake reads `· late`. - const label = `${scheduleKindLabel(item.kind)} · ${item.status}${item.late ? " · late" : ""}`; - const titleBudget = Math.max(6, inner - label.length - 4); - return ( - - - {scheduleStatusGlyph(item.status)} {endTruncate(item.title, titleBudget)} {label} - - {detail ? ( - - {" "}{endTruncate(detail, inner - 2)} - - ) : null} - - ); - })} - {hiddenAfter > 0 ? {` ↓ ${hiddenAfter} more`} : null} + {capped.visible.map((item) => renderItem(item, false))} + {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} + {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? ( + {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`} + ) : null} + {earlierExpanded ? grouped.earlier.map((item) => renderItem(item, true)) : null} + {earlierExpanded && grouped.clearedCount > 0 ? {` restore (${grouped.clearedCount})`} : null} ); } // Background command tasks — mirrors the desktop actions-pane Background // section. Each row: `$ status` (ASCII, one line). -function ChatInfoBackgroundBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { +function ChatInfoBackgroundBlock({ info, brandColor, width, viewState }: { info: ChatInfoSnapshot; brandColor: string; width: number; viewState: SubagentPaneViewState }) { if (!info.backgroundWork.length) return null; const inner = Math.max(10, width - 4); - const visible = info.backgroundWork.slice(0, SCHEDULE_VISIBLE_CAP); - const hiddenAfter = info.backgroundWork.length - visible.length; + const clearedIds = new Set(viewState.cleared?.background ?? []); + const grouped = groupPaneSectionItems(info.backgroundWork, { + isEarlier: isEarlierBackgroundItem, + isCleared: (item) => clearedIds.has(item.id), + isPinned: () => false, + }); + const capped = viewState.showAll?.background + ? { visible: grouped.active, hiddenCount: 0 } + : capPaneSectionItems(grouped.active, BACKGROUND_ACTIVE_CAP, (item) => item.status === "failed"); + const earlierExpanded = viewState.earlierExpanded?.background === true; + const renderItem = (item: ChatScheduledWorkSnapshot) => { + const raw = (item.title || item.prompt || item.summary || "").trim(); + const label = backgroundCommandLabel(raw) || raw || "background command"; + const status = ` ${item.status}`; + const labelBudget = Math.max(6, inner - status.length - 4); + return ( + + {"$ "}{endTruncate(label, labelBudget)} {item.status} + + ); + }; return ( - - {visible.map((item) => { - const raw = (item.title || item.prompt || item.summary || "").trim(); - const label = backgroundCommandLabel(raw) || raw || "background command"; - const status = ` ${item.status}`; - const labelBudget = Math.max(6, inner - status.length - 4); - return ( - - {"$ "}{endTruncate(label, labelBudget)} {item.status} - - ); - })} - {hiddenAfter > 0 ? {` ↓ ${hiddenAfter} more`} : null} + + {capped.visible.map(renderItem)} + {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} + {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})`} : null} + {earlierExpanded ? grouped.earlier.map(renderItem) : null} ); } @@ -1181,10 +1243,12 @@ function ChatInfoPane({ info, selectedIndex, width, + subagentPaneViewState, }: { info: ChatInfoSnapshot; selectedIndex: number; width: number; + subagentPaneViewState: SubagentPaneViewState; }) { const brand = theme.provider(info.provider); // With the resume row visible the selection space shifts by one (0 = resume, @@ -1197,10 +1261,10 @@ function ChatInfoPane({ - + - - + + ); @@ -2147,6 +2211,7 @@ function RightPaneComponent({ modelPickerInputs, onModelPickerMeasureOrigin, scrollOffsetRows = 0, + subagentPaneViewState = {}, }: { content: RightPaneContent; formValues?: Record; @@ -2156,6 +2221,7 @@ function RightPaneComponent({ activeProvider?: AdeCodeProvider | null; width?: number; scrollOffsetRows?: number; + subagentPaneViewState?: SubagentPaneViewState; /** Reports the model-picker's measured content origin for click hit-testing. */ onModelPickerMeasureOrigin?: (origin: { x: number; y: number; width: number }) => void; /** Data passed in by app.tsx for the model-picker content kind. */ @@ -2310,7 +2376,7 @@ function RightPaneComponent({ ) : null} {content.kind === "chat-info" ? ( - + ) : null} {content.kind === "model-picker" && modelPickerInputs ? ( diff --git a/apps/ade-cli/src/tuiClient/subagentPane.ts b/apps/ade-cli/src/tuiClient/subagentPane.ts index 1b0a20665..3300c47cf 100644 --- a/apps/ade-cli/src/tuiClient/subagentPane.ts +++ b/apps/ade-cli/src/tuiClient/subagentPane.ts @@ -6,13 +6,19 @@ export { buildSubagentTranscriptEvents, isLifecycleEventForSnapshot, selectedSubagentSnapshot, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneSelectableLineOffsets, subagentTranscriptMessagesToEvents, + windowSubagentPaneRows, } from "../../../desktop/src/shared/chatSubagents"; export type { + SubagentPaneDisclosureSection, SubagentPaneRow, SubagentPaneSection, + SubagentPaneTarget, + SubagentPaneViewSection, + SubagentPaneViewState, } from "../../../desktop/src/shared/chatSubagents"; export type SubagentPaneContent = SharedSubagentPaneContent & { diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index c0d6d8fbd..ff698ce9b 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -174,6 +174,7 @@ export type ChatInfoSnapshot = { provider: AdeCodeProvider; modelLabel: string; laneLabel: string | null; + claudeTag?: string | null; contextPercent: number | null; tokenSummary: string | null; goal: CodexThreadGoal | null; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 4431f0e2f..60fb90c15 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -79,10 +79,12 @@ describe("isAllowedAdeAction", () => { }); it("exposes subagent transcript reads through the chat runtime action surface", () => { + expect(isAllowedAdeAction("chat", "getMainTranscript")).toBe(true); expect(isAllowedAdeAction("chat", "getSubagentTranscript")).toBe(true); expect(isAllowedAdeAction("chat", "readTranscript")).toBe(true); expect(isAllowedAdeAction("chat", "sendMessage")).toBe(true); expect(isAllowedAdeAction("chat", "messageSession")).toBe(true); + expect(isCtoOnlyAdeAction("chat", "getMainTranscript")).toBe(false); expect(isCtoOnlyAdeAction("chat", "getSubagentTranscript")).toBe(false); expect(isCtoOnlyAdeAction("chat", "readTranscript")).toBe(false); expect(isCtoOnlyAdeAction("chat", "sendMessage")).toBe(false); @@ -317,6 +319,7 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { it("exposes CTO identity session and scan wrappers for runtime-backed CTO views", () => { const chatActions = ADE_ACTION_ALLOWLIST.chat ?? []; expect(chatActions).toContain("ensureCtoSession"); + expect(chatActions).toContain("getMainTranscript"); expect(chatActions).toContain("getSubagentTranscript"); expect(chatActions).toContain("modelCatalog"); expect(ADE_ACTION_ALLOWLIST.cto_state ?? []).toContain("runProjectScan"); @@ -921,6 +924,21 @@ describe("runtime session actions", () => { }); }); + it("forwards remote main transcript reads through chat actions", async () => { + const transcript = [{ role: "assistant", content: "main output" }]; + const getMainTranscript = vi.fn(async () => transcript); + const runtime = { + agentChatService: { getMainTranscript }, + } as unknown as Parameters[0]; + const chatService = getAdeActionDomainServices(runtime).chat as { + getMainTranscript: (args: { sessionId: string }) => Promise>; + } & Record; + + expect(listAllowedAdeActionNames("chat", chatService)).toContain("getMainTranscript"); + await expect(chatService.getMainTranscript({ sessionId: "chat-1" })).resolves.toEqual(transcript); + expect(getMainTranscript).toHaveBeenCalledWith({ sessionId: "chat-1" }); + }); + it("adds getDelta from the runtime session delta service", () => { const delta = { sessionId: "session-1", filesChanged: 2 }; const runtime = { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 74c036ed4..f729b08d0 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -431,6 +431,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { - claudePointers.set(pointer.chatSessionId, pointer); - return pointer; + const existing = pointer.chatSessionId + ? claudePointers.get(pointer.chatSessionId) + : Array.from(claudePointers.values()).find((candidate) => candidate.sessionId === pointer.sessionId); + const next = { + ...existing, + ...pointer, + title: pointer.title !== undefined ? pointer.title : existing?.title ?? null, + tags: pointer.tags !== undefined ? pointer.tags : existing?.tags ?? [], + }; + if (next.chatSessionId) claudePointers.set(next.chatSessionId, next); + return next; }), + getClaudeSessionPointer: vi.fn((sdkSessionId: string) => ( + Array.from(claudePointers.values()).find((pointer) => pointer.sessionId === sdkSessionId) ?? null + )), getClaudeSessionPointerByChatSessionId: vi.fn((chatSessionId: string) => claudePointers.get(chatSessionId) ?? null), listClaudeSessionPointers: vi.fn(() => Array.from(claudePointers.values())), } as any; @@ -1278,6 +1291,142 @@ function createMockProjectConfigService() { } as any; } +const SCHEDULED_WORK_STATE_KEY = "agent-chat:scheduled-work:v1"; +const SCHEDULE_TEST_START = Date.parse("2026-07-10T09:00:00.000Z"); + +function createScheduledWorkDb(initialState: ChatScheduledWorkState | null = null) { + const values = new Map(); + if (initialState) values.set(SCHEDULED_WORK_STATE_KEY, structuredClone(initialState)); + return { + db: { + getJson: vi.fn((key: string) => structuredClone(values.get(key) ?? null)), + setJson: vi.fn((key: string, value: unknown) => { + values.set(key, structuredClone(value)); + }), + }, + readState: (): ChatScheduledWorkState | null => { + const state = values.get(SCHEDULED_WORK_STATE_KEY); + return state ? structuredClone(state) as ChatScheduledWorkState : null; + }, + }; +} + +function storedWakeup( + sessionId: string, + overrides: Partial = {}, +): ChatScheduledWorkRecord { + return { + id: `wakeup:${sessionId}`, + sessionId, + kind: "wakeup", + prompt: "Check PR CI and report the result.", + reason: "Check PR CI", + fireAt: Date.now() + 60_000, + createdAt: Date.now(), + status: "scheduled", + pausedFlag: false, + lateFlag: false, + ...overrides, + }; +} + +function installClaudeWakeupFixture(args: { + sdkSessionId: string; + delaySeconds: number; + prompt?: string; +}) { + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + const handle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: args.sdkSessionId, + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ + type: "tool_use", + id: `tool-${args.sdkSessionId}`, + name: "ScheduleWakeup", + input: { + delaySeconds: args.delaySeconds, + reason: "Check PR CI", + prompt: args.prompt ?? "Check PR CI and report the result.", + }, + }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: args.sdkSessionId, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close, + sessionId: args.sdkSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(handle as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(handle as any); + return { handle, send, close }; +} + +function installClaudeResponseFixture(args: { + sdkSessionId: string; + responseText: string; +}) { + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const handle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: args.sdkSessionId, + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ type: "text", text: args.responseText }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: args.sdkSessionId, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: args.sdkSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(handle as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(handle as any); + return { handle, send }; +} + function createService(overrides: Record = {}) { const logger = createLogger(); const laneService = createMockLaneService(); @@ -1506,6 +1655,9 @@ beforeEach(() => { vi.mocked(claudeSdkResumeSessionCompat).mockReset(); vi.mocked(query).mockReset(); vi.mocked(startup).mockReset(); + vi.mocked(getSessionMessages).mockReset(); + vi.mocked(getSessionMessages).mockResolvedValue([]); + vi.mocked(tagSession).mockClear(); installClaudeSdkCompatMocks(); vi.mocked(resolveClaudeCodeExecutable).mockClear(); vi.mocked(resolveClaudeCodeExecutable).mockReturnValue({ path: "/usr/local/bin/claude", source: "path" }); @@ -3031,6 +3183,7 @@ describe("createAgentChatService", () => { const claudeSubprocessReaper = { register: vi.fn(), spawnClaudeCodeProcess: vi.fn(() => spawnedProcess), + reapForSession: vi.fn(), reapAll: vi.fn(), liveRecords: vi.fn(() => []), }; @@ -3078,6 +3231,12 @@ describe("createAgentChatService", () => { cwd: expect.any(String), }), ); + + await service.dispose({ sessionId: session.id }); + expect(claudeSubprocessReaper.reapForSession).toHaveBeenCalledWith( + session.id, + "ended_session", + ); }); it("appends discovered project slash commands to the Claude system prompt", async () => { @@ -6648,6 +6807,42 @@ describe("createAgentChatService", () => { expect(summary!.sessionId).toBe(created.id); expect(summary!.provider).toBe("opencode"); }); + + it("surfaces and updates the first mirrored Claude SDK tag", async () => { + installClaudeResponseFixture({ sdkSessionId: "sdk-tag-session", responseText: "unused" }); + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const created = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: created.id, + text: "Create the SDK session before tagging.", + timeoutMs: 15_000, + }); + + await service.updateSession({ sessionId: created.id, tag: "review-ready" }); + expect(tagSession).toHaveBeenCalledWith(expect.any(String), "review-ready", { + dir: fs.realpathSync(tmpRoot), + }); + expect(sessionService.getClaudeSessionPointerByChatSessionId(created.id)?.tags).toEqual(["review-ready"]); + await expect(service.getSessionSummary(created.id)).resolves.toMatchObject({ + claudeTag: "review-ready", + }); + expect(events).toContainEqual(expect.objectContaining({ + event: { type: "session_meta_updated", claudeTag: "review-ready" }, + })); + + await service.updateSession({ sessionId: created.id, tag: "" }); + expect(tagSession).toHaveBeenLastCalledWith(expect.any(String), null, { + dir: fs.realpathSync(tmpRoot), + }); + await expect(service.getSessionSummary(created.id)).resolves.toMatchObject({ claudeTag: null }); + }); }); // -------------------------------------------------------------------------- @@ -6720,6 +6915,41 @@ describe("createAgentChatService", () => { }); }); + describe("Claude SessionStore reads", () => { + it("maps SDK messages and forwards paging plus system-message options", async () => { + const { service } = createService(); + vi.mocked(getSessionMessages).mockResolvedValue([{ + type: "assistant", + uuid: "wire-1", + session_id: "sdk-session-1", + parent_tool_use_id: null, + message: { + id: "msg-1", + role: "assistant", + content: [{ type: "text", text: "SDK transcript text" }], + }, + }] as any); + + await expect(service.getClaudeSessionMessages({ + sessionId: "sdk-session-1", + laneId: "lane-1", + limit: 25, + offset: 3, + includeSystemMessages: true, + })).resolves.toEqual([expect.objectContaining({ + uuid: "wire-1", + sessionId: "sdk-session-1", + text: "SDK transcript text", + })]); + expect(getSessionMessages).toHaveBeenCalledWith("sdk-session-1", { + dir: fs.realpathSync(tmpRoot), + limit: 25, + offset: 3, + includeSystemMessages: true, + }); + }); + }); + // -------------------------------------------------------------------------- // listSubagents // -------------------------------------------------------------------------- @@ -11138,6 +11368,140 @@ describe("createAgentChatService", () => { ); }); + it("dispose cancels durable schedules and emits cancelled scheduled_work_update", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-cancel", + delaySeconds: 60, + }); + const { service, sessionService } = createService({ + db: scheduledWork.db, + 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: "Check CI again later.", + }); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "scheduled" }), + ]); + + await service.dispose({ sessionId: session.id }); + + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "disposed", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "cancelled" }), + ]); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "scheduled_work_update", + status: "cancelled", + }), + }), + ])); + service.forceDisposeAll(); + }); + + it("a scheduled fire after dispose does not resume the session", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-no-fire", + delaySeconds: 1, + }); + const { service, sessionService } = createService({ + db: scheduledWork.db, + 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: "Wake once to check CI.", + }); + await service.dispose({ sessionId: session.id }); + const startedTurnsBeforeAdvance = events.filter((event) => + event.sessionId === session.id + && event.event.type === "status" + && event.event.turnStatus === "started" + ).length; + + await vi.advanceTimersByTimeAsync(65_000); + + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "disposed", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("cancelled"); + expect(events.filter((event) => + event.sessionId === session.id + && event.event.type === "status" + && event.event.turnStatus === "started" + )).toHaveLength(startedTurnsBeforeAdvance); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake != null + )).toBe(false); + service.forceDisposeAll(); + }); + + it("finishSession clears pending native scheduled wakes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-pending-native-wake", + delaySeconds: 60, + prompt: "Stale native wake must not survive dispose.", + }); + const { service } = createService({ + db: scheduledWork.db, + 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: "Queue a native wake.", + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired"); + expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1); + + await service.dispose({ sessionId: session.id }); + + expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.reason === "Stale native wake must not survive dispose." + )).toBe(false); + service.forceDisposeAll(); + }); + it("evicts disposed chats from the live managed session cache", async () => { const { service } = createService(); const session = await service.createSession({ @@ -11265,6 +11629,7 @@ describe("createAgentChatService", () => { const claudeSubprocessReaper = { register: vi.fn(), spawnClaudeCodeProcess: vi.fn(), + reapForSession: vi.fn(), reapAll: vi.fn(), liveRecords: vi.fn(() => []), }; @@ -11274,6 +11639,138 @@ describe("createAgentChatService", () => { expect(claudeSubprocessReaper.reapAll).toHaveBeenCalledWith("dispose_all"); }); + + it("disposeAll ends sessions as detached and preserves scheduled work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-all-detached", + delaySeconds: 60, + }); + const { service, sessionService } = createService({ db: scheduledWork.db }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule work across restart.", + }); + + await service.disposeAll(); + + expect(sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.id, + status: "detached", + })); + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "detached", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "scheduled" }), + ]); + }); + + it("scheduled fire into a detached session still delivers by cold resume", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + installClaudeWakeupFixture({ + sdkSessionId: "sdk-detached-cold-resume", + delaySeconds: 60, + prompt: "Deliver this wake after restart.", + }); + const first = createService({ db: scheduledWork.db }); + const session = await first.service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await first.service.runSessionTurn({ + sessionId: session.id, + text: "Schedule restart work.", + }); + await first.service.disposeAll(); + + const events: AgentChatEventEnvelope[] = []; + installClaudeResponseFixture({ + sdkSessionId: "sdk-detached-cold-resume", + responseText: "Cold scheduled work delivered.", + }); + const restarted = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + await restarted.service.refreshScheduledWork(); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.reason === "Check PR CI" + )).toBe(true); + }); + + expect(restarted.sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "running", + endedAt: null, + })); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "done" + && event.event.status === "completed" + )).toBe(true); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("done"); + restarted.service.forceDisposeAll(); + }); + }); + + describe("disposeForLane", () => { + it("cancels schedules for lane sessions including unmanaged ones", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + mockState.sessions.set("unmanaged-lane-1", { + id: "unmanaged-lane-1", + laneId: "lane-1", + toolType: "claude-chat", + status: "running", + startedAt: new Date().toISOString(), + endedAt: null, + archivedAt: null, + }); + mockState.sessions.set("unmanaged-lane-2", { + id: "unmanaged-lane-2", + laneId: "lane-2", + toolType: "claude-chat", + status: "running", + startedAt: new Date().toISOString(), + endedAt: null, + archivedAt: null, + }); + const scheduledWork = createScheduledWorkDb({ + version: 1, + schedules: [ + storedWakeup("unmanaged-lane-1"), + storedWakeup("unmanaged-lane-2"), + storedWakeup("missing-session"), + ], + pausedSessionIds: [], + }); + const { service } = createService({ db: scheduledWork.db }); + + await expect(service.disposeForLane("lane-1")).resolves.toBe(0); + + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: "missing-session", status: "cancelled" }), + expect.objectContaining({ sessionId: "unmanaged-lane-1", status: "cancelled" }), + expect.objectContaining({ sessionId: "unmanaged-lane-2", status: "scheduled" }), + ]); + service.forceDisposeAll(); + }); }); describe("forceDisposeAll", () => { @@ -21160,6 +21657,159 @@ describe("createAgentChatService", () => { expect(sessionService.reopen).toHaveBeenCalledWith(session.id); }); + it("repairs a spliced dedicated envelope transcript before Claude resume", async () => { + installClaudeResponseFixture({ sdkSessionId: "sdk-splice-repair", responseText: "unused" }); + const initial = createService(); + const session = await initial.service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await initial.service.dispose({ sessionId: session.id }); + + const persisted = readPersistedChatState(session.id); + writePersistedChatState(session.id, { ...persisted, sdkSessionId: "sdk-splice-repair" }); + const transcriptPath = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + const legacyTranscriptPath = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + const fragments = ["Full", " ", "SDK", " ", "answer"]; + const splicedTranscript = `${fragments.map((text, index) => JSON.stringify({ + sessionId: session.id, + timestamp: "2026-07-10T12:00:00.000Z", + sequence: index + 1, + event: { + type: "text", + text, + messageId: `wire-${index + 1}`, + turnId: "turn-spliced", + }, + })).join("\n")}\n`; + fs.writeFileSync(transcriptPath, splicedTranscript, "utf8"); + fs.writeFileSync(legacyTranscriptPath, splicedTranscript, "utf8"); + vi.mocked(getSessionMessages).mockResolvedValue([{ + type: "assistant", + uuid: "wire-sdk", + session_id: "sdk-splice-repair", + parent_tool_use_id: null, + message: { + id: "msg-stable-sdk", + role: "assistant", + content: [{ type: "text", text: "Full SDK answer" }], + }, + }] as any); + vi.mocked(parseAgentChatTranscript).mockImplementation((raw) => String(raw) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line))); + installClaudeResponseFixture({ sdkSessionId: "sdk-splice-repair", responseText: "unused" }); + + const repairEvents: AgentChatEventEnvelope[] = []; + const resumed = createService({ + onEvent: (event: AgentChatEventEnvelope) => repairEvents.push(event), + }); + await resumed.service.resumeSession({ sessionId: session.id }); + await resumed.service.runSessionTurn({ + sessionId: session.id, + text: "Continue after resume.", + timeoutMs: 15_000, + }); + await vi.waitFor(() => { + expect(getSessionMessages).toHaveBeenCalledWith("sdk-splice-repair", { dir: fs.realpathSync(tmpRoot) }); + }); + await vi.waitFor(() => { + const textEvents = resumed.service.getChatEventHistory(session.id).events + .filter((entry) => entry.event.type === "text"); + expect(textEvents.find((entry) => entry.event.type === "text" && entry.event.messageId === "msg-stable-sdk")?.event).toMatchObject({ + type: "text", + text: "Full SDK answer", + messageId: "msg-stable-sdk", + }); + }); + expect(fs.existsSync(`${transcriptPath}.splice.bak`)).toBe(true); + expect(resumed.logger.info).toHaveBeenCalledWith( + "agent_chat.envelope_splice_repaired", + expect.objectContaining({ sessionId: session.id, repairedTurns: 1 }), + ); + expect(repairEvents).toContainEqual(expect.objectContaining({ + sessionId: session.id, + event: { type: "session_meta_updated", historyInvalidated: true }, + })); + }); + + it("resolves an ADE chat id to persisted and pointer-backed Claude main transcripts", async () => { + installClaudeResponseFixture({ sdkSessionId: "sdk-main-transcript", responseText: "unused" }); + const { service, sessionService } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await service.dispose({ sessionId: session.id }); + + const persisted = readPersistedChatState(session.id); + writePersistedChatState(session.id, { ...persisted, sdkSessionId: "sdk-persisted-main" }); + vi.mocked(getSessionMessages).mockResolvedValue([{ + type: "assistant", + uuid: "assistant-persisted", + session_id: "sdk-persisted-main", + parent_tool_use_id: null, + message: { id: "msg-persisted", role: "assistant", content: [{ type: "text", text: "Persisted transcript" }] }, + }] as any); + + expect(await service.getMainTranscript({ sessionId: session.id })).toEqual([ + expect.objectContaining({ uuid: "assistant-persisted", text: "Persisted transcript" }), + ]); + expect(getSessionMessages).toHaveBeenLastCalledWith("sdk-persisted-main", expect.objectContaining({ + dir: fs.realpathSync(tmpRoot), + includeSystemMessages: true, + })); + + const pointerState = { ...persisted }; + delete pointerState.sdkSessionId; + writePersistedChatState(session.id, pointerState); + sessionService.upsertClaudeSessionPointer({ + sessionId: "sdk-pointer-main", + laneId: "lane-1", + laneName: "Primary", + chatSessionId: session.id, + title: null, + tags: [], + createdAt: "2026-07-10T12:00:00.000Z", + updatedAt: "2026-07-10T12:00:00.000Z", + }); + vi.mocked(getSessionMessages).mockResolvedValue([{ + type: "system", + uuid: "system-pointer", + session_id: "sdk-pointer-main", + parent_tool_use_id: null, + message: { role: "system", content: "Pointer transcript" }, + }] as any); + + expect(await service.getMainTranscript({ sessionId: session.id })).toEqual([ + expect.objectContaining({ uuid: "system-pointer", type: "system", text: "Pointer transcript" }), + ]); + expect(getSessionMessages).toHaveBeenLastCalledWith("sdk-pointer-main", expect.objectContaining({ + includeSystemMessages: true, + })); + }); + + it("gates main transcripts to Claude and byte-bounds the response", async () => { + const { service } = createService(); + const codex = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); + expect(await service.getMainTranscript({ sessionId: codex.id })).toBeNull(); + expect(getSessionMessages).not.toHaveBeenCalled(); + + installClaudeResponseFixture({ sdkSessionId: "sdk-bounded-main", responseText: "unused" }); + const claude = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await service.dispose({ sessionId: claude.id }); + const persisted = readPersistedChatState(claude.id); + writePersistedChatState(claude.id, { ...persisted, sdkSessionId: "sdk-bounded-main" }); + const huge = "x".repeat(2_200_000); + vi.mocked(getSessionMessages).mockResolvedValue([ + { type: "assistant", uuid: "old-huge", session_id: "sdk-bounded-main", parent_tool_use_id: null, message: { role: "assistant", content: huge } }, + { type: "assistant", uuid: "new-huge", session_id: "sdk-bounded-main", parent_tool_use_id: null, message: { role: "assistant", content: huge } }, + ] as any); + + const result = await service.getMainTranscript({ sessionId: claude.id }); + expect(result).toHaveLength(1); + expect(result?.[0]?.uuid).toBe("new-huge"); + }); + it("keeps requested Codex policy and reasoning effort across resume", async () => { mockState.codexResponseOverrides.set("thread/resume", () => ({ thread: { id: "thread-effective-resume" }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 44da697a8..e4da118d6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -52,6 +52,7 @@ import { isCorruptThinkingTranscriptError, repairClaudeResumeTranscript, } from "./claudeThinkingTranscriptRepair"; +import { repairSplicedEnvelopeFileSync } from "./chatEnvelopeSpliceRepair"; import { discoverClaudePluginPaths, discoverClaudePlugins, @@ -223,6 +224,7 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatMainTranscriptArgs, AgentChatSubagentTranscriptArgs, AgentChatSubagentTranscriptMessage, AgentChatSuggestLaneNameArgs, @@ -5801,6 +5803,7 @@ export function createAgentChatService(args: { const CHAT_EVENT_HISTORY_BUFFER_MAX_CHARS = 4_000_000; const CHAT_EVENT_HISTORY_RESPONSE_MAX_CHARS = 8_000_000; const eventHistoryBySession = new Map(); + const envelopeSpliceRepairAttemptedSessionIds = new Set(); const safeJsonChars = (value: unknown): number => { try { @@ -12023,6 +12026,13 @@ export function createAgentChatService(args: { if (preserveProviderResumeState) persistChatState(managed); cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.query?.close(); } catch { /* ignore */ } + if ( + openCodeReason === "ended_session" + || openCodeReason === "handle_close" + || openCodeReason === "model_switch" + ) { + claudeSubprocessReaper.reapForSession(managed.session.id, openCodeReason); + } managed.runtime.inputPump?.close(); try { managed.runtime.warmQuery?.close(); } catch { /* ignore */ } managed.runtime.query = null; @@ -12221,6 +12231,7 @@ export function createAgentChatService(args: { status: TerminalSessionStatus, options?: { exitCode?: number | null; summary?: string | null } ): Promise => { + pendingNativeScheduledWakeBySession.delete(managed.session.id); if (managed.endedNotified) return; managed.endedNotified = true; clearSubagentSnapshots(managed.session.id); @@ -12233,6 +12244,22 @@ export function createAgentChatService(args: { } managed.localPendingInputs.clear(); + if (status === "disposed") { + await scheduledWorkReady; + if (scheduledWorkScheduler) { + // This cancel snapshot runs while the session row still reads "running", + // so a racing in-flight-turn upsert could slip past it. That is safe only + // because saveState is synchronous (better-sqlite3): the awaits resolve on + // microtasks, sessionService.end below runs before any timer fires, and + // the leaked schedule cancels at fire time via sessionState === "ended". + // If saveState ever becomes async I/O, mark the row terminal first. + await Promise.all( + scheduledWorkScheduler.list(managed.session.id).map((schedule) => + scheduledWorkScheduler!.cancel(schedule.id)), + ); + } + } + if (options?.summary !== undefined) { sessionService.setSummary(managed.session.id, options.summary); } @@ -14371,7 +14398,7 @@ export function createAgentChatService(args: { if (runtime.interrupted) { throw new Error("Claude turn interrupted during warmup."); } - let sessionQuery = ensureClaudeQuery(managed, runtime); + let sessionQuery = await ensureClaudeQuery(managed, runtime); const turnPermissionMode = resolveClaudeTurnPermissionMode(managed); @@ -14390,7 +14417,7 @@ export function createAgentChatService(args: { error: String(permErr), }); resetClaudeQuerySession(managed, runtime, "session_reset", { clearSdkSessionId: true }); - sessionQuery = ensureClaudeQuery(managed, runtime); + sessionQuery = await ensureClaudeQuery(managed, runtime); sessionControl = getClaudeQueryControl(sessionQuery); if (typeof sessionControl.setPermissionMode === "function") { await sessionControl.setPermissionMode(turnPermissionMode); @@ -22027,7 +22054,79 @@ export function createAgentChatService(args: { } }; - const ensureClaudeQuery = (managed: ManagedChatSession, runtime: ClaudeRuntime): ClaudeQuery => { + const repairClaudeEnvelopeSplicesBeforeResume = async ( + managed: ManagedChatSession, + sdkSessionId: string, + ): Promise => { + const sessionId = managed.session.id; + if (envelopeSpliceRepairAttemptedSessionIds.has(sessionId)) return; + envelopeSpliceRepairAttemptedSessionIds.add(sessionId); + + let sdkMessages: ClaudeSdkSessionMessage[]; + try { + sdkMessages = await getClaudeSdkSessionMessages(sdkSessionId, { + dir: managed.laneWorktreePath, + }); + } catch (error) { + // A transient fetch failure (store locked, file not flushed yet) should + // not burn the once-per-process attempt — release the guard so a later + // resume can retry the repair. + envelopeSpliceRepairAttemptedSessionIds.delete(sessionId); + logger.debug("agent_chat.envelope_splice_repair_skipped", { + sessionId, + sdkSessionId, + reason: "sdk_messages_unavailable", + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const transcriptPaths = [...new Set([ + path.join(chatTranscriptsDir, `${sessionId}.jsonl`), + managed.transcriptPath, + ].filter((candidate) => candidate.trim().length > 0))]; + let repairedTurns = 0; + let filesChanged = 0; + for (const transcriptPath of transcriptPaths) { + flushQueuedTranscriptWrite(transcriptPath); + const result = repairSplicedEnvelopeFileSync(transcriptPath, sdkMessages, { + onSkip: (reason, detail) => { + logger.debug("agent_chat.envelope_splice_repair_skipped", { + sessionId, + sdkSessionId, + transcriptPath, + reason, + ...(reason === "oversize" && typeof detail === "number" ? { fileBytes: detail } : {}), + }); + }, + }); + if (result.changed) { + filesChanged += 1; + // The dedicated and legacy files mirror the same turns. Report the + // logical repaired-turn count, not the number of repaired copies. + repairedTurns = Math.max(repairedTurns, result.repairedTurns); + } + } + + if (!filesChanged) { + logger.debug("agent_chat.envelope_splice_repair_not_needed", { sessionId, sdkSessionId }); + return; + } + eventHistoryBySession.delete(sessionId); + transcriptHistoryCacheBySession.delete(sessionId); + logger.info("agent_chat.envelope_splice_repaired", { + sessionId, + sdkSessionId, + repairedTurns, + filesChanged, + }); + emitTransientChatEnvelope(sessionId, { + type: "session_meta_updated", + historyInvalidated: true, + }); + }; + + const ensureClaudeQuery = async (managed: ManagedChatSession, runtime: ClaudeRuntime): Promise => { if (runtime.query && runtime.inputPump) return runtime.query; const pump = new ClaudeInputPump(); @@ -22068,6 +22167,10 @@ export function createAgentChatService(args: { at: "resume", }); } + // Must run AFTER the thinking-transcript repair: that repair can rekey + // SDK message ids, and the splice repair keys rebuilt envelopes to the + // post-rekey ids it reads via getSessionMessages. + await repairClaudeEnvelopeSplicesBeforeResume(managed, options.resume); } let sessionQuery: ClaudeQuery; @@ -22325,6 +22428,7 @@ export function createAgentChatService(args: { at: "prewarm", }); } + await repairClaudeEnvelopeSplicesBeforeResume(managed, options.resume); } if (runtime.warmupCancelled) { @@ -28638,6 +28742,9 @@ export function createAgentChatService(args: { : hasPersistedCodexServiceTier ? persisted?.codexServiceTier ?? null : undefined; + const claudeTag = provider === "claude" + ? getClaudeSessionPointerForChat(row.id)?.tags[0] ?? null + : undefined; return { sessionId: row.id, laneId: row.laneId, @@ -28719,6 +28826,7 @@ export function createAgentChatService(args: { lastActivityAt: liveSession?.lastActivityAt ?? persisted?.updatedAt ?? row.endedAt ?? row.startedAt, lastOutputPreview: row.lastOutputPreview, summary: row.summary ?? null, + ...(provider === "claude" ? { claudeTag } : {}), nextWakeAt: (() => { const next = scheduledWorkScheduler?.nextWakeAt(row.id) ?? null; return next == null ? null : new Date(next).toISOString(); @@ -28799,6 +28907,9 @@ export function createAgentChatService(args: { await scheduledWorkScheduler?.refreshGlobalPause(); }; + const pendingNativeScheduledWakeCountForTesting = (sessionId: string): number => + pendingNativeScheduledWakeBySession.get(sessionId)?.length ?? 0; + const hasActiveWorkloads = (): boolean => { for (const managed of managedSessions.values()) { if (managed.closed || managed.deleted) continue; @@ -28889,6 +29000,20 @@ export function createAgentChatService(args: { errors.push(`${sessionId}: ${error instanceof Error ? error.message : String(error)}`); } } + await scheduledWorkReady; + if (scheduledWorkScheduler) { + await Promise.all( + scheduledWorkScheduler.list() + .filter((schedule) => { + const row = sessionService.get(schedule.sessionId); + // `!row` deliberately widens the sweep to globally-orphaned + // schedules (no session row left) — they would self-cancel at + // fire time anyway; lane teardown just reaps them earlier. + return !row || row.laneId === laneId; + }) + .map((schedule) => scheduledWorkScheduler!.cancel(schedule.id)), + ); + } if (errors.length > 0) { throw new Error(`Failed to close ${errors.length} chat session${errors.length === 1 ? "" : "s"}: ${errors.join("; ")}`); } @@ -30043,7 +30168,10 @@ export function createAgentChatService(args: { return await loadModelCatalogRequest(catalogArgs); }; - const dispose = async ({ sessionId }: AgentChatDisposeArgs): Promise => { + const disposeManagedSession = async ( + { sessionId }: AgentChatDisposeArgs, + terminalStatus: "disposed" | "detached", + ): Promise => { const managed = ensureManagedSession(sessionId); abortActiveBashControllers(managed, "Session disposed."); @@ -30139,11 +30267,15 @@ export function createAgentChatService(args: { cancelQueuedSteers(managed, managed.runtime, "disposed"); } - await finishSession(managed, "disposed", { + await finishSession(managed, terminalStatus, { summary: managed.preview ? `Session closed: ${managed.preview}` : "Session closed." }); }; + const dispose = async (args: AgentChatDisposeArgs): Promise => { + await disposeManagedSession(args, "disposed"); + }; + const deleteSession = async ({ sessionId }: AgentChatDeleteArgs): Promise => { const trimmedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; if (!trimmedSessionId.length) { @@ -30256,7 +30388,7 @@ export function createAgentChatService(args: { scheduledWorkScheduler?.dispose(); for (const sessionId of [...managedSessions.keys()]) { try { - await dispose({ sessionId }); + await disposeManagedSession({ sessionId }, "detached"); } catch { // ignore shutdown errors } @@ -30776,6 +30908,10 @@ export function createAgentChatService(args: { mirrorClaudeSessionPointer(managed, managed.runtime.sdkSessionId, { tags: normalizedTag && normalizedTag.length ? [normalizedTag] : [], }); + emitTransientChatEnvelope(sessionId, { + type: "session_meta_updated", + claudeTag: normalizedTag && normalizedTag.length ? normalizedTag : null, + }); } // Allow resetting manuallyNamed independently when no title change is provided if (manuallyNamed !== undefined && title === undefined) { @@ -31113,6 +31249,21 @@ export function createAgentChatService(args: { }; }; + const mapClaudeSdkSessionMessage = ( + message: ClaudeSdkSessionMessage, + ): AgentChatClaudeSessionMessage => { + const parentToolUseId = (message as unknown as { parent_tool_use_id?: unknown }).parent_tool_use_id; + const text = extractClaudeSessionMessageText(message.message); + return { + type: message.type, + uuid: message.uuid, + sessionId: message.session_id, + parentToolUseId: typeof parentToolUseId === "string" ? parentToolUseId : null, + message: message.message, + ...(text ? { text } : {}), + }; + }; + const getClaudeSessionMessages = async ({ sessionId, laneId, @@ -31136,18 +31287,7 @@ export function createAgentChatService(args: { ...(normalizedOffset !== undefined ? { offset: normalizedOffset } : {}), ...(typeof includeSystemMessages === "boolean" ? { includeSystemMessages } : {}), }); - return messages.map((message: ClaudeSdkSessionMessage) => { - const parentToolUseId = (message as unknown as { parent_tool_use_id?: unknown }).parent_tool_use_id; - const text = extractClaudeSessionMessageText(message.message); - return { - type: message.type, - uuid: message.uuid, - sessionId: message.session_id, - parentToolUseId: typeof parentToolUseId === "string" ? parentToolUseId : null, - message: message.message, - ...(text ? { text } : {}), - }; - }); + return messages.map(mapClaudeSdkSessionMessage); }; /** @@ -31444,6 +31584,48 @@ export function createAgentChatService(args: { ): AgentChatSubagentTranscriptMessage[] => keepNewestWithinCharBudget(messages, SUBAGENT_TRANSCRIPT_RESPONSE_MAX_CHARS, safeJsonChars); + const getMainTranscript = async ({ + sessionId, + limit, + offset, + }: AgentChatMainTranscriptArgs): Promise => { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId.length) throw new Error("sessionId is required."); + const row = sessionService.get(normalizedSessionId); + if (!row || !isChatToolType(row.toolType)) return null; + + const managed = managedSessions.get(normalizedSessionId) ?? null; + const persisted = readPersistedState(normalizedSessionId); + const provider = managed?.session.provider ?? persisted?.provider ?? providerFromToolType(row.toolType); + if (provider !== "claude") return null; + + const pointer = getClaudeSessionPointerForChat(normalizedSessionId); + const claudeSessionId = ( + (managed?.runtime?.kind === "claude" ? managed.runtime.sdkSessionId : null) + ?? persisted?.sdkSessionId + ?? pointer?.sessionId + ?? "" + ).trim(); + if (!claudeSessionId) return null; + + const normalizedLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0 + ? Math.min(Math.trunc(limit), 500) + : undefined; + const normalizedOffset = typeof offset === "number" && Number.isFinite(offset) && offset > 0 + ? Math.trunc(offset) + : undefined; + const laneFallback = managed?.laneWorktreePath + ? { dir: managed.laneWorktreePath } + : await resolveClaudeSessionLaneFallback(row.laneId); + const messages = await getClaudeSdkSessionMessages(claudeSessionId, { + ...(laneFallback.dir ? { dir: laneFallback.dir } : {}), + ...(normalizedLimit !== undefined ? { limit: normalizedLimit } : {}), + ...(normalizedOffset !== undefined ? { offset: normalizedOffset } : {}), + includeSystemMessages: true, + }); + return boundSubagentTranscriptResponse(messages.map(mapClaudeSdkSessionMessage)); + }; + function mergeSubagentTranscriptMessages( left: AgentChatSubagentTranscriptMessage[], right: AgentChatSubagentTranscriptMessage[], @@ -31900,7 +32082,7 @@ export function createAgentChatService(args: { if (runtime.warmupDone) { await runtime.warmupDone.catch(() => undefined); } - const sessionQuery = ensureClaudeQuery(managed, runtime); + const sessionQuery = await ensureClaudeQuery(managed, runtime); return sessionQuery; }; @@ -32828,7 +33010,9 @@ export function createAgentChatService(args: { sessionState: (sessionId) => { const row = sessionService.get(sessionId); if (!row) return "missing"; - return row.archivedAt ? "archived" : "active"; + if (row.archivedAt) return "archived"; + if (row.status === "running" || row.status === "detached") return "active"; + return "ended"; }, fire: async (schedule, context) => { const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString(); @@ -32885,7 +33069,9 @@ export function createAgentChatService(args: { const row = sessionService.get(schedule.sessionId); if (!row || row.archivedAt) return; const managed = managedSessions.get(schedule.sessionId) - ?? (status === "fired" ? ensureManagedSession(schedule.sessionId) : null); + ?? (status === "fired" && (row.status === "running" || row.status === "detached") + ? ensureManagedSession(schedule.sessionId) + : null); if (!managed || managed.session.provider !== "claude") return; const eventStatus: ScheduledWorkEvent["status"] = status === "done" ? "completed" : status; @@ -32936,6 +33122,7 @@ export function createAgentChatService(args: { messageSession, setScheduledWorkPaused, refreshScheduledWork, + pendingNativeScheduledWakeCountForTesting, readTranscript, setOrchestrationFields, getCodexGoal, @@ -32974,6 +33161,7 @@ export function createAgentChatService(args: { listClaudeSessions, getClaudeSessionInfo, getClaudeSessionMessages, + getMainTranscript, getSubagentTranscript, killDroidWorker, getContextUsage, diff --git a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts new file mode 100644 index 000000000..0df7c4e96 --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts @@ -0,0 +1,249 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + repairSplicedEnvelopeFileSync, + repairSplicedEnvelopeLines, +} from "./chatEnvelopeSpliceRepair"; + +const textEnvelope = ( + text: string, + messageId: string, + options: { turnId?: string; sequence?: number; sessionId?: string } = {}, +) => JSON.stringify({ + sessionId: options.sessionId ?? "chat-1", + timestamp: "2026-07-10T12:00:00.000Z", + sequence: options.sequence ?? 1, + event: { + type: "text", + text, + messageId, + turnId: options.turnId ?? "turn-1", + }, +}); + +const eventEnvelope = (event: Record, sequence: number) => JSON.stringify({ + sessionId: "chat-1", + timestamp: "2026-07-10T12:00:00.000Z", + sequence, + event, +}); + +const assistantMessage = (id: string, text: string): SessionMessage => ({ + type: "assistant", + uuid: `wire-${id}`, + session_id: "sdk-1", + parent_tool_use_id: null, + message: { id, role: "assistant", content: [{ type: "text", text }] }, +} as SessionMessage); + +const splicedRun = (turnId = "turn-1", sequenceStart = 1) => [ + textEnvelope("Hello", "wire-1", { turnId, sequence: sequenceStart }), + textEnvelope(" ", "wire-2", { turnId, sequence: sequenceStart + 1 }), + textEnvelope("from", "wire-3", { turnId, sequence: sequenceStart + 2 }), + textEnvelope(" the", "wire-4", { turnId, sequence: sequenceStart + 3 }), + textEnvelope(" SDK", "wire-5", { turnId, sequence: sequenceStart + 4 }), +]; + +describe("repairSplicedEnvelopeLines", () => { + it("rebuilds five distinct fragments as one SDK-backed message", () => { + const input = splicedRun(); + const result = repairSplicedEnvelopeLines(input, [assistantMessage("msg-stable", "Hello from the SDK")]); + + expect(result.changed).toBe(true); + expect(result.repairedTurns).toBe(1); + expect(result.lines).toHaveLength(1); + expect(JSON.parse(result.lines[0]!)).toMatchObject({ + sequence: 1, + event: { type: "text", text: "Hello from the SDK", messageId: "msg-stable", turnId: "turn-1" }, + }); + }); + + it("leaves healthy fragments sharing one message id byte-identical", () => { + const input = [ + textEnvelope("one", "msg-stable", { sequence: 1 }), + textEnvelope("two", "msg-stable", { sequence: 2 }), + textEnvelope("three", "msg-stable", { sequence: 3 }), + ]; + expect(repairSplicedEnvelopeLines(input, [])).toEqual({ + lines: input, + changed: false, + repairedTurns: 0, + }); + }); + + it("does not join separate assistant messages across a tool call", () => { + const tool = eventEnvelope({ type: "tool_call", tool: "Read", args: {}, itemId: "tool-1", turnId: "turn-1" }, 2); + const input = [ + textEnvelope("first", "msg-1", { sequence: 1 }), + tool, + textEnvelope("second", "msg-2", { sequence: 3 }), + ]; + expect(repairSplicedEnvelopeLines(input, [])).toEqual({ + lines: input, + changed: false, + repairedTurns: 0, + }); + }); + + it("leaves a two-fragment run below the detector threshold untouched", () => { + const input = [ + textEnvelope("one", "wire-1", { sequence: 1 }), + textEnvelope("two", "wire-2", { sequence: 2 }), + ]; + expect(repairSplicedEnvelopeLines(input, [])).toEqual({ + lines: input, + changed: false, + repairedTurns: 0, + }); + }); + + it("preserves ADE-only and unparseable lines byte-identically around repaired runs", () => { + const before = eventEnvelope({ type: "approval_request", itemId: "approval-1", kind: "tool_call", description: "Allow?" }, 1); + const between = eventEnvelope({ type: "scheduled_work_update", id: "schedule-1", kind: "wakeup", status: "scheduled", origin: "schedule_wakeup", title: "Later" }, 8); + const unknown = "{ definitely-not-json"; + const input = [ + before, + ...splicedRun("turn-1", 2), + between, + unknown, + ...splicedRun("turn-2", 9), + ]; + const result = repairSplicedEnvelopeLines(input, []); + + expect(result.changed).toBe(true); + expect(result.repairedTurns).toBe(2); + expect(result.lines).toContain(before); + expect(result.lines).toContain(between); + expect(result.lines).toContain(unknown); + expect(result.lines.indexOf(before)).toBeLessThan(result.lines.indexOf(between)); + expect(result.lines.indexOf(between)).toBeLessThan(result.lines.indexOf(unknown)); + }); + + it("uses SDK stable ids and full text for each matched assistant message", () => { + const input = [ + textEnvelope("Alpha", "wire-1", { sequence: 11 }), + textEnvelope("Beta", "wire-2", { sequence: 12 }), + textEnvelope("Gamma", "wire-3", { sequence: 13 }), + ]; + const result = repairSplicedEnvelopeLines(input, [ + assistantMessage("msg-a", "AlphaBeta"), + assistantMessage("msg-b", "Gamma"), + ]); + + expect(result.lines.map((line) => JSON.parse(line).event)).toEqual([ + expect.objectContaining({ text: "AlphaBeta", messageId: "msg-a" }), + expect.objectContaining({ text: "Gamma", messageId: "msg-b" }), + ]); + expect(result.lines.map((line) => JSON.parse(line).sequence)).toEqual([11, 12]); + }); + + it("rebuilds three SDK assistant messages without breaking idempotency", () => { + const input = [ + textEnvelope("Alpha", "wire-1", { sequence: 21 }), + textEnvelope("Beta", "wire-2", { sequence: 22 }), + textEnvelope("Gamma", "wire-3", { sequence: 23 }), + ]; + const sdkMessages = [ + assistantMessage("msg-a", "Alpha"), + assistantMessage("msg-b", "Beta"), + assistantMessage("msg-c", "Gamma"), + ]; + const once = repairSplicedEnvelopeLines(input, sdkMessages); + const twice = repairSplicedEnvelopeLines(once.lines, sdkMessages); + + expect(once.lines.map((line) => JSON.parse(line).event.messageId)).toEqual(["msg-a", "msg-b", "msg-c"]); + expect(twice).toEqual({ lines: once.lines, changed: false, repairedTurns: 0 }); + }); + + it("falls back to a local merge when SDK text extends beyond the run", () => { + // The SDK message is a superset ("Hello from the SDK, and more") of the + // run's text. Rebuilding from it would splice content the ADE transcript + // never showed into history — the repair must keep the exact ADE text. + const input = splicedRun(); + const result = repairSplicedEnvelopeLines(input, [assistantMessage("msg-super", "Hello from the SDK, and more")]); + expect(result.lines).toHaveLength(1); + expect(JSON.parse(result.lines[0]!).event).toMatchObject({ + text: "Hello from the SDK", + messageId: "wire-1", + }); + }); + + it("falls back to a local merge when SDK messages cover only a prefix of the run", () => { + // The SDK transcript is missing the run's tail (" the SDK"). A rebuild from + // the partial SDK match would silently drop that tail — the repair must + // prefer the lossless local merge instead. + const input = splicedRun(); + const result = repairSplicedEnvelopeLines(input, [assistantMessage("msg-prefix", "Hello from")]); + expect(result.lines).toHaveLength(1); + expect(JSON.parse(result.lines[0]!).event).toMatchObject({ + text: "Hello from the SDK", + messageId: "wire-1", + }); + }); + + it("falls back to a local merge when SDK text does not overlap", () => { + const input = splicedRun(); + const result = repairSplicedEnvelopeLines(input, [assistantMessage("msg-other", "Unrelated")]); + expect(result.lines).toHaveLength(1); + expect(JSON.parse(result.lines[0]!).event).toMatchObject({ + text: "Hello from the SDK", + messageId: "wire-1", + }); + }); + + it("is idempotent and byte-identical on the second pass", () => { + const once = repairSplicedEnvelopeLines(splicedRun(), [assistantMessage("msg-stable", "Hello from the SDK")]); + const twice = repairSplicedEnvelopeLines(once.lines, [assistantMessage("msg-stable", "Hello from the SDK")]); + expect(twice.changed).toBe(false); + expect(twice.repairedTurns).toBe(0); + expect(twice.lines).toEqual(once.lines); + }); +}); + +describe("repairSplicedEnvelopeFileSync", () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "chat-envelope-splice-")); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("atomically rewrites and keeps the original one-time backup", () => { + const filePath = path.join(directory, "chat.jsonl"); + const original = `${splicedRun().join("\n")}\n`; + fs.writeFileSync(filePath, original, "utf8"); + + expect(repairSplicedEnvelopeFileSync(filePath, [assistantMessage("msg-stable", "Hello from the SDK")])) + .toEqual({ changed: true, repairedTurns: 1 }); + expect(fs.readFileSync(`${filePath}.splice.bak`, "utf8")).toBe(original); + expect(fs.readFileSync(filePath, "utf8").endsWith("\n")).toBe(true); + expect(fs.readdirSync(directory).some((name) => name.includes(".tmp"))).toBe(false); + + const firstBackup = fs.readFileSync(`${filePath}.splice.bak`, "utf8"); + fs.writeFileSync(filePath, original.replaceAll("wire-", "again-"), "utf8"); + expect(repairSplicedEnvelopeFileSync(filePath, [])).toEqual({ changed: true, repairedTurns: 1 }); + expect(fs.readFileSync(`${filePath}.splice.bak`, "utf8")).toBe(firstBackup); + }); + + it("skips a file larger than 64 MB", () => { + const filePath = path.join(directory, "oversize.jsonl"); + fs.writeFileSync(filePath, "", "utf8"); + fs.truncateSync(filePath, 64 * 1024 * 1024 + 1); + const skips: string[] = []; + expect(repairSplicedEnvelopeFileSync(filePath, [], { onSkip: (reason) => skips.push(reason) })) + .toEqual({ changed: false, repairedTurns: 0 }); + expect(skips).toEqual(["oversize"]); + expect(fs.existsSync(`${filePath}.splice.bak`)).toBe(false); + }); + + it("returns a no-op for a missing file", () => { + expect(repairSplicedEnvelopeFileSync(path.join(directory, "missing.jsonl"), [])) + .toEqual({ changed: false, repairedTurns: 0 }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts new file mode 100644 index 000000000..474804747 --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts @@ -0,0 +1,229 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; + +const MAX_ENVELOPE_REPAIR_BYTES = 64 * 1024 * 1024; + +export type ChatEnvelopeSpliceRepairResult = { + changed: boolean; + repairedTurns: number; +}; + +const NO_REPAIR: ChatEnvelopeSpliceRepairResult = { changed: false, repairedTurns: 0 }; + +type ParsedTextEnvelope = { + envelope: AgentChatEventEnvelope; + messageId: string; + text: string; + turnId: string; +}; + +type SdkAssistantMessage = { + id: string; + text: string; + normalizedText: string; +}; + +function parseTextEnvelope(line: string): ParsedTextEnvelope | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const envelope = parsed as AgentChatEventEnvelope; + if (!envelope.event || envelope.event.type !== "text") return null; + const turnId = envelope.event.turnId?.trim() ?? ""; + const messageId = envelope.event.messageId?.trim() ?? ""; + if (!turnId || !messageId || typeof envelope.event.text !== "string") return null; + return { envelope, messageId, text: envelope.event.text, turnId }; +} + +function normalizeText(value: string): string { + return value.normalize("NFKC").replace(/\s+/g, ""); +} + +function extractSdkMessageId(message: SessionMessage): string | null { + const payload = message.message; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const id = (payload as { id?: unknown }).id; + return typeof id === "string" && id.trim().length ? id.trim() : null; +} + +function extractSdkAssistantText(message: SessionMessage): string { + const payload = message.message; + if (typeof payload === "string") return payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return ""; + const content = (payload as { content?: unknown }).content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.flatMap((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) return []; + const record = block as { type?: unknown; text?: unknown }; + return record.type === "text" && typeof record.text === "string" ? [record.text] : []; + }).join(""); +} + +function sdkAssistantMessages(messages: SessionMessage[]): SdkAssistantMessage[] { + return messages.flatMap((message) => { + if (message.type !== "assistant") return []; + const id = extractSdkMessageId(message); + const text = extractSdkAssistantText(message); + const normalizedText = normalizeText(text); + return id && normalizedText ? [{ id, text, normalizedText }] : []; + }); +} + +function findSdkMatch( + runText: string, + messages: SdkAssistantMessage[], + startAt: number, +): { messages: SdkAssistantMessage[]; nextIndex: number } | null { + const normalizedRun = normalizeText(runText); + if (!normalizedRun) return null; + + for (let start = startAt; start < messages.length; start += 1) { + let combined = ""; + for (let end = start; end < messages.length; end += 1) { + combined = normalizeText(`${combined}${messages[end]!.normalizedText}`); + // Only an EXACT match may rebuild. A superset match (SDK text merely + // starting with the run) could splice content the ADE transcript never + // showed into history; a partial match (SDK covering only a prefix of + // the run) would drop the run's uncovered tail. Both fall back to the + // caller's lossless local merge instead. + if (combined === normalizedRun) { + return { messages: messages.slice(start, end + 1), nextIndex: end + 1 }; + } + if (normalizedRun.startsWith(combined)) continue; + break; + } + } + return null; +} + +function rebuiltLine( + first: AgentChatEventEnvelope, + messageId: string, + text: string, + sequenceOffset = 0, +): string { + const event = first.event.type === "text" + ? { ...first.event, text, messageId } + : first.event; + const envelope: AgentChatEventEnvelope = { + ...first, + event, + ...(typeof first.sequence === "number" ? { sequence: first.sequence + sequenceOffset } : {}), + }; + return JSON.stringify(envelope); +} + +/** + * Repair the historical Claude stream-fragment splice signature in ADE JSONL. + * Unknown/non-envelope lines and every non-text envelope are retained verbatim. + */ +export function repairSplicedEnvelopeLines( + lines: string[], + sdkMessages: SessionMessage[], +): { lines: string[]; changed: boolean; repairedTurns: number } { + const assistants = sdkAssistantMessages(sdkMessages); + const output: string[] = []; + let sdkCursor = 0; + const repairedTurnIds = new Set(); + + for (let index = 0; index < lines.length;) { + const first = parseTextEnvelope(lines[index] ?? ""); + if (!first) { + output.push(lines[index] ?? ""); + index += 1; + continue; + } + + const run: ParsedTextEnvelope[] = [first]; + let nextIndex = index + 1; + while (nextIndex < lines.length) { + const next = parseTextEnvelope(lines[nextIndex] ?? ""); + if (!next || next.turnId !== first.turnId) break; + run.push(next); + nextIndex += 1; + } + + const distinctIds = new Set(run.map((entry) => entry.messageId)); + const detected = run.length >= 3 && distinctIds.size === run.length; + if (!detected) { + output.push(...lines.slice(index, nextIndex)); + index = nextIndex; + continue; + } + + const runText = run.map((entry) => entry.text).join(""); + const sdkMatch = findSdkMatch(runText, assistants, sdkCursor); + if (sdkMatch) { + output.push(...sdkMatch.messages.map((message, offset) => + rebuiltLine(first.envelope, message.id, message.text, offset))); + sdkCursor = sdkMatch.nextIndex; + } else { + output.push(rebuiltLine(first.envelope, first.messageId, runText)); + } + repairedTurnIds.add(first.turnId); + index = nextIndex; + } + + const changed = output.length !== lines.length + || output.some((line, index) => line !== lines[index]); + + return { + lines: changed ? output : lines.slice(), + changed, + repairedTurns: changed ? repairedTurnIds.size : 0, + }; +} + +/** Bounded, best-effort, atomic file wrapper. Never throws. */ +export function repairSplicedEnvelopeFileSync( + filePath: string, + sdkMessages: SessionMessage[], + options?: { onSkip?: (reason: "oversize" | "read_failed" | "write_failed", detail?: unknown) => void }, +): ChatEnvelopeSpliceRepairResult { + try { + if (!filePath || !fs.existsSync(filePath)) return NO_REPAIR; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return NO_REPAIR; + if (stat.size > MAX_ENVELOPE_REPAIR_BYTES) { + options?.onSkip?.("oversize", stat.size); + return NO_REPAIR; + } + const raw = fs.readFileSync(filePath, "utf8"); + const repaired = repairSplicedEnvelopeLines(raw.split("\n"), sdkMessages); + if (!repaired.changed) return NO_REPAIR; + + const backupPath = `${filePath}.splice.bak`; + if (!fs.existsSync(backupPath)) { + try { + fs.copyFileSync(filePath, backupPath); + } catch { + // Backup is best-effort; the atomic replacement remains safe. + } + } + + const tempPath = `${filePath}.splice-${process.pid}-${randomUUID()}.tmp`; + try { + fs.writeFileSync(tempPath, repaired.lines.join("\n"), "utf8"); + fs.renameSync(tempPath, filePath); + } catch (error) { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup failure. + } + options?.onSkip?.("write_failed", error); + return NO_REPAIR; + } + return { changed: true, repairedTurns: repaired.repairedTurns }; + } catch (error) { + options?.onSkip?.("read_failed", error); + return NO_REPAIR; + } +} diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts index 54847b7aa..ccdcf2ba9 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts @@ -49,6 +49,115 @@ afterEach(() => { }); describe("createChatScheduledWorkScheduler", () => { + it("cancels persisted work for an ended session during start reconciliation", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = storedState([wakeup()]); + const transitions: string[] = []; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => "ended", + fire, + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fire).not.toHaveBeenCalled(); + expect(transitions).toEqual(["cancelled"]); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + id: "wake-1", + status: "cancelled", + })); + scheduler.dispose(); + }); + + it("cancels work when its session becomes ended before processDue fires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let sessionState: "active" | "ended" = "active"; + let state: ChatScheduledWorkState | null = null; + const transitions: string[] = []; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => sessionState, + fire, + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + await scheduler.upsert(wakeup({ fireAt: START + 1_000 })); + + sessionState = "ended"; + await vi.advanceTimersByTimeAsync(1_000); + + expect(fire).not.toHaveBeenCalled(); + expect(transitions).toEqual(["scheduled", "cancelled"]); + expect(requireState(state).schedules[0]?.status).toBe("cancelled"); + scheduler.dispose(); + }); + + it("does not let an ended session claim a native scheduled fire", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let sessionState: "active" | "ended" = "active"; + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => sessionState, + fire: createFireMock(), + }); + await scheduler.upsert(wakeup({ fireAt: START })); + + sessionState = "ended"; + const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended"); + + expect(claimed).toBeNull(); + expect(scheduler.list("session-1")[0]).toEqual(expect.objectContaining({ + id: "wake-1", + status: "scheduled", + })); + scheduler.dispose(); + }); + + it("persists a new schedule as cancelled when its session is ended", async () => { + let state: ChatScheduledWorkState | null = null; + const transitions: string[] = []; + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => "ended", + fire: createFireMock(), + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + + const schedule = await scheduler.upsert(wakeup()); + + expect(schedule.status).toBe("cancelled"); + expect(transitions).toEqual(["cancelled"]); + expect(requireState(state).schedules[0]?.status).toBe("cancelled"); + scheduler.dispose(); + }); + it("persists an arm and re-arms it in a fresh scheduler after restart", async () => { vi.useFakeTimers(); vi.setSystemTime(START); diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index 906ecf6eb..8ca7ba2dc 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -55,7 +55,7 @@ export type ChatScheduledWorkSchedulerOptions = { now?: () => number; timers?: ChatScheduledWorkTimerApi; isGlobalPaused(): boolean; - sessionState(sessionId: string): "active" | "archived" | "missing"; + sessionState(sessionId: string): "active" | "ended" | "archived" | "missing"; fire(schedule: ChatScheduledWorkRecord, context: { late: boolean }): Promise; onTransition?: ( schedule: ChatScheduledWorkRecord, diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts index f0df4a914..1fb242940 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts @@ -144,6 +144,66 @@ describe("createClaudeSubprocessReaper", () => { expect(child.killedWith).toEqual(["SIGTERM", "SIGKILL"]); }); + it("reapForSession terminates only the matching session's live subprocesses", () => { + vi.useFakeTimers(); + const logger = createLogger(); + const matchingChild = createProcess(2469); + const otherChild = createProcess(2470); + const reaper = createClaudeSubprocessReaper({ + logger, + killGraceMs: 25, + }); + reaper.register(matchingChild, { + sessionId: "chat-matching", + laneId: "lane-1", + cwd: "/tmp/lane-1", + }, "claude", []); + reaper.register(otherChild, { + sessionId: "chat-other", + laneId: "lane-2", + cwd: "/tmp/lane-2", + }, "claude", []); + + reaper.reapForSession("chat-matching", "ended_session"); + + expect(matchingChild.killedWith).toEqual(["SIGTERM"]); + expect(otherChild.killedWith).toEqual([]); + expect(reaper.liveRecords().map((record) => record.sessionId)).toEqual([ + "chat-matching", + "chat-other", + ]); + + vi.advanceTimersByTime(25); + + expect(matchingChild.killedWith).toEqual(["SIGTERM", "SIGKILL"]); + expect(otherChild.killedWith).toEqual([]); + }); + + it("escalates to SIGKILL for a hung child even though killed=true after SIGTERM", () => { + // Node sets child.killed as soon as ANY signal is delivered — it does NOT + // mean the process exited. A hung child must still get the escalation. + vi.useFakeTimers(); + const logger = createLogger(); + const child = createProcess(9911); + child.kill = vi.fn((signal: NodeJS.Signals) => { + child.killedWith.push(signal); + child.killed = true; // Node-faithful: true after the first delivered signal. + return true; + }); + const reaper = createClaudeSubprocessReaper({ logger, killGraceMs: 25 }); + reaper.register(child, { + sessionId: "chat-hung", + laneId: "lane-1", + cwd: "/tmp/lane-1", + }, "claude", []); + + reaper.reapForSession("chat-hung", "ended_session"); + expect(child.killedWith).toEqual(["SIGTERM"]); + + vi.advanceTimersByTime(25); + expect(child.killedWith).toEqual(["SIGTERM", "SIGKILL"]); + }); + it("reaps subprocesses left behind by a crashed ADE owner", () => { vi.useFakeTimers(); const logger = createLogger(); diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts index 9dcb5c0bc..3e164a710 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts @@ -22,6 +22,11 @@ export type ClaudeSubprocessRecord = ClaudeSubprocessMetadata & { }; type ClaudeChildProcess = ChildProcessByStdio; +type LiveClaudeSubprocess = { + record: ClaudeSubprocessRecord; + process: SpawnedProcess; + killTimer: ReturnType | null; +}; export type ClaudeSubprocessReaper = ReturnType; @@ -43,7 +48,7 @@ export function createClaudeSubprocessReaper(args: { ? null : args.registryPath ?? path.join(os.tmpdir(), "ade-claude-subprocesses.json"); const processKill = args.processKill ?? ((pid: number, signal?: NodeJS.Signals | 0) => process.kill(pid, signal as NodeJS.Signals | undefined)); - const live = new Map | null }>(); + const live = new Map(); const readRegistry = (): ClaudeSubprocessRecord[] => { if (!registryPath || !fs.existsSync(registryPath)) return []; @@ -226,43 +231,61 @@ export function createClaudeSubprocessReaper(args: { return child; }; - const reapAll = (reason: string): void => { - for (const [pid, entry] of live) { - const child = entry.process; - if (!child.killed && child.exitCode === null) { - logger.warn("agent_chat.claude_subprocess_terminate", { + const terminateLiveEntry = ( + pid: number, + entry: LiveClaudeSubprocess, + reason: string, + ): void => { + const child = entry.process; + // `child.killed` only means "a signal was sent", not "the process exited" — + // gate on exit/signal codes so a hung child still gets the SIGKILL escalation. + const exited = () => child.exitCode !== null || (child as { signalCode?: string | null }).signalCode != null; + if (exited() || entry.killTimer) return; + logger.warn("agent_chat.claude_subprocess_terminate", { + pid, + sessionId: entry.record.sessionId, + reason, + }); + try { + child.kill("SIGTERM"); + } catch { + // Best effort; the process may already be gone. + } + entry.killTimer = setTimer(() => { + if (!exited()) { + logger.warn("agent_chat.claude_subprocess_kill", { pid, sessionId: entry.record.sessionId, reason, }); try { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } catch { // Best effort; the process may already be gone. } - entry.killTimer = setTimer(() => { - if (!child.killed && child.exitCode === null) { - logger.warn("agent_chat.claude_subprocess_kill", { - pid, - sessionId: entry.record.sessionId, - reason, - }); - try { - child.kill("SIGKILL"); - } catch { - // Best effort; the process may already be gone. - } - } - removeRegistryPid(pid); - }, killGraceMs); - entry.killTimer.unref?.(); } + removeRegistryPid(pid); + }, killGraceMs); + entry.killTimer.unref?.(); + }; + + const reapForSession = (sessionId: string, reason: string): void => { + for (const [pid, entry] of live) { + if (entry.record.sessionId !== sessionId) continue; + terminateLiveEntry(pid, entry, reason); + } + }; + + const reapAll = (reason: string): void => { + for (const [pid, entry] of live) { + terminateLiveEntry(pid, entry, reason); } }; return { register, spawnClaudeCodeProcess, + reapForSession, reapAll, reapStaleRegistry, liveRecords: (): ClaudeSubprocessRecord[] => [...live.values()].map((entry) => ({ ...entry.record })), diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c78fca4a9..c1c632a3c 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -259,6 +259,7 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatMainTranscriptArgs, AgentChatSubagentTranscriptArgs, AgentChatSubagentTranscriptMessage, AgentChatClaudeOutputStyle, @@ -979,6 +980,7 @@ function projectChatOntoSession( const base: TerminalSessionSummary = { ...session, nextWakeAt: chat.nextWakeAt, + ...(chat.claudeTag !== undefined ? { claudeTag: chat.claudeTag } : {}), ...(chat.orchestrationRunId ? { orchestrationRunId: chat.orchestrationRunId, @@ -6339,6 +6341,14 @@ export function registerIpc({ return ctx.agentChatService.getClaudeSessionMessages(arg); }); + ipcMain.handle(IPC.agentChatGetMainTranscript, async (_event, arg: AgentChatMainTranscriptArgs): Promise => { + if (!arg || typeof arg.sessionId !== "string" || !arg.sessionId.trim().length) { + throw new Error("sessionId is required."); + } + const ctx = ensureAgentChatContext(); + return ctx.agentChatService.getMainTranscript(arg); + }); + ipcMain.handle(IPC.agentChatGetSubagentTranscript, async (_event, arg: AgentChatSubagentTranscriptArgs): Promise => { const ctx = ensureAgentChatContext(); return ctx.agentChatService.getSubagentTranscript(arg); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index e253ffafe..a00dfe6a1 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1829,6 +1829,40 @@ describe("registerIpc sync bridge", () => { expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 25 }); }); + it("validates and forwards main transcript requests", async () => { + const transcript = [{ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }]; + const getMainTranscript = vi.fn(async () => transcript); + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }, + agentChatService: { getMainTranscript }, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + await expect( + ipcHandlers.get(IPC.agentChatGetMainTranscript)?.( + eventForSender(), + { sessionId: " " }, + ), + ).rejects.toThrow("sessionId is required"); + expect(getMainTranscript).not.toHaveBeenCalled(); + + const args = { sessionId: " chat-1 ", limit: 100, offset: 5 }; + await expect( + ipcHandlers.get(IPC.agentChatGetMainTranscript)?.(eventForSender(), args), + ).resolves.toBe(transcript); + expect(getMainTranscript).toHaveBeenCalledWith(args); + }); + it("disposes a live terminal runtime before deleting the session", async () => { const terminalSession = { id: "terminal-1", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 1accb8f22..a53a142d4 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -138,6 +138,7 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatMainTranscriptArgs, AgentChatSubagentTranscriptArgs, AgentChatSubagentTranscriptMessage, AgentChatContextUsage, @@ -1352,6 +1353,9 @@ declare global { getClaudeSessionMessages: ( args: AgentChatClaudeSessionMessagesArgs, ) => Promise; + getMainTranscript: ( + args: AgentChatMainTranscriptArgs, + ) => Promise; getSubagentTranscript: ( args: AgentChatSubagentTranscriptArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 714b55c18..cd25c5a6b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -314,6 +314,7 @@ import type { AgentChatClaudeSessionListArgs, AgentChatClaudeSessionMessage, AgentChatClaudeSessionMessagesArgs, + AgentChatMainTranscriptArgs, AgentChatSubagentTranscriptArgs, AgentChatSubagentTranscriptMessage, AgentChatContextUsage, @@ -5364,6 +5365,12 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("chat", "getClaudeSessionMessages", { args }, () => ipcRenderer.invoke(IPC.agentChatGetClaudeSessionMessages, args), ), + getMainTranscript: async ( + args: AgentChatMainTranscriptArgs, + ): Promise => + callProjectRuntimeActionOr("chat", "getMainTranscript", { args }, () => + ipcRenderer.invoke(IPC.agentChatGetMainTranscript, args), + ), getSubagentTranscript: async ( args: AgentChatSubagentTranscriptArgs, ): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index b91629fd1..ee746673d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4741,6 +4741,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { listClaudeSessions: resolvedArg([]), getClaudeSessionInfo: resolvedArg(null), getClaudeSessionMessages: resolvedArg([]), + getMainTranscript: resolvedArg(null), getSubagentTranscript: resolvedArg(null), getContextUsage: resolvedArg(null), rewindFiles: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 3d0e6decc..8225256eb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -434,6 +434,63 @@ describe("AgentChatMessageList transcript rendering", () => { }); }); + it("copies a multi-block assistant turn from the last text row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "First block.", itemId: "text-1", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { type: "text", text: "Second block.", itemId: "text-2", turnId: "turn-1" }, + }, + ]); + + expect(screen.getAllByRole("button", { name: "Copy message" })).toHaveLength(2); + const turnButton = screen.getByRole("button", { name: "Copy whole turn" }); + fireEvent.click(turnButton); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("First block.\n\nSecond block.")); + }); + + it("does not add turn-copy chrome for single-block or legacy null-turn text", () => { + const { rerender } = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "One block.", itemId: "text-1", turnId: "turn-1" }, + }, + ]); + expect(screen.queryByRole("button", { name: "Copy whole turn" })).toBeNull(); + + rerender( + + + , + ); + expect(screen.queryByRole("button", { name: "Copy whole turn" })).toBeNull(); + }); + it("copies assistant code blocks from the transcript", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 1aaf6311c..65ca71ee6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -287,6 +287,30 @@ function getEventTurnId(event: AgentChatEvent): string | null { return turnId.length ? turnId : null; } +export type AssistantTurnCopyInfo = { + text: string; + lastTextEventKey: string; + textEventCount: number; +}; + +export function deriveAssistantTurnCopyMap( + rows: readonly TranscriptRenderEnvelope[], +): Map { + const result = new Map(); + for (const row of rows) { + if (row.event.type !== "text") continue; + const turnId = getEventTurnId(row.event); + if (!turnId) continue; + const existing = result.get(turnId); + result.set(turnId, { + text: existing ? `${existing.text}\n\n${row.event.text}` : row.event.text, + lastTextEventKey: row.key, + textEventCount: (existing?.textEventCount ?? 0) + 1, + }); + } + return result; +} + function basenamePathLabel(value: string): string { const normalized = normalizePath(value); const basename = normalized.split("/").pop()?.trim(); @@ -622,9 +646,13 @@ type RenderEnvelope = { function MessageCopyButton({ value, className, + label = "Copy", + title = "Copy message", }: { value: string; className?: string; + label?: string; + title?: string; }) { const [copied, setCopied] = useState(false); const mountedRef = useRef(true); @@ -673,11 +701,11 @@ function MessageCopyButton({ className, )} onClick={handleCopy} - title={copied ? "Copied" : "Copy message"} - aria-label={copied ? "Copied" : "Copy message"} + title={copied ? "Copied" : title} + aria-label={copied ? "Copied" : title} > {copied ? : } - {copied ? "Copied" : "Copy"} + {copied ? "Copied" : label} ); } @@ -2626,6 +2654,7 @@ function renderEvent( mosaic?: MosaicRenderContext; /** Scroll a row into view by its stable render key (subagent jump affordances). */ onScrollToRowKey?: (rowKey: string) => void; + assistantTurnCopy?: { text: string } | null; } ) { const event = envelope.event; @@ -2768,8 +2797,15 @@ function renderEvent( > {/* Unbubbled assistant prose — plain markdown on the flat canvas (Codex/t3 reference). */}
-
+
+ {options?.assistantTurnCopy ? ( + + ) : null}
@@ -4129,6 +4165,7 @@ type EventRowProps = { mosaic?: MosaicRenderContext; anchored?: boolean; onScrollToRowKey?: (rowKey: string) => void; + assistantTurnCopy?: { text: string } | null; }; const EventRow = React.memo(function EventRow({ @@ -4161,6 +4198,7 @@ const EventRow = React.memo(function EventRow({ mosaic, anchored, onScrollToRowKey, + assistantTurnCopy, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -4223,6 +4261,7 @@ const EventRow = React.memo(function EventRow({ turnDiffSummaries, mosaic, onScrollToRowKey, + assistantTurnCopy, })} {envelope.event.type === "done" ? ( { + const byRowKey = new Map(); + for (const info of deriveAssistantTurnCopyMap(rows).values()) { + if (info.textEventCount >= 2) byRowKey.set(info.lastTextEventKey, info); + } + return byRowKey; + }, [rows]); const groupedRows = useMemo(() => groupChatTranscriptRows(rows), [rows]); const groupedRowKeys = useMemo(() => groupedRows.map((row) => row.key), [groupedRows]); const prevGroupedRowKeysRef = useRef(null); @@ -5336,6 +5382,7 @@ function AgentChatMessageListMain({ const rowTurnActive = Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId) && !sessionEnded; const anchored = envelope.key === anchoredRowKey; + const assistantTurnCopy = assistantTurnCopyByRowKey.get(envelope.key) ?? null; if (virtualized) { return ( @@ -5372,6 +5419,7 @@ function AgentChatMessageListMain({ mosaic={mosaic} anchored={anchored} onScrollToRowKey={scrollToRowKey} + assistantTurnCopy={assistantTurnCopy} /> ); } @@ -5407,9 +5455,10 @@ function AgentChatMessageListMain({ mosaic={mosaic} anchored={anchored} onScrollToRowKey={scrollToRowKey} + assistantTurnCopy={assistantTurnCopy} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 57af247a5..0982b5653 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1307,6 +1307,73 @@ describe("AgentChatPane companion drawers", () => { expect(screen.queryByRole("button", { name: "Close chat actions drawer" })).toBeNull(); }); + it("swaps the main chat into the expanded Claude SDK transcript view", async () => { + const session = buildSession("session-claude-main", { + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + status: "idle", + claudeTag: "review-ready", + }); + installAdeMocks({ sessions: [session], includeClaudeModel: true }); + const getMainTranscript = vi.fn().mockResolvedValue([{ + type: "assistant", + uuid: "sdk-message-1", + sessionId: "sdk-session-1", + parentToolUseId: null, + message: { + id: "msg-stable-1", + role: "assistant", + content: [ + { type: "text", text: "Provider fidelity answer" }, + { type: "tool_use", id: "toolu-1", name: "Read", input: { file_path: "src/app.tsx" } }, + ], + }, + }]); + window.ade.agentChat.getMainTranscript = getMainTranscript as any; + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Agents" })); + fireEvent.click(await screen.findByRole("button", { name: "View full session transcript" })); + + expect(await screen.findByText("Full session transcript (SDK)")).toBeTruthy(); + expect(screen.getByText("Provider-fidelity view — ADE events (approvals, schedules, notices) are not shown.")).toBeTruthy(); + await waitFor(() => { + expect(getMainTranscript).toHaveBeenCalledWith({ sessionId: session.sessionId }); + }); + expect(await screen.findByText("Provider fidelity answer")).toBeTruthy(); + expect(screen.getByText("review-ready")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Return to chat" })).toBeTruthy(); + }); + + it("refetches selected envelope history after a repair invalidation signal", async () => { + const session = buildSession("session-repaired", { status: "idle" }); + const { emitChatEvent } = installAdeMocks({ + sessions: [session], + eventHistory: { + sessionId: session.sessionId, + events: [], + truncated: false, + sessionFound: true, + }, + }); + const getEventHistory = window.ade.agentChat.getEventHistory as ReturnType; + renderPane(session); + + await waitFor(() => expect(getEventHistory.mock.calls.length).toBeGreaterThan(0)); + const callsBeforeInvalidation = getEventHistory.mock.calls.length; + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-07-10T12:00:00.000Z", + event: { type: "session_meta_updated", historyInvalidated: true }, + }); + }); + + await waitFor(() => expect(getEventHistory.mock.calls.length).toBeGreaterThan(callsBeforeInvalidation)); + }); + it("persists split resize from the real divider on a working panel", async () => { renderDrawerPane(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index db8114943..32a3fa1a5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -3158,6 +3158,7 @@ export function AgentChatPane({ status: "running" | "completed" | "failed" | "stopped"; background: boolean; } | null>(null); + const [mainTranscriptView, setMainTranscriptView] = useState(false); const [rewindConfirmDialog, setRewindConfirmDialog] = useState(null); const [cursorCloudLaunchModeOpen, setCursorCloudLaunchModeOpen] = useState(false); const cursorCloudPanelRef = useRef(null); @@ -3781,6 +3782,9 @@ export function AgentChatPane({ const [subagentTranscriptLoading, setSubagentTranscriptLoading] = useState(false); const [subagentTranscriptUnsupported, setSubagentTranscriptUnsupported] = useState(false); const [subagentMetadata, setSubagentMetadata] = useState(null); + const [mainTranscript, setMainTranscript] = useState(null); + const [mainTranscriptLoading, setMainTranscriptLoading] = useState(false); + const [mainTranscriptUnsupported, setMainTranscriptUnsupported] = useState(false); // Drill-in (subagent takeover) view-model. Computed here, before any early // return, so the useMemo below is an unconditional hook (react-hooks rules). @@ -3826,6 +3830,19 @@ export function AgentChatPane({ subagentView, ]); + const mainTranscriptEventsForDisplay = useMemo(() => { + if (!mainTranscriptView) return EMPTY_CHAT_EVENTS; + return buildSubagentEventHistory({ + sessionId: selectedSessionId, + subagentId: selectedSessionId ?? "main", + subagentName: "Full session transcript", + prompt: null, + messages: mainTranscript, + loading: mainTranscriptLoading, + unsupported: mainTranscriptUnsupported, + }); + }, [mainTranscript, mainTranscriptLoading, mainTranscriptUnsupported, mainTranscriptView, selectedSessionId]); + useEffect(() => { if (!subagentView || !selectedSessionId) { setSubagentTranscript(null); @@ -3885,6 +3902,49 @@ export function AgentChatPane({ }; }, [subagentView, subagentViewSnapshot?.status, selectedSessionId]); + useEffect(() => { + if (!mainTranscriptView || !selectedSessionId) { + setMainTranscript(null); + setMainTranscriptLoading(false); + setMainTranscriptUnsupported(false); + return; + } + const fetchTranscript = window.ade?.agentChat?.getMainTranscript; + if (typeof fetchTranscript !== "function") { + setMainTranscript(null); + setMainTranscriptUnsupported(true); + return; + } + let cancelled = false; + const tick = async () => { + try { + setMainTranscriptLoading(true); + const result = await fetchTranscript({ sessionId: selectedSessionId }); + if (cancelled) return; + setMainTranscriptUnsupported(result === null); + setMainTranscript(result); + } catch (error) { + // eslint-disable-next-line no-console + console.error("agentChat.getMainTranscript failed", error); + if (!cancelled) setMainTranscript([]); + } finally { + if (!cancelled) setMainTranscriptLoading(false); + } + }; + void tick(); + const intervalId = selectedSession?.status === "active" + ? window.setInterval(() => { void tick(); }, 1500) + : null; + return () => { + cancelled = true; + if (intervalId !== null) window.clearInterval(intervalId); + }; + }, [mainTranscriptView, selectedSession?.status, selectedSessionId]); + + useEffect(() => { + setMainTranscriptView(false); + }, [selectedSessionId]); + useEffect(() => { if (subagentView && !chatActionsOpen) { setSubagentView(null); @@ -3918,6 +3978,7 @@ export function AgentChatPane({ status: snapshot.status, background: snapshot.background ?? false, }); + setMainTranscriptView(false); }; window.addEventListener("ade:chat:open-info", handler); return () => window.removeEventListener("ade:chat:open-info", handler); @@ -4716,8 +4777,11 @@ export function AgentChatPane({ tone: "muted", }); } + if (selectedSession?.claudeTag?.trim()) { + chips.push({ label: selectedSession.claudeTag.trim(), tone: "muted" }); + } return chips; - }, [resolvedChips, selectedSessionImportedProvider]); + }, [resolvedChips, selectedSession?.claudeTag, selectedSessionImportedProvider]); // Keep configured models selectable unless a caller explicitly constrains // this surface. Unconstrained sessions keep their active model visible even @@ -6113,8 +6177,12 @@ export function AgentChatPane({ // chat event since it doesn't represent transcript content. if (envelope.event.type === "session_meta_updated") { const meta = envelope.event; + if (meta.historyInvalidated === true && envelope.sessionId === selectedSessionIdRef.current) { + void loadHistory(envelope.sessionId, { force: true }); + } const summaryPatch: Partial = {}; if (typeof meta.title === "string" && meta.title.length > 0) summaryPatch.title = meta.title; + if (meta.claudeTag !== undefined) summaryPatch.claudeTag = meta.claudeTag; if (meta.permissionMode !== undefined) summaryPatch.permissionMode = meta.permissionMode; if (meta.interactionMode !== undefined) summaryPatch.interactionMode = meta.interactionMode; if (meta.claudePermissionMode !== undefined) summaryPatch.claudePermissionMode = meta.claudePermissionMode; @@ -6313,7 +6381,7 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [clearPromptSuggestionForSession, isRemoteProject, isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); + }, [clearPromptSuggestionForSession, isRemoteProject, isTileVisible, layoutVariant, loadHistory, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { if (!isTileActive) return undefined; @@ -9324,8 +9392,11 @@ export function AgentChatPane({ const ChatActionsToolbarIcon = chatActionsToolbarIcon; const proofArtifactCount = computerUseSnapshot?.artifacts?.length ?? 0; const proofSessionId = selectedSessionId ?? ""; - const agentsTabContent = selectedSubagentPaneAvailable || selectedTodoItems.length > 0 || selectedScheduledWorkSnapshots.length > 0 ? ( + const canViewMainTranscript = selectedSession?.provider === "claude" + && selectedSubagentCapability.canViewFullTranscript; + const agentsTabContent = selectedSubagentPaneAvailable || selectedTodoItems.length > 0 || selectedScheduledWorkSnapshots.length > 0 || canViewMainTranscript ? ( { + setMainTranscriptView(false); setSubagentView({ taskId: selection.taskId, agentId: selection.agentId, @@ -9357,6 +9430,10 @@ export function AgentChatPane({ }); }} onClearSelectedSubagent={() => setSubagentView(null)} + onViewMainTranscript={canViewMainTranscript ? () => { + setSubagentView(null); + setMainTranscriptView(true); + } : undefined} probeSubagentTranscript={probeSubagentTranscript} capability={selectedSubagentCapability} selectedTaskId={subagentView?.taskId ?? null} @@ -10127,14 +10204,16 @@ export function AgentChatPane({ permissionModeLocked={permissionModeLocked || identitySessionSettingsBusy || projectTransitionBlocksChat} hideNativeControls={hideNativeControls} messagePlaceholder={effectiveMessagePlaceholder} - inputLockMessage={subagentView - ? `Viewing ${subagentMetadata?.label + inputLockMessage={mainTranscriptView + ? "Viewing full session transcript" + : subagentView + ? `Viewing ${subagentMetadata?.label ?? subagentMetadata?.agentNickname ?? subagentView.agentType ?? subagentViewSnapshot?.description ?? subagentView.agentId ?? subagentView.taskId}` - : null} + : null} onExecutionModeChange={handleExecutionModeChange} onInteractionModeChange={(value) => { void updateNativeControls({ interactionMode: value }); }} onClaudeModeChange={handleClaudeModeChange} @@ -10698,7 +10777,7 @@ export function AgentChatPane({ const SIDE_PANE_FADE = { duration: 0.16, ease: [0.4, 0, 0.2, 1] as const }; const FLOATING_PANE_CARD_CLASS = - "ade-floating-side-pane flex w-full flex-col overflow-y-auto rounded-xl border border-white/[0.07] bg-[color:var(--work-sidebar-bg,#161618)] shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)]"; + "ade-floating-side-pane flex w-full flex-col overflow-hidden rounded-xl border border-white/[0.07] bg-[color:var(--work-sidebar-bg,#161618)] shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)]"; const renderFloatingPane = (content: React.ReactNode) => ( ) : null} + {mainTranscriptView ? ( +
+
+
Full session transcript (SDK)
+
Provider-fidelity view — ADE events (approvals, schedules, notices) are not shown.
+
+ +
+ ) : null} {/* Codex chat goal is rendered in the Agents tab via ChatSubagentsPanel; the in-chat banner was removed so the chat header stays clean and goal context lives next to subagents + progress where it belongs. */} 0, )} loadingOlderHistory={Boolean( !subagentView + && !mainTranscriptView && selectedSessionId && olderHistoryLoadingBySession[selectedSessionId], )} - onLoadOlderHistory={!subagentView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} + onLoadOlderHistory={!subagentView && !mainTranscriptView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} laneId={laneId} @@ -10967,8 +11069,8 @@ export function AgentChatPane({ }} onCodexRecovery={(args: AgentChatRecoverCodexTurnArgs) => window.ade.agentChat.recoverCodexTurn(args)} - mosaic={subagentView ? undefined : mosaicContext} - scrollToRowKeyRequest={subagentView ? null : wakeJumpRequest} + mosaic={subagentView || mainTranscriptView ? undefined : mosaicContext} + scrollToRowKeyRequest={subagentView || mainTranscriptView ? null : wakeJumpRequest} /> {sessionDelta ? (
diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 7f0561ffb..eb664fd45 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -188,6 +188,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); fireEvent.click(screen.getByTitle("Audit chat renderer")); await waitFor(() => expect(probeSubagentTranscript).toHaveBeenCalledTimes(1)); @@ -414,6 +415,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { // Background section shows smart labels (cwd stripped from the collapsed row). expect(screen.getByText("npx vitest run t")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); expect(screen.getByText("npm run build")).toBeTruthy(); // Schedule row present. @@ -516,7 +518,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.getByText("paused")).toBeTruthy(); }); - it("moves fired one-shot wakeups into collapsed history and marks late fires", () => { + it("moves fired one-shot wakeups into the collapsed Earlier group and marks late fires", () => { const firedAt = new Date(2026, 4, 12, 8, 41).toISOString(); render( { ); expect(screen.getByTitle("Recurring CI check")).toBeTruthy(); - const historyToggle = screen.getByRole("button", { name: "History (1)" }); - expect(historyToggle.getAttribute("aria-expanded")).toBe("false"); + const earlierToggle = screen.getByRole("button", { name: "Earlier (1)" }); + expect(earlierToggle.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByText("✓ Check PR CI · fired 8:41 AM · late")).toBeNull(); - fireEvent.click(historyToggle); + fireEvent.click(earlierToggle); - expect(historyToggle.getAttribute("aria-expanded")).toBe("true"); + expect(earlierToggle.getAttribute("aria-expanded")).toBe("true"); expect(screen.getByText("✓ Check PR CI · fired 8:41 AM · late")).toBeTruthy(); expect(screen.queryByText("done")).toBeNull(); }); @@ -597,7 +599,89 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); expect(screen.getByText("done")).toBeTruthy(); expect(screen.queryByText("running")).toBeNull(); }); + + it("keeps the small case free of collapse, Earlier, Show all, and Clear chrome", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Subagents/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /Earlier/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + }); + + it("caps active rows, exempts failed rows, and reveals all rows once", () => { + const snapshots = Array.from({ length: 14 }, (_, index): ChatSubagentSnapshot => ({ + ...baseSnapshot, + taskId: `running-${index}`, + description: `Running ${index}`, + background: false, + })); + snapshots.push({ + ...baseSnapshot, + taskId: "failed-beyond-cap", + description: "Failed beyond cap", + status: "failed", + background: false, + }); + + render(); + + expect(screen.getByTitle("Failed beyond cap")).toBeTruthy(); + expect(screen.queryByTitle("Running 13")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show all (2 running)" })); + expect(screen.getByTitle("Running 13")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); + }); + + it("clears and restores Earlier rows with the normalized per-session storage shape", () => { + const sessionId = "pane-persistence"; + window.localStorage.removeItem(`ade.chat.paneUi.v1:${sessionId}`); + window.localStorage.removeItem(`ade.chat.paneCleared.v1:${sessionId}`); + const completed = ["done-1", "done-2"].map((taskId): ChatSubagentSnapshot => ({ + ...baseSnapshot, + taskId, + description: taskId, + status: "completed", + background: false, + })); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getByText("Subagents · all clear")).toBeTruthy(); + expect(JSON.parse(window.localStorage.getItem(`ade.chat.paneCleared.v1:${sessionId}`) ?? "null")).toEqual({ + subagents: ["done-1", "done-2"], + background: [], + schedule: [], + }); + fireEvent.click(screen.getByRole("button", { name: "Restore (2)" })); + expect(screen.getByRole("button", { name: "Earlier (2)" })).toBeTruthy(); + }); + + it("owns the pane scroller and uses sticky opaque section headers", () => { + render(); + + const scroller = screen.getByTestId("chat-subagents-pane-scroll"); + expect(scroller.className).toContain("overflow-y-auto"); + const header = screen.getByText("Subagents").closest("div"); + expect(header?.className).toContain("sticky"); + expect(header?.className).toContain("--work-sidebar-bg"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 1f41aeb85..a965b0109 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; import { CaretDown, @@ -17,11 +17,23 @@ import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExec import { derivePlan } from "./chatExecutionSummary"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; -import type { ChatInfoPlanStep } from "../../../shared/chatSubagents"; -import { isBackgroundShellCommand } from "../../../shared/chatSubagents"; +import type { ChatInfoPlanStep, PaneSectionKey } from "../../../shared/chatSubagents"; +import { + BACKGROUND_ACTIVE_CAP, + PROGRESS_CAP, + SCHEDULE_ACTIVE_CAP, + SUBAGENTS_ACTIVE_CAP, + TASKS_CAP, + capPaneSectionItems, + groupPaneSectionItems, + isBackgroundShellCommand, + isEarlierSubagentSnapshot, +} from "../../../shared/chatSubagents"; import { backgroundCommandCwd, backgroundCommandLabel, + isEarlierBackgroundItem, + isEarlierScheduleItem, isFiredOneShotWakeup, scheduledNextFireLabel, } from "../../../shared/chatScheduledWork"; @@ -32,6 +44,117 @@ import { CodexGoalCard } from "./codex/CodexGoalCard"; import { ChatSubagentGlyph, chatSubagentColor, chatSubagentDisplayName } from "./chatSubagentIdentity"; const GLYPH_SIZE = 16; +const PANE_UI_STORAGE_PREFIX = "ade.chat.paneUi.v1"; +const PANE_CLEARED_STORAGE_PREFIX = "ade.chat.paneCleared.v1"; +const PANE_STORAGE_ENTRY_CAP = 100; + +type PaneUiStorageState = { + collapsed: Partial>; + earlier: Partial, boolean>>; +}; + +type PaneClearedStorageState = Record, string[]>; + +type PaneStorage = Pick; + +const EMPTY_PANE_UI_STATE: PaneUiStorageState = { collapsed: {}, earlier: {} }; +const EMPTY_PANE_CLEARED_STATE: PaneClearedStorageState = { subagents: [], background: [], schedule: [] }; +const PANE_SECTION_KEYS: PaneSectionKey[] = ["progress", "tasks", "subagents", "background", "schedule"]; +const PANE_EARLIER_SECTION_KEYS = ["subagents", "background", "schedule"] as const; + +export function chatPaneUiStorageKey(sessionId: string): string { + return `${PANE_UI_STORAGE_PREFIX}:${sessionId}`; +} + +export function chatPaneClearedStorageKey(sessionId: string): string { + return `${PANE_CLEARED_STORAGE_PREFIX}:${sessionId}`; +} + +function booleanMap(value: unknown, keys: readonly T[]): Partial> { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record = value as Record; + const result: Partial> = {}; + for (const key of keys) { + if (typeof record[key] === "boolean") result[key] = record[key] as boolean; + } + return result; +} + +export function parseChatPaneUiState(raw: string | null): PaneUiStorageState { + if (!raw) return EMPTY_PANE_UI_STATE; + try { + const parsed = JSON.parse(raw) as { collapsed?: unknown; earlier?: unknown }; + return { + collapsed: booleanMap(parsed.collapsed, PANE_SECTION_KEYS), + earlier: booleanMap(parsed.earlier, PANE_EARLIER_SECTION_KEYS), + }; + } catch { + return EMPTY_PANE_UI_STATE; + } +} + +export function parseChatPaneClearedState(raw: string | null): PaneClearedStorageState { + if (!raw) return EMPTY_PANE_CLEARED_STATE; + try { + const parsed = JSON.parse(raw) as Record; + return Object.fromEntries(PANE_EARLIER_SECTION_KEYS.map((section) => [ + section, + Array.isArray(parsed[section]) + ? [...new Set(parsed[section].filter((id): id is string => typeof id === "string" && id.length > 0))] + : [], + ])) as PaneClearedStorageState; + } catch { + return EMPTY_PANE_CLEARED_STATE; + } +} + +export function cleanupChatPaneStorage(storage: PaneStorage): void { + for (const prefix of [PANE_UI_STORAGE_PREFIX, PANE_CLEARED_STORAGE_PREFIX]) { + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(`${prefix}:`)) keys.push(key); + } + for (const key of keys.slice(0, Math.max(0, keys.length - PANE_STORAGE_ENTRY_CAP))) { + storage.removeItem(key); + } + } +} + +function readPaneUiState(sessionId?: string | null): PaneUiStorageState { + if (!sessionId || typeof window === "undefined") return EMPTY_PANE_UI_STATE; + try { + return parseChatPaneUiState(window.localStorage.getItem(chatPaneUiStorageKey(sessionId))); + } catch { + return EMPTY_PANE_UI_STATE; + } +} + +function readPaneClearedState(sessionId?: string | null): PaneClearedStorageState { + if (!sessionId || typeof window === "undefined") return EMPTY_PANE_CLEARED_STATE; + try { + return parseChatPaneClearedState(window.localStorage.getItem(chatPaneClearedStorageKey(sessionId))); + } catch { + return EMPTY_PANE_CLEARED_STATE; + } +} + +function paneSectionHint(args: { + activeCount: number; + earlierCount: number; + clearedCount: number; + runningCount?: number; + failedCount?: number; +}): string { + const active = args.runningCount && args.failedCount + ? `${args.runningCount} running · ${args.failedCount} failed` + : `${args.activeCount}`; + return [ + active, + ...(args.earlierCount ? [`${args.earlierCount} earlier`] : []), + ...(args.clearedCount ? [`${args.clearedCount} hidden`] : []), + ].join(" · "); +} type GlyphCategory = "subagent" | "background"; @@ -73,15 +196,59 @@ function SectionHeader({ action, tone = "neutral", emphasized = false, + sticky = false, + collapsible = false, + collapsed = false, + onToggle, }: { label: string; hint?: string; action?: ReactNode; tone?: SectionTone; emphasized?: boolean; + sticky?: boolean; + collapsible?: boolean; + collapsed?: boolean; + onToggle?: () => void; }) { + const wrapperClass = cn( + "flex items-center justify-between px-3.5", + emphasized ? "pb-1.5 pt-3" : "pb-1 pt-2.5", + sticky && "sticky top-0 z-[5] bg-[color:var(--work-sidebar-bg,#161618)]", + ); + if (collapsible) { + return ( +
+ + {action ? {action} : null} +
+ ); + } return ( -
+
+ {open ? ( + + {children} + + ) : null} + + ); +} + +function EarlierToggle({ + count, + clearedCount, + expanded, + onToggle, +}: { + count: number; + clearedCount: number; + expanded: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function ShowAllButton({ hiddenLabel, onClick }: { hiddenLabel: string; onClick: () => void }) { + return ( + + ); +} + +function PaneTextAction({ children, onClick }: { children: ReactNode; onClick: () => void }) { + return ( + + ); +} + +type ScalablePaneSectionKey = keyof PaneClearedStorageState; + +function PaneScalableSection({ + sectionKey, label, hint, tone, cap, groups, capped, paneUi, sticky, extraHeaderAction, + idOf, renderActiveRow, renderEarlierRow, onToggleCollapsed, onToggleEarlier, + onClear, onRestore, onShowAll, showAll, hasPrecedingSection, showAllLabel, + animateActiveRows = true, animateEarlierRows = false, keepEmptyActiveList = true, + subagentTaskIdOf, +}: { + sectionKey: ScalablePaneSectionKey; + label: string; + hint: string; + tone: SectionTone; + cap: number; + groups: { active: T[]; earlier: T[]; clearedCount: number }; + capped: { visible: T[]; hiddenCount: number }; + paneUi: PaneUiStorageState; + sticky: boolean; + extraHeaderAction?: ReactNode; + idOf: (item: T) => string; + renderActiveRow: (item: T) => ReactNode; + renderEarlierRow: (item: T) => ReactNode; + onToggleCollapsed: () => void; + onToggleEarlier: () => void; + onClear: (ids: string[]) => void; + onRestore: () => void; + onShowAll: () => void; + showAll: boolean; + hasPrecedingSection: boolean; + showAllLabel?: string; + animateActiveRows?: boolean; + animateEarlierRows?: boolean; + keepEmptyActiveList?: boolean; + subagentTaskIdOf?: (item: T) => string; +}) { + const allClear = groups.active.length === 0 && groups.earlier.length === 0 && groups.clearedCount > 0; + const collapsible = groups.earlier.length > 0 || groups.active.length > cap; + const collapsed = collapsible && paneUi.collapsed[sectionKey] === true; + const earlierExpanded = paneUi.earlier[sectionKey] === true; + const sectionAction = allClear ? ( + Restore ({groups.clearedCount}) + ) : groups.earlier.length > 0 ? ( + onClear(groups.earlier.map(idOf))}>Clear + ) : null; + const renderRows = (items: T[], renderRow: (item: T) => ReactNode, animated: boolean) => { + const rows = items.map((item) => animated ? ( + + {renderRow(item)} + + ) : {renderRow(item)}); + return animated ? {rows} : rows; + }; + + return ( +
+ {sectionAction}{extraHeaderAction}} + /> + + {allClear ?
{label} · all clear
: null} + {keepEmptyActiveList || capped.visible.length > 0 ? ( +
+ {renderRows(capped.visible, renderActiveRow, animateActiveRows)} +
+ ) : null} + {!showAll && capped.hiddenCount > 0 ? ( + + ) : null} + {groups.earlier.length > 0 || groups.clearedCount > 0 ? ( +
+ + +
+ {renderRows(groups.earlier, renderEarlierRow, animateEarlierRows)} +
+ {groups.clearedCount > 0 ? ( +
Restore ({groups.clearedCount})
+ ) : null} +
+
+ ) : null} +
+
+ ); +} + /* ── Progress bar — 1 px hairline rule ── */ function ProgressBar({ percent }: { percent: number }) { @@ -580,6 +915,7 @@ export type SubagentSelection = { }; export function ChatSubagentsPanel({ + sessionId, snapshots, events, onSelectSubagent, @@ -600,7 +936,9 @@ export function ChatSubagentsPanel({ backgroundItems = [], schedulesPaused = false, onToggleSchedulesPaused, + onViewMainTranscript, }: { + sessionId?: string | null; snapshots: ChatSubagentSnapshot[]; events: AgentChatEventEnvelope[]; onSelectSubagent?: (selection: SubagentSelection) => void; @@ -631,9 +969,13 @@ export function ChatSubagentsPanel({ schedulesPaused?: boolean; /** Pause or resume all durable schedules for this chat. */ onToggleSchedulesPaused?: () => void; + /** Opens the provider-fidelity transcript for the parent Claude session. */ + onViewMainTranscript?: () => void; }) { const [expanded, setExpanded] = useState(false); - const [scheduleHistoryExpanded, setScheduleHistoryExpanded] = useState(false); + const [paneUi, setPaneUi] = useState(() => readPaneUiState(sessionId)); + const [paneCleared, setPaneCleared] = useState(() => readPaneClearedState(sessionId)); + const [showAll, setShowAll] = useState>>({}); // Which agent's inline details drawer is open (agents with no transcript). const [expandedTaskId, setExpandedTaskId] = useState(null); // Which agent we're currently probing for a transcript (shows a row spinner). @@ -641,17 +983,77 @@ export function ChatSubagentsPanel({ // Cached probe outcomes per task so repeat clicks are instant. Running agents // are never cached — their transcript can appear after a later poll. const [probeResults, setProbeResults] = useState>({}); + const paneScrollRef = useRef(null); - const plan = useMemo(() => derivePlan(events), [events]); + useEffect(() => { + setPaneUi(readPaneUiState(sessionId)); + setPaneCleared(readPaneClearedState(sessionId)); + setShowAll({}); + }, [sessionId]); - const { activeScheduleItems, scheduleHistoryItems } = useMemo(() => { - const active: ChatScheduledWorkSnapshot[] = []; - const history: ChatScheduledWorkSnapshot[] = []; - for (const item of scheduleItems) { - (isFiredOneShotWakeup(item) ? history : active).push(item); + useEffect(() => { + if (typeof window === "undefined") return; + try { + cleanupChatPaneStorage(window.localStorage); + } catch { + // Renderer storage is best-effort; disclosure remains available in memory. } - return { activeScheduleItems: active, scheduleHistoryItems: history }; - }, [scheduleItems]); + }, []); + + const updatePaneUi = useCallback((update: (current: PaneUiStorageState) => PaneUiStorageState) => { + setPaneUi((current) => { + const next = update(current); + if (sessionId) { + try { + window.localStorage.setItem(chatPaneUiStorageKey(sessionId), JSON.stringify(next)); + } catch { + // Keep the in-memory state when localStorage is blocked. + } + } + return next; + }); + }, [sessionId]); + + const updatePaneCleared = useCallback((update: (current: PaneClearedStorageState) => PaneClearedStorageState) => { + setPaneCleared((current) => { + const next = update(current); + if (sessionId) { + try { + window.localStorage.setItem(chatPaneClearedStorageKey(sessionId), JSON.stringify(next)); + } catch { + // Keep the in-memory state when localStorage is blocked. + } + } + return next; + }); + }, [sessionId]); + + const toggleSection = useCallback((section: PaneSectionKey) => { + updatePaneUi((current) => ({ + ...current, + collapsed: { ...current.collapsed, [section]: current.collapsed[section] !== true }, + })); + }, [updatePaneUi]); + + const toggleEarlier = useCallback((section: "subagents" | "background" | "schedule") => { + updatePaneUi((current) => ({ + ...current, + earlier: { ...current.earlier, [section]: current.earlier[section] !== true }, + })); + }, [updatePaneUi]); + + const clearEarlier = useCallback((section: "subagents" | "background" | "schedule", ids: string[]) => { + updatePaneCleared((current) => ({ + ...current, + [section]: [...new Set([...current[section], ...ids])], + })); + }, [updatePaneCleared]); + + const restoreCleared = useCallback((section: "subagents" | "background" | "schedule") => { + updatePaneCleared((current) => ({ ...current, [section]: [] })); + }, [updatePaneCleared]); + + const plan = useMemo(() => derivePlan(events), [events]); const { subagents, runningCount, completedCount, bgRunningCount } = useMemo(() => { // ONE merged subagent list — foreground + background-run agents together. @@ -688,18 +1090,75 @@ export function ChatSubagentsPanel({ }; }, [snapshots]); + const pinnedSubagentIds = useMemo(() => new Set( + [selectedTaskId, expandedTaskId].filter((id): id is string => Boolean(id)), + ), [expandedTaskId, selectedTaskId]); + const clearedSubagentIds = useMemo(() => new Set(paneCleared.subagents), [paneCleared.subagents]); + const clearedBackgroundIds = useMemo(() => new Set(paneCleared.background), [paneCleared.background]); + const clearedScheduleIds = useMemo(() => new Set(paneCleared.schedule), [paneCleared.schedule]); + + const subagentGroups = useMemo(() => groupPaneSectionItems(subagents, { + isEarlier: isEarlierSubagentSnapshot, + isCleared: (snapshot) => clearedSubagentIds.has(snapshot.taskId), + isPinned: (snapshot) => pinnedSubagentIds.has(snapshot.taskId), + }), [clearedSubagentIds, pinnedSubagentIds, subagents]); + const backgroundGroups = useMemo(() => groupPaneSectionItems(backgroundItems, { + isEarlier: isEarlierBackgroundItem, + isCleared: (snapshot) => clearedBackgroundIds.has(snapshot.id), + isPinned: () => false, + }), [backgroundItems, clearedBackgroundIds]); + const scheduleGroups = useMemo(() => groupPaneSectionItems(scheduleItems, { + isEarlier: isEarlierScheduleItem, + isCleared: (snapshot) => clearedScheduleIds.has(snapshot.id), + isPinned: () => false, + }), [clearedScheduleIds, scheduleItems]); + + const cappedSubagents = useMemo(() => ( + showAll.subagents + ? { visible: subagentGroups.active, hiddenCount: 0 } + : capPaneSectionItems(subagentGroups.active, SUBAGENTS_ACTIVE_CAP, (snapshot) => ( + snapshot.status === "failed" || pinnedSubagentIds.has(snapshot.taskId) + )) + ), [pinnedSubagentIds, showAll.subagents, subagentGroups.active]); + const cappedBackground = useMemo(() => ( + showAll.background + ? { visible: backgroundGroups.active, hiddenCount: 0 } + : capPaneSectionItems(backgroundGroups.active, BACKGROUND_ACTIVE_CAP, (snapshot) => snapshot.status === "failed") + ), [backgroundGroups.active, showAll.background]); + const cappedSchedule = useMemo(() => ( + showAll.schedule + ? { visible: scheduleGroups.active, hiddenCount: 0 } + : capPaneSectionItems(scheduleGroups.active, SCHEDULE_ACTIVE_CAP, (snapshot) => snapshot.status === "failed") + ), [scheduleGroups.active, showAll.schedule]); + + useEffect(() => { + if (!selectedTaskId) return; + if (paneUi.collapsed.subagents) { + updatePaneUi((current) => ({ + ...current, + collapsed: { ...current.collapsed, subagents: false }, + })); + } + const frame = window.requestAnimationFrame(() => { + const target = [...(paneScrollRef.current?.querySelectorAll("[data-subagent-task-id]") ?? [])] + .find((element) => element.dataset.subagentTaskId === selectedTaskId); + target?.scrollIntoView?.({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [paneUi.collapsed.subagents, selectedTaskId, updatePaneUi]); + const headerSummary = useMemo(() => { const parts: string[] = []; if (runningCount) parts.push(`${runningCount} running`); if (bgRunningCount) parts.push(`${bgRunningCount} bg`); - if (backgroundItems.length) parts.push(`${backgroundItems.length} background`); - if (activeScheduleItems.length) parts.push(`${activeScheduleItems.length} scheduled`); - if (scheduleHistoryItems.length) parts.push(`${scheduleHistoryItems.length} history`); + if (backgroundGroups.active.length) parts.push(`${backgroundGroups.active.length} background`); + if (scheduleGroups.active.length) parts.push(`${scheduleGroups.active.length} scheduled`); + if (scheduleGroups.earlier.length) parts.push(`${scheduleGroups.earlier.length} earlier`); if (completedCount) parts.push(`${completedCount} done`); if (!parts.length && subagents.length) parts.push(`${subagents.length} tracked`); if (!parts.length) parts.push("idle"); return parts.join(" · "); - }, [runningCount, bgRunningCount, backgroundItems.length, activeScheduleItems.length, scheduleHistoryItems.length, completedCount, subagents.length]); + }, [backgroundGroups.active.length, completedCount, runningCount, bgRunningCount, scheduleGroups.active.length, scheduleGroups.earlier.length, subagents.length]); const takeover = (snap: ChatSubagentSnapshot) => { setExpandedTaskId(null); @@ -780,8 +1239,16 @@ export function ChatSubagentsPanel({ const planComplete = plan?.steps.filter((step) => step.status === "completed").length ?? 0; const planTotal = plan?.steps.length ?? 0; const planPercent = planTotal > 0 ? Math.round((planComplete / planTotal) * 100) : 0; + const progressCollapsible = planTotal > PROGRESS_CAP; + const progressCollapsed = progressCollapsible && paneUi.collapsed.progress === true; + const visiblePlanSteps = plan && !showAll.progress ? plan.steps.slice(0, PROGRESS_CAP) : plan?.steps ?? []; + const hiddenPlanCount = Math.max(0, planTotal - visiblePlanSteps.length); const taskComplete = todoItems.filter((item) => item.status === "completed").length; const taskActive = todoItems.filter((item) => item.status === "in_progress").length; + const tasksCollapsible = todoItems.length > TASKS_CAP; + const tasksCollapsed = tasksCollapsible && paneUi.collapsed.tasks === true; + const visibleTodoItems = showAll.tasks ? todoItems : todoItems.slice(0, TASKS_CAP); + const hiddenTaskCount = todoItems.length - visibleTodoItems.length; const taskHint = todoItems.length ? [ `${taskComplete}/${todoItems.length} complete`, @@ -789,15 +1256,35 @@ export function ChatSubagentsPanel({ ].join(" · ") : undefined; + const subagentRunningCount = subagentGroups.active.filter((item) => item.status === "running").length; + const subagentFailedCount = subagentGroups.active.filter((item) => item.status === "failed").length; + const backgroundRunningCount = backgroundGroups.active.filter((item) => item.status === "running").length; + const backgroundFailedCount = backgroundGroups.active.filter((item) => item.status === "failed").length; + const scheduleRunningCount = scheduleGroups.active.filter((item) => item.status === "running").length; + const scheduleFailedCount = scheduleGroups.active.filter((item) => item.status === "failed").length; + + const stickyHeaders = variant === "pane"; + const hasGoal = Boolean(goal?.objective?.trim()); const hasTasks = todoItems.length > 0; const hasSubagents = subagents.length > 0; const hasBackground = backgroundItems.length > 0; const hasScheduled = scheduleItems.length > 0; const hasAnything = hasGoal || Boolean(plan) || hasTasks || hasSubagents || hasBackground || hasScheduled; + const renderSubagentPaneRow = (snap: ChatSubagentSnapshot) => ( + handleRowClick(snap)} + /> + ); const body = ( -
+
{/* ── Goal (Codex chat goal) ───────────────────────────────── */} {hasGoal && goal ? ( toggleSection("progress")} /> - -
    - {plan.steps.map((step, index) => { + + +
      + {visiblePlanSteps.map((step, index) => { const isCompleted = step.status === "completed"; const isInProgress = step.status === "in_progress"; const isFailed = step.status === "failed"; @@ -841,7 +1333,11 @@ export function ChatSubagentsPanel({ ); })} -
    +
+ {hiddenPlanCount > 0 ? ( + setShowAll((current) => ({ ...current, progress: true }))} /> + ) : null} + ) : null} @@ -858,126 +1354,80 @@ export function ChatSubagentsPanel({ hint={taskHint} tone="workflow" emphasized + sticky={stickyHeaders} + collapsible={tasksCollapsible} + collapsed={tasksCollapsed} + onToggle={() => toggleSection("tasks")} /> - + + + {hiddenTaskCount > 0 ? ( + setShowAll((current) => ({ ...current, tasks: true }))} /> + ) : null} + ) : null} {/* ── Subagents (merged foreground + background-run agents) ──── */} {hasSubagents ? ( -
- -
- {subagents.map((snap) => ( - handleRowClick(snap)} - /> - ))} -
-
+ snap.taskId} + renderActiveRow={renderSubagentPaneRow} renderEarlierRow={renderSubagentPaneRow} + onToggleCollapsed={() => toggleSection("subagents")} onToggleEarlier={() => toggleEarlier("subagents")} + onClear={(ids) => clearEarlier("subagents", ids)} onRestore={() => restoreCleared("subagents")} + onShowAll={() => setShowAll((current) => ({ ...current, subagents: true }))} showAll={showAll.subagents === true} + hasPrecedingSection={Boolean(hasGoal || plan || hasTasks)} showAllLabel={`${cappedSubagents.hiddenCount} running`} + animateEarlierRows subagentTaskIdOf={(snap) => snap.taskId} + /> ) : null} {/* ── Background (background command tasks) ─────────────────── */} {hasBackground ? ( -
- -
- {backgroundItems.map((item) => ( - - ))} -
-
+ item.id} + renderActiveRow={(item) => } + renderEarlierRow={(item) => } + onToggleCollapsed={() => toggleSection("background")} onToggleEarlier={() => toggleEarlier("background")} + onClear={(ids) => clearEarlier("background", ids)} onRestore={() => restoreCleared("background")} + onShowAll={() => setShowAll((current) => ({ ...current, background: true }))} showAll={showAll.background === true} + hasPrecedingSection={Boolean(hasGoal || plan || hasTasks || hasSubagents)} + /> ) : null} {/* ── Schedule (schedule kinds only) ───────────────────────── */} {hasScheduled ? ( -
- - {schedulesPaused - ? - : } - - ) : null} - /> - {activeScheduleItems.length ? ( -
- {activeScheduleItems.map((item) => ( - - ))} -
- ) : null} - {scheduleHistoryItems.length ? ( -
- - {scheduleHistoryExpanded ? ( -
- {scheduleHistoryItems.map((item) => ( - - ))} -
- ) : null} -
- ) : null} -
+ {onToggleSchedulesPaused ? ( + + ) : null}} + idOf={(item) => item.id} + renderActiveRow={(item) => } + renderEarlierRow={(item) => isFiredOneShotWakeup(item) + ? + : } + onToggleCollapsed={() => toggleSection("schedule")} onToggleEarlier={() => toggleEarlier("schedule")} + onClear={(ids) => clearEarlier("schedule", ids)} onRestore={() => restoreCleared("schedule")} + onShowAll={() => setShowAll((current) => ({ ...current, schedule: true }))} showAll={showAll.schedule === true} + hasPrecedingSection={Boolean(hasGoal || plan || hasTasks || hasSubagents || hasBackground)} + animateActiveRows={false} keepEmptyActiveList={false} + /> ) : null} {/* ── Single-agent empty state ─────────────────────────────── */} @@ -988,13 +1438,27 @@ export function ChatSubagentsPanel({
) : null} + {onViewMainTranscript ? ( +
+ +
+ ) : null} +
); if (variant === "pane") { return ( -
- {body} +
+
+ {body} +
); } diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index fed840122..84e752b3a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -106,6 +106,20 @@ describe("SessionCard orchestration identity", () => { expect(screen.queryByLabelText("Awaiting your input")).toBeNull(); }); + + it("renders a Claude session tag beside the title", () => { + render( + , + ); + + expect(screen.getByText("customer-ready").getAttribute("title")).toBe("customer-ready"); + }); }); describe("SessionCard auto-naming status", () => { diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index d53397c0e..c08ff967a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -365,6 +365,17 @@ export const SessionCard = React.memo(function SessionCard({ > {primaryText} + {session.claudeTag?.trim() ? ( + + {session.claudeTag.trim()} + + ) : null} {attentionBadge ? : null}
{importedFrom ? ( diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx new file mode 100644 index 000000000..826d2eb7c --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -0,0 +1,95 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { SessionContextMenu } from "./SessionContextMenu"; + +afterEach(cleanup); + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "chat-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "claude-chat", + title: "Claude chat", + status: "running", + startedAt: "2026-07-10T12:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "idle", + resumeCommand: null, + ...overrides, + }; +} + +function renderMenu(session: TerminalSessionSummary, onSetChatTag = vi.fn()) { + const onClose = vi.fn(); + render( + , + ); + return { onClose, onSetChatTag }; +} + +describe("SessionContextMenu Claude tags", () => { + it("opens an inline input and treats an empty submit as clearing the tag", () => { + const session = makeSession({ claudeTag: "review-ready" }); + const { onClose, onSetChatTag } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Set tag…" })); + const input = screen.getByRole("textbox", { name: "Set Claude session tag" }) as HTMLInputElement; + expect(input.value).toBe("review-ready"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSetChatTag).toHaveBeenCalledWith(session, null); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("submits a non-empty tag for the session", () => { + const session = makeSession(); + const { onClose, onSetChatTag } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Set tag…" })); + const input = screen.getByRole("textbox", { name: "Set Claude session tag" }) as HTMLInputElement; + fireEvent.change(input, { target: { value: "review-ready" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSetChatTag).toHaveBeenCalledWith(session, "review-ready"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not offer SDK tags for non-Claude chat sessions", () => { + renderMenu(makeSession({ toolType: "codex-chat" })); + expect(screen.queryByRole("button", { name: "Set tag…" })).toBeNull(); + }); + + it("does not offer SDK tags for ended Claude sessions", () => { + // Tag writes need a live Claude SDK runtime; the menu gates on running. + renderMenu(makeSession({ status: "disposed", endedAt: "2026-07-10T13:00:00.000Z" })); + expect(screen.queryByRole("button", { name: "Set tag…" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 71fdaa0e2..711068756 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -20,6 +20,7 @@ type SessionContextMenuProps = { onGoToLane: (session: TerminalSessionSummary) => void; onCopySessionId: (id: string) => void; onRename: (session: TerminalSessionSummary, newTitle: string) => void; + onSetChatTag?: (session: TerminalSessionSummary, tag: string | null) => void; onCopySessionDeepLink?: (session: TerminalSessionSummary) => void; onOpenSessionInWeb?: (session: TerminalSessionSummary) => void; onTogglePinned?: (session: TerminalSessionSummary) => void; @@ -40,6 +41,7 @@ export function SessionContextMenu({ onGoToLane, onCopySessionId, onRename, + onSetChatTag, onCopySessionDeepLink, onOpenSessionInWeb, onTogglePinned, @@ -48,28 +50,30 @@ export function SessionContextMenu({ onRemoveFromGrid, }: SessionContextMenuProps) { const [renaming, setRenaming] = useState(false); + const [tagging, setTagging] = useState(false); const [draft, setDraft] = useState(""); const inputRef = useRef(null); const finalizedRef = useRef(false); const { ref: menuRef, position: clampedPosition } = useClampedFixedPosition( menu ? { x: menu.x, y: menu.y } : null, - renaming, + renaming || tagging, ); - // Reset rename state when menu changes + // Reset inline edit state when the target menu changes. useEffect(() => { setRenaming(false); + setTagging(false); setDraft(""); finalizedRef.current = false; }, [menu]); - // Focus input when entering rename mode + // Focus whichever inline editor was opened. useEffect(() => { - if (renaming && inputRef.current) { + if ((renaming || tagging) && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } - }, [renaming]); + }, [renaming, tagging]); if (!menu) return null; @@ -87,6 +91,13 @@ export function SessionContextMenu({ } onClose(); }; + const commitTag = () => { + if (finalizedRef.current) return; + finalizedRef.current = true; + const trimmed = draft.trim(); + onSetChatTag?.(session, trimmed.length ? trimmed : null); + onClose(); + }; return ( <> @@ -122,7 +133,26 @@ export function SessionContextMenu({ />
)} - {!renaming && ( + {tagging && ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitTag(); } + if (e.key === "Escape") { e.preventDefault(); finalizedRef.current = true; onClose(); } + }} + onBlur={commitTag} + className="w-full rounded border border-border/30 bg-transparent px-2 py-1 text-xs text-[--color-fg] outline-none focus:border-[--color-accent]" + placeholder="Tag (empty clears)..." + maxLength={48} + /> +
+ )} + {!renaming && !tagging && ( )} + {/* Tag writes need a live Claude SDK runtime (updateSession throws for + ended sessions), so only offer the item while the session runs. */} + {!renaming && !tagging && session.toolType === "claude-chat" && isRunning && onSetChatTag ? ( + + ) : null} {isRunning && session.ptyId && !isChat ? (