From b164cb22bb9a0c3c252e418e72e016a4af8b0f11 Mon Sep 17 00:00:00 2001 From: Samigos Date: Thu, 5 Mar 2026 20:18:25 +0200 Subject: [PATCH 1/3] feat: add answered user-input conversation items --- .../messages/components/MessageRows.tsx | 80 +++++++++++++++++ .../messages/components/Messages.test.tsx | 40 +++++++++ src/features/messages/components/Messages.tsx | 12 +++ .../messages/utils/messageRenderUtils.ts | 11 ++- .../threads/hooks/useThreadUserInput.test.tsx | 69 +++++++++++++++ .../threads/hooks/useThreadUserInput.ts | 85 ++++++++++++++++++- src/styles/messages.css | 43 ++++++++++ src/types.ts | 11 +++ src/utils/threadItems.ts | 40 +++++++++ src/utils/threadText.ts | 12 +++ 10 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 src/features/threads/hooks/useThreadUserInput.test.tsx diff --git a/src/features/messages/components/MessageRows.tsx b/src/features/messages/components/MessageRows.tsx index 5d4094362..50ac4a18f 100644 --- a/src/features/messages/components/MessageRows.tsx +++ b/src/features/messages/components/MessageRows.tsx @@ -75,6 +75,12 @@ type DiffRowProps = { item: Extract; }; +type UserInputRowProps = { + item: Extract; + isExpanded: boolean; + onToggle: (id: string) => void; +}; + type ToolRowProps = MarkdownFileLinkProps & { item: Extract; isExpanded: boolean; @@ -592,6 +598,80 @@ export const DiffRow = memo(function DiffRow({ item }: DiffRowProps) { ); }); +export const UserInputRow = memo(function UserInputRow({ + item, + isExpanded, + onToggle, +}: UserInputRowProps) { + const first = item.questions[0]; + const previewQuestion = + first?.question?.trim() || first?.header?.trim() || "Input requested"; + const firstAnswer = first?.answers[0]?.trim() || "No answer provided"; + const previewAnswer = + first && first.answers.length > 1 + ? `${firstAnswer} +${first.answers.length - 1}` + : firstAnswer; + const extraQuestions = Math.max(0, item.questions.length - 1); + + return ( +
+ + {isExpanded && ( +
+ {item.questions.map((question, index) => { + const title = question.question || question.header || `Question ${index + 1}`; + return ( +
+
{title}
+ {question.answers.length > 0 ? ( +
+ {question.answers.map((answer, answerIndex) => ( +
+ {answer} +
+ ))} +
+ ) : ( +
+ No answer provided. +
+ )} +
+ ); + })} +
+ )} +
+ + ); +}); + export const ToolRow = memo(function ToolRow({ item, isExpanded, diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index 800622286..4066a84d3 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -903,6 +903,46 @@ describe("Messages", () => { expect(screen.getByText("Done in 0:04")).toBeTruthy(); }); + it("renders answered user input items with preview and expandable details", () => { + const items: ConversationItem[] = [ + { + id: "user-input-1", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Confirm", + question: "Proceed with deployment?", + answers: ["Yes", "user_note: after running tests"], + }, + ], + }, + ]; + + render( + , + ); + + expect( + screen.getByText(/Proceed with deployment\?: Yes \+1/), + ).toBeTruthy(); + expect(screen.queryByText("user_note: after running tests")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Toggle answered input details" }), + ); + + expect(screen.getByText("user_note: after running tests")).toBeTruthy(); + }); + it("merges consecutive explore items under a single explored block", async () => { const items: ConversationItem[] = [ { diff --git a/src/features/messages/components/Messages.tsx b/src/features/messages/components/Messages.tsx index 93ef2e356..99388d951 100644 --- a/src/features/messages/components/Messages.tsx +++ b/src/features/messages/components/Messages.tsx @@ -34,6 +34,7 @@ import { ReasoningRow, ReviewRow, ToolRow, + UserInputRow, WorkingIndicator, } from "./MessageRows"; @@ -429,6 +430,17 @@ export const Messages = memo(function Messages({ /> ); } + if (item.kind === "userInput") { + const isExpanded = expandedItems.has(item.id); + return ( + + ); + } if (item.kind === "diff") { return ; } diff --git a/src/features/messages/utils/messageRenderUtils.ts b/src/features/messages/utils/messageRenderUtils.ts index 4db28258b..346f00ccd 100644 --- a/src/features/messages/utils/messageRenderUtils.ts +++ b/src/features/messages/utils/messageRenderUtils.ts @@ -24,7 +24,7 @@ export type MessageImage = { export type ToolGroupItem = Extract< ConversationItem, - { kind: "tool" | "reasoning" | "explore" } + { kind: "tool" | "reasoning" | "explore" | "userInput" } >; export type ToolGroup = { @@ -220,7 +220,12 @@ export function normalizeMessageImageSrc(path: string) { } function isToolGroupItem(item: ConversationItem): item is ToolGroupItem { - return item.kind === "tool" || item.kind === "reasoning" || item.kind === "explore"; + return ( + item.kind === "tool" || + item.kind === "reasoning" || + item.kind === "explore" || + item.kind === "userInput" + ); } function mergeExploreItems( @@ -517,6 +522,8 @@ export function scrollKeyForItems(items: ConversationItem[]) { switch (last.kind) { case "message": return `${last.id}-${last.text.length}`; + case "userInput": + return `${last.id}-${last.status}-${last.questions.length}`; case "reasoning": return `${last.id}-${last.summary.length}-${last.content.length}`; case "explore": diff --git a/src/features/threads/hooks/useThreadUserInput.test.tsx b/src/features/threads/hooks/useThreadUserInput.test.tsx new file mode 100644 index 000000000..0001a98c2 --- /dev/null +++ b/src/features/threads/hooks/useThreadUserInput.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { respondToUserInputRequest } from "@services/tauri"; +import { useThreadUserInput } from "./useThreadUserInput"; + +vi.mock("@services/tauri", () => ({ + respondToUserInputRequest: vi.fn().mockResolvedValue(undefined), +})); + +describe("useThreadUserInput", () => { + it("submits request-user-input answers and appends an answered item", async () => { + const dispatch = vi.fn(); + const { result } = renderHook(() => useThreadUserInput({ dispatch })); + const request = { + workspace_id: "ws-1", + request_id: "req-7", + params: { + thread_id: "thread-1", + turn_id: "turn-1", + item_id: "item-1", + questions: [ + { + id: "q-choice", + header: "Pick", + question: "Which option?", + options: [ + { label: "A", description: "Option A" }, + { label: "B", description: "Option B" }, + ], + }, + ], + }, + }; + const response = { + answers: { + "q-choice": { answers: ["A", "user_note: with details"] }, + }, + }; + + await act(async () => { + await result.current.handleUserInputSubmit(request, response); + }); + + expect(respondToUserInputRequest).toHaveBeenCalledWith( + "ws-1", + "req-7", + response.answers, + ); + expect(dispatch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: "upsertItem", + workspaceId: "ws-1", + threadId: "thread-1", + item: expect.objectContaining({ + id: "user-input-ws-1-thread-1-turn-1-item-1", + kind: "userInput", + status: "answered", + }), + }), + ); + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: "removeUserInputRequest", + requestId: "req-7", + workspaceId: "ws-1", + }); + }); +}); diff --git a/src/features/threads/hooks/useThreadUserInput.ts b/src/features/threads/hooks/useThreadUserInput.ts index fd69ca5ba..fe4e5c135 100644 --- a/src/features/threads/hooks/useThreadUserInput.ts +++ b/src/features/threads/hooks/useThreadUserInput.ts @@ -1,6 +1,10 @@ import { useCallback } from "react"; import type { Dispatch } from "react"; -import type { RequestUserInputRequest, RequestUserInputResponse } from "@/types"; +import type { + ConversationItem, + RequestUserInputRequest, + RequestUserInputResponse, +} from "@/types"; import { respondToUserInputRequest } from "@services/tauri"; import type { ThreadAction } from "./useThreadsReducer"; @@ -8,6 +12,78 @@ type UseThreadUserInputOptions = { dispatch: Dispatch; }; +function asString(value: unknown) { + return typeof value === "string" ? value : value ? String(value) : ""; +} + +function buildUserInputConversationItem( + request: RequestUserInputRequest, + response: RequestUserInputResponse, +): Extract { + const threadId = asString(request.params.thread_id).trim(); + const turnId = asString(request.params.turn_id).trim(); + const itemId = asString(request.params.item_id).trim(); + const requestId = asString(request.request_id).trim(); + const answered = response.answers ?? {}; + const seen = new Set(); + const questions = request.params.questions.map((question, index) => { + const id = question.id || `question-${index + 1}`; + seen.add(id); + const record = answered[id]; + const answers = Array.isArray(record?.answers) + ? record.answers.map((entry) => asString(entry).trim()).filter(Boolean) + : []; + return { + id, + header: asString(question.header).trim(), + question: asString(question.question).trim(), + answers, + }; + }); + const extra = Object.entries(answered) + .filter(([id]) => !seen.has(id)) + .map(([id, value]) => { + const answers = Array.isArray(value?.answers) + ? value.answers.map((entry) => asString(entry).trim()).filter(Boolean) + : []; + return { + id, + header: "", + question: id, + answers, + }; + }); + const entries = [...questions, ...extra]; + if (!entries.length) { + entries.push({ + id: "user-input", + header: "", + question: "Input requested", + answers: [], + }); + } + return { + id: itemId + ? [ + "user-input", + request.workspace_id, + threadId || "thread", + turnId || "turn", + itemId, + ].join("-") + : [ + "user-input", + request.workspace_id, + threadId || "thread", + turnId || "turn", + `request-${requestId || "unknown"}`, + ].join("-"), + kind: "userInput", + status: "answered", + questions: entries, + }; +} + export function useThreadUserInput({ dispatch }: UseThreadUserInputOptions) { const handleUserInputSubmit = useCallback( async (request: RequestUserInputRequest, response: RequestUserInputResponse) => { @@ -16,6 +92,13 @@ export function useThreadUserInput({ dispatch }: UseThreadUserInputOptions) { request.request_id, response.answers, ); + const item = buildUserInputConversationItem(request, response); + dispatch({ + type: "upsertItem", + workspaceId: request.workspace_id, + threadId: request.params.thread_id, + item, + }); dispatch({ type: "removeUserInputRequest", requestId: request.request_id, diff --git a/src/styles/messages.css b/src/styles/messages.css index 3e3dcdb9b..6aac73e5a 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -694,6 +694,49 @@ word-break: break-word; } +.user-input-inline-preview { + overflow-wrap: anywhere; + word-break: break-word; +} + +.user-input-inline-details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.user-input-inline-entry { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.user-input-inline-question { + color: var(--text-stronger); + font-weight: 500; + overflow-wrap: anywhere; + word-break: break-word; +} + +.user-input-inline-answers { + display: flex; + flex-direction: column; + gap: 3px; +} + +.user-input-inline-answer { + color: var(--text-muted); + font-size: 11px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.user-input-inline-empty-answer { + color: var(--text-faint); + font-size: 11px; +} + .tool-inline-clamp { display: -webkit-box; -webkit-line-clamp: 3; diff --git a/src/types.ts b/src/types.ts index a69f6bc21..73ffd4910 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,17 @@ export type ConversationItem = text: string; images?: string[]; } + | { + id: string; + kind: "userInput"; + status: "answered"; + questions: { + id: string; + header: string; + question: string; + answers: string[]; + }[]; + } | { id: string; kind: "reasoning"; summary: string; content: string } | { id: string; kind: "diff"; title: string; diff: string; status?: string } | { id: string; kind: "review"; state: "started" | "completed"; text: string } diff --git a/src/utils/threadItems.ts b/src/utils/threadItems.ts index d265ba75a..183f6d9e3 100644 --- a/src/utils/threadItems.ts +++ b/src/utils/threadItems.ts @@ -277,6 +277,17 @@ export function normalizeItem(item: ConversationItem): ConversationItem { if (item.kind === "message") { return { ...item, text: truncateText(item.text) }; } + if (item.kind === "userInput") { + return { + ...item, + questions: item.questions.map((question) => ({ + ...question, + header: truncateText(question.header, 300), + question: truncateText(question.question, 2000), + answers: question.answers.map((answer) => truncateText(answer, 2000)), + })), + }; + } if (item.kind === "explore") { return item; } @@ -686,6 +697,22 @@ export function upsertItem(list: ConversationItem[], item: ConversationItem) { return next; } + if (existing.kind === "userInput" && item.kind === "userInput") { + const incomingHasAnswers = item.questions.some((question) => question.answers.length > 0); + const existingHasAnswers = existing.questions.some( + (question) => question.answers.length > 0, + ); + next[index] = { + ...existing, + ...item, + questions: + incomingHasAnswers || !existingHasAnswers || item.questions.length >= existing.questions.length + ? item.questions + : existing.questions, + }; + return next; + } + if (existing.kind === "reasoning" && item.kind === "reasoning") { const existingSummary = existing.summary ?? ""; const incomingSummary = item.summary ?? ""; @@ -1158,6 +1185,19 @@ function chooseRicherItem(remote: ConversationItem, local: ConversationItem) { if (remote.kind === "message" && local.kind === "message") { return local.text.length > remote.text.length ? local : remote; } + if (remote.kind === "userInput" && local.kind === "userInput") { + const remoteScore = remote.questions.reduce( + (total, question) => + total + question.question.length + question.answers.join("\n").length, + 0, + ); + const localScore = local.questions.reduce( + (total, question) => + total + question.question.length + question.answers.join("\n").length, + 0, + ); + return localScore > remoteScore ? local : remote; + } if (remote.kind === "reasoning" && local.kind === "reasoning") { const remoteLength = remote.summary.length + remote.content.length; const localLength = local.summary.length + local.content.length; diff --git a/src/utils/threadText.ts b/src/utils/threadText.ts index 87b60a71e..43792833e 100644 --- a/src/utils/threadText.ts +++ b/src/utils/threadText.ts @@ -16,6 +16,16 @@ function formatReasoning(item: Extract) return parts.join("\n"); } +function formatUserInput(item: Extract) { + const lines = item.questions.map((entry, index) => { + const title = entry.question || entry.header || `Question ${index + 1}`; + const answers = + entry.answers.length > 0 ? entry.answers.join(" | ") : "No answer provided"; + return `- ${title}: ${answers}`; + }); + return ["Input answered:", ...lines].join("\n"); +} + function formatTool(item: Extract) { const parts = [`Tool: ${item.title}`]; if (item.detail) { @@ -63,6 +73,8 @@ export function buildThreadTranscript(items: ConversationItem[]) { switch (item.kind) { case "message": return formatMessage(item); + case "userInput": + return formatUserInput(item); case "reasoning": return formatReasoning(item); case "explore": From efb9b1bac62065f10e1f1f08967f549af5623e7e Mon Sep 17 00:00:00 2001 From: Samigos Date: Thu, 5 Mar 2026 20:48:23 +0200 Subject: [PATCH 2/3] fix: preserve answered userInput entries on stale upserts --- src/utils/threadItems.test.ts | 36 +++++++++++++++++++++++++++++++++++ src/utils/threadItems.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/utils/threadItems.test.ts b/src/utils/threadItems.test.ts index 3cfbd60ae..0a5ae9bef 100644 --- a/src/utils/threadItems.test.ts +++ b/src/utils/threadItems.test.ts @@ -634,6 +634,42 @@ describe("threadItems", () => { } }); + it("preserves existing userInput answers when incoming payload has equal question count and no answers", () => { + const existing: ConversationItem = { + id: "user-input-1", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Confirm", + question: "Proceed?", + answers: ["Yes"], + }, + ], + }; + const incoming: ConversationItem = { + id: "user-input-1", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Confirm", + question: "Proceed?", + answers: [], + }, + ], + }; + + const next = upsertItem([existing], incoming); + expect(next).toHaveLength(1); + expect(next[0].kind).toBe("userInput"); + if (next[0].kind === "userInput") { + expect(next[0].questions[0]?.answers).toEqual(["Yes"]); + } + }); + it("builds user message text from mixed inputs", () => { const item = buildConversationItemFromThreadItem({ type: "userMessage", diff --git a/src/utils/threadItems.ts b/src/utils/threadItems.ts index 183f6d9e3..02a0ef972 100644 --- a/src/utils/threadItems.ts +++ b/src/utils/threadItems.ts @@ -706,7 +706,7 @@ export function upsertItem(list: ConversationItem[], item: ConversationItem) { ...existing, ...item, questions: - incomingHasAnswers || !existingHasAnswers || item.questions.length >= existing.questions.length + incomingHasAnswers || !existingHasAnswers || item.questions.length > existing.questions.length ? item.questions : existing.questions, }; From 7414f01acb39077b11072574bdb8355db0e0ce02 Mon Sep 17 00:00:00 2001 From: Samigos Date: Thu, 5 Mar 2026 22:02:29 +0200 Subject: [PATCH 3/3] fix(thread-items): preserve answered questions on partial userInput upserts --- src/utils/threadItems.test.ts | 96 +++++++++++++++++++++++++++++++++++ src/utils/threadItems.ts | 33 +++++++++--- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/utils/threadItems.test.ts b/src/utils/threadItems.test.ts index 0a5ae9bef..fcf131192 100644 --- a/src/utils/threadItems.test.ts +++ b/src/utils/threadItems.test.ts @@ -670,6 +670,102 @@ describe("threadItems", () => { } }); + it("preserves existing answers for questions that are empty in a partial userInput upsert", () => { + const existing: ConversationItem = { + id: "user-input-2", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Question 1", + question: "Choose release mode", + answers: ["Safe"], + }, + { + id: "q2", + header: "Question 2", + question: "Choose deployment time", + answers: ["Tonight"], + }, + ], + }; + const incoming: ConversationItem = { + id: "user-input-2", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Question 1", + question: "Choose release mode", + answers: ["Fast"], + }, + { + id: "q2", + header: "Question 2", + question: "Choose deployment time", + answers: [], + }, + ], + }; + + const next = upsertItem([existing], incoming); + expect(next).toHaveLength(1); + expect(next[0].kind).toBe("userInput"); + if (next[0].kind === "userInput") { + expect(next[0].questions).toHaveLength(2); + expect(next[0].questions[0]?.answers).toEqual(["Fast"]); + expect(next[0].questions[1]?.answers).toEqual(["Tonight"]); + } + }); + + it("preserves answered questions missing from a partial userInput upsert", () => { + const existing: ConversationItem = { + id: "user-input-3", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Question 1", + question: "Primary answer", + answers: ["A"], + }, + { + id: "q2", + header: "Question 2", + question: "Secondary answer", + answers: ["B"], + }, + ], + }; + const incoming: ConversationItem = { + id: "user-input-3", + kind: "userInput", + status: "answered", + questions: [ + { + id: "q1", + header: "Question 1", + question: "Primary answer", + answers: ["A2"], + }, + ], + }; + + const next = upsertItem([existing], incoming); + expect(next).toHaveLength(1); + expect(next[0].kind).toBe("userInput"); + if (next[0].kind === "userInput") { + expect(next[0].questions).toHaveLength(2); + expect(next[0].questions[0]?.id).toBe("q1"); + expect(next[0].questions[0]?.answers).toEqual(["A2"]); + expect(next[0].questions[1]?.id).toBe("q2"); + expect(next[0].questions[1]?.answers).toEqual(["B"]); + } + }); + it("builds user message text from mixed inputs", () => { const item = buildConversationItemFromThreadItem({ type: "userMessage", diff --git a/src/utils/threadItems.ts b/src/utils/threadItems.ts index 02a0ef972..1a780b2d4 100644 --- a/src/utils/threadItems.ts +++ b/src/utils/threadItems.ts @@ -548,6 +548,30 @@ function mergeExploreEntries(base: ExploreEntry[], next: ExploreEntry[]) { return deduped; } +function mergeUserInputQuestions( + existing: Extract["questions"], + incoming: Extract["questions"], +) { + const existingById = new Map(existing.map((question) => [question.id, question])); + const merged = incoming.map((question) => { + const prior = existingById.get(question.id); + if (!prior) { + return question; + } + const incomingHasAnswers = question.answers.length > 0; + return { + ...prior, + ...question, + header: question.header.trim() ? question.header : prior.header, + question: question.question.trim() ? question.question : prior.question, + answers: incomingHasAnswers ? question.answers : prior.answers, + }; + }); + const incomingIds = new Set(incoming.map((question) => question.id)); + const missingExisting = existing.filter((question) => !incomingIds.has(question.id)); + return [...merged, ...missingExisting]; +} + function summarizeCommandExecution(item: Extract) { if (isFailedStatus(item.status)) { return null; @@ -698,17 +722,10 @@ export function upsertItem(list: ConversationItem[], item: ConversationItem) { } if (existing.kind === "userInput" && item.kind === "userInput") { - const incomingHasAnswers = item.questions.some((question) => question.answers.length > 0); - const existingHasAnswers = existing.questions.some( - (question) => question.answers.length > 0, - ); next[index] = { ...existing, ...item, - questions: - incomingHasAnswers || !existingHasAnswers || item.questions.length > existing.questions.length - ? item.questions - : existing.questions, + questions: mergeUserInputQuestions(existing.questions, item.questions), }; return next; }