Skip to content
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 @@ -84,6 +84,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
282 changes: 282 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,288 @@ 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"');
});

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"');
});

it("renders MCP 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: "MCP tool",
tone: "tool",
itemType: "mcp_tool_call",
command: "rg TODO",
stdout: "apps/web/src/session-logic.ts:1:TODO",
exitCode: 0,
},
},
]}
/>,
);

expect(markup).toContain("MCP tool");
expect(markup).toContain("rg TODO");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Expand MCP tool"');
});

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"');
});

it("renders dynamic tool patch metadata as expandable file-change 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 patch tool",
tone: "tool",
itemType: "dynamic_tool_call",
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",
stdout: "applied patch",
exitCode: 0,
durationMs: 1234,
},
},
]}
/>,
);

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

it("renders dynamic tool output metadata as expandable command rows without a command", 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 output tool",
tone: "tool",
itemType: "dynamic_tool_call",
stdout: "updated files",
exitCode: 0,
},
},
]}
/>,
);

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

it("renders command execution patch metadata as expandable file-change 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: "Ran command",
tone: "tool",
itemType: "command_execution",
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("Ran command");
expect(markup).toContain("apps/web/src/session-logic.ts");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain('aria-label="Expand Ran command"');
});

it("renders mixed dynamic tool command and patch metadata", 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 edit tool",
tone: "tool",
itemType: "dynamic_tool_call",
command: "apply_patch",
stdout: "updated files",
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",
exitCode: 0,
},
},
]}
/>,
);

expect(markup).toContain("apply_patch");
expect(markup).toContain("apps/web/src/session-logic.ts");
expect(markup).toContain('aria-label="Expand Dynamic edit tool"');
});

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