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
118 changes: 118 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.agentUi.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* T3-CUSTOM(expbkt3): agent views must survive work-log grouping.
*
* Tool rows collapse into a "+N tool calls" toggle once several land in a row,
* which is right for a record that work happened and wrong for an agent view:
* the view *is* the answer, so collapsing it hides the thing the user asked
* for. These tests pin that a `t3_show_ui` row is always rendered on its own,
* without the reader expanding anything.
*/
import { describe, expect, it } from "vite-plus/test";

import { deriveMessagesTimelineRows, workEntryStaysVisible } from "./MessagesTimeline.logic";

function toolEntry(id: string, at: string) {
return {
id: `entry-${id}`,
kind: "work" as const,
createdAt: at,
entry: {
id,
createdAt: at,
turnId: "turn-1" as never,
label: "Ran a tool",
tone: "tool" as const,
itemType: "mcp_tool_call" as const,
toolLifecycleStatus: "completed" as const,
},
};
}

function agentUiEntry(id: string, at: string) {
const base = toolEntry(id, at);
return {
...base,
entry: { ...base.entry, label: "Showed a view", agentUi: { renderId: `aui_${id}` } },
};
}

const derive = (timelineEntries: ReadonlyArray<ReturnType<typeof toolEntry>>) =>
deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});

/** Every work entry the reader can see without expanding a toggle. */
function visibleWorkEntryIds(rows: ReturnType<typeof derive>): ReadonlyArray<string> {
const ids: string[] = [];
for (const row of rows) {
if (row.kind === "work") {
for (const entry of row.groupedEntries) ids.push(entry.id);
}
}
return ids;
}

describe("workEntryStaysVisible", () => {
it("covers agent views as well as spawn rows", () => {
expect(workEntryStaysVisible({ agentUi: { renderId: "aui_1" } } as never)).toBe(true);
expect(
workEntryStaysVisible({ agentSpawn: { workflowId: null, agentTaskIds: [] } } as never),
).toBe(true);
});

it("leaves an ordinary tool row collapsible", () => {
expect(workEntryStaysVisible({ label: "Ran a tool" } as never)).toBe(false);
expect(workEntryStaysVisible({ agentUi: undefined } as never)).toBe(false);
});
});

describe("agent views in the work log", () => {
it("renders the view without the reader expanding anything", () => {
const rows = derive([
toolEntry("tool-1", "2026-01-01T00:00:01Z"),
toolEntry("tool-2", "2026-01-01T00:00:02Z"),
agentUiEntry("view-1", "2026-01-01T00:00:03Z"),
toolEntry("tool-3", "2026-01-01T00:00:04Z"),
]);

expect(visibleWorkEntryIds(rows)).toContain("view-1");
});

it("still folds the same run away when none of them is a view", () => {
// The control. Without a view the whole settled turn folds and no work row
// survives — which is exactly what was swallowing the box before, and what
// must keep happening for ordinary tool calls.
const rows = derive([
toolEntry("tool-1", "2026-01-01T00:00:01Z"),
toolEntry("tool-2", "2026-01-01T00:00:02Z"),
toolEntry("tool-3", "2026-01-01T00:00:03Z"),
toolEntry("tool-4", "2026-01-01T00:00:04Z"),
]);

expect(visibleWorkEntryIds(rows)).toEqual([]);
expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
});

it("keeps every view visible when several land in one run", () => {
const rows = derive([
toolEntry("tool-1", "2026-01-01T00:00:01Z"),
agentUiEntry("view-1", "2026-01-01T00:00:02Z"),
toolEntry("tool-2", "2026-01-01T00:00:03Z"),
agentUiEntry("view-2", "2026-01-01T00:00:04Z"),
toolEntry("tool-3", "2026-01-01T00:00:05Z"),
]);

const visible = visibleWorkEntryIds(rows);
expect(visible).toContain("view-1");
expect(visible).toContain("view-2");
});

it("renders a lone view on its own", () => {
const rows = derive([agentUiEntry("view-1", "2026-01-01T00:00:01Z")]);
expect(visibleWorkEntryIds(rows)).toEqual(["view-1"]);
});
});
22 changes: 16 additions & 6 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
export const TIMELINE_CONTENT_MAX_WIDTH = 768;
export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;

// T3-CUSTOM(expbkt3): rows that must never collapse into a "+N tool calls"
// toggle, and never fold away with their turn.
//
// A spawn CTA hides a still-running fleet when folded; an agent view is worse,
// because the view *is* the answer the agent produced, not a record that work
// happened. Behind a toggle it may as well not have been drawn.
export function workEntryStaysVisible(entry: WorkLogEntry): boolean {
return entry.agentSpawn !== undefined || entry.agentUi !== undefined;
}

export function workEntryIsVisibleInGroup(
entry: WorkLogEntry,
expandedToolGroupEntry = false,
Expand Down Expand Up @@ -615,7 +625,7 @@ function deriveTurnFolds(input: {
// Agent-spawn CTA rows never fold: workflows outlive their launching
// turn (dynamic spawns, background execution), and folding the CTA
// when the turn settles makes a still-running fleet invisible.
if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
if (entry.kind === "work" && workEntryStaysVisible(entry.entry)) {
continue;
}
hiddenEntryIds.add(entry.id);
Expand Down Expand Up @@ -742,7 +752,7 @@ export function deriveMessagesTimelineRows(input: {
}
if (entry.kind === "work") {
return (
entry.entry.agentSpawn === undefined &&
!workEntryStaysVisible(entry.entry) &&
workLogEntryIsToolLike(entry.entry) &&
entry.entry.toolLifecycleStatus === "inProgress"
);
Expand All @@ -757,7 +767,7 @@ export function deriveMessagesTimelineRows(input: {
if (
!entryBelongsToActiveTurn(entry, index) ||
entry.kind !== "work" ||
entry.entry.agentSpawn !== undefined ||
workEntryStaysVisible(entry.entry) ||
entry.entry.tone === "error" ||
!workLogEntryIsToolLike(entry.entry)
) {
Expand Down Expand Up @@ -875,7 +885,7 @@ export function deriveMessagesTimelineRows(input: {
const onlyToolEntries = visibleGroupedEntries.every(
(entry) =>
workLogEntryIsToolLike(entry) &&
entry.agentSpawn === undefined &&
!workEntryStaysVisible(entry) &&
entry.tone !== "error",
);
const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
Expand Down Expand Up @@ -951,12 +961,12 @@ export function deriveMessagesTimelineRows(input: {
// (review finding: concatenating two filtered lists moved a
// mid-group spawn row above earlier tool rows).
const overflowCandidates = visibleGroupedEntries.filter(
(entry) => entry.agentSpawn === undefined,
(entry) => !workEntryStaysVisible(entry),
);
const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
const visibleEntries = visibleGroupedEntries.filter(
(entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
(entry) => workEntryStaysVisible(entry) || !hiddenIds.has(entry.id),
);
const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;

Expand Down
Loading