diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a87dc2c2a444b7945f29778b081b6453d0c6748a..b6e5f2a2927e506035d50c021c608be0b8361181 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -361,28 +361,79 @@ describe("MessagesTimeline", () => { }); const questionToggle = renderer!.root.find( (node) => - node.props["aria-label"]?.startsWith("Question answer submitted:") && + node.props["aria-label"]?.startsWith("Provide a spec") && node.props["aria-expanded"] === false, ); expect(questionToggle.props["aria-label"]).toContain( Object.values(answers)[0] ?? "spec.txt", ); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // The question leads the collapsed row so the exchange reads as a + // question and answer without expanding (heading + accessible label). + expect(JSON.stringify(renderer!.toJSON()).match(/Provide a spec/g)).toHaveLength(2); await act(() => questionToggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); - expect(markup.match(/Provide a spec/g)).toHaveLength(1); + // Expanded, the question also appears in the history: label, heading, history. + expect(markup.match(/Provide a spec/g)).toHaveLength(3); expect(markup).toContain("spec.txt"); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); await act(() => questionToggle.props.onClick()); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // Collapsing hides the history but keeps the question heading. + expect(JSON.stringify(renderer!.toJSON())).toContain("Provide a spec"); } finally { await act(() => renderer?.unmount()); } }, ); + it("leads an unanswered question row with the question text", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + const questionToggle = renderer!.root.find( + (node) => + node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false, + ); + const markup = JSON.stringify(renderer!.toJSON()); + // Heading + accessible label. + expect(markup.match(/Which repository\?/g)).toHaveLength(2); + await act(() => questionToggle.props.onClick()); + // Expanded history adds a third occurrence alongside heading and label. + expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3); + } finally { + await act(() => renderer?.unmount()); + } + }); + it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf7faa735fede7b15636c7c608102a92fb800a8c..8ac769f33e7fb41e4601a84250dbd2befa26ad0e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { getQuestionAnswerPreview, getQuestionAnswerText, + getQuestionTextPreview, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; import { @@ -3262,7 +3263,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ); } - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const questionHeading = row.entry.questionAnswer + ? getQuestionTextPreview(row.entry.questionAnswer) + : ""; + const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -3275,17 +3279,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract - {label} - + {label} + {getQuestionAnswerPreview(row.entry.questionAnswer)} @@ -4760,10 +4757,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); - const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); - const answerPreview = workEntry.questionAnswer - ? getQuestionAnswerPreview(workEntry.questionAnswer) - : null; + // The question is the row's identity: a generic "User input submitted" + // label buries what was asked, so lead with the question text and keep the + // answer as the trailing preview. + const questionHeading = workEntry.questionAnswer + ? getQuestionTextPreview(workEntry.questionAnswer) + : ""; + const previewText = + displayLabel ?? (questionHeading || workEntryDisplayLabel(workEntry, workspaceRoot)); + const answerPreview = + workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer) + ? getQuestionAnswerPreview(workEntry.questionAnswer) + : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -4860,7 +4865,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

= {}, +): UserInputAttachmentAnswerPayload { + return { + requestId: ApprovalRequestId.make("request-1"), + questionTextById: { scope: "Which repository?" }, + answers: { scope: "Use the private repository" }, + attachmentsByQuestionId: {}, + ...overrides, + }; +} + +describe("getQuestionTextPreview", () => { + it("joins the question texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which repository?", name: "What name?" } }), + ), + ).toBe("Which repository? · What name?"); + }); + + it("normalizes whitespace and skips blank texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which\nrepository?", x: " " } }), + ), + ).toBe("Which repository?"); + }); + + it("returns an empty string without question texts", () => { + expect(getQuestionTextPreview(answer({ questionTextById: undefined }))).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 18dea7dd83c29feb8436a03d1b3128239cacd7fc..d1bb4370efbfa2aac46ad57edc6cc0d9e1adb7e2 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -163,6 +163,13 @@ export function getQuestionAnswerText(value: unknown): string { return nested ? getQuestionAnswerText(nested.answers) : ""; } +export function getQuestionTextPreview(answer: UserInputAttachmentAnswerPayload): string { + return Object.values(answer.questionTextById ?? {}) + .map((text) => text.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" · "); +} + export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); const attachments = Object.values(answer.attachmentsByQuestionId)