diff --git a/apps/web/src/components/chat/MessagesTimeline.agentUi.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.agentUi.logic.test.ts new file mode 100644 index 00000000000..0a163f2171c --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.agentUi.logic.test.ts @@ -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>) => + 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): ReadonlyArray { + 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"]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a78a9af1eb0..3afb95f1c08 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -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, @@ -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); @@ -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" ); @@ -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) ) { @@ -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); @@ -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;