Skip to content
Open
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
117 changes: 117 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,123 @@ describe("MessagesTimeline", () => {
}
});

it("keeps supplemental tool detail visible after expanding command rows", async () => {
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "work-command",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "work-command",
createdAt: MESSAGE_CREATED_AT,
label: "Ran command",
detail: "Fetched dependency metadata",
command: "pnpm outdated --json",
stdout: "[]",
exitCode: 0,
durationMs: 250,
tone: "tool",
},
},
]}
/>,
);

try {
expect(document.body.textContent).not.toContain("Fetched dependency metadata");

const row = page.getByRole("button", { name: "Expand Ran command" });
await row.click();

await expect.element(page.getByText("Fetched dependency metadata")).toBeVisible();
expect(document.body.textContent).toContain("pnpm outdated --json");
await expect.element(page.getByText("[]")).toBeVisible();
} finally {
await screen.unmount();
}
});

it("does not duplicate supplemental detail that matches command output", async () => {
const duplicateOutput = "No changes detected";
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "work-command",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "work-command",
createdAt: MESSAGE_CREATED_AT,
label: "Ran command",
detail: duplicateOutput,
command: "git diff --check",
stdout: duplicateOutput,
exitCode: 0,
durationMs: 250,
tone: "tool",
},
},
]}
/>,
);

try {
const row = page.getByRole("button", { name: "Expand Ran command" });
await row.click();

await expect.element(page.getByText(duplicateOutput)).toBeVisible();
const occurrences = document.body.textContent?.match(new RegExp(duplicateOutput, "gu")) ?? [];
expect(occurrences).toHaveLength(1);
} finally {
await screen.unmount();
}
});

it("keeps file-change supplemental detail when it matches unrendered output", async () => {
const detail = "Patch applied";
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "work-file-change",
kind: "work",
createdAt: MESSAGE_CREATED_AT,
entry: {
id: "work-file-change",
createdAt: MESSAGE_CREATED_AT,
label: "Changed files",
detail,
command: "apply_patch",
stdout: detail,
changedFiles: ["apps/web/src/components/chat/MessagesTimeline.tsx"],
patch:
"diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx\n--- a/apps/web/src/components/chat/MessagesTimeline.tsx\n+++ b/apps/web/src/components/chat/MessagesTimeline.tsx\n@@ -1 +1 @@\n-old\n+new\n",
tone: "tool",
itemType: "file_change",
},
},
]}
/>,
);

try {
const row = page.getByRole("button", { name: "Expand Changed files" });
await row.click();

await expect.element(page.getByText(detail)).toBeVisible();
const occurrences = document.body.textContent?.match(new RegExp(detail, "gu")) ?? [];
expect(occurrences).toHaveLength(1);
} finally {
await screen.unmount();
}
});

it("snaps to the bottom when timeline rows appear after an initially empty render", async () => {
const requestAnimationFrameSpy = vi
.spyOn(window, "requestAnimationFrame")
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
computeStableMessagesTimelineRows,
computeMessageDurationStart,
deriveMessagesTimelineRows,
getRenderableCommandOutputLines,
hasRenderableCommandOutput,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
} from "./MessagesTimeline.logic";
Expand Down Expand Up @@ -204,6 +206,29 @@ describe("resolveAssistantMessageCopyState", () => {
});
});

describe("hasRenderableCommandOutput", () => {
it("hides nullish and empty command output streams", () => {
expect(hasRenderableCommandOutput(undefined)).toBe(false);
expect(hasRenderableCommandOutput(null)).toBe(false);
expect(hasRenderableCommandOutput("")).toBe(false);
});

it("renders command output streams when the provider emitted content", () => {
expect(hasRenderableCommandOutput("stdout\n")).toBe(true);
expect(hasRenderableCommandOutput(" ")).toBe(false);
expect(hasRenderableCommandOutput("\n\t\n")).toBe(false);
});

it("preserves intentional blank command output lines", () => {
expect(getRenderableCommandOutputLines("\nstdout\n \n\t\nstderr\n")).toEqual([
"stdout",
" ",
"\t",
"stderr",
]);
});
});

describe("deriveMessagesTimelineRows", () => {
it("only enables assistant copy for the terminal assistant message in a turn", () => {
const rows = deriveMessagesTimelineRows({
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ export function resolveAssistantMessageCopyState({
};
}

export function hasRenderableCommandOutput(value: string | null | undefined): value is string {
return getRenderableCommandOutputLines(value).length > 0;
}

export function getRenderableCommandOutputLines(value: string | null | undefined): string[] {
if (typeof value !== "string" || value.length === 0) {
return [];
}
const lines = value.split(/\r?\n/u);
let startIndex = 0;
let endIndex = lines.length;
while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) {
startIndex += 1;
}
while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) {
endIndex -= 1;
}
return lines.slice(startIndex, endIndex);
}

function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {
const lastAssistantMessageIdByResponseKey = new Map<string, string>();
let nullTurnResponseIndex = 0;
Expand Down
Loading
Loading