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
59 changes: 55 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "question-entry",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "question-work",
createdAt: MESSAGE_CREATED_AT,
label: "User input requested",
tone: "tool",
questionAnswer: {
requestId: ApprovalRequestId.make("question-request"),
answers: {},
questionTextById: { scope: "Which repository?" },
attachmentsByQuestionId: {},
},
},
},
]}
/>,
);
});
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 },
Expand Down
37 changes: 21 additions & 16 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
import {
getQuestionAnswerPreview,
getQuestionAnswerText,
getQuestionTextPreview,
hasQuestionAnswer,
} from "@t3tools/client-runtime/work-log/user-input";
import {
Expand Down Expand Up @@ -3245,7 +3246,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
/>
);
}
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 (
Expand All @@ -3258,17 +3262,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
>
<LiveActivityRow
label={
row.entry.questionAnswer ? (
row.entry.questionAnswer && hasQuestionAnswer(row.entry.questionAnswer) ? (
<span className="flex min-w-0 gap-1.5">
<span className="shrink-0">{label}</span>
<span
className={cn(
"truncate",
!row.expanded && hasQuestionAnswer(row.entry.questionAnswer)
? "text-foreground"
: "text-muted-foreground",
)}
>
<span className="min-w-0 truncate">{label}</span>
<span className="min-w-0 truncate text-foreground">
{getQuestionAnswerPreview(row.entry.questionAnswer)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</span>
</span>
Expand Down Expand Up @@ -4851,10 +4848,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
Expand Down Expand Up @@ -4951,7 +4956,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
<p className="flex min-w-0 w-full items-baseline gap-1.5 text-sm leading-relaxed">
<span
className={cn(
answerPreview ? "shrink-0" : "min-w-0 flex-1",
answerPreview ? "min-w-0" : "min-w-0 flex-1",
expanded ? "whitespace-pre-wrap break-words select-text" : "truncate",
headingClass,
)}
Expand Down
37 changes: 37 additions & 0 deletions packages/client-runtime/src/work-log/userInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vite-plus/test";
import { ApprovalRequestId, type UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
import { getQuestionTextPreview } from "./userInput.ts";

function answer(
overrides: Partial<UserInputAttachmentAnswerPayload> = {},
): 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("");
});
});
7 changes: 7 additions & 0 deletions packages/client-runtime/src/work-log/userInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading