| author | |
| committer | |
| log | 916ec94f93da686303f878d506a760efd13506c1 |
| tree | 186a38c190ce50d006697c7d1af246d7a2bdbb5c |
| parent | 88fbc2cac77ff2bf0d406c44754ecd8014976f5f |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { |
| 361 | 361 | }); |
| 362 | 362 | const questionToggle = renderer!.root.find( |
| 363 | 363 | (node) => |
| 364 | node.props["aria-label"]?.startsWith("Question answer submitted:") && | |
| 364 | node.props["aria-label"]?.startsWith("Provide a spec") && | |
| 365 | 365 | node.props["aria-expanded"] === false, |
| 366 | 366 | ); |
| 367 | 367 | expect(questionToggle.props["aria-label"]).toContain( |
| 368 | 368 | Object.values(answers)[0] ?? "spec.txt", |
| 369 | 369 | ); |
| 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); | |
| 371 | 373 | await act(() => questionToggle.props.onClick()); |
| 372 | 374 | 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); | |
| 374 | 377 | expect(markup).toContain("spec.txt"); |
| 375 | 378 | expect(markup).toContain("Provide a screenshot"); |
| 376 | 379 | expect(markup).toContain("shot.png"); |
| 377 | 380 | for (const answer of Object.values(answers)) expect(markup).toContain(answer); |
| 378 | 381 | 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"); | |
| 380 | 384 | } finally { |
| 381 | 385 | await act(() => renderer?.unmount()); |
| 382 | 386 | } |
| 383 | 387 | }, |
| 384 | 388 | ); |
| 385 | 389 | |
| 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 | ||
| 386 | 437 | it.each([ |
| 387 | 438 | { toolLifecycleStatus: "inProgress", isAtEnd: true }, |
| 388 | 439 | { toolLifecycleStatus: "inProgress", isAtEnd: false }, |
apps/web/src/components/chat/MessagesTimeline.tsx+21-16| ... | ... | @@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; |
| 4 | 4 | import { |
| 5 | 5 | getQuestionAnswerPreview, |
| 6 | 6 | getQuestionAnswerText, |
| 7 | getQuestionTextPreview, | |
| 7 | 8 | hasQuestionAnswer, |
| 8 | 9 | } from "@t3tools/client-runtime/work-log/user-input"; |
| 9 | 10 | import { |
| ... | ... | @@ -3262,7 +3263,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 3262 | 3263 | /> |
| 3263 | 3264 | ); |
| 3264 | 3265 | } |
| 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); | |
| 3266 | 3270 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 3267 | 3271 | |
| 3268 | 3272 | return ( |
| ... | ... | @@ -3275,17 +3279,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 3275 | 3279 | > |
| 3276 | 3280 | <LiveActivityRow |
| 3277 | 3281 | label={ |
| 3278 | row.entry.questionAnswer ? ( | |
| 3282 | row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? ( | |
| 3279 | 3283 | <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"> | |
| 3289 | 3286 | {getQuestionAnswerPreview(row.entry.questionAnswer)} |
| 3290 | 3287 | </span> |
| 3291 | 3288 | </span> |
| ... | ... | @@ -4760,10 +4757,18 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 4760 | 4757 | showWarningIndicator || showDestructiveRowStyle |
| 4761 | 4758 | ? undefined |
| 4762 | 4759 | : (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; | |
| 4767 | 4772 | const viewedImagePath = workEntryViewedImagePath(workEntry); |
| 4768 | 4773 | const viewedImage = |
| 4769 | 4774 | viewedImagePath && threadRef |
| ... | ... | @@ -4860,7 +4865,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { |
| 4860 | 4865 | <p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed"> |
| 4861 | 4866 | <span |
| 4862 | 4867 | className={cn( |
| 4863 | answerPreview ? "shrink-0" : "min-w-0 flex-1", | |
| 4868 | answerPreview ? "min-w-0" : "min-w-0 flex-1", | |
| 4864 | 4869 | expanded ? "whitespace-pre-wrap break-words select-text" : "truncate", |
| 4865 | 4870 | headingClass, |
| 4866 | 4871 | )} |
packages/client-runtime/src/work-log/userInput.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { ApprovalRequestId, type UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; | |
| 3 | import { getQuestionTextPreview } from "./userInput.ts"; | |
| 4 | ||
| 5 | function 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 | ||
| 17 | describe("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 { |
| 163 | 163 | return nested ? getQuestionAnswerText(nested.answers) : ""; |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | export 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 | ||
| 166 | 173 | export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { |
| 167 | 174 | const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); |
| 168 | 175 | const attachments = Object.values(answer.attachmentsByQuestionId) |