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
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 @@ -260,6 +262,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 @@ -114,6 +114,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
128 changes: 128 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ beforeAll(() => {
documentElement: {
classList,
offsetHeight: 0,
removeAttribute: () => {},
setAttribute: () => {},
},
});
});
Expand Down Expand Up @@ -259,6 +261,132 @@ describe("MessagesTimeline", () => {
expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts");
});

it("renders command work entries as expandable rows", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Ran command",
tone: "tool",
itemType: "command_execution",
command: "vp test",
stdout,
stderr: "warning",
exitCode: 0,
durationMs: 1234,
},
},
]}
/>,
);

expect(markup).toContain("Ran command");
expect(markup).toContain("vp test");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Expand Ran command - vp test"');
});

it("renders dynamic tool command metadata as expandable command rows", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Dynamic tool",
tone: "tool",
itemType: "dynamic_tool_call",
command: "vp test",
stdout: "passed",
exitCode: 0,
durationMs: 1234,
},
},
]}
/>,
);

expect(markup).toContain("Dynamic tool");
expect(markup).toContain("vp test");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Expand Dynamic tool - vp test"');
});

it("does not render typed non-command stdout as command details", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Web search",
tone: "tool",
itemType: "web_search",
stdout: "search results",
durationMs: 1234,
},
},
]}
/>,
);

expect(markup).toContain("Web search");
expect(markup).not.toContain('aria-expanded="false"');
expect(markup).not.toContain('aria-label="Expand Web search"');
});

it("renders file-change work entries as expandable rows", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt: "2026-03-17T19:12:28.000Z",
entry: {
id: "work-1",
createdAt: "2026-03-17T19:12:28.000Z",
label: "Changed files",
tone: "tool",
itemType: "file_change",
changedFiles: ["apps/web/src/session-logic.ts"],
patch:
"diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts\n--- a/apps/web/src/session-logic.ts\n+++ b/apps/web/src/session-logic.ts\n@@ -1 +1 @@\n-old\n+new\n",
},
},
]}
/>,
);

expect(markup).toContain("Changed files");
expect(markup).toContain("apps/web/src/session-logic.ts");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Expand Changed files - apps/web/src/session-logic.ts"');
});

it("renders review comment contexts as structured cards instead of raw tags", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
Expand Down
Loading
Loading