feat(desktop): add conversation transcript variant for focus-mode agent sessions

**Category:** feature
**User Impact:** In the wide/standalone agent session panel the observer
transcript now reads like a conversation — the human's prompt as a
right-aligned bubble, the agent's reply as unboxed prose at a comfortable
measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan
as a live checklist, and session/status noise receded to quiet dividers.

**Problem:** The transcript had two presentations — the dense `default`
activity feed and `compactPreview` — both tuned for a narrow panel. Read at
full width they are a scan surface, not something you read a turn in.

**Solution:** A third, purely additive `conversation` variant, threaded
through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an
optional prop with a width-based default. Tool items deliberately route
through the same `ToolActivity` presenter as `default` so the parallel
tool-chain-card work merges cleanly, and the drawer/shell is untouched.

The byte-for-byte guarantee for the existing variants is enforced by a
captured-markup fixture, not by inspection: the baseline was produced by
mounting the same transcript on unmodified code, and the test fails on any
drift in `default` or `compactPreview` output.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-01
2026-08-21 18:57:17 -07:00
parent 074561233e
commit 7927fa37a3
16 changed files with 1662 additions and 363 deletions

View File

@@ -0,0 +1,358 @@
import * as React from "react";
import { CheckCheck, Clock, Radio } from "lucide-react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import { useAgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
import {
formatTurnSetupLabel,
turnSetupDetail,
turnSetupTimestamp,
} from "./agentSessionTranscriptGrouping";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { ConversationDivider } from "./activityRenderClasses/ConversationDivider";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
/**
* Opt-in only: source pills are useful while iterating on observer parsing, but
* they should not appear for every local dev session.
*/
export const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
function shouldShowTranscriptAcpSource() {
// `import.meta.env` is bundler-provided; guard it so this module stays
// importable from node-based render tests (same pattern as
// features/onboarding/devFreshOnboarding.ts).
const envValue = import.meta.env?.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
if (envValue === "1" || envValue === "true") {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return (
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
);
} catch {
return false;
}
}
export function TranscriptAcpSourceBadge({ source }: { source: string }) {
return (
<span
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
data-testid="transcript-acp-source"
title={`ACP wire source: ${source}`}
>
{source}
</span>
);
}
export function TurnPromptBlock({
context,
profiles,
setup,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div data-testid="transcript-prompt-bundle">
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
{context ? (
<TranscriptAcpSourceBadge source="session/prompt:context" />
) : null}
</div>
) : null}
<PromptUserMessage
context={context}
item={user}
profiles={profiles}
setup={setup}
/>
</div>
);
}
function PromptUserMessage({
context = null,
item,
profiles,
setup = [],
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const variant = useAgentSessionTranscriptVariant();
const [contextOpen, setContextOpen] = React.useState(false);
const contextSections = React.useMemo(
() => [...(context?.sections ?? [])],
[context],
);
return (
<>
<UserMessageBubble
bubbleClassName={variant === "conversation" ? undefined : "p-2.5"}
footer={
<TurnSetupFooter
contextOpen={contextOpen}
hasContext={contextSections.length > 0}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
onOpenChange={setContextOpen}
open={contextOpen}
sections={contextSections}
setup={setup}
/>
</>
);
}
function PromptContextDialog({
onOpenChange,
open,
sections,
setup,
}: {
onOpenChange: (open: boolean) => void;
open: boolean;
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex min-w-0 max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={sections} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
contextOpen = false,
hasContext = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
contextOpen?: boolean;
hasContext?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = hasContext && onContextOpenChange != null;
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
title={tooltipText || "Show prompt context"}
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
)}
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
</div>
);
}
export function getTranscriptMessageLink(
item: Extract<TranscriptItem, { type: "message" }>,
) {
if (!item.channelId || !item.messageId) return null;
return {
channelId: item.channelId,
messageId: item.messageId,
};
}
export function TurnSetupStatus({
items,
}: {
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const variant = useAgentSessionTranscriptVariant();
const timestamp = turnSetupTimestamp(items);
if (items.length === 0 || !timestamp) {
return null;
}
// Focus mode recedes turn setup to a quiet centered divider: the checks-icon
// summary is ingress plumbing, not something a reader judges the turn by.
if (variant === "conversation") {
return (
<ConversationDivider
label={formatPromptSetupSummary(items) || formatTurnSetupLabel(items)}
testId="transcript-turn-setup"
title={formatTranscriptTimestampTitle(timestamp)}
/>
);
}
return (
<div
className="rounded-md px-2"
title={formatTranscriptTimestampTitle(timestamp)}
>
<TurnSetupFooter
items={items}
showTimestamp={false}
timestamp={timestamp}
/>
</div>
);
}
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states (based on live-frame observation, not harness affinity):
* - `"current"` — most recent session observed via the live relay subscription.
* - `"most-recent"` — newest visible session with no matching live frames
* (loaded from archive or session ended before observation).
* - `"earlier"` — an older session preceding the most-recent one.
*/
export function SessionBoundaryDivider({
labelState,
sessionStartTimestamp,
}: {
labelState: "current" | "most-recent" | "earlier";
sessionStartTimestamp: string;
}) {
const variant = useAgentSessionTranscriptVariant();
const label =
labelState === "current"
? "Latest live-observed session"
: labelState === "most-recent"
? "Most recent observed session"
: "Earlier observed session";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
if (variant === "conversation") {
return (
<ConversationDivider
icon={
labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)
}
label={`${label} · ${formattedDate}`}
testId="session-boundary-divider"
/>
);
}
return (
<div
className="flex items-center gap-2 px-3 py-2"
data-testid="session-boundary-divider"
>
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)}
{label}
{" · "}
{formattedDate}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,387 @@
/**
* Rendering contract for the `conversation` transcript variant (focus mode).
*
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
* (variant context + derived turn meta) is exercised end to end rather than
* asserting against re-implemented render classes.
*
* The last test is the important one: `conversation` is purely additive, so the
* `default` and `compactPreview` markup for the same transcript must be
* byte-identical to the markup captured before the variant existed. That
* snapshot lives in AgentSessionTranscriptList.conversation.baseline.json and
* was produced by mounting this same transcript on the pre-change commit
* (f79421673) — regenerate it only when a deliberate change to the other
* variants is being made.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const BASELINE_MARKUP = JSON.parse(
readFileSync(
new URL(
"./AgentSessionTranscriptList.conversation.baseline.json",
import.meta.url,
),
"utf8",
),
);
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
class NoopObserver {
disconnect() {}
observe() {}
unobserve() {}
}
Object.assign(globalThis, {
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
IntersectionObserver: NoopObserver,
MutationObserver: dom.window.MutationObserver,
ResizeObserver: NoopObserver,
document: dom.window.document,
getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
localStorage: dom.window.localStorage,
self: dom.window,
window: dom.window,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
writable: true,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
let cleanup;
let render;
let createElement;
let createMemoryHistory;
let createRootRoute;
let createRouter;
let RouterProvider;
let AgentSessionTranscriptList;
let resetActiveAgentTurnsStore;
let syncAgentTurnsFromEvents;
const AGENT = {
agentAvatarUrl: null,
agentName: "Test Agent",
agentPubkey: "f".repeat(64),
};
const AUTHOR = "a".repeat(64);
function items() {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
return [
{
...shared,
id: "msg:user",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "please summarize the plan",
timestamp: "2026-06-14T19:00:00.000Z",
messageId: "event-1",
authorPubkey: AUTHOR,
},
{
...shared,
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp: "2026-06-14T19:00:02.000Z",
},
{
...shared,
id: "plan:1",
type: "plan",
renderClass: "plan",
title: "Plan",
text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
timestamp: "2026-06-14T19:00:07.000Z",
},
{
...shared,
id: "msg:assistant",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "Here is the summary with `code`.",
timestamp: "2026-06-14T19:00:09.000Z",
},
];
}
async function renderTranscript(variant, overrides = {}) {
const rootRoute = createRootRoute({
component: () =>
createElement(AgentSessionTranscriptList, {
...AGENT,
emptyDescription: "nothing yet",
items: items(),
variant,
...overrides,
}),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
return render(createElement(RouterProvider, { router }));
}
before(async () => {
({ cleanup, render } = await import("@testing-library/react"));
({ createElement } = await import("react"));
({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
await import("@tanstack/react-router"));
({ AgentSessionTranscriptList } = await import(
"./AgentSessionTranscriptList.tsx"
));
({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import(
"../activeAgentTurnsStore.ts"
));
});
afterEach(() => {
cleanup?.();
resetActiveAgentTurnsStore?.();
});
after(() => dom.window.close());
test("conversation marks the transcript container and centers a reading column", async () => {
const { container } = await renderTranscript("conversation");
const log = container.querySelector("[data-transcript-variant]");
assert.ok(log, "conversation should tag its container for variant styling");
assert.equal(log.getAttribute("data-transcript-variant"), "conversation");
assert.match(log.className, /max-w-3xl/);
assert.match(log.className, /gap-8/);
});
test("conversation renders the prompt as a filled right-aligned bubble with an author label", async () => {
const { container } = await renderTranscript("conversation");
const author = container.querySelector(
'[data-testid="transcript-user-message-author"]',
);
assert.ok(author, "conversation should label the prompt author");
assert.equal(author.textContent, "Ada");
const row = container.querySelector(
'[data-testid="transcript-user-message"]',
);
assert.match(row.className, /justify-end/);
const bubble = row.querySelector(".rounded-2xl");
assert.match(bubble.className, /bg-muted\/60/);
// Focus mode shows the whole prompt rather than clamping it.
assert.doesNotMatch(bubble.className, /max-h-36/);
});
test("conversation renders agent messages as unboxed prose at full fidelity", async () => {
const { container } = await renderTranscript("conversation");
const message = container.querySelector(
'[data-testid="transcript-assistant-message"]',
);
assert.ok(message, "assistant message should render");
assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
// The shared markdown renderer is reused, so inline code still renders as code.
assert.ok(message.querySelector("code"), "markdown/code fidelity preserved");
});
test("conversation collapses a finished thought into a Thought for Ns disclosure", async () => {
const { container } = await renderTranscript("conversation");
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.ok(disclosure, "thought should render");
assert.equal(disclosure.tagName, "DETAILS");
assert.equal(disclosure.open, false, "finished thoughts start collapsed");
// thought at :02, next turn item (plan) at :07 → 5s.
assert.equal(
disclosure
.querySelector('[data-testid="transcript-thought-disclosure"]')
.textContent.trim(),
"Thought for 5s",
);
});
test("conversation auto-opens the thought disclosure while it is streaming", async () => {
// A live turn on this channel makes the trailing item the streaming one, which
// is exactly the condition the disclosure auto-opens for.
syncAgentTurnsFromEvents(AGENT.agentPubkey, [
{
seq: 1,
timestamp: "2026-06-14T19:00:02.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
},
]);
const streamingThought = items().slice(0, 2);
const { container } = await renderTranscript("conversation", {
channelId: "chan-1",
items: streamingThought,
});
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, true, "streaming thought should be open");
const summary = disclosure.querySelector(
'[data-testid="transcript-thought-disclosure"]',
);
// Shimmer paints a visual-only aria-hidden duplicate of the label, so match a
// prefix rather than the whole text node.
assert.match(summary.textContent, /^Thinking…/);
assert.doesNotMatch(summary.textContent, /Thought for/);
});
test("conversation renders the plan as a checklist card with in-place progress", async () => {
const { container } = await renderTranscript("conversation");
const card = container.querySelector('[data-testid="transcript-plan-item"]');
assert.ok(card, "plan should render");
assert.equal(card.getAttribute("data-variant"), "conversation-plan-card");
assert.equal(
card.querySelector('[data-testid="transcript-plan-progress"]').textContent,
"1/3 complete",
);
const statuses = [...card.querySelectorAll("[data-plan-entry-status]")].map(
(entry) => entry.getAttribute("data-plan-entry-status"),
);
assert.deepEqual(statuses, ["completed", "in_progress", "pending"]);
});
test("conversation quiets session boundaries and status rows into centered dividers", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
const { container } = await renderTranscript("conversation", {
items: [
...items(),
{
...shared,
id: "msg:user2",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "next task",
timestamp: "2026-06-14T19:05:01.000Z",
messageId: "event-2",
authorPubkey: AUTHOR,
},
{
...shared,
id: "life:status",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:05:02.000Z",
},
],
});
// A second session id draws the boundary rule between the two runs.
const boundary = container.querySelector(
'[data-testid="session-boundary-divider"]',
);
assert.ok(boundary, "a second session should draw a boundary");
assert.equal(boundary.getAttribute("data-variant"), "conversation-divider");
const status = container.querySelector(
'[data-testid="transcript-lifecycle-item"]',
);
assert.ok(status, "status lifecycle row should render");
assert.equal(status.getAttribute("data-variant"), "conversation-divider");
assert.match(status.textContent, /Context compacted/);
});
test("conversation keeps errors and permission gates loud", async () => {
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const { container } = await renderTranscript("conversation", {
items: [
...items(),
{
...shared,
id: "life:permission",
type: "lifecycle",
renderClass: "permission",
title: "Permission requested",
text: "write src/main.rs\nOptions: Allow, Deny",
outcome: "Approved (once)",
timestamp: "2026-06-14T19:00:11.000Z",
},
{
...shared,
id: "life:error",
type: "lifecycle",
renderClass: "error",
title: "Turn failed",
text: "the harness exited",
timestamp: "2026-06-14T19:00:12.000Z",
},
],
});
const permission = container.querySelector(
'[data-testid="transcript-permission-item"]',
);
assert.ok(permission, "permission item should render");
assert.match(permission.className, /amber/);
assert.equal(
permission.getAttribute("data-variant"),
null,
"permission gates never route through the quiet divider",
);
assert.equal(
permission
.querySelector('[data-testid="transcript-permission-outcome"]')
.textContent.trim(),
"Approved (once)",
);
// The error shares the lifecycle testid, so pick it out by its copy.
const error = [
...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'),
].find((node) => node.textContent.includes("Turn failed"));
assert.ok(error, "error lifecycle item should still render");
assert.match(error.className, /destructive/);
assert.equal(
error.getAttribute("data-variant"),
null,
"errors never route through the quiet divider",
);
});
test("default and compactPreview markup is unchanged by the conversation variant", async () => {
for (const variant of ["default", "compactPreview"]) {
const { container } = await renderTranscript(variant);
assert.equal(
container.innerHTML,
BASELINE_MARKUP[variant],
`${variant} transcript markup drifted`,
);
cleanup();
}
});

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { CheckCheck, Clock, Radio } from "lucide-react";
import { Radio } from "lucide-react";
import {
useActiveAgentTurns,
@@ -14,24 +14,25 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useStableArrayShallow } from "@/shared/hooks/useStableReference";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import type { TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import {
AgentSessionTranscriptTurnMetaProvider,
AgentSessionTranscriptVariantProvider,
type AgentSessionTranscriptVariant,
useAgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import {
SessionBoundaryDivider,
SHOW_TRANSCRIPT_ACP_SOURCE,
TranscriptAcpSourceBadge,
TurnPromptBlock,
TurnSetupStatus,
getTranscriptMessageLink,
} from "./AgentSessionTranscriptChrome";
import { buildConversationTurnMeta } from "./agentSessionConversationMeta";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
@@ -48,10 +49,7 @@ import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types
import type { FileEditDiff } from "./agentSessionFileEditDiff";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
getDisplayBlockKey,
turnSetupDetail,
turnSetupTimestamp,
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
@@ -59,9 +57,6 @@ import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { hasFileEditLineDiff } from "./FileEditDiffView";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
const ROW_ENTER_SPRING = {
damping: 38,
@@ -84,33 +79,8 @@ function useHasCompletedInitialRender() {
return ref;
}
/**
* Opt-in only: source pills are useful while iterating on observer parsing, but
* they should not appear for every local dev session.
*/
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
export type AgentSessionTranscriptEmptyState = "idle" | "loading";
function shouldShowTranscriptAcpSource() {
const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
if (envValue === "1" || envValue === "true") {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
return (
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
);
} catch {
return false;
}
}
export function AgentSessionTranscriptList({
agentAvatarUrl,
agentName,
@@ -181,6 +151,7 @@ export function AgentSessionTranscriptList({
});
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
const animationsDisabled =
@@ -195,6 +166,13 @@ export function AgentSessionTranscriptList({
const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
const hasRenderableContent =
items.length > 0 && hasRenderableDisplayContent(displayBlocks, variant);
// Streaming/duration hints for the conversation variant's thought disclosure.
// Returns a shared constant for the other variants, so their render output is
// untouched.
const turnMeta = React.useMemo(
() => buildConversationTurnMeta(displayBlocks, { isTurnLive, variant }),
[displayBlocks, isTurnLive, variant],
);
const scrollContainerClassNames = cn(
"w-full",
@@ -240,45 +218,51 @@ export function AgentSessionTranscriptList({
aria-live="polite"
className={cn(
"flex w-full flex-col",
isCompactPreview ? "gap-1" : "gap-4",
isCompactPreview ? "gap-1" : isConversation ? "gap-8" : "gap-4",
// Focus mode reads as a document: center the column and cap it at a
// comfortable measure so long agent prose does not run edge to edge.
isConversation && "mx-auto max-w-3xl",
autoTail && "pb-4",
contentContainerClassName,
)}
data-transcript-variant={isConversation ? "conversation" : undefined}
ref={autoTail ? contentRef : undefined}
role="log"
>
<AgentSessionTranscriptVariantProvider value={variant}>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled || !hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
<AgentSessionTranscriptTurnMetaProvider value={turnMeta}>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled || !hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
measures the outer wrapper for layout animations, which
would otherwise force skipped offscreen rows to render. */}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? <TurnLivenessIndicator /> : null}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? <TurnLivenessIndicator /> : null}
</AgentSessionTranscriptTurnMetaProvider>
</AgentSessionTranscriptVariantProvider>
</div>
</motion.div>
@@ -337,18 +321,6 @@ function isRenderableCompactItem(item: TranscriptItem) {
return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed";
}
function TranscriptAcpSourceBadge({ source }: { source: string }) {
return (
<span
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
data-testid="transcript-acp-source"
title={`ACP wire source: ${source}`}
>
{source}
</span>
);
}
function TranscriptDisplayBlockView({
agentAvatarUrl,
agentName,
@@ -361,6 +333,7 @@ function TranscriptDisplayBlockView({
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
// Streaming tool calls land as new segments inside the current turn block
@@ -394,7 +367,10 @@ function TranscriptDisplayBlockView({
return (
<div
className={cn("flex flex-col", isCompactPreview ? "gap-2.5" : "gap-4")}
className={cn(
"flex flex-col",
isCompactPreview ? "gap-2.5" : isConversation ? "gap-3" : "gap-4",
)}
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
@@ -652,198 +628,6 @@ function ToolRunSummaryLabel({
);
}
function TurnPromptBlock({
context,
profiles,
setup,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div data-testid="transcript-prompt-bundle">
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
{context ? (
<TranscriptAcpSourceBadge source="session/prompt:context" />
) : null}
</div>
) : null}
<PromptUserMessage
context={context}
item={user}
profiles={profiles}
setup={setup}
/>
</div>
);
}
function PromptUserMessage({
context = null,
item,
profiles,
setup = [],
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const [contextOpen, setContextOpen] = React.useState(false);
const contextSections = React.useMemo(
() => [...(context?.sections ?? [])],
[context],
);
return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
contextOpen={contextOpen}
hasContext={contextSections.length > 0}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
onOpenChange={setContextOpen}
open={contextOpen}
sections={contextSections}
setup={setup}
/>
</>
);
}
function PromptContextDialog({
onOpenChange,
open,
sections,
setup,
}: {
onOpenChange: (open: boolean) => void;
open: boolean;
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex min-w-0 max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={sections} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
contextOpen = false,
hasContext = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
contextOpen?: boolean;
hasContext?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = hasContext && onContextOpenChange != null;
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
title={tooltipText || "Show prompt context"}
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
)}
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
</div>
);
}
function getTranscriptMessageLink(
item: Extract<TranscriptItem, { type: "message" }>,
) {
if (!item.channelId || !item.messageId) return null;
return {
channelId: item.channelId,
messageId: item.messageId,
};
}
function TranscriptItemRow({
agentAvatarUrl,
agentName,
@@ -906,74 +690,6 @@ function TranscriptRowTimestamp({
);
}
function TurnSetupStatus({
items,
}: {
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const timestamp = turnSetupTimestamp(items);
if (items.length === 0 || !timestamp) {
return null;
}
return (
<div
className="rounded-md px-2"
title={formatTranscriptTimestampTitle(timestamp)}
>
<TurnSetupFooter
items={items}
showTimestamp={false}
timestamp={timestamp}
/>
</div>
);
}
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states (based on live-frame observation, not harness affinity):
* - `"current"` — most recent session observed via the live relay subscription.
* - `"most-recent"` — newest visible session with no matching live frames
* (loaded from archive or session ended before observation).
* - `"earlier"` — an older session preceding the most-recent one.
*/
function SessionBoundaryDivider({
labelState,
sessionStartTimestamp,
}: {
labelState: "current" | "most-recent" | "earlier";
sessionStartTimestamp: string;
}) {
const label =
labelState === "current"
? "Latest live-observed session"
: labelState === "most-recent"
? "Most recent observed session"
: "Earlier observed session";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
return (
<div
className="flex items-center gap-2 px-3 py-2"
data-testid="session-boundary-divider"
>
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{labelState === "current" ? (
<Radio aria-hidden="true" className="h-3 w-3" />
) : (
<Clock aria-hidden="true" className="h-3 w-3" />
)}
{label}
{" · "}
{formattedDate}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName,

View File

@@ -0,0 +1,42 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
/**
* Quiet centered rule used by the `conversation` variant for items that mark
* structure rather than work: session boundaries and turn setup/status.
*
* VISION_ACTIVITY's "failures rise; reads recede" applies to salience, not just
* color — in focus mode these items recede to a hairline with a small centered
* caption so the reader's eye stays on the prompt/response rhythm. Errors and
* permission gates keep their loud treatment and never route here.
*/
export function ConversationDivider({
className,
icon,
label,
testId,
title,
}: {
className?: string;
icon?: React.ReactNode;
label: string;
testId?: string;
title?: string;
}) {
return (
<div
className={cn("flex items-center gap-2 py-1", className)}
data-testid={testId}
data-variant="conversation-divider"
title={title}
>
<div className="h-px flex-1 bg-border/60" />
<span className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground/70">
{icon}
<span className="truncate">{label}</span>
</span>
<div className="h-px flex-1 bg-border/60" />
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
import { ConversationDivider } from "./ConversationDivider";
import { ToolActivity } from "./ToolActivity";
import type { ActivityRenderClassItemProps } from "./types";
@@ -118,12 +120,45 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
);
}
return (
<LifecycleStatusRow item={props.item} timestampTitle={timestampTitle} />
);
}
/**
* Plain lifecycle status ("Turn started", "Context compacted") — structure, not
* work. Focus mode recedes it to a quiet centered divider so the reader's eye
* stays on the prompt/response rhythm; errors and permission gates never reach
* here and keep their loud treatment.
*
* Split into its own component so `LifecycleActivity` stays hook-free above its
* early returns.
*/
function LifecycleStatusRow({
item,
timestampTitle,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "lifecycle" }>;
timestampTitle: string | undefined;
}) {
const variant = useAgentSessionTranscriptVariant();
if (variant === "conversation") {
return (
<ConversationDivider
label={[item.title, item.text].filter(Boolean).join(" · ")}
testId="transcript-lifecycle-item"
title={timestampTitle}
/>
);
}
return (
<ActivityRow testId="transcript-lifecycle-item" title={timestampTitle}>
<ActivityRowLabel
object={props.item.text || undefined}
object={item.text || undefined}
openToneScope="none"
verb={props.item.title}
verb={item.title}
/>
</ActivityRow>
);

View File

@@ -28,6 +28,7 @@ function MessageItem({
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const messageLink = getTranscriptMessageLink(item);
@@ -63,7 +64,16 @@ function MessageItem({
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
className={
isCompactPreview
? "text-xs leading-4"
: isConversation
? // Focus mode reads as prose: no box, comfortable line
// height, and full-fidelity markdown/code from the shared
// renderer.
"leading-relaxed"
: "leading-5"
}
content={text || " "}
/>
</div>

View File

@@ -1,3 +1,6 @@
import { Check, ListChecks, Loader } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
@@ -5,6 +8,12 @@ import {
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import {
formatPlanChecklistProgress,
parsePlanChecklist,
type PlanChecklistEntry,
} from "../agentSessionPlanChecklist";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -16,14 +25,27 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
return null;
}
if (props.item.isUpdate) {
return <PlanItem item={props.item} />;
}
function PlanItem({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "plan" }>;
}) {
const variant = useAgentSessionTranscriptVariant();
// Plan-update markers stay a one-line "Updated plan · 3/5 complete" row in
// every variant: in focus mode the card below already carries current state,
// so the marker is a timeline note and must not compete with it.
if (item.isUpdate) {
return (
<ActivityRow
testId="transcript-plan-update-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel
object={<PlanUpdateLabelObject text={props.item.text} />}
object={<PlanUpdateLabelObject text={item.text} />}
openToneScope="none"
verb="Updated"
/>
@@ -31,22 +53,125 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
);
}
if (variant === "conversation") {
return <ConversationPlanCard item={item} />;
}
return (
<ActivityRow
testId="transcript-plan-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel object="plan" openToneScope="tool" verb="Updated" />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || "No plan details."}
content={item.text.trim() || "No plan details."}
/>
</ActivityRowContent>
</ActivityRow>
);
}
/**
* Focus-mode plan: a checklist card rather than a collapsed markdown row.
*
* The plan item is mutated in place by the transcript reducer (`replaceItem`
* keeps the same item id), so this card is the one surface that shows current
* plan state — re-rendering it as entries flip is the "updates in place"
* behavior; no local state is involved.
*
* Adapters that send free-form plan text instead of ACP `entries[]` produce no
* checklist lines, so the card falls back to the markdown body.
*/
function ConversationPlanCard({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "plan" }>;
}) {
const text = item.text.trim();
const checklist = parsePlanChecklist(text);
const progress = formatPlanChecklistProgress(checklist);
return (
<div
className="not-prose w-full rounded-xl border border-border/70 bg-card/60 px-4 py-3"
data-testid="transcript-plan-item"
data-variant="conversation-plan-card"
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<ListChecks aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span>Plan</span>
{progress ? (
<>
<span aria-hidden="true">·</span>
<span data-testid="transcript-plan-progress">{progress}</span>
</>
) : null}
</div>
{checklist.entries.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1.5">
{checklist.entries.map((entry, index) => (
<PlanChecklistRow
entry={entry}
// biome-ignore lint/suspicious/noArrayIndexKey: plan entries have no stable id and are replaced wholesale on each update
key={index}
/>
))}
</ul>
) : (
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">
<Markdown
className="leading-relaxed"
content={text || "No plan details."}
/>
</div>
)}
</div>
);
}
function PlanChecklistRow({ entry }: { entry: PlanChecklistEntry }) {
return (
<li
className="flex items-start gap-2 text-sm leading-relaxed"
data-plan-entry-status={entry.status}
>
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
{entry.status === "completed" ? (
<Check aria-hidden="true" className="h-3.5 w-3.5 text-primary" />
) : entry.status === "in_progress" ? (
<Loader
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
) : (
<span className="h-3 w-3 rounded-[3px] border border-border" />
)}
</span>
<span
className={cn(
entry.status === "completed"
? "text-muted-foreground line-through"
: entry.status === "in_progress"
? "text-foreground"
: "text-muted-foreground",
)}
>
{entry.label}
</span>
<span className="sr-only">
{entry.status === "completed"
? " (completed)"
: entry.status === "in_progress"
? " (in progress)"
: " (pending)"}
</span>
</li>
);
}
function PlanUpdateLabelObject({ text }: { text: string }) {
return (
<>

View File

@@ -1,10 +1,20 @@
import * as React from "react";
import { Brain, ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { Shimmer } from "@/shared/ui/Shimmer";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatThoughtDisclosureLabel } from "../agentSessionConversationMeta";
import {
useAgentSessionTranscriptTurnMeta,
useAgentSessionTranscriptVariant,
} from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -16,18 +26,87 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
return null;
}
return <ThoughtItem item={props.item} />;
}
function ThoughtItem({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
}) {
const variant = useAgentSessionTranscriptVariant();
if (variant === "conversation") {
return <ConversationThought item={item} />;
}
return (
<ActivityRow
testId="transcript-thought-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
<ActivityRowLabel openToneScope="tool" verb={item.title} />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || " "}
/>
<Markdown className="leading-5" content={item.text.trim() || " "} />
</ActivityRowContent>
</ActivityRow>
);
}
/**
* Focus-mode reasoning disclosure: collapsed by default so the reader sees the
* prompt/response rhythm, open while the thought is the streaming tail of a
* live turn so reasoning can be watched as it arrives, and collapsed again once
* the turn produces its next item (that is exactly when the item stops being
* the streaming tail — no timers involved).
*
* A reader who opens or closes it takes over: `userOpen` pins the state so the
* next stream transition cannot yank the panel out from under them.
*/
function ConversationThought({
item,
}: {
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
}) {
const turnMeta = useAgentSessionTranscriptTurnMeta();
const isStreaming = turnMeta.streamingItemId === item.id;
const [userOpen, setUserOpen] = React.useState<boolean | null>(null);
const isOpen = userOpen ?? isStreaming;
const label = formatThoughtDisclosureLabel({
durationSeconds: turnMeta.thoughtDurationSecondsById[item.id],
isStreaming,
});
return (
<details
className="not-prose group w-full"
data-testid="transcript-thought-item"
onToggle={(event) => {
setUserOpen(event.currentTarget.open);
}}
open={isOpen}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<summary
className="flex w-full cursor-pointer list-none items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
data-testid="transcript-thought-disclosure"
>
<Brain aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
{isStreaming ? <Shimmer>{label}</Shimmer> : <span>{label}</span>}
<ChevronDown
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
"group-open:rotate-180",
)}
/>
</summary>
<div className="mt-2 border-l border-border/60 pl-3 text-sm leading-relaxed text-muted-foreground">
<Markdown
className="leading-relaxed"
content={item.text.trim() || " "}
/>
</div>
</details>
);
}

View File

@@ -33,12 +33,18 @@ export function UserMessageBubble({
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
const shouldClampBubble = !isCompactPreview;
const isConversation = variant === "conversation";
// Focus mode shows the whole prompt: the reader is here to read the turn, and
// the channel-context affordance already lives in the footer, so clamping the
// bubble would hide the one thing they came for.
const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const text = item.text.trim();
// The bubble stays a link back to the originating channel message in both
// polished variants; only the dense preview drops it.
const messageLink =
shouldClampBubble && item.channelId && item.messageId
!isCompactPreview && item.channelId && item.messageId
? { channelId: item.channelId, messageId: item.messageId }
: null;
const authorProfile = item.authorPubkey
@@ -130,9 +136,21 @@ export function UserMessageBubble({
className={cn(
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
isCompactPreview && "items-start",
// Focus mode caps the prompt to a chat-bubble measure rather than the
// full reading column, so the right-aligned turn opener reads as an
// utterance against the agent's full-width prose.
isConversation && "max-w-[85%] flex-initial",
className,
)}
>
{isConversation ? (
<span
className="px-1 text-xs font-medium text-muted-foreground"
data-testid="transcript-user-message-author"
>
{authorLabel}
</span>
) : null}
<div
className={cn(
"w-full min-w-0 rounded-2xl border border-border/70 bg-transparent p-3 text-sm leading-relaxed text-foreground",
@@ -140,6 +158,9 @@ export function UserMessageBubble({
messageLink &&
"group/bubble cursor-pointer transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isCompactPreview && "p-2 text-xs leading-4",
// A filled surface (not just a hairline) is what makes the prompt
// read as the human's turn at a glance in focus mode.
isConversation && "border-transparent bg-muted/60 px-4 py-2.5",
bubbleClassName,
)}
ref={bubbleRef}
@@ -156,7 +177,17 @@ export function UserMessageBubble({
) : null}
{messageLink ? <MessageLinkHoverCue /> : null}
</div>
{footer}
{isConversation && footer ? (
// Timestamp/context row is chrome, not content: focus mode keeps it
// out of the reading rhythm until the row is hovered or
// keyboard-focused. Other variants render `footer` bare so their
// markup is unchanged.
<div className="opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
{footer}
</div>
) : (
footer
)}
</div>
</div>
);

View File

@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildConversationTurnMeta,
formatThoughtDisclosureLabel,
} from "./agentSessionConversationMeta.ts";
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext.ts";
import {
formatPlanChecklistProgress,
parsePlanChecklist,
} from "./agentSessionPlanChecklist.ts";
import { resolveAgentSessionTranscriptVariant } from "./agentSessionTranscriptVariantChoice.ts";
function item(overrides) {
return {
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
...overrides,
};
}
function turnBlock(segments) {
return { kind: "turn", turnId: "turn-1", segments };
}
// ---- buildConversationTurnMeta ----
test("buildConversationTurnMeta returns the shared empty value for other variants", () => {
for (const variant of ["default", "compactPreview"]) {
assert.equal(
buildConversationTurnMeta([turnBlock([])], {
isTurnLive: true,
variant,
}),
EMPTY_TRANSCRIPT_TURN_META,
`${variant} must allocate nothing so its render output is untouched`,
);
}
});
test("buildConversationTurnMeta times a thought against the turn's next item", () => {
const meta = buildConversationTurnMeta(
[
turnBlock([
{
kind: "item",
item: item({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:02.000Z",
}),
},
{
kind: "item",
item: item({
id: "msg:1",
type: "message",
renderClass: "message",
role: "assistant",
title: "Agent",
text: "done",
timestamp: "2026-06-14T19:00:14.000Z",
}),
},
]),
],
{ isTurnLive: false, variant: "conversation" },
);
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 12 });
assert.equal(meta.streamingItemId, null);
});
test("buildConversationTurnMeta leaves a trailing thought untimed and marks it streaming when live", () => {
const blocks = [
turnBlock([
{
kind: "item",
item: item({
id: "thought:tail",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:02.000Z",
}),
},
]),
];
const live = buildConversationTurnMeta(blocks, {
isTurnLive: true,
variant: "conversation",
});
assert.deepEqual(live.thoughtDurationSecondsById, {});
assert.equal(live.streamingItemId, "thought:tail");
const idle = buildConversationTurnMeta(blocks, {
isTurnLive: false,
variant: "conversation",
});
assert.equal(idle.streamingItemId, null);
});
test("buildConversationTurnMeta counts a prompt as an item and skips setup segments", () => {
const meta = buildConversationTurnMeta(
[
turnBlock([
{
kind: "setup",
items: [
item({
id: "life:1",
type: "lifecycle",
renderClass: "status",
title: "Turn started",
text: "",
timestamp: "2026-06-14T19:00:00.000Z",
}),
],
},
{
kind: "item",
item: item({
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "…",
timestamp: "2026-06-14T19:00:01.000Z",
}),
},
{
kind: "prompt",
context: null,
setup: [],
user: item({
id: "msg:steer",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "actually…",
timestamp: "2026-06-14T19:00:04.000Z",
}),
},
]),
],
{ isTurnLive: true, variant: "conversation" },
);
// Setup contributes nothing, so the thought is timed against the steer prompt.
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 3 });
assert.equal(meta.streamingItemId, "msg:steer");
});
// ---- formatThoughtDisclosureLabel ----
test("formatThoughtDisclosureLabel says Thinking while streaming", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 4, isStreaming: true }),
"Thinking…",
);
});
test("formatThoughtDisclosureLabel reports a duration once the turn moved on", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 9, isStreaming: false }),
"Thought for 9s",
);
});
test("formatThoughtDisclosureLabel avoids a misleading 0s", () => {
assert.equal(
formatThoughtDisclosureLabel({ durationSeconds: 0, isStreaming: false }),
"Thought for a moment",
);
assert.equal(formatThoughtDisclosureLabel({ isStreaming: false }), "Thought");
});
// ---- parsePlanChecklist ----
test("parsePlanChecklist reads the checkbox markdown the transcript builds", () => {
const checklist = parsePlanChecklist(
[
"- [x] read the transcript",
"- [ ] write the summary (in progress)",
"- [ ] ship it",
].join("\n"),
);
assert.deepEqual(checklist.entries, [
{ label: "read the transcript", status: "completed" },
{ label: "write the summary", status: "in_progress" },
{ label: "ship it", status: "pending" },
]);
assert.equal(checklist.completedCount, 1);
assert.equal(formatPlanChecklistProgress(checklist), "1/3 complete");
});
test("parsePlanChecklist yields nothing for free-form plan text", () => {
const checklist = parsePlanChecklist("We will read, then write, then ship.");
assert.deepEqual(checklist.entries, []);
assert.equal(formatPlanChecklistProgress(checklist), null);
});
// ---- resolveAgentSessionTranscriptVariant ----
test("resolveAgentSessionTranscriptVariant opts a wide panel into conversation", () => {
assert.equal(
resolveAgentSessionTranscriptVariant({
isSinglePanelView: false,
widthPx: 720,
}),
"conversation",
);
});
test("resolveAgentSessionTranscriptVariant keeps narrow and single-panel layouts on default", () => {
assert.equal(
resolveAgentSessionTranscriptVariant({
isSinglePanelView: false,
widthPx: 380,
}),
"default",
);
assert.equal(
resolveAgentSessionTranscriptVariant({
isSinglePanelView: true,
widthPx: 1200,
}),
"default",
);
});

View File

@@ -0,0 +1,115 @@
import type {
AgentSessionTranscriptTurnMeta,
AgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext";
import type {
TranscriptDisplayBlock,
TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import type { TranscriptItem } from "./agentSessionTypes";
/**
* Flatten a turn's segments into the leaf items that the reader actually sees
* in the order they appear. Prompt segments contribute their user message;
* setup segments contribute nothing (they render as a quiet divider, not work);
* summary segments contribute their collapsed tool items.
*/
function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] {
if (segment.kind === "prompt") return [segment.user];
if (segment.kind === "setup") return [];
if (segment.kind === "summary") return segment.summary.items;
return [segment.item];
}
function elapsedSeconds(from: string, to: string): number | null {
const start = Date.parse(from);
const end = Date.parse(to);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
const seconds = Math.round((end - start) / 1_000);
return seconds >= 0 ? seconds : null;
}
/**
* Derive the `conversation` variant's streaming/duration hints from the display
* blocks.
*
* Two facts the individual render classes cannot see on their own:
*
* - **Which item is streaming.** Only the trailing leaf item of the final turn
* can be mid-stream, and only when the agent's turn is actually live. The
* thought disclosure auto-opens for exactly that item, so the reader watches
* reasoning arrive rather than clicking to find it.
* - **How long a thought ran.** A thought is finished once the turn produces a
* later item; the gap between the thought's timestamp and that next item's
* timestamp is the "Thought for Ns" duration. Thoughts with no following
* item are still open, so they get no duration.
*
* Returns the shared empty value for non-conversation variants so the default
* and compactPreview paths allocate nothing and stay byte-identical.
*/
export function buildConversationTurnMeta(
displayBlocks: TranscriptDisplayBlock[],
options: { isTurnLive: boolean; variant: AgentSessionTranscriptVariant },
): AgentSessionTranscriptTurnMeta {
if (options.variant !== "conversation") {
return EMPTY_TRANSCRIPT_TURN_META;
}
const thoughtDurationSecondsById: Record<string, number> = {};
let streamingItemId: string | null = null;
for (const block of displayBlocks) {
if (block.kind !== "turn") continue;
const items = block.segments.flatMap(turnSegmentItems);
for (let index = 0; index < items.length; index++) {
const item = items[index];
if (item.type !== "thought") continue;
const next = items[index + 1];
if (!next) continue;
const seconds = elapsedSeconds(item.timestamp, next.timestamp);
if (seconds !== null) {
thoughtDurationSecondsById[item.id] = seconds;
}
}
}
if (options.isTurnLive) {
const lastBlock = displayBlocks[displayBlocks.length - 1];
if (lastBlock?.kind === "turn") {
const items = lastBlock.segments.flatMap(turnSegmentItems);
streamingItemId = items[items.length - 1]?.id ?? null;
} else if (lastBlock?.kind === "single") {
streamingItemId = lastBlock.item.id;
}
}
return { streamingItemId, thoughtDurationSecondsById };
}
/**
* Disclosure label for a thought in the `conversation` variant.
*
* While the thought is the streaming tail of a live turn the reader is watching
* reasoning arrive, so the label stays present tense. Once the turn has moved
* on we know how long it ran and say so; a thought we cannot time (missing or
* unparseable timestamps) still gets a stable past-tense label rather than a
* misleading "0s".
*/
export function formatThoughtDisclosureLabel(options: {
durationSeconds?: number;
isStreaming: boolean;
}): string {
if (options.isStreaming) {
return "Thinking…";
}
const seconds = options.durationSeconds;
if (seconds === undefined) {
return "Thought";
}
if (seconds < 1) {
return "Thought for a moment";
}
return `Thought for ${seconds}s`;
}

View File

@@ -0,0 +1,54 @@
/**
* Parse the markdown checklist a `plan` transcript item carries into structured
* entries, so the `conversation` variant can render it as a checklist card
* instead of a block of markdown.
*
* The text is produced by `formatPlanEntry` in agentSessionTranscriptHelpers:
* `- [x] content` for completed entries, `- [ ] content` otherwise, with an
* ` (in progress)` suffix when the ACP entry status is `in_progress`. Adapters
* that send free-form `content` text instead of `entries[]` yield text with no
* checklist lines at all — those return no entries and the card falls back to
* rendering the raw markdown.
*/
export type PlanChecklistEntry = {
label: string;
status: "completed" | "in_progress" | "pending";
};
export type PlanChecklist = {
completedCount: number;
entries: PlanChecklistEntry[];
};
const CHECKLIST_LINE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
const IN_PROGRESS_SUFFIX = /\s*\(in progress\)\s*$/i;
export function parsePlanChecklist(text: string): PlanChecklist {
const entries: PlanChecklistEntry[] = [];
for (const line of text.split(/\r?\n/)) {
const match = line.match(CHECKLIST_LINE);
if (!match) continue;
const completed = match[1].toLowerCase() === "x";
const rawLabel = match[2].trim();
const inProgress = !completed && IN_PROGRESS_SUFFIX.test(rawLabel);
entries.push({
label: rawLabel.replace(IN_PROGRESS_SUFFIX, "").trim(),
status: completed ? "completed" : inProgress ? "in_progress" : "pending",
});
}
return {
completedCount: entries.filter((entry) => entry.status === "completed")
.length,
entries,
};
}
/** "2/5 complete" progress caption, or null when there is nothing to count. */
export function formatPlanChecklistProgress(
checklist: PlanChecklist,
): string | null {
if (checklist.entries.length === 0) return null;
return `${checklist.completedCount}/${checklist.entries.length} complete`;
}

View File

@@ -1,6 +1,19 @@
import * as React from "react";
export type AgentSessionTranscriptVariant = "default" | "compactPreview";
/**
* Presentation modes for the observer transcript.
*
* - `default` — the polished activity feed (agent panel, thread panel).
* - `compactPreview` — dense, single-column preview (profile panel cards).
* - `conversation` — full-cover focus-mode reading view: prompts as
* right-aligned bubbles, agent messages as unboxed prose,
* thoughts/plans as disclosures, lifecycle as quiet
* dividers. Tool items render exactly as `default`.
*/
export type AgentSessionTranscriptVariant =
| "default"
| "compactPreview"
| "conversation";
const AgentSessionTranscriptVariantContext =
React.createContext<AgentSessionTranscriptVariant>("default");
@@ -11,3 +24,34 @@ export const AgentSessionTranscriptVariantProvider =
export function useAgentSessionTranscriptVariant() {
return React.useContext(AgentSessionTranscriptVariantContext);
}
/**
* Per-render derivation the `conversation` variant needs but individual render
* classes cannot see on their own: which item is currently streaming (the
* trailing item of a live turn) and how long each thought ran before the turn
* produced its next item. Derived once by the list from the display blocks —
* see `buildConversationTurnMeta`.
*/
export type AgentSessionTranscriptTurnMeta = {
/** Trailing item of a live turn, or null when nothing is streaming. */
streamingItemId: string | null;
/** Thought item id → seconds elapsed until the turn's next item. */
thoughtDurationSecondsById: Record<string, number>;
};
export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
streamingItemId: null,
thoughtDurationSecondsById: {},
};
const AgentSessionTranscriptTurnMetaContext =
React.createContext<AgentSessionTranscriptTurnMeta>(
EMPTY_TRANSCRIPT_TURN_META,
);
export const AgentSessionTranscriptTurnMetaProvider =
AgentSessionTranscriptTurnMetaContext.Provider;
export function useAgentSessionTranscriptTurnMeta() {
return React.useContext(AgentSessionTranscriptTurnMetaContext);
}

View File

@@ -0,0 +1,45 @@
import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
/**
* Narrowest panel width that still reads as a conversation.
*
* The `conversation` variant lays the transcript out as a document: a centered
* reading column with right-aligned prompt bubbles capped well inside it. Below
* roughly this width the bubble/prose asymmetry collapses and the dense
* `default` activity feed is the more legible presentation, so the wide layout
* is the only one that opts in.
*
* Calibrated against `shared/layout/auxiliaryPanelLayout.ts`: the panel opens at
* `AUXILIARY_PANEL_DEFAULT_WIDTH_PX` (380) and clamps to at least
* `AUXILIARY_PANEL_MAX_WIDTH_PX` (720), so 640 sits deliberately in the top of
* that range — a panel at its default or merely-widened size keeps the feed, and
* only a reader who has pulled it near full width (or a full-cover host that
* pins the variant explicitly) gets the reading layout.
*/
export const CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX = 640;
/**
* Pick the transcript variant for an agent session panel from its layout.
*
* Callers may always override by passing an explicit `transcriptVariant`; this
* is only the default when they don't. Kept as a pure function so the threshold
* is testable without mounting the panel.
*/
export function resolveAgentSessionTranscriptVariant({
isSinglePanelView,
widthPx,
}: {
isSinglePanelView: boolean;
widthPx: number;
}): AgentSessionTranscriptVariant {
// Single-panel view stacks the panel over the channel on a narrow viewport
// and renders it at 100% width, so `widthPx` (the stored/resizable width)
// does not describe what the reader sees. Keep the default feed there.
if (isSinglePanelView) {
return "default";
}
return widthPx >= CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX
? "conversation"
: "default";
}

View File

@@ -16,6 +16,8 @@ import {
scopeByChannel,
} from "@/features/agents/ui/agentSessionPanelLayout";
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
import { resolveAgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptVariantChoice";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
@@ -81,6 +83,11 @@ type AgentSessionThreadPanelProps = {
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
/**
* Overrides the transcript presentation. Omit to let the panel pick from its
* own layout — see `resolveAgentSessionTranscriptVariant`.
*/
transcriptVariant?: AgentSessionTranscriptVariant;
};
export function AgentSessionThreadPanel({
@@ -95,9 +102,16 @@ export function AgentSessionThreadPanel({
onClose,
widthPx,
transparentChrome = false,
transcriptVariant,
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
// Wide/standalone reading layouts get the conversation transcript; narrow and
// single-panel layouts keep the dense activity feed. An explicit prop always
// wins so callers can pin a presentation.
const resolvedTranscriptVariant =
transcriptVariant ??
resolveAgentSessionTranscriptVariant({ isSinglePanelView, widthPx });
const sessionChannelId = channelId ?? channel?.id ?? null;
// Unified working signal, scoped to this panel's channel (or all channels
// when the panel is unscoped) — observer turns primary, typing fallback.
@@ -495,6 +509,7 @@ export function AgentSessionThreadPanel({
rawLayout="exclusive"
showHeader={false}
showRaw={showRawFeed}
transcriptVariant={resolvedTranscriptVariant}
/>
</div>
</AuxiliaryPanelBody>