From 94b6b66013f798c2c8a8f3701b4439fe9b127376 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:22:34 -0400 Subject: [PATCH 1/7] Claude V2: AskUserQuestion & tool-use tracking Add support for Claude AskUserQuestion by emitting ADE pending input requests, waiting for user approvals, and mapping answers back into the SDK input. Track open Claude tool uses and emit synthetic tool_result events when a tool_use_summary arrives or when a turn finalizes (completed/failed/interrupted). Preserve Claude SDK session ids across local timeouts so turns can be resumed; remove the hard abort of long-running Claude turns and adjust runSessionTurn default timeout handling (TURN_TIMEOUT_MS renamed to RUN_SESSION_TURN_WAIT_TIMEOUT_MS). Also add HTML preview support for AskUserQuestion, various test coverage for session continuity, tool result emission, AskUserQuestion bridging, and long-running turns, plus smaller test and git helper refactors. Other minor housekeeping: add @xterm/addon-webgl to package.json, update .ade identity metadata, and remove a stray *.bak from .ade/.gitignore. --- .ade/.gitignore | 1 - .ade/cto/identity.yaml | 7 +- apps/desktop/package-lock.json | 7 + apps/desktop/package.json | 1 + .../src/main/services/ai/authDetector.test.ts | 62 +- .../services/chat/agentChatService.test.ts | 558 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 294 +++++++-- .../services/git/gitOperationsService.test.ts | 141 ++++- .../chat/AgentChatComposer.test.tsx | 25 + .../components/chat/AgentChatComposer.tsx | 19 +- .../chat/AgentChatMessageList.test.tsx | 129 +++- .../components/chat/AgentChatMessageList.tsx | 232 +++++--- .../chat/AgentChatPane.submit.test.tsx | 98 ++- .../components/chat/AgentChatPane.tsx | 149 +++-- .../chat/AgentQuestionModal.test.tsx | 198 +++++++ .../components/chat/AgentQuestionModal.tsx | 412 ++++++++++--- .../components/chat/ChatSurfaceShell.tsx | 5 +- .../components/chat/ChatWorkLogBlock.tsx | 175 ++++-- .../chat/chatTranscriptRows.test.ts | 46 ++ .../components/chat/chatTranscriptRows.ts | 66 ++- .../components/chat/pendingInput.test.ts | 39 ++ .../renderer/components/chat/pendingInput.ts | 7 + .../lanes/LaneGitActionsPane.test.tsx | 162 ++++- .../components/lanes/LaneGitActionsPane.tsx | 3 + .../components/lanes/LaneWorkPane.tsx | 1 + .../components/lanes/TilingLayout.tsx | 2 +- .../lanes/useLaneWorkSessions.test.ts | 91 ++- .../components/lanes/useLaneWorkSessions.ts | 41 +- .../missions/AgentChannels.test.tsx | 4 +- .../shared/UnifiedModelSelector.tsx | 17 +- .../ResolverTerminalModal.tsx | 4 +- .../terminals/PackedSessionGrid.test.tsx | 173 ++++++ .../terminals/PackedSessionGrid.tsx | 317 ++++++++++ .../terminals/SessionInfoPopover.tsx | 9 +- .../components/terminals/SessionListPane.tsx | 21 +- .../terminals/TerminalView.test.tsx | 333 +++++++++++ .../components/terminals/TerminalView.tsx | 231 ++++++-- .../components/terminals/TerminalsPage.tsx | 20 +- .../components/terminals/WorkStartSurface.tsx | 4 +- .../components/terminals/WorkViewArea.tsx | 202 ++++--- .../terminals/packedSessionGridMath.test.ts | 64 ++ .../terminals/packedSessionGridMath.ts | 193 ++++++ .../terminals/useWorkSessions.test.ts | 59 ++ .../components/terminals/useWorkSessions.ts | 54 +- apps/desktop/src/renderer/lib/sessions.ts | 42 +- apps/desktop/src/shared/types/chat.ts | 3 + 46 files changed, 4168 insertions(+), 553 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/PackedSessionGrid.test.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/packedSessionGridMath.test.ts create mode 100644 apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts diff --git a/.ade/.gitignore b/.ade/.gitignore index 6d2c67a58..19685b6ff 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -4,7 +4,6 @@ local.secret.yaml ade.db ade.db-* ade.db-wal -*.bak embeddings.db mcp.sock artifacts/ diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index f87dc6cc5..e7a73393e 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,5 +1,5 @@ name: CTO -version: 2 +version: 1 persona: >- You are the CTO for this project inside ADE. @@ -28,7 +28,4 @@ openclawContextPolicy: - secret - token - system_prompt -onboardingState: - completedSteps: [] - dismissedAt: 2026-04-01T23:35:05.209Z -updatedAt: 2026-04-01T23:35:05.211Z +updatedAt: 1970-01-01T00:00:00.000Z diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 321ed127a..023ff5b51 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "ai": "^6.0.141", @@ -7480,6 +7481,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e15e5d55..00f60dc05 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "ai": "^6.0.141", diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 26416a9ef..d18ecdc4a 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -260,38 +260,54 @@ describe("authDetector", () => { it("finds codex through an npm-global prefix when PATH lookup fails", async () => { tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auth-detector-")); const prefixDir = path.join(tempHomeDir, ".npm-global"); + const preferredCodexPath = path.join(prefixDir, "bin", "codex"); fs.mkdirSync(path.join(prefixDir, "bin"), { recursive: true }); fs.writeFileSync(path.join(tempHomeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); - fs.writeFileSync(path.join(prefixDir, "bin", "codex"), "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(path.join(prefixDir, "bin", "codex"), 0o755); + fs.writeFileSync(preferredCodexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(preferredCodexPath, 0o755); process.env.HOME = tempHomeDir; process.env.PATH = "/usr/bin:/bin"; - spawnMock.mockImplementation((command: string, args: string[] = []) => { - if (args[0] === "--version") { - if (command === "codex") return fakeError(); - if (command === path.join(prefixDir, "bin", "codex")) return fakeChild({ status: 0, stdout: "0.105.0\n" }); - return fakeError(); + const realStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { + const resolved = String(candidatePath); + if (resolved.endsWith("/codex") && resolved !== preferredCodexPath) { + const error = new Error(`ENOENT: no such file or directory, stat '${resolved}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; } - if (command === "which") { + return realStatSync(candidatePath, options as fs.StatOptions | undefined); + }) as typeof fs.statSync); + + try { + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "codex") return fakeError(); + if (command === preferredCodexPath) return fakeChild({ status: 0, stdout: "0.105.0\n" }); + return fakeError(); + } + if (command === "which") { + return fakeChild({ status: 1 }); + } + if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); + } return fakeChild({ status: 1 }); - } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { - return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); - } - return fakeChild({ status: 1 }); - }); + }); - const statuses = await detectCliAuthStatuses(); - const codex = statuses.find((entry) => entry.cli === "codex"); + const statuses = await detectCliAuthStatuses(); + const codex = statuses.find((entry) => entry.cli === "codex"); - expect(codex).toEqual({ - cli: "codex", - installed: true, - path: path.join(prefixDir, "bin", "codex"), - authenticated: true, - verified: true, - }); + expect(codex).toEqual({ + cli: "codex", + installed: true, + path: preferredCodexPath, + authenticated: true, + verified: true, + }); + } finally { + statSpy.mockRestore(); + } }); it("repairs PATH from the interactive shell during a forced refresh", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 905f67047..0e9b57c1e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -834,6 +834,33 @@ describe("createAgentChatService", () => { expect(opts?.allowedTools).toContain("mcp__ade__*"); }); + it("requests HTML previews for Claude AskUserQuestion", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-ask-user-preview", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { + toolConfig?: { askUserQuestion?: { previewFormat?: string } }; + } | undefined; + expect(opts?.toolConfig?.askUserQuestion?.previewFormat).toBe("html"); + }); + it("attaches ADE MCP servers through the Claude V2 query controls", async () => { vi.mocked(providerResolver.normalizeCliMcpServers).mockImplementation((_provider, servers) => servers ?? {}); const setMcpServers = vi.fn().mockResolvedValue({ @@ -3374,6 +3401,227 @@ describe("createAgentChatService", () => { service.resumeSession({ sessionId: "unknown-session-id" }), ).rejects.toThrow(/not found/i); }); + + it("preserves Claude V2 session continuity after an idle timeout", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let primaryStreamCall = 0; + let primaryClosed = false; + const primarySend = vi.fn().mockResolvedValue(undefined); + const resumedSend = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const primarySession = { + send: primarySend, + stream: vi.fn(() => (async function* () { + primaryStreamCall += 1; + if (primaryStreamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "Partial answer" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + + while (!primaryClosed) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error("aborted by user"); + })()), + close: vi.fn(() => { + primaryClosed = true; + }), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + const resumedSession = { + send: resumedSend, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "assistant", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "You were asking about the new chat buttons." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(primarySession as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(resumedSession as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Add the new chat button", + timeoutMs: 120_000, + }); + await vi.advanceTimersByTimeAsync(76_000); + await firstTurn; + + const persistedAfterTimeout = readPersistedChatState(session.id); + expect(persistedAfterTimeout.sdkSessionId).toBe("sdk-session-1"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "error", + message: expect.stringContaining("turn was reset so you can retry"), + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "status", + turnStatus: "failed", + }), + }), + ]), + ); + + events.length = 0; + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "what happened?", + timeoutMs: 15_000, + }); + + expect(unstable_v2_resumeSession).toHaveBeenCalledWith( + "sdk-session-1", + expect.objectContaining({ model: "sonnet" }), + ); + expect(resumedSend).toHaveBeenCalledTimes(1); + expect(followUp.outputText).toContain("new chat buttons"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not abort Claude turns solely because they run longer than five minutes", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-long-running", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + for (let index = 0; index < 6; index += 1) { + yield { + type: "assistant", + session_id: "sdk-session-long-running", + message: { + content: [{ type: "text", text: `Chunk ${index + 1}. ` }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + await new Promise((resolve) => setTimeout(resolve, 60_000)); + } + + yield { + type: "assistant", + session_id: "sdk-session-long-running", + message: { + content: [{ type: "text", text: "Finished after a long run." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-long-running", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const turn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep working until the implementation is done.", + timeoutMs: 500_000, + }); + + await vi.advanceTimersByTimeAsync(361_000); + const result = await turn; + + expect(result.outputText).toContain("Finished after a long run."); + expect(events.find((event) => event.event.type === "status" && event.event.turnStatus === "failed")).toBeUndefined(); + expect(events.find((event) => event.event.type === "status" && event.event.turnStatus === "interrupted")).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); }); // -------------------------------------------------------------------------- @@ -4246,6 +4494,316 @@ describe("createAgentChatService", () => { await sendPromise; }); + it("emits completed Claude tool_result rows when tool_use_summary arrives", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-tool-summary", + slash_commands: [], + }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-use-1", + name: "Read", + input: { file_path: "apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx" }, + }, + }, + }; + yield { + type: "tool_use_summary", + summary: "Checked the shared chat renderer", + preceding_tool_use_ids: ["tool-use-1"], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-tool-summary", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the shared chat renderer.", + }); + + const completedToolResults = events.filter((event) => + event.event.type === "tool_result" + && event.event.itemId === "tool-use-1" + && event.event.status === "completed" + ); + + expect(completedToolResults).toHaveLength(1); + expect(completedToolResults[0]!.event.type).toBe("tool_result"); + if (completedToolResults[0]!.event.type !== "tool_result") { + throw new Error("Expected tool_result"); + } + expect(completedToolResults[0]!.event.result).toMatchObject({ + synthetic: true, + source: "claude_tool_use_summary", + summary: "Checked the shared chat renderer", + }); + }); + + it("emits completed Claude tool_result rows for open tools when the turn ends without a tool summary", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-tool-fallback", + slash_commands: [], + }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-use-2", + name: "Read", + input: { file_path: "apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx" }, + }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-tool-fallback", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the grouped work log renderer.", + }); + + const completedToolResults = events.filter((event) => + event.event.type === "tool_result" + && event.event.itemId === "tool-use-2" + && event.event.status === "completed" + ); + + expect(completedToolResults).toHaveLength(1); + expect(completedToolResults[0]!.event.type).toBe("tool_result"); + if (completedToolResults[0]!.event.type !== "tool_result") { + throw new Error("Expected tool_result"); + } + expect(completedToolResults[0]!.event.result).toMatchObject({ + synthetic: true, + source: "claude_turn_finalization", + finalTurnStatus: "completed", + }); + }); + + it("bridges Claude AskUserQuestion through ADE's question UI", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let permissionResult: Record | null = null; + + const askInput = { + questions: [ + { + question: "What should we do about the two task list views?", + header: "Task views", + options: [ + { + label: "Remove the TurnSummaryCard tasks", + description: "Keep only the inline task list.", + preview: "
Inline only

Compact stream, no bottom summary card.

", + }, + { + label: "Keep both, improve summary", + description: "Keep both task views, but make the summary less intrusive.", + preview: "
Hybrid

Inline progress plus a compact summary card.

", + }, + ], + multiSelect: false, + }, + { + question: "Should the inline task list pin while tasks are active?", + header: "Inline pinning", + options: [ + { label: "Yes, pin while active" }, + { label: "No, let it scroll" }, + ], + multiSelect: false, + }, + ], + }; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-ask-user", + slash_commands: [], + }; + return; + } + + const sessionOpts = vi.mocked(unstable_v2_createSession).mock.calls.at(-1)?.[0] as any; + permissionResult = await sessionOpts.canUseTool("AskUserQuestion", askInput, { + signal: new AbortController().signal, + toolUseID: "tool-ask-user-1", + }); + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Thanks, I can continue now." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-ask-user", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + permissionMode: "plan", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Figure out the task list UX and ask any clarifying questions you need.", + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && typeof (event.event.detail as { request?: { providerMetadata?: { tool?: string } } } | undefined)?.request?.providerMetadata?.tool === "string" + && ((event.event.detail as { request?: { providerMetadata?: { tool?: string } } }).request?.providerMetadata?.tool === "AskUserQuestion"), + ); + + const request = (approvalEvent.event.detail as { + request: { + kind: string; + questions: Array<{ + id: string; + question: string; + options?: Array<{ preview?: string; previewFormat?: string }>; + }>; + }; + }).request; + expect(request.kind).toBe("structured_question"); + expect(request.questions.map((question) => question.question)).toEqual([ + "What should we do about the two task list views?", + "Should the inline task list pin while tasks are active?", + ]); + expect(request.questions[0]?.options?.[0]).toMatchObject({ + preview: "
Inline only

Compact stream, no bottom summary card.

", + previewFormat: "html", + }); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + answers: { + "What should we do about the two task list views?": "Keep both, improve summary", + "Should the inline task list pin while tasks are active?": "Yes, pin while active", + }, + }); + + await sendPromise; + + expect(permissionResult).toMatchObject({ + behavior: "allow", + updatedInput: { + answers: { + "What should we do about the two task list views?": "Keep both, improve summary", + "Should the inline task list pin while tasks are active?": "Yes, pin while active", + }, + }, + }); + }); + it("initializes the Cursor runtime before validating the first turn", async () => { const events: AgentChatEventEnvelope[] = []; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index bbe2bbe39..85de2f67b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -816,7 +816,7 @@ const MAX_TRANSCRIPT_READ_CHARS = 40_000; const AUTO_TITLE_MAX_CHARS = 48; const REASONING_ACTIVITY_DETAIL = "Thinking through the answer"; const WORKING_ACTIVITY_DETAIL = "Preparing response"; -const TURN_TIMEOUT_MS = 300_000; // 5 minutes – overall turn-level timeout +const RUN_SESSION_TURN_WAIT_TIMEOUT_MS = 300_000; // default wait for programmatic runSessionTurn callers const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 75_000; const AUTO_TITLE_SYSTEM_PROMPT = `You title software development chat sessions. Return only the title text. @@ -2493,6 +2493,110 @@ export function createAgentChatService(args: { return headline; }; + const hasClaudeAskUserAnswers = (input: Record): boolean => { + const answers = asRecord(input.answers); + if (!answers) return false; + return Object.values(answers).some((value) => typeof value === "string" && value.trim().length > 0); + }; + + const buildClaudeAskUserPendingRequest = ( + runtime: ClaudeRuntime, + input: Record, + sdkOptions?: { toolUseID?: string }, + ): PendingInputRequest | null => { + const rawQuestions = Array.isArray(input.questions) ? input.questions : []; + const questions: PendingInputQuestion[] = []; + + for (const rawQuestion of rawQuestions) { + const questionRecord = asRecord(rawQuestion); + if (!questionRecord) continue; + + const question = typeof questionRecord.question === "string" ? questionRecord.question.trim() : ""; + if (!question.length) continue; + + const header = typeof questionRecord.header === "string" ? questionRecord.header.trim() : ""; + const isMultiSelect = questionRecord.multiSelect === true; + const options = Array.isArray(questionRecord.options) + ? questionRecord.options + .map((rawOption) => { + const optionRecord = asRecord(rawOption); + if (!optionRecord) return null; + const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : ""; + if (!label.length) return null; + const description = typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; + const preview = typeof optionRecord.preview === "string" ? optionRecord.preview : ""; + return { + label, + value: label, + ...(description.length ? { description } : {}), + ...(label.endsWith("(Recommended)") ? { recommended: true } : {}), + ...(preview.trim().length ? { preview, previewFormat: "html" as const } : {}), + }; + }) + .filter((option): option is NonNullable => option != null) + : []; + + questions.push({ + id: question, + question, + ...(header.length ? { header } : {}), + ...(options.length ? { options } : {}), + ...(isMultiSelect ? { multiSelect: true } : {}), + allowsFreeform: true, + ...(isMultiSelect + ? { + impact: + "This question allows multiple selections. If you want more than one option, type them as a comma-separated answer.", + } + : {}), + }); + } + + if (questions.length === 0) return null; + + const firstQuestion = questions[0]; + const hasStructuredChoices = questions.length > 1 || questions.some((question) => (question.options?.length ?? 0) > 0); + const itemId = randomUUID(); + return { + requestId: itemId, + itemId, + source: "claude", + kind: hasStructuredChoices ? "structured_question" : "question", + title: questions.length === 1 ? "Question from Claude" : "Questions from Claude", + description: questions.length === 1 + ? firstQuestion?.question ?? "Claude needs an answer before it can continue." + : "Claude needs a few answers before it can continue.", + questions, + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + tool: "AskUserQuestion", + questionCount: questions.length, + ...(sdkOptions?.toolUseID ? { toolUseID: sdkOptions.toolUseID } : {}), + }, + turnId: runtime.activeTurnId ?? null, + }; + }; + + const buildClaudeAskUserUpdatedInput = ( + input: Record, + request: PendingInputRequest, + response: { answers?: Record; responseText?: string | null }, + ): Record => { + const normalizedAnswers = normalizePendingInputAnswers(request, response.answers, response.responseText); + const mappedAnswers = Object.fromEntries( + Object.entries(normalizedAnswers) + .map(([questionId, values]) => [questionId, values.join(", ").trim()] as const) + .filter(([, answer]) => answer.length > 0), + ); + + return { + ...input, + ...(Object.keys(mappedAnswers).length > 0 ? { answers: mappedAnswers } : {}), + }; + }; + const buildClaudeCanUseTool = ( runtime: ClaudeRuntime, managed: ManagedChatSession, @@ -2590,6 +2694,57 @@ export function createAgentChatService(args: { }; } + if (toolName === "AskUserQuestion") { + if (hasClaudeAskUserAnswers(input)) { + return { behavior: "allow" }; + } + + const request = buildClaudeAskUserPendingRequest(runtime, input, sdkOptions); + if (!request) { + return { behavior: "allow" }; + } + + const approvalItemId = request.itemId ?? request.requestId; + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? "Claude needs input before it can continue.", + detail: { + tool: "AskUserQuestion", + questionCount: request.questions.length, + ...(sdkOptions?.toolUseID ? { toolUseID: sdkOptions.toolUseID } : {}), + }, + }); + + let response: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; + try { + response = await new Promise((resolve) => { + runtime.approvals.set(approvalItemId, { kind: "question", resolve, request }); + }); + } finally { + runtime.approvals.delete(approvalItemId); + } + + if (response.decision === "cancel" || response.decision === "decline") { + return { + behavior: "deny", + message: "The user declined to answer the questions.", + }; + } + + const updatedInput = buildClaudeAskUserUpdatedInput(input, request, response); + if (!hasClaudeAskUserAnswers(updatedInput)) { + return { + behavior: "deny", + message: "The user did not provide answers to the questions.", + }; + } + + return { + behavior: "allow", + updatedInput, + }; + } + // ── Memory orientation guard ── const state = runtime.turnMemoryPolicyState; if (isMemorySearchToolName(toolName) && state) { @@ -5506,9 +5661,54 @@ export function createAgentChatService(args: { let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); const emittedSyntheticItemIds = new Set(); + const openClaudeToolUses = new Map(); const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); const emittedClaudeTodoIds = new Set(); + const emitClaudeToolCompletion = ( + itemId: string, + result: Record, + ): void => { + const toolMeta = openClaudeToolUses.get(itemId); + if (!toolMeta) return; + openClaudeToolUses.delete(itemId); + emitChatEvent(managed, { + type: "tool_result", + tool: toolMeta.toolName, + result, + itemId, + turnId, + status: "completed", + }); + }; + const completeClaudeToolUsesFromSummary = ( + toolUseIds: string[], + summaryText: string, + ): void => { + const cleanedSummary = summaryText.trim(); + for (const toolUseId of toolUseIds) { + const normalizedToolUseId = toolUseId.trim(); + if (!normalizedToolUseId || !openClaudeToolUses.has(normalizedToolUseId)) continue; + emitClaudeToolCompletion(normalizedToolUseId, { + synthetic: true, + source: "claude_tool_use_summary", + summary: cleanedSummary || `Completed ${openClaudeToolUses.get(normalizedToolUseId)?.toolName ?? "tool"}.`, + }); + } + }; + const flushOpenClaudeToolUses = ( + finalTurnStatus: "completed" | "failed" | "interrupted", + ): void => { + const remainingToolUses = [...openClaudeToolUses.entries()]; + for (const [itemId, toolMeta] of remainingToolUses) { + emitClaudeToolCompletion(itemId, { + synthetic: true, + source: "claude_turn_finalization", + finalTurnStatus, + summary: `Completed ${toolMeta.toolName} when the Claude turn ended.`, + }); + } + }; const maybeEmitTodoUpdate = (toolName: string, input: unknown, itemId: string): void => { if (toolName !== "TodoWrite") return; if (emittedClaudeTodoIds.has(itemId)) return; @@ -5517,7 +5717,6 @@ export function createAgentChatService(args: { emittedClaudeTodoIds.add(itemId); emitChatEvent(managed, { type: "todo_update", items: todoItems, turnId }); }; - let turnTimeout: ReturnType | undefined; let idleTimeout: ReturnType | undefined; let timeoutError: Error | null = null; const buildDoneModelPayload = (): { model: string; modelId?: string } => @@ -5548,10 +5747,6 @@ export function createAgentChatService(args: { return `claude-${kind}:${turnId}:${contentIndex}`; }; const clearClaudeTurnTimers = (): void => { - if (turnTimeout) { - clearTimeout(turnTimeout); - turnTimeout = undefined; - } if (idleTimeout) { clearTimeout(idleTimeout); idleTimeout = undefined; @@ -5567,7 +5762,8 @@ export function createAgentChatService(args: { }); cancelClaudeWarmup(managed, runtime, "timeout"); try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.sdkSessionId = null; + // Keep the persisted Claude V2 session id so the next turn can resume + // the same conversation after this local process is torn down. }; const bumpClaudeIdleDeadline = (): void => { if (idleTimeout) { @@ -5582,13 +5778,6 @@ export function createAgentChatService(args: { }; try { - turnTimeout = setTimeout(() => { - failClaudeTurn( - `Claude turn exceeded ${Math.round(TURN_TIMEOUT_MS / 1000)}s. The runtime was reset so you can retry.`, - "timeout", - ); - }, TURN_TIMEOUT_MS); - const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); @@ -5959,6 +6148,7 @@ export function createAgentChatService(args: { const nextActivity = activityForToolName(toolName); if (!emittedClaudeToolIds.has(itemId)) { emittedClaudeToolIds.add(itemId); + openClaudeToolUses.set(itemId, { toolName }); emitChatEvent(managed, { type: "activity", activity: nextActivity.activity, @@ -6075,6 +6265,7 @@ export function createAgentChatService(args: { const nextActivity = activityForToolName(toolName); if (!emittedClaudeToolIds.has(itemId)) { emittedClaudeToolIds.add(itemId); + openClaudeToolUses.set(itemId, { toolName }); emitChatEvent(managed, { type: "activity", activity: nextActivity.activity, @@ -6204,10 +6395,12 @@ export function createAgentChatService(args: { // tool_use_summary — summarizes groups of tool calls if ((msg as any).type === "tool_use_summary") { const summaryMsg = msg as any; + const toolUseIds = Array.isArray(summaryMsg.preceding_tool_use_ids) ? summaryMsg.preceding_tool_use_ids.map(String) : []; + completeClaudeToolUsesFromSummary(toolUseIds, String(summaryMsg.summary ?? "")); emitChatEvent(managed, { type: "tool_use_summary", summary: String(summaryMsg.summary ?? ""), - toolUseIds: Array.isArray(summaryMsg.preceding_tool_use_ids) ? summaryMsg.preceding_tool_use_ids.map(String) : [], + toolUseIds, turnId, }); continue; @@ -6247,6 +6440,7 @@ export function createAgentChatService(args: { // ── Turn completion ── clearClaudeTurnTimers(); + flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); // Note: v2Session is NOT closed here — it stays alive for the next turn runtime.activeQuery = null; runtime.busy = false; @@ -6300,6 +6494,12 @@ export function createAgentChatService(args: { runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; + const effectiveError = timeoutError ?? error; + const finalToolStatus: "completed" | "failed" | "interrupted" = + runtime.interrupted || isAbortRelatedError(effectiveError) + ? "interrupted" + : "failed"; + flushOpenClaudeToolUses(finalToolStatus); // Close V2 session on error so the next turn starts fresh try { runtime.v2Session?.close(); } catch { /* ignore */ } @@ -6317,7 +6517,28 @@ export function createAgentChatService(args: { status: "interrupted", ...doneModel, }); - } else if (isAbortRelatedError(error)) { + } else if (timeoutError) { + managed.session.status = "idle"; + const errorMessage = effectiveError instanceof Error ? effectiveError.message : String(effectiveError); + reportProviderRuntimeFailure("claude", errorMessage); + emitChatEvent(managed, { + type: "error", + message: errorMessage, + turnId, + }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + ...doneModel, + }); + + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: `Turn failed: ${errorMessage}`, + }); + } else if (isAbortRelatedError(effectiveError)) { // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. // Treat as interruption to avoid surfacing raw SDK messages like "aborted by user". managed.session.status = "idle"; @@ -6330,10 +6551,10 @@ export function createAgentChatService(args: { }); } else { managed.session.status = "idle"; - const isAuthFailure = isClaudeRuntimeAuthError(error); + const isAuthFailure = isClaudeRuntimeAuthError(effectiveError); const errorMessage = isAuthFailure ? CLAUDE_RUNTIME_AUTH_ERROR - : (error instanceof Error ? error.message : String(error)); + : (effectiveError instanceof Error ? effectiveError.message : String(effectiveError)); if (isAuthFailure) { reportProviderRuntimeAuthFailure("claude", CLAUDE_RUNTIME_AUTH_ERROR); } else { @@ -6358,11 +6579,11 @@ export function createAgentChatService(args: { }); // If resume failed, clear sessionId and the caller can retry fresh - if (runtime.sdkSessionId && String(error).includes("session")) { + if (runtime.sdkSessionId && String(effectiveError).includes("session")) { logger.warn("agent_chat.claude_sdk_session_error", { sessionId: managed.session.id, sdkSessionId: runtime.sdkSessionId, - error: error instanceof Error ? error.message : String(error), + error: effectiveError instanceof Error ? effectiveError.message : String(effectiveError), }); runtime.sdkSessionId = null; managed.runtimeInvalidated = true; @@ -6449,8 +6670,6 @@ export function createAgentChatService(args: { }); }; - let turnTimeout: ReturnType | undefined; - try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); @@ -6490,22 +6709,6 @@ export function createAgentChatService(args: { const abortController = new AbortController(); runtime.abortController = abortController; - // Turn-level timeout: abort if the entire turn exceeds the limit - turnTimeout = setTimeout(() => { - logger.warn("agent_chat.turn_timeout", { - sessionId: managed.session.id, - turnId, - timeoutMs: TURN_TIMEOUT_MS, - }); - emitChatEvent(managed, { - type: "error", - message: `Turn timed out after ${TURN_TIMEOUT_MS / 1000}s. The agent loop was aborted.`, - turnId, - }); - runtime.interrupted = true; - abortController.abort(); - }, TURN_TIMEOUT_MS); - const streamMessages = runtime.messages.map((message, index): ModelMessage => { const isCurrentUserMessage = index === runtime.messages.length - 1 && message.role === "user"; if (!isCurrentUserMessage) { @@ -7031,7 +7234,6 @@ export function createAgentChatService(args: { // ── Shared turn completion ── persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); - clearTimeout(turnTimeout); if (runtime.interrupted) { runtime.busy = false; runtime.activeTurnId = null; @@ -7086,7 +7288,6 @@ export function createAgentChatService(args: { } } } catch (error) { - clearTimeout(turnTimeout); runtime.busy = false; runtime.activeTurnId = null; runtime.abortController = null; @@ -7298,7 +7499,8 @@ export function createAgentChatService(args: { question?: string; isOther?: boolean; isSecret?: boolean; - options?: Array<{ label?: string; description?: string }> | null; + multiSelect?: boolean; + options?: Array<{ label?: string; description?: string; preview?: string; previewFormat?: "markdown" | "html" }> | null; }>; } | null) ?? {}; const itemId = String(params.itemId ?? randomUUID()); @@ -7312,10 +7514,12 @@ export function createAgentChatService(args: { const label = typeof option?.label === "string" ? option.label.trim() : ""; if (!label.length) return []; const description = typeof option?.description === "string" ? option.description.trim() : ""; + const preview = typeof option?.preview === "string" ? option.preview : ""; return [{ label, value: label, ...(description ? { description } : {}), + ...(preview.trim().length ? { preview, ...(option?.previewFormat ? { previewFormat: option.previewFormat } : {}) } : {}), }]; }) : []; @@ -7323,6 +7527,7 @@ export function createAgentChatService(args: { id: questionId, header: typeof question?.header === "string" && question.header.trim().length ? question.header.trim() : `Question ${index + 1}`, question: questionText, + ...(question?.multiSelect === true ? { multiSelect: true } : {}), allowsFreeform: question?.isOther === true || options.length === 0, isSecret: question?.isSecret === true, ...(options.length ? { options } : {}), @@ -8482,6 +8687,11 @@ export function createAgentChatService(args: { pathToClaudeCodeExecutable: claudeExecutable.path, }; if (!lightweight) { + opts.toolConfig = { + askUserQuestion: { + previewFormat: "html", + }, + }; opts.systemPrompt = { type: "preset", preset: "claude_code", @@ -12259,7 +12469,7 @@ export function createAgentChatService(args: { attachments = [], reasoningEffort, executionMode, - timeoutMs = 300_000, + timeoutMs = RUN_SESSION_TURN_WAIT_TIMEOUT_MS, }: AgentChatSendArgs & { timeoutMs?: number }): Promise<{ sessionId: string; provider: AgentChatProvider; diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index df2090841..4d27e7177 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -14,6 +14,46 @@ vi.mock("./git", () => ({ import { createGitOperationsService } from "./gitOperationsService"; +function createTestGitOperationsService(branchRef = "feature/stash-test") { + const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const mockFinish = vi.fn(); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef, + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { + start: mockStart, + finish: mockFinish, + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + return { + service, + mockStart, + mockFinish, + }; +} + describe("gitOperationsService.stashClear", () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,38 +62,7 @@ describe("gitOperationsService.stashClear", () => { it("calls git stash clear with the lane worktree path and returns the action result", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); mockGit.runGitOrThrow.mockResolvedValue(undefined); - - const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); - const mockFinish = vi.fn(); - - const service = createGitOperationsService({ - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/stash-test", - worktreePath: "/tmp/ade-lane", - laneType: "worktree", - }), - } as any, - operationService: { - start: mockStart, - finish: mockFinish, - } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, - aiIntegrationService: { - getFeatureFlag: () => false, - getStatus: vi.fn(async () => ({ availableModelIds: [] })), - generateCommitMessage: vi.fn(), - } as any, - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any, - }); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); const result = await service.stashClear({ laneId: "lane-1" }); @@ -81,6 +90,74 @@ describe("gitOperationsService.stashClear", () => { }); }); +describe("gitOperationsService stash item commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls git stash pop with the lane worktree path and stash ref", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); + + const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["stash", "pop", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_pop", + metadata: expect.objectContaining({ stashRef: "stash@{1}" }), + }), + ); + expect(mockFinish).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "op-1", + status: "succeeded", + }), + ); + }); + + it("calls git stash drop with the lane worktree path and stash ref", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); + + const result = await service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["stash", "drop", "stash@{0}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_drop", + metadata: expect.objectContaining({ stashRef: "stash@{0}" }), + }), + ); + expect(mockFinish).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "op-1", + status: "succeeded", + }), + ); + }); +}); + describe("gitOperationsService.generateCommitMessage", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 50d1c4440..68de57864 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -216,4 +216,29 @@ describe("AgentChatComposer", () => { expect(screen.queryByTitle("Include project context (PRD + architecture) with first message")).toBeNull(); }); + + it("uses a constrained resizable textarea in grid-tile mode", () => { + renderComposer({ + layoutVariant: "grid-tile", + composerMaxHeightPx: 128, + }); + + const textarea = screen.getByPlaceholderText("Steer the active turn...") as HTMLTextAreaElement; + expect(textarea.dataset.chatLayoutVariant).toBe("grid-tile"); + expect(textarea.style.maxHeight).toBe("128px"); + expect(textarea.className).toContain("resize-y"); + }); + + it("shows only the available session models when the chat catalog is restricted", () => { + renderComposer({ + availableModelIds: ["openai/gpt-5.4-codex", "openai/gpt-5.2-codex"], + restrictModelCatalogToAvailable: true, + turnActive: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + + expect(screen.getByText("GPT-5.2-Codex")).toBeTruthy(); + expect(screen.queryByText("Claude Sonnet 4.6")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 27fbfbd4c..3c1b3ac64 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -263,6 +263,8 @@ function PendingSteerItem({ export function AgentChatComposer({ surfaceMode = "standard", + layoutVariant = "standard", + composerMaxHeightPx = null, sdkSlashCommands = [], modelId, availableModelIds, @@ -320,12 +322,15 @@ export function AgentChatComposer({ promptSuggestion, subagentSnapshots = [], chatHasMessages = false, + restrictModelCatalogToAvailable = false, pendingSteers = [], onCancelSteer, onEditSteer, onOpenAiSettings, }: { surfaceMode?: ChatSurfaceMode; + layoutVariant?: "standard" | "grid-tile"; + composerMaxHeightPx?: number | null; sdkSlashCommands?: AgentChatSlashCommand[]; modelId: string; availableModelIds?: string[]; @@ -387,6 +392,7 @@ export function AgentChatComposer({ promptSuggestion?: string | null; subagentSnapshots?: ChatSubagentSnapshot[]; chatHasMessages?: boolean; + restrictModelCatalogToAvailable?: boolean; pendingSteers?: Array<{ steerId: string; text: string }>; onCancelSteer?: (steerId: string) => void; onEditSteer?: (steerId: string, text: string) => void; @@ -934,7 +940,10 @@ export function AgentChatComposer({ <> @@ -1112,6 +1121,7 @@ export function AgentChatComposer({ value={modelId} onChange={onModelChange} availableModelIds={availableModelIds} + catalogMode={restrictModelCatalogToAvailable ? "available-only" : "all"} disabled={modelSelectionLocked} showReasoning reasoningEffort={reasoningEffort} @@ -1306,9 +1316,14 @@ export function AgentChatComposer({ if (val.startsWith("/")) { setSlashQuery(val.slice(1)); setSlashCursor(0); } }} className={cn( - "min-h-[44px] max-h-[200px] w-full resize-none bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + "min-h-[44px] w-full bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + layoutVariant === "grid-tile" ? "resize-y" : "max-h-[200px] resize-none", dragActive ? "opacity-30" : "", )} + style={layoutVariant === "grid-tile" && composerMaxHeightPx != null + ? { maxHeight: `${composerMaxHeightPx}px` } + : undefined} + data-chat-layout-variant={layoutVariant} placeholder={turnActive ? "Steer the active turn..." : (promptSuggestion ? "" : (messagePlaceholder ?? "Message the assistant..."))} onKeyDown={handleKeyDown} onPaste={handlePaste} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index d23135fd3..621d79955 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -267,10 +267,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Work log (2)")).toBeTruthy(); - - // Only the most recent entry is visible by default; expand to reveal the older one - fireEvent.click(screen.getByRole("button", { name: "Show 1 more" })); + expect(screen.getByText("Ran shell")).toBeTruthy(); fireEvent.click(findButtonByTextContent(/npm test/i)); fireEvent.click(findButtonByTextContent(/npm run lint/i)); @@ -309,10 +306,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Work log (2)")).toBeTruthy(); - - // Only the most recent entry is visible by default; expand to reveal the older one - fireEvent.click(screen.getByRole("button", { name: "Show 1 more" })); + expect(screen.getByText("Edited files")).toBeTruthy(); fireEvent.click(findButtonByTextContent(/foo\.ts/i)); fireEvent.click(findButtonByTextContent(/bar\.ts/i)); @@ -322,7 +316,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(body).toContain("bar.ts"); }); - it("shows only the most recent work-log entry by default and expands overflow on demand", () => { + it("shows the four most recent work-log entries by default and expands overflow on demand", () => { renderMessageList( Array.from({ length: 7 }, (_, index) => ({ sessionId: "session-1", @@ -340,16 +334,98 @@ describe("AgentChatMessageList transcript rendering", () => { })), ); - expect(screen.getByText("Work log (7)")).toBeTruthy(); - expect(screen.getByText("Show 6 more")).toBeTruthy(); - expect(screen.queryByText(/Shell - echo 1/i)).toBeNull(); + expect(screen.getByText("Ran shell")).toBeTruthy(); + expect(screen.getByText("Show 3 earlier")).toBeTruthy(); + expect(screen.queryByText(/echo 3/i)).toBeNull(); + expect(findButtonByTextContent(/echo 4/i)).toBeTruthy(); expect(findButtonByTextContent(/echo 7/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Show 6 more" })); + fireEvent.click(screen.getByRole("button", { name: "Show 3 earlier" })); expect(findButtonByTextContent(/echo 1/i)).toBeTruthy(); }); + it("uses a bounded assistant bubble width for long markdown responses", () => { + const view = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "text", + text: "Streaming response", + itemId: "text-1", + turnId: "turn-1", + }, + }, + ]); + + expect(view.container.innerHTML).toContain("max-w-[78ch]"); + }); + + it("renders markdown tables inside a dedicated scroll shell", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "text", + text: [ + "| Aspect | ADE | Other UI |", + "| --- | --- | --- |", + "| Task progress | Flat tool cards | Step-based progress |", + ].join("\n"), + itemId: "text-table", + turnId: "turn-1", + }, + }, + ]); + + const table = screen.getByRole("table"); + expect(table.parentElement?.className).toContain("overflow-x-auto"); + expect(screen.getByText("Task progress")).toBeTruthy(); + }); + + it("absorbs tool summaries into the grouped work-log header instead of rendering a separate row", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_call", + tool: "functions.exec_command", + args: { cmd: "pwd" }, + itemId: "tool-1", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "tool_result", + tool: "functions.exec_command", + result: { stdout: "/tmp/project" }, + itemId: "tool-1", + turnId: "turn-1", + status: "completed", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:02.000Z", + event: { + type: "tool_use_summary", + summary: "Checked the current working directory", + toolUseIds: ["tool-1"], + turnId: "turn-1", + }, + }, + ]); + + expect(screen.getByText("Checked the current working directory")).toBeTruthy(); + expect(screen.queryByText("Tool summary")).toBeNull(); + }); + it("makes workspace markdown links open the Files tab", () => { renderMessageList( [ @@ -623,13 +699,16 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - expect(screen.getAllByText("1 of 2 tasks completed").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 file changed/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 background agent/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/2 complete").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 file$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 agent$/i).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("1 active").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("+1").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("-1").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole("button", { name: "Review changes" })).toBeNull(); + fireEvent.click(screen.getByText("Turn recap")); fireEvent.click(screen.getByRole("button", { name: "Review changes" })); expect(screen.getByTestId("location").textContent).toBe("/files::{\"laneId\":\"lane-123\"}"); @@ -819,12 +898,15 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - expect(screen.getAllByText("1 of 2 tasks completed").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/2 complete").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Inspect shared renderer").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Implement calmer transcript rows").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 file changed/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 background agent/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 file$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 agent$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole("button", { name: /Review changes/i })).toBeNull(); + fireEvent.click(screen.getByText("Turn recap")); fireEvent.click(screen.getByRole("button", { name: /Review changes/i })); expect(screen.getByTestId("location").textContent).toBe( @@ -857,7 +939,8 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("1 of 1 tasks completed")).toBeTruthy(); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/1 complete").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText(/Claude Sonnet 4\.6/).length).toBeGreaterThanOrEqual(1); }); @@ -896,14 +979,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - const reasoningButtons = screen.getAllByRole("button", { name: /Thought for/i }); + const reasoningButtons = screen.getAllByRole("button", { name: /Thought/i }); expect(reasoningButtons).toHaveLength(2); fireEvent.click(reasoningButtons[0]!); fireEvent.click(reasoningButtons[1]!); - expect(screen.getByText("First thought.")).toBeTruthy(); - expect(screen.getByText("Second thought.")).toBeTruthy(); + expect(screen.getAllByText("First thought.")).toHaveLength(2); + expect(screen.getAllByText("Second thought.")).toHaveLength(2); expect(screen.queryByText("First thought.Second thought.")).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index c204670f4..6fcad77b9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -539,7 +539,7 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ }, [onOpenWorkspacePath, workspaceLaneId]); return ( -
+
), table: ({ children }) => ( -
- {children}
+
+ {children}
), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), pre: ({ children }) => (
               {children}
@@ -1147,7 +1160,7 @@ function renderEvent(
         
+ {isLive ? ( Thinking... ) : ( - `Thought for ${durationLabel}` + <> + Thought + {durationLabel ? ( + + {durationLabel} + + ) : null} + {reasoningPreview ? ( + + {reasoningPreview} + + ) : null} + )} } @@ -2261,74 +2287,147 @@ function TurnSummaryCard({ const completedCount = summary.tasks.filter((task) => task.status === "completed").length; const totalCount = summary.tasks.length; const filesLabel = summary.files.length - ? `${summary.files.length} file${summary.files.length === 1 ? "" : "s"} changed` + ? `${summary.files.length} file${summary.files.length === 1 ? "" : "s"}` : null; const agentsLabel = summary.backgroundAgentCount - ? `${summary.backgroundAgentCount} background agent${summary.backgroundAgentCount === 1 ? "" : "s"}` + ? `${summary.backgroundAgentCount} agent${summary.backgroundAgentCount === 1 ? "" : "s"}` : null; + const taskLabel = totalCount ? `${completedCount}/${totalCount} complete` : null; + const hasDetails = summary.tasks.length > 0 || summary.files.length > 0 || summary.backgroundAgentCount > 0; + + const summaryHeader = ( +
+ + + Turn recap + + {taskLabel ? ( + {taskLabel} + ) : null} + {filesLabel ? ( + + {filesLabel} + {summary.totalAdditions > 0 ? +{summary.totalAdditions} : null} + {summary.totalDeletions > 0 ? -{summary.totalDeletions} : null} + + ) : null} + {agentsLabel ? ( + + {agentsLabel} + {summary.activeBackgroundAgentCount > 0 ? {summary.activeBackgroundAgentCount} active : null} + + ) : null} + {summary.turnModel?.label ? ( + + + {summary.turnModel.label} + + ) : null} +
+ ); - return ( -
-
-
- - - {totalCount - ? `${completedCount} of ${totalCount} tasks completed` - : filesLabel ?? agentsLabel ?? "Turn summary"} - - {summary.turnModel?.label ? ( - - - {summary.turnModel.label} + const details = hasDetails ? ( +
+ {summary.tasks.length > 0 ? ( +
+
Tasks
+
+ {summary.tasks.map((task) => ( +
+
+ +
+
+ {task.description} +
+ + {task.status.replace("_", " ")} + +
+ ))} +
+
+ ) : null} + {summary.files.length > 0 ? ( +
+
Files
+
+ {summary.files.map((file) => { + const basename = basenamePathLabel(file.path); + const dirname = dirnamePathLabel(file.path); + return ( +
+
+ +
+
+
+ {formatFileAction(file.kind)} + {basename} + {file.additions > 0 ? +{file.additions} : null} + {file.deletions > 0 || file.kind === "delete" ? -{file.deletions} : null} +
+ {dirname ? ( +
+ {dirname} +
+ ) : null} +
+
+ ); + })} +
+
+ ) : null} + {summary.backgroundAgentCount > 0 ? ( +
+
Background agents
+
+ + + {summary.backgroundAgentCount} running in the background + {summary.activeBackgroundAgentCount > 0 ? `, ${summary.activeBackgroundAgentCount} currently active` : ""} - ) : null} - {onReviewChanges && summary.files.length > 0 ? ( - - ) : null} +
-
- - {summary.tasks.length ? ( -
- {summary.tasks.map((task, index) => ( -
-
- -
-
- {task.description} -
-
- ))} + ) : null} + {onReviewChanges && summary.files.length > 0 ? ( +
+
) : null} - -
- {filesLabel ? ( - - {filesLabel} - {summary.totalAdditions > 0 ? +{summary.totalAdditions} : null} - {summary.totalDeletions > 0 ? -{summary.totalDeletions} : null} - - ) : null} - {agentsLabel ? ( - - {agentsLabel} - {summary.activeBackgroundAgentCount > 0 ? {summary.activeBackgroundAgentCount} active : null} - - ) : null} -
+ ) : null; + + return ( + + {details} + ); } @@ -2361,7 +2460,7 @@ function deriveActiveTurnId(events: AgentChatEventEnvelope[]): string | null { function getGroupedTurnId(envelope: TranscriptGroupedEnvelope | undefined): string | null { if (!envelope) return null; if (envelope.event.type === "work_log_group") { - return envelope.event.entries[0]?.turnId ?? null; + return envelope.event.turnId ?? envelope.event.entries[0]?.turnId ?? null; } return "turnId" in envelope.event ? envelope.event.turnId ?? null : null; } @@ -2421,6 +2520,7 @@ const EventRow = React.memo(function EventRow({ ? ( ) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 23906ca95..d429f865d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router-dom"; import { createDefaultComputerUsePolicy, type AgentChatEventEnvelope, + type AgentChatSession, type AgentChatSessionSummary, } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; @@ -41,6 +42,24 @@ function buildSession(sessionId: string, overrides: Partial = {}): AgentChatSession { + return { + id: sessionId, + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4-codex", + status: "idle", + sessionProfile: "workflow", + reasoningEffort: "xhigh", + executionMode: "focused", + computerUse: createDefaultComputerUsePolicy(), + createdAt: "2026-03-24T05:57:45.700Z", + lastActivityAt: "2026-03-24T05:57:45.700Z", + ...overrides, + }; +} + function buildStatusStartedTranscript(sessionId: string): string { return `${JSON.stringify({ sessionId, @@ -76,7 +95,7 @@ function installAdeMocks(options?: { sendError?: Error; steerError?: Error; listError?: Error; - handoffResult?: { session: { id: string }; usedFallbackSummary: boolean }; + handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean }; sessions?: AgentChatSessionSummary[]; includeClaudeModel?: boolean; }) { @@ -90,9 +109,10 @@ function installAdeMocks(options?: { ? vi.fn().mockRejectedValue(options.listError) : vi.fn().mockResolvedValue(options?.sessions ?? [buildSession("session-1")]); const handoff = vi.fn().mockResolvedValue(options?.handoffResult ?? { - session: { id: "handoff-session-1" }, + session: buildCreatedSession("handoff-session-1"), usedFallbackSummary: false, }); + const create = vi.fn().mockResolvedValue(buildCreatedSession("created-session")); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); globalThis.window.ade = { @@ -134,7 +154,7 @@ function installAdeMocks(options?: { respondToInput: vi.fn().mockResolvedValue(undefined), warmupModel: vi.fn().mockResolvedValue(undefined), fileSearch: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ id: "created-session" }), + create, dispose: vi.fn().mockResolvedValue(undefined), }, sessions: { @@ -158,6 +178,7 @@ function installAdeMocks(options?: { send, steer, list, + create, handoff, emitChatEvent: (event: AgentChatEventEnvelope) => { for (const listener of chatEventListeners) { @@ -257,6 +278,37 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByLabelText("Waiting for your input")).toBeTruthy(); }); + it("falls back to the session summary when a chat is awaiting input", async () => { + const session = buildSession("session-1", { + status: "active", + awaitingInput: true, + }); + installAdeMocks({ + sessions: [session], + }); + + renderTabbedPane(session); + + expect(await screen.findByLabelText("Waiting for your input")).toBeTruthy(); + expect(screen.queryByLabelText("Agent working")).toBeNull(); + }); + + it("does not keep showing a working indicator when the session summary is idle", async () => { + const session = buildSession("session-1", { + status: "idle", + }); + installAdeMocks({ + sessions: [session], + transcript: buildStatusStartedTranscript(session.sessionId), + }); + + renderTabbedPane(session); + + await waitFor(() => { + expect(screen.queryByLabelText("Agent working")).toBeNull(); + }); + }); + it("keeps the draft cleared after send succeeds even if session refresh fails", async () => { const session = buildSession("session-1"); const { send, list } = installAdeMocks({ @@ -600,7 +652,7 @@ describe("AgentChatPane submit recovery", () => { const onSessionCreated = vi.fn().mockResolvedValue(undefined); const { handoff } = installAdeMocks({ handoffResult: { - session: { id: "session-2" }, + session: buildCreatedSession("session-2"), usedFallbackSummary: false, }, }); @@ -625,7 +677,43 @@ describe("AgentChatPane submit recovery", () => { sourceSessionId: session.sessionId, targetModelId: "openai/gpt-5.4-mini", }); - expect(onSessionCreated).toHaveBeenCalledWith("session-2"); + expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "session-2" })); + }); + }); + + it("does not wait for onSessionCreated before sending the first message in a new chat", async () => { + const onSessionCreated = vi.fn().mockImplementation(() => new Promise(() => {})); + const { send, create } = installAdeMocks({ sessions: [] }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: /OpenAI/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Ship the instant route fix." } }); + fireEvent.click(screen.getByTitle("Send")); + + await waitFor(() => { + expect(create).toHaveBeenCalled(); + expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "created-session" })); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "Ship the instant route fix.", + displayText: "Ship the instant route fix.", + })); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a84bfa5f4..0264d4e73 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -14,6 +14,7 @@ import { type AgentChatFileRef, type AgentChatInteractionMode, type AiProviderConnectionStatus, + type AgentChatSession, type AgentChatUnifiedPermissionMode, type AgentChatSessionProfile, type ChatSurfaceChip, @@ -534,6 +535,7 @@ export function AgentChatPane({ permissionModeLocked = false, presentation, embeddedWorkLayout = false, + layoutVariant = "standard", onSessionCreated, }: { laneId: string | null; @@ -550,7 +552,8 @@ export function AgentChatPane({ presentation?: ChatSurfacePresentation; /** Work tab draft: flatter shell, no duplicate header chrome above the composer. */ embeddedWorkLayout?: boolean; - onSessionCreated?: (sessionId: string) => void | Promise; + layoutVariant?: "standard" | "grid-tile"; + onSessionCreated?: (session: AgentChatSession) => void | Promise; }) { const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { @@ -611,6 +614,8 @@ export function AgentChatPane({ const [handoffOpen, setHandoffOpen] = useState(false); const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); + const shellRef = useRef(null); + const [composerMaxHeightPx, setComposerMaxHeightPx] = useState(null); const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); const loadedHistoryRef = useRef>(new Set()); @@ -652,6 +657,8 @@ export function AgentChatPane({ return [...selectedEvents, optimisticOutgoingMessage.envelope]; }, [optimisticOutgoingMessage, selectedEvents, selectedSessionId]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); + const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; + const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; const activeProviderConnection = selectedSession?.provider === "claude" ? (providerConnections?.claude ?? null) @@ -660,7 +667,6 @@ export function AgentChatPane({ : selectedSession?.provider === "cursor" ? (providerConnections?.cursor ?? null) : null; - const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; const pendingApprovalIds = useMemo(() => { const ids = new Set(); for (const entry of pendingInputsBySession[selectedSessionId ?? ""] ?? []) { @@ -783,7 +789,7 @@ export function AgentChatPane({ && !isPersistentIdentitySurface && (selectedSession.surface ?? "work") === "work", ); - const handoffBlocked = turnActive || Boolean(pendingInput) || handoffBusy; + const handoffBlocked = turnActive || selectedSessionAwaitingInput || handoffBusy; const handoffButtonTitle = handoffBlocked ? "Wait for the current output or approval to finish before handing off this chat." : "Create a new work chat on another model and seed it with a summary of this chat."; @@ -878,6 +884,17 @@ export function AgentChatPane({ const rows = await window.ade.agentChat.list({ laneId }); const nextRows = sortSessionSummariesByRecency(rows, localTouchBySessionRef.current); setSessions(nextRows); + setTurnActiveBySession((prev) => { + let next: Record | null = null; + for (const row of nextRows) { + const shouldAppearRunning = row.status === "active" && row.awaitingInput !== true; + if ((prev[row.sessionId] ?? false) && !shouldAppearRunning) { + next ??= { ...prev }; + next[row.sessionId] = false; + } + } + return next ?? prev; + }); const nextSessionIds = new Set(nextRows.map((row) => row.sessionId)); for (const sessionId of [...localTouchBySessionRef.current.keys()]) { if (!nextSessionIds.has(sessionId) && !optimisticSessionIdsRef.current.has(sessionId)) { @@ -994,7 +1011,10 @@ export function AgentChatPane({ } }, []); - const loadHistory = useCallback(async (sessionId: string) => { + const loadHistory = useCallback(async (sessionId: string, options?: { force?: boolean }) => { + if (options?.force) { + loadedHistoryRef.current.delete(sessionId); + } if (loadedHistoryRef.current.has(sessionId)) return; loadedHistoryRef.current.add(sessionId); @@ -1027,15 +1047,18 @@ export function AgentChatPane({ } const derived = deriveRuntimeState(merged); + const sessionSummary = sessions.find((entry) => entry.sessionId === sessionId) + ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); + const allowRunningFromSummary = sessionSummary?.status === "active" && sessionSummary.awaitingInput !== true; eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); - setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: derived.turnActive })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: allowRunningFromSummary ? derived.turnActive : false })); setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: derived.pendingSteers })); } catch { // Ignore transcript history failures. } - }, []); + }, [initialSessionSummary, sessions]); const clearSessionView = useCallback((sessionId: string) => { eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; @@ -1578,6 +1601,10 @@ export function AgentChatPane({ setModelId(snapshot.nextModelId); setReasoningEffort(snapshot.nextReasoningEffort); }, []); + const notifySessionCreated = useCallback((session: AgentChatSession) => { + if (!onSessionCreated) return; + void Promise.resolve(onSessionCreated(session)).catch(() => {}); + }, [onSessionCreated]); const createSession = useCallback(async (): Promise => { if (createSessionPromiseRef.current) { @@ -1611,11 +1638,8 @@ export function AgentChatPane({ modelId, }).then(() => refreshSessions()).catch(() => { /* warmup is best-effort */ }); } - // Await tab navigation and session-list refresh before returning so the - // caller doesn't send the first message while the user is still on the - // blank "new chat" screen. - await onSessionCreated?.(created.id); - await refreshSessions().catch(() => {}); + notifySessionCreated(created); + void refreshSessions().catch(() => {}); return created.id; })(); createSessionPromiseRef.current = createPromise; @@ -1626,7 +1650,7 @@ export function AgentChatPane({ createSessionPromiseRef.current = null; } } - }, [buildNativeControlPayload, computerUsePolicy, laneId, modelId, onSessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [buildNativeControlPayload, computerUsePolicy, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); const handoffSession = useCallback(async () => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; @@ -1638,14 +1662,14 @@ export function AgentChatPane({ targetModelId: handoffModelId, }); setHandoffOpen(false); - await onSessionCreated?.(result.session.id); + notifySessionCreated(result.session); void refreshSessions().catch(() => {}); } catch (handoffError) { setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); } finally { setHandoffBusy(false); } - }, [canShowHandoff, handoffBlocked, handoffModelId, onSessionCreated, refreshSessions, selectedSessionId]); + }, [canShowHandoff, handoffBlocked, handoffModelId, notifySessionCreated, refreshSessions, selectedSessionId]); // ── Eager session creation ── // Create a session as soon as we have a model + lane, so slash commands, @@ -1952,6 +1976,23 @@ export function AgentChatPane({ } }, [isPersistentIdentitySurface, patchSessionSummary, refreshComputerUseSnapshot, refreshSessions, selectedSessionId, sessionMutationKind]); + useEffect(() => { + if (layoutVariant !== "grid-tile") { + setComposerMaxHeightPx(null); + return; + } + const node = shellRef.current; + if (!node || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const next = Math.max(96, Math.min(168, Math.floor(entry.contentRect.height * 0.28))); + setComposerMaxHeightPx(next); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [layoutVariant]); + if (!laneId) { return ( @@ -2024,6 +2065,7 @@ export function AgentChatPane({ value={handoffModelId} onChange={setHandoffModelId} availableModelIds={handoffAvailableModelIds} + catalogMode="available-only" showReasoning={false} onOpenAiSettings={openAiProvidersSettings} /> @@ -2071,8 +2113,8 @@ export function AgentChatPane({ {sessions.map((session) => { const title = chatSessionTitle(session); const isActive = session.sessionId === selectedSessionId; - const isRunning = turnActiveBySession[session.sessionId] ?? false; - const sessionNeedsInput = Boolean(pendingInputsBySession[session.sessionId]?.length); + const sessionNeedsInput = Boolean(pendingInputsBySession[session.sessionId]?.length) || session.awaitingInput === true; + const isRunning = !sessionNeedsInput && turnActiveBySession[session.sessionId] === true; const sessionIndicatorStatus = sessionNeedsInput ? "waiting" : isRunning ? "working" : null; return (
) : selectedSessionId ? ( -
+
{/* Chat column */} -
+
-
- Artifacts - + layoutVariant === "grid-tile" ? ( +
+
+ Artifacts + +
+
+ refreshComputerUseSnapshot(selectedSessionId, { force: true })} + /> +
-
- refreshComputerUseSnapshot(selectedSessionId, { force: true })} - /> + ) : ( +
+
+ Artifacts + +
+
+ refreshComputerUseSnapshot(selectedSessionId, { force: true })} + /> +
-
+ ) ) : null}
) : ( diff --git a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx new file mode 100644 index 000000000..119d7f736 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx @@ -0,0 +1,198 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { PendingInputRequest } from "../../../shared/types"; +import { AgentQuestionModal } from "./AgentQuestionModal"; + +afterEach(cleanup); + +function renderModal(request: PendingInputRequest) { + const onClose = vi.fn(); + const onDecline = vi.fn(); + const onSubmit = vi.fn(); + + render( + , + ); + + return { onClose, onDecline, onSubmit }; +} + +describe("AgentQuestionModal", () => { + it("supports multi-select answers and preview rendering", () => { + const request: PendingInputRequest = { + requestId: "req-1", + itemId: "item-1", + source: "claude", + kind: "structured_question", + title: "Task list decision", + description: "Claude needs help choosing a layout.", + questions: [ + { + id: "layout", + header: "Layout", + question: "Which layouts should we keep exploring?", + multiSelect: true, + allowsFreeform: true, + options: [ + { + label: "Cards", + value: "Cards", + preview: "
Cards preview panel
", + previewFormat: "html", + }, + { + label: "Table", + value: "Table", + preview: "
Table preview panel
", + previewFormat: "html", + recommended: true, + }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + expect(screen.getByText("Task list decision")).toBeTruthy(); + expect(screen.getByText("Table preview panel")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Cards/i })); + expect(screen.getByText("Cards preview panel")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Table/i })); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Kanban, Timeline" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /Send answer/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + answers: { + layout: ["Cards", "Table", "Kanban", "Timeline"], + }, + responseText: null, + }); + }); + + it("does not allow passive dismissal for blocking requests", () => { + const request: PendingInputRequest = { + requestId: "req-blocking", + itemId: "item-blocking", + source: "claude", + kind: "structured_question", + title: "Blocking clarification", + description: "Claude needs a decision before it can continue.", + questions: [ + { + id: "direction", + header: "Direction", + question: "Which direction should we take?", + allowsFreeform: true, + options: [ + { label: "Option A", value: "Option A" }, + { label: "Option B", value: "Option B" }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onClose } = renderModal(request); + + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.click(screen.getByText("Blocking clarification").closest(".fixed") as HTMLElement); + + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Close question modal")).toBeNull(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); + }); + + it("lets freeform text override a single selected option", () => { + const request: PendingInputRequest = { + requestId: "req-2", + itemId: "item-2", + source: "claude", + kind: "structured_question", + description: "Claude needs a final preference.", + questions: [ + { + id: "summary", + header: "Summary", + question: "What should the summary card do?", + allowsFreeform: true, + options: [ + { label: "Collapse automatically", value: "Collapse automatically" }, + { label: "Stay expanded", value: "Stay expanded" }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + fireEvent.click(screen.getByRole("button", { name: /Collapse automatically/i })); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Collapse unless the agent is actively streaming." }, + }); + + fireEvent.click(screen.getByRole("button", { name: /Send answer/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + answers: { + summary: "Collapse unless the agent is actively streaming.", + }, + responseText: "Collapse unless the agent is actively streaming.", + }); + }); + + it("submits on Cmd/Ctrl+Enter when answers are ready", () => { + const request: PendingInputRequest = { + requestId: "req-shortcut", + itemId: "item-shortcut", + source: "claude", + kind: "question", + description: "Claude needs a concise answer.", + questions: [ + { + id: "reply", + header: "Reply", + question: "What should Claude do next?", + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + fireEvent.change(screen.getByRole("textbox", { name: "Reply" }), { + target: { value: "Use the shared modal everywhere chat questions appear." }, + }); + fireEvent.keyDown(window, { key: "Enter", metaKey: true }); + + expect(onSubmit).toHaveBeenCalledWith({ + answers: { + reply: "Use the shared modal everywhere chat questions appear.", + }, + responseText: "Use the shared modal everywhere chat questions appear.", + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx index 87bbc7a7b..ec9b910ac 100644 --- a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx @@ -1,4 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; import { ChatCircleText, HandPalm, PaperPlaneTilt, X } from "@phosphor-icons/react"; import type { PendingInputRequest } from "../../../shared/types"; import { Button } from "../ui/Button"; @@ -10,76 +14,146 @@ type AgentQuestionModalProps = { onDecline?: () => void; }; +type QuestionDraft = { + text: string; + selectedValues: string[]; + activePreviewValue: string | null; +}; + +function createEmptyDraft(): QuestionDraft { + return { + text: "", + selectedValues: [], + activePreviewValue: null, + }; +} + export function AgentQuestionModal({ request, onClose, onSubmit, onDecline, }: AgentQuestionModalProps) { - const [answers, setAnswers] = useState>({}); + const [drafts, setDrafts] = useState>({}); + const passiveDismissAllowed = request.canProceedWithoutAnswer; + const questionCountLabel = request.questions.length === 1 ? "1 question" : `${request.questions.length} questions`; + const modalTitle = request.title?.trim().length + ? request.title.trim() + : request.kind === "structured_question" + ? "Input needed" + : "Agent question"; useEffect(() => { - setAnswers({}); + setDrafts({}); }, [request.itemId, request.requestId]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.stopPropagation(); - onClose(); - } - }; - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }, [onClose]); + const getDraft = (questionId: string): QuestionDraft => drafts[questionId] ?? createEmptyDraft(); const normalizedAnswers = useMemo(() => { - return Object.fromEntries( - Object.entries(answers) - .map(([questionId, value]) => [questionId, value.trim()]) - .filter((entry): entry is [string, string] => entry[1].length > 0), - ); - }, [answers]); + const next: Record = {}; + + for (const question of request.questions) { + const draft = drafts[question.id] ?? createEmptyDraft(); + const selectedValues = draft.selectedValues + .map((value) => value.trim()) + .filter((value, index, values) => value.length > 0 && values.indexOf(value) === index); + const text = draft.text.trim(); + + if (question.multiSelect) { + const extraValues = text.length > 0 + ? text.split(",").map((value) => value.trim()).filter((value) => value.length > 0) + : []; + const values = [...selectedValues]; + for (const value of extraValues) { + if (!values.includes(value)) values.push(value); + } + if (values.length > 0) { + next[question.id] = values; + } + continue; + } + + if (text.length > 0) { + next[question.id] = text; + continue; + } + + if (selectedValues[0]) { + next[question.id] = selectedValues[0]; + } + } + + return next; + }, [drafts, request.questions]); const canSubmit = request.canProceedWithoutAnswer || request.questions.every((question) => { const value = normalizedAnswers[question.id]; - return typeof value === "string" && value.length > 0; + return (typeof value === "string" && value.length > 0) + || (Array.isArray(value) && value.length > 0); }); - const handleSubmit = () => { + const handleSubmit = useCallback(() => { const primaryQuestionId = request.questions[0]?.id; + const primaryAnswer = primaryQuestionId ? normalizedAnswers[primaryQuestionId] : undefined; onSubmit({ answers: { ...normalizedAnswers }, - responseText: primaryQuestionId ? (normalizedAnswers[primaryQuestionId] ?? null) : null, + responseText: typeof primaryAnswer === "string" ? primaryAnswer : null, }); - }; + }, [normalizedAnswers, onSubmit, request.questions]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (!passiveDismissAllowed) return; + event.stopPropagation(); + onClose(); + return; + } + if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && canSubmit) { + event.preventDefault(); + handleSubmit(); + } + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [canSubmit, handleSubmit, onClose, passiveDismissAllowed]); return (
{ - if (event.target === event.currentTarget) onClose(); + if (passiveDismissAllowed && event.target === event.currentTarget) onClose(); }} >
-
-
- -
- {request.kind === "structured_question" ? "Input needed" : "Agent question"} +
+
+
+ +
+ {request.kind === "structured_question" ? "Input needed" : "Agent question"} +
+
+ {request.source} +
+
+ {questionCountLabel} +
-
- {request.source} +
+ {modalTitle}
- + {passiveDismissAllowed ? ( + + ) : null}
@@ -96,11 +170,28 @@ export function AgentQuestionModal({
{request.questions.map((question, index) => { - const selectedValue = answers[question.id] ?? ""; - const helperText = question.defaultAssumption ?? question.impact ?? null; - const placeholder = question.options?.length - ? "Choose an option or type a custom answer..." - : "Type the answer you want the agent to follow..."; + const draft = getDraft(question.id); + const selectedValues = draft.selectedValues; + const selectedValue = selectedValues[0] ?? ""; + const helperText = [question.defaultAssumption, question.impact].filter((value): value is string => Boolean(value?.trim())).join(" "); + const placeholder = question.multiSelect + ? "Select one or more options, or type comma-separated custom answers..." + : question.options?.length + ? "Choose an option or type a custom answer..." + : "Type the answer you want the agent to follow..."; + const normalizedQuestionAnswer = normalizedAnswers[question.id]; + const selectedAnswerValues = Array.isArray(normalizedQuestionAnswer) + ? normalizedQuestionAnswer + : typeof normalizedQuestionAnswer === "string" && normalizedQuestionAnswer.trim().length > 0 + ? [normalizedQuestionAnswer.trim()] + : []; + const previewOptions = (question.options ?? []).filter((option) => typeof option.preview === "string" && option.preview.trim().length > 0); + const activePreviewOption = ( + previewOptions.find((option) => option.value === draft.activePreviewValue) + ?? previewOptions.find((option) => selectedValues.includes(option.value)) + ?? previewOptions.find((option) => option.recommended) + ?? previewOptions[0] + ) ?? null; return (
@@ -108,6 +199,11 @@ export function AgentQuestionModal({
{question.header?.trim() || `Question ${index + 1}`}
+ {question.multiSelect ? ( +
+ Multi-select +
+ ) : null} {question.isSecret ? (
Secret @@ -123,51 +219,209 @@ export function AgentQuestionModal({
) : null} {question.options?.length ? ( -
- {question.options.map((option) => { - const isSelected = selectedValue === option.value; - return ( - - ); - })} + {option.description ? ( +
+ {option.description} +
+ ) : null} + + ); + })} +
+ {!previewOptions.length && selectedAnswerValues.length > 0 ? ( +
+ {selectedAnswerValues.map((value) => ( +
+ {value} +
+ ))} +
+ ) : null} + {activePreviewOption?.preview?.trim().length ? ( +
+
+
+ Preview +
+
+ {activePreviewOption.label} +
+
+
+

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + pre: ({ children }) => ( +
    +                                    {children}
    +                                  
    + ), + code: ({ children, className }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + }} + > + {activePreviewOption.preview} +
    +
    +
    + ) : null} +
    + ) : null} + {question.options?.length && question.allowsFreeform !== false ? ( +
    + {question.multiSelect + ? "Selected options stay active while you add custom answers. Separate custom answers with commas." + : "Leave the text box empty to send the selected option as-is. Typing a custom answer overrides the selection."}
    ) : null} {question.allowsFreeform !== false ? ( question.isSecret ? ( setAnswers((current) => ({ ...current, [question.id]: event.target.value }))} + value={draft.text} + onChange={(event) => { + const nextText = event.target.value; + setDrafts((current) => { + const existing = current[question.id] ?? createEmptyDraft(); + return { + ...current, + [question.id]: { + ...existing, + text: nextText, + selectedValues: + !question.multiSelect + && nextText.trim().length > 0 + && existing.selectedValues.length > 0 + && nextText.trim() !== existing.selectedValues[0] + ? [] + : existing.selectedValues, + }, + }; + }); + }} placeholder={placeholder} className="h-10 w-full border border-border/20 bg-[linear-gradient(180deg,rgba(17,15,26,0.94),rgba(13,11,19,0.9))] px-3 font-mono text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/30 focus:border-sky-300/35" + aria-label={question.header?.trim() || question.question} /> ) : (