Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/features/messages/components/MessageRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ type DiffRowProps = {
item: Extract<ConversationItem, { kind: "diff" }>;
};

type UserInputRowProps = {
item: Extract<ConversationItem, { kind: "userInput" }>;
isExpanded: boolean;
onToggle: (id: string) => void;
};

type ToolRowProps = MarkdownFileLinkProps & {
item: Extract<ConversationItem, { kind: "tool" }>;
isExpanded: boolean;
Expand Down Expand Up @@ -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 (
<div className={`tool-inline user-input-inline ${isExpanded ? "tool-inline-expanded" : ""}`}>
<button
type="button"
className="tool-inline-bar-toggle"
onClick={() => onToggle(item.id)}
aria-expanded={isExpanded}
aria-label="Toggle answered input details"
/>
<div className="tool-inline-content">
<button
type="button"
className="tool-inline-summary tool-inline-toggle"
onClick={() => onToggle(item.id)}
aria-expanded={isExpanded}
>
<Check className="tool-inline-icon completed" size={14} aria-hidden />
<span className="tool-inline-label">answered:</span>
<span className="tool-inline-value user-input-inline-preview">
{previewQuestion}: {previewAnswer}
{extraQuestions > 0 ? ` +${extraQuestions} more` : ""}
</span>
</button>
{isExpanded && (
<div className="user-input-inline-details">
{item.questions.map((question, index) => {
const title = question.question || question.header || `Question ${index + 1}`;
return (
<div
key={`${question.id}-${index}`}
className="user-input-inline-entry"
>
<div className="user-input-inline-question">{title}</div>
{question.answers.length > 0 ? (
<div className="user-input-inline-answers">
{question.answers.map((answer, answerIndex) => (
<div
key={`${question.id}-answer-${answerIndex}`}
className="user-input-inline-answer"
>
{answer}
</div>
))}
</div>
) : (
<div className="user-input-inline-empty-answer">
No answer provided.
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
});

export const ToolRow = memo(function ToolRow({
item,
isExpanded,
Expand Down
40 changes: 40 additions & 0 deletions src/features/messages/components/Messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Messages
items={items}
threadId="thread-1"
workspaceId="ws-1"
isThinking={false}
openTargets={[]}
selectedOpenAppId=""
/>,
);

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[] = [
{
Expand Down
12 changes: 12 additions & 0 deletions src/features/messages/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
ReasoningRow,
ReviewRow,
ToolRow,
UserInputRow,
WorkingIndicator,
} from "./MessageRows";

Expand Down Expand Up @@ -429,6 +430,17 @@ export const Messages = memo(function Messages({
/>
);
}
if (item.kind === "userInput") {
const isExpanded = expandedItems.has(item.id);
return (
<UserInputRow
key={item.id}
item={item}
isExpanded={isExpanded}
onToggle={toggleExpanded}
/>
);
}
if (item.kind === "diff") {
return <DiffRow key={item.id} item={item} />;
}
Expand Down
11 changes: 9 additions & 2 deletions src/features/messages/utils/messageRenderUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type MessageImage = {

export type ToolGroupItem = Extract<
ConversationItem,
{ kind: "tool" | "reasoning" | "explore" }
{ kind: "tool" | "reasoning" | "explore" | "userInput" }
>;

export type ToolGroup = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand Down
69 changes: 69 additions & 0 deletions src/features/threads/hooks/useThreadUserInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
85 changes: 84 additions & 1 deletion src/features/threads/hooks/useThreadUserInput.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,89 @@
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";

type UseThreadUserInputOptions = {
dispatch: Dispatch<ThreadAction>;
};

function asString(value: unknown) {
return typeof value === "string" ? value : value ? String(value) : "";
}

function buildUserInputConversationItem(
request: RequestUserInputRequest,
response: RequestUserInputResponse,
): Extract<ConversationItem, { kind: "userInput" }> {
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<string>();
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) => {
Expand All @@ -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,
Expand Down
Loading
Loading