authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-30 10:04:24+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 21:04:24-03:00
log916ec94f93da686303f878d506a760efd13506c1
tree186a38c190ce50d006697c7d1af246d7a2bdbb5c
parent88fbc2cac77ff2bf0d406c44754ecd8014976f5f
signature Signed by PGP key B5690EEEBB952194

fix(web): show the agent's question on user-input timeline rows (#12900)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

4 files changed, 120 insertions(+), 20 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx+55-4
......@@ -361,28 +361,79 @@ describe("MessagesTimeline", () => {
361361 });
362362 const questionToggle = renderer!.root.find(
363363 (node) =>
364 node.props["aria-label"]?.startsWith("Question answer submitted:") &&
364 node.props["aria-label"]?.startsWith("Provide a spec") &&
365365 node.props["aria-expanded"] === false,
366366 );
367367 expect(questionToggle.props["aria-label"]).toContain(
368368 Object.values(answers)[0] ?? "spec.txt",
369369 );
370 expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec");
370 // The question leads the collapsed row so the exchange reads as a
371 // question and answer without expanding (heading + accessible label).
372 expect(JSON.stringify(renderer!.toJSON()).match(/Provide a spec/g)).toHaveLength(2);
371373 await act(() => questionToggle.props.onClick());
372374 const markup = JSON.stringify(renderer!.toJSON());
373 expect(markup.match(/Provide a spec/g)).toHaveLength(1);
375 // Expanded, the question also appears in the history: label, heading, history.
376 expect(markup.match(/Provide a spec/g)).toHaveLength(3);
374377 expect(markup).toContain("spec.txt");
375378 expect(markup).toContain("Provide a screenshot");
376379 expect(markup).toContain("shot.png");
377380 for (const answer of Object.values(answers)) expect(markup).toContain(answer);
378381 await act(() => questionToggle.props.onClick());
379 expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec");
382 // Collapsing hides the history but keeps the question heading.
383 expect(JSON.stringify(renderer!.toJSON())).toContain("Provide a spec");
380384 } finally {
381385 await act(() => renderer?.unmount());
382386 }
383387 },
384388 );
385389
390 it("leads an unanswered question row with the question text", async () => {
391 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
392 vi.stubGlobal("requestAnimationFrame", () => 0);
393 vi.stubGlobal("cancelAnimationFrame", () => {});
394 let renderer: ReactTestRenderer | undefined;
395 try {
396 await act(() => {
397 renderer = create(
398 <MessagesTimeline
399 {...buildProps()}
400 timelineEntries={[
401 {
402 id: "question-entry",
403 kind: "work",
404 createdAt: MESSAGE_CREATED_AT,
405 entry: {
406 id: "question-work",
407 createdAt: MESSAGE_CREATED_AT,
408 label: "User input requested",
409 tone: "tool",
410 questionAnswer: {
411 requestId: ApprovalRequestId.make("question-request"),
412 answers: {},
413 questionTextById: { scope: "Which repository?" },
414 attachmentsByQuestionId: {},
415 },
416 },
417 },
418 ]}
419 />,
420 );
421 });
422 const questionToggle = renderer!.root.find(
423 (node) =>
424 node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false,
425 );
426 const markup = JSON.stringify(renderer!.toJSON());
427 // Heading + accessible label.
428 expect(markup.match(/Which repository\?/g)).toHaveLength(2);
429 await act(() => questionToggle.props.onClick());
430 // Expanded history adds a third occurrence alongside heading and label.
431 expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3);
432 } finally {
433 await act(() => renderer?.unmount());
434 }
435 });
436
386437 it.each([
387438 { toolLifecycleStatus: "inProgress", isAtEnd: true },
388439 { toolLifecycleStatus: "inProgress", isAtEnd: false },
apps/web/src/components/chat/MessagesTimeline.tsx+21-16
......@@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
44import {
55 getQuestionAnswerPreview,
66 getQuestionAnswerText,
7 getQuestionTextPreview,
78 hasQuestionAnswer,
89} from "@t3tools/client-runtime/work-log/user-input";
910import {
......@@ -3262,7 +3263,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
32623263 />
32633264 );
32643265 }
3265 const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
3266 const questionHeading = row.entry.questionAnswer
3267 ? getQuestionTextPreview(row.entry.questionAnswer)
3268 : "";
3269 const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
32663270 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
32673271
32683272 return (
......@@ -3275,17 +3279,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
32753279 >
32763280 <LiveActivityRow
32773281 label={
3278 row.entry.questionAnswer ? (
3282 row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? (
32793283 <span className="flex min-w-0 gap-1.5">
3280 <span className="shrink-0">{label}</span>
3281 <span
3282 className={cn(
3283 "truncate",
3284 !row.expanded && hasQuestionAnswer(row.entry.questionAnswer)
3285 ? "text-foreground"
3286 : "text-muted-foreground",
3287 )}
3288 >
3284 <span className="min-w-0 truncate">{label}</span>
3285 <span className="min-w-0 truncate text-foreground">
32893286 {getQuestionAnswerPreview(row.entry.questionAnswer)}
32903287 </span>
32913288 </span>
......@@ -4760,10 +4757,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
47604757 showWarningIndicator || showDestructiveRowStyle
47614758 ? undefined
47624759 : (workEntry.toolIcon ?? workEntry.toolSource?.icon);
4763 const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot);
4764 const answerPreview = workEntry.questionAnswer
4765 ? getQuestionAnswerPreview(workEntry.questionAnswer)
4766 : null;
4760 // The question is the row's identity: a generic "User input submitted"
4761 // label buries what was asked, so lead with the question text and keep the
4762 // answer as the trailing preview.
4763 const questionHeading = workEntry.questionAnswer
4764 ? getQuestionTextPreview(workEntry.questionAnswer)
4765 : "";
4766 const previewText =
4767 displayLabel ?? (questionHeading || workEntryDisplayLabel(workEntry, workspaceRoot));
4768 const answerPreview =
4769 workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer)
4770 ? getQuestionAnswerPreview(workEntry.questionAnswer)
4771 : null;
47674772 const viewedImagePath = workEntryViewedImagePath(workEntry);
47684773 const viewedImage =
47694774 viewedImagePath && threadRef
......@@ -4860,7 +4865,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
48604865 <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
48614866 <span
48624867 className={cn(
4863 answerPreview ? "shrink-0" : "min-w-0 flex-1",
4868 answerPreview ? "min-w-0" : "min-w-0 flex-1",
48644869 expanded ? "whitespace-pre-wrap break-words select-text" : "truncate",
48654870 headingClass,
48664871 )}
packages/client-runtime/src/work-log/userInput.test.ts created+37
......@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vite-plus/test";
2import { ApprovalRequestId, type UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
3import { getQuestionTextPreview } from "./userInput.ts";
4
5function answer(
6 overrides: Partial<UserInputAttachmentAnswerPayload> = {},
7): UserInputAttachmentAnswerPayload {
8 return {
9 requestId: ApprovalRequestId.make("request-1"),
10 questionTextById: { scope: "Which repository?" },
11 answers: { scope: "Use the private repository" },
12 attachmentsByQuestionId: {},
13 ...overrides,
14 };
15}
16
17describe("getQuestionTextPreview", () => {
18 it("joins the question texts", () => {
19 expect(
20 getQuestionTextPreview(
21 answer({ questionTextById: { scope: "Which repository?", name: "What name?" } }),
22 ),
23 ).toBe("Which repository? · What name?");
24 });
25
26 it("normalizes whitespace and skips blank texts", () => {
27 expect(
28 getQuestionTextPreview(
29 answer({ questionTextById: { scope: "Which\nrepository?", x: " " } }),
30 ),
31 ).toBe("Which repository?");
32 });
33
34 it("returns an empty string without question texts", () => {
35 expect(getQuestionTextPreview(answer({ questionTextById: undefined }))).toBe("");
36 });
37});
packages/client-runtime/src/work-log/userInput.ts+7
......@@ -163,6 +163,13 @@ export function getQuestionAnswerText(value: unknown): string {
163163 return nested ? getQuestionAnswerText(nested.answers) : "";
164164}
165165
166export function getQuestionTextPreview(answer: UserInputAttachmentAnswerPayload): string {
167 return Object.values(answer.questionTextById ?? {})
168 .map((text) => text.replace(/\s+/g, " ").trim())
169 .filter(Boolean)
170 .join(" · ");
171}
172
166173export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string {
167174 const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean);
168175 const attachments = Object.values(answer.attachmentsByQuestionId)