diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 019b4d7d9..4da048712 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -3,6 +3,7 @@ import {
AlertCircle,
Bot,
Brain,
+ CheckCheck,
ChevronDown,
CircleDot,
Loader2,
@@ -19,9 +20,18 @@ import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Markdown } from "@/shared/ui/markdown";
import { Shimmer } from "@/shared/ui/Shimmer";
+import { Toggle } from "@/shared/ui/toggle";
import { UserAvatar } from "@/shared/ui/UserAvatar";
-import type { TranscriptItem } from "./agentSessionTypes";
+import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { ToolItem } from "./AgentSessionToolItem";
+import {
+ buildTranscriptDisplayBlocks,
+ formatTurnSetupLabel,
+ turnSetupDetail,
+ turnSetupTimestamp,
+ type TranscriptDisplayBlock,
+ type TranscriptTurnSegment,
+} from "./agentSessionTranscriptGrouping";
import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTime } from "./agentSessionUtils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -50,6 +60,10 @@ export function AgentSessionTranscriptList({
() => buildTranscriptPresentation(items, isWorking),
[items, isWorking],
);
+ const displayBlocks = React.useMemo(
+ () => buildTranscriptDisplayBlocks(items),
+ [items],
+ );
if (items.length === 0) {
return (
@@ -81,26 +95,15 @@ export function AgentSessionTranscriptList({
className={cn("w-full", compact ? "py-0.5" : "py-1")}
role="log"
>
- {items.map((item) => (
-
- {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
-
- ) : null}
-
-
+ {displayBlocks.map((block) => (
+
))}
@@ -315,6 +318,351 @@ function getStateLabel(
}
}
+function getDisplayBlockKey(block: TranscriptDisplayBlock) {
+ if (block.kind === "single") {
+ return block.item.id;
+ }
+ return `turn:${block.turnId}`;
+}
+
+function TranscriptDisplayBlockView({
+ activeItemIds,
+ agentName,
+ block,
+ compact,
+ profiles,
+}: {
+ activeItemIds: ReadonlySet;
+ agentName: string;
+ block: TranscriptDisplayBlock;
+ compact: boolean;
+ profiles?: UserProfileLookup;
+}) {
+ if (block.kind === "single") {
+ return (
+
+ );
+ }
+
+ return (
+
+ {block.segments.map((segment) => (
+
+ ))}
+
+ );
+}
+
+function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
+ if (segment.kind === "setup") {
+ return `turn:${turnId}:setup`;
+ }
+ if (segment.kind === "prompt") {
+ return `turn:${turnId}:prompt`;
+ }
+ return segment.item.id;
+}
+
+function TranscriptTurnSegmentView({
+ activeItemIds,
+ agentName,
+ compact,
+ profiles,
+ segment,
+}: {
+ activeItemIds: ReadonlySet;
+ agentName: string;
+ compact: boolean;
+ profiles?: UserProfileLookup;
+ segment: TranscriptTurnSegment;
+}) {
+ if (segment.kind === "prompt") {
+ return (
+
+ );
+ }
+
+ if (segment.kind === "setup") {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function TurnPromptBlock({
+ compact,
+ context,
+ profiles,
+ setup,
+ user,
+}: {
+ compact: boolean;
+ context: Extract | null;
+ profiles?: UserProfileLookup;
+ setup: Extract[];
+ user: Extract;
+}) {
+ return (
+
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? (
+
+
+ {context ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+function PromptUserMessage({
+ compact,
+ context = null,
+ item,
+ profiles,
+ setup = [],
+}: {
+ compact: boolean;
+ context?: Extract | null;
+ item: Extract;
+ profiles?: UserProfileLookup;
+ setup?: Extract[];
+}) {
+ const [contextOpen, setContextOpen] = React.useState(false);
+ const text = item.text.trim();
+ const authorProfile = item.authorPubkey
+ ? profiles?.[item.authorPubkey.toLowerCase()]
+ : null;
+ const authorLabel = item.authorPubkey
+ ? resolveUserLabel({
+ pubkey: item.authorPubkey,
+ fallbackName: item.title,
+ profiles,
+ })
+ : item.title || "User";
+
+ return (
+
+
+
+
+
{text}
+ {contextOpen && context ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function PromptContextSections({ sections }: { sections: PromptSection[] }) {
+ return (
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+
+
+ {section.body.trim() || "No metadata."}
+
+
+ ))}
+
+ );
+}
+
+function TurnSetupFooter({
+ context = null,
+ contextOpen = false,
+ items,
+ onContextOpenChange,
+ timestamp,
+}: {
+ context?: Extract | null;
+ contextOpen?: boolean;
+ items: Extract[];
+ onContextOpenChange?: (open: boolean) => void;
+ timestamp: string;
+}) {
+ const label = formatTurnSetupLabel(items);
+ const detail = turnSetupDetail(items);
+ const tooltipText = [label, detail].filter(Boolean).join(" · ");
+ const showSetup = items.length > 0;
+ const showContext = context != null && context.sections.length > 0;
+
+ if (!showSetup && !showContext) {
+ return ;
+ }
+
+ return (
+
+ {showSetup ? (
+
+
+
+
+
+ {tooltipText}
+
+
+ ) : null}
+ {showContext ? (
+
+ Context
+
+ {context.sections.length}
+
+
+ ) : null}
+
+
+ );
+}
+
+function TranscriptItemRow({
+ activeItemIds,
+ agentName,
+ compact,
+ item,
+ profiles,
+}: {
+ activeItemIds: ReadonlySet;
+ agentName: string;
+ compact: boolean;
+ item: TranscriptItem;
+ profiles?: UserProfileLookup;
+}) {
+ return (
+
+ {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
+
+ ) : null}
+
+
+ );
+}
+
+function TurnSetupStatus({
+ compact,
+ items,
+}: {
+ compact: boolean;
+ items: Extract[];
+}) {
+ const timestamp = turnSetupTimestamp(items);
+ if (items.length === 0 || !timestamp) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
function getItemSpacingClass(item: TranscriptItem) {
if (item.type === "lifecycle") {
return "mt-2 first:mt-0";
@@ -495,16 +843,25 @@ function ThoughtItem({
function MetadataItem({
compact,
+ embedded = false,
item,
}: {
compact: boolean;
+ embedded?: boolean;
item: Extract;
}) {
return (
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
index dda17b174..a5cd0c96b 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
@@ -3,6 +3,101 @@ import test from "node:test";
import { buildTranscript } from "./agentSessionTranscript.ts";
+const turnId = "turn-abc";
+const sessionId = "sess-1";
+const channelId = "channel-1";
+const baseTimestamp = "2026-06-14T22:20:23.000Z";
+
+function makeTurnEvents() {
+ return [
+ {
+ seq: 1,
+ timestamp: baseTimestamp,
+ kind: "turn_started",
+ agentIndex: 0,
+ channelId,
+ sessionId: null,
+ turnId,
+ payload: { triggeringEventIds: ["event-1"] },
+ },
+ {
+ seq: 2,
+ timestamp: baseTimestamp,
+ kind: "session_resolved",
+ agentIndex: 0,
+ channelId,
+ sessionId,
+ turnId,
+ payload: { sessionId, isNewSession: false },
+ },
+ {
+ seq: 3,
+ timestamp: baseTimestamp,
+ kind: "acp_write",
+ agentIndex: 0,
+ channelId,
+ sessionId,
+ turnId,
+ payload: {
+ method: "session/prompt",
+ params: {
+ sessionId,
+ prompt: [
+ {
+ type: "text",
+ text: "[Buzz event: message]\nContent: @Ned deliberate, wider pass\nFrom: Tyler hex: abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
+ },
+ ],
+ },
+ },
+ },
+ {
+ seq: 4,
+ timestamp: "2026-06-14T22:20:47.000Z",
+ kind: "acp_read",
+ agentIndex: 0,
+ channelId,
+ sessionId,
+ turnId,
+ payload: {
+ method: "session/update",
+ params: {
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "msg-1",
+ content: [{ type: "text", text: "On it." }],
+ },
+ },
+ },
+ },
+ ];
+}
+
+test("buildTranscript attaches turnId and sessionId to generated items", () => {
+ const items = buildTranscript(makeTurnEvents());
+
+ assert.ok(items.length >= 4);
+ for (const item of items) {
+ assert.equal(item.turnId, turnId);
+ assert.equal(item.channelId, channelId);
+ }
+
+ const sessionResolved = items.find(
+ (item) =>
+ item.type === "lifecycle" && item.acpSource === "session_resolved",
+ );
+ assert.equal(sessionResolved?.sessionId, sessionId);
+
+ const userPrompt = items.find(
+ (item) =>
+ item.type === "message" &&
+ item.role === "user" &&
+ item.acpSource === "session/prompt:user",
+ );
+ assert.ok(userPrompt);
+ assert.equal(userPrompt.sessionId, sessionId);
+});
+
test("buildTranscript tags assistant chunks with agent_message_chunk", () => {
const items = buildTranscript([
{
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts
index 62d642f59..945945526 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts
@@ -108,6 +108,12 @@ function sealOpenMessages(d: TranscriptDraft) {
}
}
+type TranscriptItemContext = {
+ channelId: string | null;
+ turnId: string | null;
+ sessionId: string | null;
+};
+
function upsertMessage(
d: TranscriptDraft,
id: string,
@@ -115,7 +121,7 @@ function upsertMessage(
title: string,
text: string,
timestamp: string,
- channelId: string | null,
+ ctx: TranscriptItemContext,
authorPubkey: string | null = null,
acpSource?: string,
) {
@@ -127,7 +133,9 @@ function upsertMessage(
replaceItem(d, currentKey, {
...existing,
text: existing.text + text,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId ?? existing.turnId,
+ sessionId: ctx.sessionId ?? existing.sessionId,
authorPubkey: authorPubkey ?? existing.authorPubkey,
acpSource: acpSource ?? existing.acpSource,
});
@@ -144,7 +152,9 @@ function upsertMessage(
title,
text,
timestamp,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId,
+ sessionId: ctx.sessionId,
authorPubkey,
acpSource,
});
@@ -159,7 +169,7 @@ function upsertTextItem(
title: string,
text: string,
timestamp: string,
- channelId: string | null,
+ ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -167,13 +177,25 @@ function upsertTextItem(
replaceItem(d, id, {
...existing,
text: existing.text + text,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId ?? existing.turnId,
+ sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
sealOpenMessages(d);
- pushItem(d, { id, type, title, text, timestamp, channelId, acpSource });
+ pushItem(d, {
+ id,
+ type,
+ title,
+ text,
+ timestamp,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId,
+ sessionId: ctx.sessionId,
+ acpSource,
+ });
}
function upsertMetadata(
@@ -182,7 +204,7 @@ function upsertMetadata(
title: string,
sections: PromptSection[],
timestamp: string,
- channelId: string | null,
+ ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -190,7 +212,9 @@ function upsertMetadata(
replaceItem(d, id, {
...existing,
sections,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId ?? existing.turnId,
+ sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
@@ -202,7 +226,9 @@ function upsertMetadata(
title,
sections,
timestamp,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId,
+ sessionId: ctx.sessionId,
acpSource,
});
}
@@ -218,7 +244,7 @@ function upsertTool(
result: string,
isError: boolean,
timestamp: string,
- channelId: string | null,
+ ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -248,7 +274,9 @@ function upsertTool(
existing.completedAt == null
? timestamp
: existing.completedAt,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId ?? existing.turnId,
+ sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
@@ -267,7 +295,9 @@ function upsertTool(
timestamp,
startedAt: timestamp,
completedAt: null,
- channelId,
+ channelId: ctx.channelId,
+ turnId: ctx.turnId,
+ sessionId: ctx.sessionId,
acpSource,
});
}
@@ -284,6 +314,11 @@ export function processTranscriptEvent(
const channelId = event.channelId ?? null;
const ch = channelId ?? "global";
+ const ctx: TranscriptItemContext = {
+ channelId,
+ turnId: event.turnId,
+ sessionId: event.sessionId ?? d.latestSessionId,
+ };
if (event.kind === "turn_started") {
upsertTextItem(
@@ -293,7 +328,7 @@ export function processTranscriptEvent(
"Turn started",
describeTurnStarted(event.payload),
event.timestamp,
- channelId,
+ ctx,
event.kind,
);
} else if (event.kind === "session_resolved") {
@@ -304,7 +339,7 @@ export function processTranscriptEvent(
"Session ready",
describeSessionResolved(event.payload),
event.timestamp,
- channelId,
+ ctx,
event.kind,
);
} else if (event.kind === "acp_parse_error") {
@@ -315,7 +350,7 @@ export function processTranscriptEvent(
"Wire parse error",
extractBlockText(event.payload),
event.timestamp,
- channelId,
+ ctx,
event.kind,
);
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
@@ -331,7 +366,7 @@ export function processTranscriptEvent(
title,
`${outcome}: ${error}`,
event.timestamp,
- channelId,
+ ctx,
event.kind,
);
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
@@ -350,7 +385,7 @@ export function processTranscriptEvent(
parsedPrompt.userTitle,
parsedPrompt.userText,
event.timestamp,
- channelId,
+ ctx,
parsedPrompt.userPubkey,
"session/prompt:user",
);
@@ -362,7 +397,7 @@ export function processTranscriptEvent(
"Prompt context",
parsedPrompt.sections,
event.timestamp,
- channelId,
+ ctx,
"session/prompt:context",
);
}
@@ -382,7 +417,7 @@ export function processTranscriptEvent(
"Assistant",
extractContentText(update.content),
event.timestamp,
- channelId,
+ ctx,
null,
updateType,
);
@@ -394,7 +429,7 @@ export function processTranscriptEvent(
"User",
extractContentText(update.content),
event.timestamp,
- channelId,
+ ctx,
null,
updateType,
);
@@ -406,7 +441,7 @@ export function processTranscriptEvent(
"Thinking",
extractContentText(update.content),
event.timestamp,
- channelId,
+ ctx,
updateType,
);
} else if (updateType === "tool_call") {
@@ -423,7 +458,7 @@ export function processTranscriptEvent(
extractToolResult(update),
false,
event.timestamp,
- channelId,
+ ctx,
updateType,
);
} else if (updateType === "tool_call_update") {
@@ -443,7 +478,7 @@ export function processTranscriptEvent(
extractToolResult(update),
status === "failed",
event.timestamp,
- channelId,
+ ctx,
updateType,
);
} else if (updateType === "plan") {
@@ -454,7 +489,7 @@ export function processTranscriptEvent(
"Plan",
extractContentText(update.content) || JSON.stringify(update, null, 2),
event.timestamp,
- channelId,
+ ctx,
updateType,
);
}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs
new file mode 100644
index 000000000..593c3104c
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs
@@ -0,0 +1,198 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildTranscriptDisplayBlocks,
+ flattenDisplayBlocks,
+ formatTurnSetupLabel,
+} from "./agentSessionTranscriptGrouping.ts";
+
+const baseTimestamp = "2026-06-14T22:20:23.000Z";
+
+function lifecycle(id, title, acpSource, turnId, text = "") {
+ return {
+ id,
+ type: "lifecycle",
+ title,
+ text,
+ timestamp: baseTimestamp,
+ acpSource,
+ turnId,
+ sessionId: "sess-1",
+ channelId: "channel-1",
+ };
+}
+
+function userPrompt(id, text, turnId) {
+ return {
+ id,
+ type: "message",
+ role: "user",
+ title: "Buzz event",
+ text,
+ timestamp: baseTimestamp,
+ acpSource: "session/prompt:user",
+ turnId,
+ sessionId: "sess-1",
+ channelId: "channel-1",
+ };
+}
+
+function promptContext(id, turnId) {
+ return {
+ id,
+ type: "metadata",
+ title: "Prompt context",
+ sections: [{ title: "Channel", body: "general" }],
+ timestamp: baseTimestamp,
+ acpSource: "session/prompt:context",
+ turnId,
+ sessionId: "sess-1",
+ channelId: "channel-1",
+ };
+}
+
+function assistantMessage(id, text, turnId) {
+ return {
+ id,
+ type: "message",
+ role: "assistant",
+ title: "Assistant",
+ text,
+ timestamp: "2026-06-14T22:20:47.000Z",
+ acpSource: "agent_message_chunk",
+ turnId,
+ sessionId: "sess-1",
+ channelId: "channel-1",
+ };
+}
+
+function toolCall(id, turnId) {
+ return {
+ id,
+ type: "tool",
+ title: "Shell",
+ toolName: "buzz-dev-mcp__shell",
+ buzzToolName: null,
+ status: "completed",
+ args: {},
+ result: "ok",
+ isError: false,
+ timestamp: "2026-06-14T22:20:47.000Z",
+ startedAt: "2026-06-14T22:20:47.000Z",
+ completedAt: "2026-06-14T22:20:47.400Z",
+ acpSource: "tool_call_update",
+ turnId,
+ sessionId: "sess-1",
+ channelId: "channel-1",
+ };
+}
+
+test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => {
+ const rawItems = [
+ lifecycle(
+ "turn",
+ "Turn started",
+ "turn_started",
+ "turn-1",
+ "Triggered by 1 event.",
+ ),
+ lifecycle("session", "Session ready", "session_resolved", "turn-1"),
+ userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"),
+ promptContext("context", "turn-1"),
+ assistantMessage("assistant", "Thinking out loud.", "turn-1"),
+ toolCall("tool", "turn-1"),
+ ];
+
+ const blocks = buildTranscriptDisplayBlocks(rawItems);
+ const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
+
+ assert.deepEqual(displayOrder, [
+ "prompt",
+ "turn",
+ "session",
+ "context",
+ "assistant",
+ "tool",
+ ]);
+
+ const turnBlock = blocks[0];
+ assert.equal(turnBlock?.kind, "turn");
+ assert.equal(turnBlock.segments[0]?.kind, "prompt");
+ const promptSegment = turnBlock.segments[0];
+ assert.equal(promptSegment.user.id, "prompt");
+ assert.equal(promptSegment.context?.id, "context");
+ assert.equal(promptSegment.setup.length, 2);
+ assert.equal(turnBlock.segments[1]?.kind, "item");
+});
+
+test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
+ const rawItems = [
+ lifecycle("turn", "Turn started", "turn_started", "turn-1"),
+ lifecycle("session", "Session ready", "session_resolved", "turn-1"),
+ userPrompt("prompt", "hello", "turn-1"),
+ ];
+
+ const blocks = buildTranscriptDisplayBlocks(rawItems);
+ assert.equal(blocks.length, 1);
+ assert.equal(blocks[0]?.kind, "turn");
+
+ const turnBlock = blocks[0];
+ assert.equal(turnBlock.segments.length, 1);
+ assert.equal(turnBlock.segments[0]?.kind, "prompt");
+ assert.equal(
+ formatTurnSetupLabel(turnBlock.segments[0].setup),
+ "Turn started · Session ready",
+ );
+});
+
+test("buildTranscriptDisplayBlocks keeps lifecycle visible when prompt is missing", () => {
+ const rawItems = [
+ lifecycle("turn", "Turn started", "turn_started", "turn-1"),
+ lifecycle("session", "Session ready", "session_resolved", "turn-1"),
+ ];
+
+ const blocks = buildTranscriptDisplayBlocks(rawItems);
+ const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
+
+ assert.deepEqual(displayOrder, ["turn", "session"]);
+});
+
+test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => {
+ const rawItems = [
+ lifecycle("turn", "Turn started", "turn_started", "turn-1"),
+ userPrompt("prompt", "hello", "turn-1"),
+ lifecycle(
+ "error",
+ "Turn error",
+ "turn_error",
+ "turn-1",
+ "timeout: agent hung",
+ ),
+ ];
+
+ const blocks = buildTranscriptDisplayBlocks(rawItems);
+ const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
+
+ assert.deepEqual(displayOrder, ["prompt", "turn", "error"]);
+ assert.equal(blocks[0]?.segments[0]?.kind, "prompt");
+ assert.equal(blocks[0]?.segments[1]?.kind, "item");
+ assert.equal(blocks[0]?.segments[1]?.item.id, "error");
+});
+
+test("buildTranscriptDisplayBlocks passes through items without turnId", () => {
+ const orphan = {
+ id: "orphan",
+ type: "lifecycle",
+ title: "Wire parse error",
+ text: "bad json",
+ timestamp: baseTimestamp,
+ acpSource: "acp_parse_error",
+ channelId: "channel-1",
+ };
+
+ const blocks = buildTranscriptDisplayBlocks([orphan]);
+ assert.equal(blocks.length, 1);
+ assert.equal(blocks[0]?.kind, "single");
+ assert.equal(blocks[0]?.item.id, "orphan");
+});
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
new file mode 100644
index 000000000..96caa6d97
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
@@ -0,0 +1,208 @@
+import type { TranscriptItem } from "./agentSessionTypes";
+
+export type TranscriptTurnSegment =
+ | { kind: "item"; item: TranscriptItem }
+ | { kind: "setup"; items: Extract[] }
+ | {
+ kind: "prompt";
+ user: Extract;
+ context: Extract | null;
+ setup: Extract[];
+ };
+
+export type TranscriptDisplayBlock =
+ | { kind: "single"; item: TranscriptItem }
+ | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
+
+function isUserPrompt(
+ item: TranscriptItem,
+): item is Extract {
+ return (
+ item.type === "message" &&
+ item.role === "user" &&
+ item.acpSource === "session/prompt:user"
+ );
+}
+
+function isPromptContext(
+ item: TranscriptItem,
+): item is Extract {
+ return (
+ item.type === "metadata" && item.acpSource === "session/prompt:context"
+ );
+}
+
+function isSetupLifecycle(
+ item: TranscriptItem,
+): item is Extract {
+ return (
+ item.type === "lifecycle" &&
+ (item.acpSource === "turn_started" || item.acpSource === "session_resolved")
+ );
+}
+
+function isErrorLifecycle(
+ item: TranscriptItem,
+): item is Extract {
+ return (
+ item.type === "lifecycle" && item.title.toLowerCase().includes("error")
+ );
+}
+
+type TurnBucket = {
+ turnId: string;
+ items: TranscriptItem[];
+};
+
+function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
+ const userPrompt = items.find(isUserPrompt) ?? null;
+ const setupLifecycle = items.filter(isSetupLifecycle);
+ const promptContext = items.find(isPromptContext) ?? null;
+ const consumed = new Set();
+
+ if (userPrompt) consumed.add(userPrompt);
+ for (const item of setupLifecycle) consumed.add(item);
+ if (promptContext) consumed.add(promptContext);
+
+ const activity = items.filter((item) => !consumed.has(item));
+
+ if (!userPrompt) {
+ return items.map((item) => ({ kind: "item", item }));
+ }
+
+ const segments: TranscriptTurnSegment[] = [
+ {
+ kind: "prompt",
+ user: userPrompt,
+ context: promptContext,
+ setup: setupLifecycle,
+ },
+ ];
+
+ for (const item of activity) {
+ if (isErrorLifecycle(item)) {
+ segments.push({ kind: "item", item });
+ continue;
+ }
+ if (isSetupLifecycle(item)) {
+ continue;
+ }
+ segments.push({ kind: "item", item });
+ }
+
+ return segments;
+}
+
+/**
+ * Build presentation-only display blocks from normalized transcript items.
+ * Raw observer order is preserved in the source items; this only reorders
+ * within a turn for user-facing narrative flow.
+ */
+export function buildTranscriptDisplayBlocks(
+ items: TranscriptItem[],
+): TranscriptDisplayBlock[] {
+ const blocks: TranscriptDisplayBlock[] = [];
+ const turnBuckets = new Map();
+ const displayOrder: Array<
+ { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
+ > = [];
+
+ for (const item of items) {
+ const turnId = item.turnId;
+ if (!turnId) {
+ displayOrder.push({ kind: "single", item });
+ continue;
+ }
+
+ let bucket = turnBuckets.get(turnId);
+ if (!bucket) {
+ bucket = { turnId, items: [] };
+ turnBuckets.set(turnId, bucket);
+ displayOrder.push({ kind: "turn", turnId });
+ }
+ bucket.items.push(item);
+ }
+
+ for (const entry of displayOrder) {
+ if (entry.kind === "single") {
+ blocks.push({ kind: "single", item: entry.item });
+ continue;
+ }
+
+ const bucket = turnBuckets.get(entry.turnId);
+ if (!bucket || bucket.items.length === 0) {
+ continue;
+ }
+
+ blocks.push({
+ kind: "turn",
+ turnId: entry.turnId,
+ segments: classifyTurnItems(bucket.items),
+ });
+ }
+
+ return blocks;
+}
+
+/** Flatten display blocks back to items for testing display order. */
+export function flattenDisplayBlocks(
+ blocks: TranscriptDisplayBlock[],
+): TranscriptItem[] {
+ const result: TranscriptItem[] = [];
+
+ for (const block of blocks) {
+ if (block.kind === "single") {
+ result.push(block.item);
+ continue;
+ }
+
+ for (const segment of block.segments) {
+ if (segment.kind === "item") {
+ result.push(segment.item);
+ } else if (segment.kind === "prompt") {
+ result.push(segment.user);
+ result.push(...segment.setup);
+ if (segment.context) {
+ result.push(segment.context);
+ }
+ } else {
+ result.push(...segment.items);
+ }
+ }
+ }
+
+ return result;
+}
+
+/** Human-readable labels for a collapsed turn setup row. */
+export function formatTurnSetupLabel(
+ items: Extract[],
+): string {
+ const labels = items.map((item) => item.title);
+ return labels.join(" · ");
+}
+
+/** Earliest timestamp among setup lifecycle items. */
+export function turnSetupTimestamp(
+ items: Extract[],
+): string | null {
+ if (items.length === 0) return null;
+ return items.reduce(
+ (earliest, item) =>
+ Date.parse(item.timestamp) < Date.parse(earliest)
+ ? item.timestamp
+ : earliest,
+ items[0].timestamp,
+ );
+}
+
+/** Optional detail text from setup lifecycle items (e.g. trigger count). */
+export function turnSetupDetail(
+ items: Extract[],
+): string | null {
+ const details = items
+ .map((item) => item.text.trim())
+ .filter((text) => text.length > 0);
+ if (details.length === 0) return null;
+ return details.join(" ");
+}
diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts
index 90fb2500e..c4061bb1d 100644
--- a/desktop/src/features/agents/ui/agentSessionTypes.ts
+++ b/desktop/src/features/agents/ui/agentSessionTypes.ts
@@ -23,8 +23,15 @@ export type ToolStatus = "executing" | "completed" | "failed" | "pending";
/** Observer/ACP wire label for dev-only transcript debugging. */
export type TranscriptAcpSource = string;
+/** Shared optional identity fields attached during transcript construction. */
+export type TranscriptItemIdentity = {
+ turnId?: string | null;
+ sessionId?: string | null;
+ channelId?: string | null;
+};
+
export type TranscriptItem =
- | {
+ | ({
id: string;
type: "message";
role: "assistant" | "user";
@@ -33,36 +40,32 @@ export type TranscriptItem =
timestamp: string;
acpSource?: TranscriptAcpSource;
authorPubkey?: string | null;
- channelId?: string | null;
- }
- | {
+ } & TranscriptItemIdentity)
+ | ({
id: string;
type: "thought";
title: string;
text: string;
timestamp: string;
acpSource?: TranscriptAcpSource;
- channelId?: string | null;
- }
- | {
+ } & TranscriptItemIdentity)
+ | ({
id: string;
type: "lifecycle";
title: string;
text: string;
timestamp: string;
acpSource?: TranscriptAcpSource;
- channelId?: string | null;
- }
- | {
+ } & TranscriptItemIdentity)
+ | ({
id: string;
type: "metadata";
title: string;
sections: PromptSection[];
timestamp: string;
acpSource?: TranscriptAcpSource;
- channelId?: string | null;
- }
- | {
+ } & TranscriptItemIdentity)
+ | ({
id: string;
type: "tool";
title: string;
@@ -76,8 +79,7 @@ export type TranscriptItem =
startedAt: string;
completedAt: string | null;
acpSource?: TranscriptAcpSource;
- channelId?: string | null;
- };
+ } & TranscriptItemIdentity);
export type PromptSection = {
title: string;