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

Slice B. A third, purely additive `conversation` transcript variant for the
full-cover reading surface: 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.

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

Threaded through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as
an optional prop. Tool items deliberately route through the same
`ToolActivity` presenter as `default`, which is what lets the tool-chain card
work in slice C land on top without conflict.

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

Three defects found and fixed while building it, each with a
non-vacuous test:

- The author row resolved its name through `resolveUserLabel`'s
  `fallbackName: item.title`, whose value describes the *trigger* that started
  the turn ("@Mention", "Prompt", a title-cased Buzz event kind) and is never
  an identity. Harmless where it only seeded avatar initials; the conversation
  author row promotes it to displayed text, so an unresolved sender rendered
  as if "@Mention" were their name. Identity now stops at the profile —
  display name, NIP-05 handle, truncated pubkey — and the title stays as
  trigger chrome in the footer. The existing test was asserting the bug.
- The captured-markup fixture embeds formatted timestamps, so it was
  zone-sensitive; pinning `TZ` then exposed that it is locale-sensitive too.
  Assigning `process.env.LANG` at runtime does not fix it — node resolves its
  default locale once at startup, unlike `TZ`, which `Date` reads lazily. Both
  formatting surfaces the render path reaches are overridden, including
  `Date.prototype.toLocale*`, which does not route through
  `Intl.DateTimeFormat`. `Intl.DateTimeFormat` must be replaced with a plain
  function, not an arrow: the render path calls it with `new`.

Squashed from ss-dev-01/conversation-variant (#6538) at 5e3faf443.

Co-authored-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
This commit is contained in:
ss-dev-00
2026-08-26 15:26:27 -07:00
parent 6c9b0e2fe7
commit cd2037e051
15 changed files with 2189 additions and 364 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,889 @@
/**
* 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 byte-for-byte tests at the bottom are the important ones: `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 `baselineItems()` — a transcript containing every renderable item
* kind across two sessions — on pre-change main (074561233) in a clean
* throwaway worktree. 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";
// The captured markup embeds formatted dates and times, so the fixture is only
// reproducible if every ambient formatting input is pinned. Two of them bite:
//
// - **Zone.** `formatTranscriptTimestampTitle` formats in the ambient zone
// ("… at 7:00:01 PM"), so a capture at UTC-7 fails against CI's UTC.
// - **Locale.** The session-boundary divider uses a bare `toLocaleString()`
// (`AgentSessionTranscriptChrome.tsx`), which is locale-sensitive as well as
// zone-sensitive: "6/14/2026, 7:05:00 PM" becomes "14.6.2026, 19:05:00"
// under de-DE. Node derives its default locale from LANG/LC_ALL, so this
// varies by machine independently of the zone.
//
// `TZ` can be set here because `Date` reads it lazily. The locale CANNOT: node
// resolves its default locale once at startup, so assigning `process.env.LANG`
// at runtime has no effect (verified — it silently keeps the startup locale).
// Pinning it therefore means overriding the two formatting surfaces the render
// path can reach: `Intl.DateTimeFormat` when constructed with no explicit
// locale, and `Date.prototype.toLocale*`, which does NOT route through
// `Intl.DateTimeFormat` and so needs its own patch.
//
// All of this must happen before the transcript modules are imported: their
// `Intl.DateTimeFormat` instances are module-level constants that resolve zone
// and locale once, at construction.
process.env.TZ = "UTC";
const FIXTURE_LOCALE = "en-US";
const OriginalDateTimeFormat = Intl.DateTimeFormat;
// A plain function, not an arrow: the render path calls
// `new Intl.DateTimeFormat(...)`, and an arrow function is not a constructor.
// Returning a genuine instance keeps `new`, plain calls, and `instanceof` all
// working.
function LocalePinnedDateTimeFormat(locales, options) {
return new OriginalDateTimeFormat(locales ?? FIXTURE_LOCALE, options);
}
LocalePinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
LocalePinnedDateTimeFormat.supportedLocalesOf =
OriginalDateTimeFormat.supportedLocalesOf.bind(OriginalDateTimeFormat);
Intl.DateTimeFormat = LocalePinnedDateTimeFormat;
for (const method of [
"toLocaleString",
"toLocaleDateString",
"toLocaleTimeString",
]) {
const original = Date.prototype[method];
Date.prototype[method] = function (locales, options) {
return original.call(this, locales ?? FIXTURE_LOCALE, options);
};
}
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, {
Element: dom.window.Element,
Event: dom.window.Event,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
IntersectionObserver: NoopObserver,
MutationObserver: dom.window.MutationObserver,
Node: dom.window.Node,
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 act;
let cleanup;
let render;
let createElement;
let useState;
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);
const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
/**
* What the transcript builder actually puts in a prompt item's `title`: a
* description of the trigger that started the turn, not an identity. Real values
* are "Prompt", "Buzz event", and title-cased event kinds like "@Mention"
* (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
* never display this as a name.
*/
const TRIGGER_TITLE = "@Mention";
const AUTHOR_PROFILES = {
[AUTHOR]: {
displayName: "Ada Lovelace",
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
},
};
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: TRIGGER_TITLE,
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",
},
];
}
/**
* Everything the legacy variants can render, in one transcript.
*
* The byte-for-byte contract covers `default`/`compactPreview` for EVERY item
* kind, so the baseline input has to contain every kind rather than the happy
* path: prompt (with prompt context and setup lifecycle so the ingress chrome
* renders), assistant message, thought, plan, a tool item, ordinary lifecycle
* status, error, permission — across two sessions so a session-boundary divider
* is forced too. Where `compactPreview` deliberately suppresses a kind, that
* absence is captured in the fixture and is therefore also protected.
*
* Single tool item on purpose: a run of three would collapse into a grouped
* summary and the leaf tool row would never be captured.
*/
function baselineItems() {
const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
return [
{
...first,
id: "life:setup",
type: "lifecycle",
renderClass: "status",
title: "Turn started",
text: "1 trigger",
timestamp: "2026-06-14T19:00:00.000Z",
acpSource: "turn_started",
},
{
...first,
id: "meta:context",
type: "metadata",
renderClass: "raw-rail",
title: "Prompt context",
sections: [{ title: "Channel", body: "engineering" }],
timestamp: "2026-06-14T19:00:00.500Z",
acpSource: "session/prompt:context",
},
{
...first,
id: "msg:user",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "please summarize the plan",
timestamp: "2026-06-14T19:00:01.000Z",
messageId: "event-1",
authorPubkey: AUTHOR,
acpSource: "session/prompt:user",
},
{
...first,
id: "thought:1",
type: "thought",
renderClass: "thought",
title: "Thinking",
text: "weighing the options",
timestamp: "2026-06-14T19:00:02.000Z",
},
{
...first,
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:03.000Z",
},
{
...first,
id: "tool:1",
type: "tool",
renderClass: "shell",
descriptor: {
renderClass: "shell",
label: "Ran a command",
preview: "cargo test",
tone: "neutral",
source: "shell",
},
title: "Ran a command",
toolName: "shell",
buzzToolName: null,
status: "completed",
args: { command: "cargo test" },
result: "ok",
isError: false,
timestamp: "2026-06-14T19:00:04.000Z",
startedAt: "2026-06-14T19:00:04.000Z",
completedAt: "2026-06-14T19:00:05.000Z",
},
{
...first,
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:06.000Z",
},
{
...first,
id: "life:status",
type: "lifecycle",
renderClass: "status",
title: "Context compacted",
text: "",
timestamp: "2026-06-14T19:00:07.000Z",
},
{
...first,
id: "msg:assistant",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "Here is the summary with `code`.",
timestamp: "2026-06-14T19:00:08.000Z",
},
{
...first,
id: "life:error",
type: "lifecycle",
renderClass: "error",
title: "Turn failed",
text: "the harness exited",
timestamp: "2026-06-14T19:00:09.000Z",
},
// Second session run: forces a session-boundary divider between the runs.
{
...second,
id: "msg:user2",
type: "message",
renderClass: "message",
role: "user",
title: "Ada",
text: "next task",
timestamp: "2026-06-14T19:05:00.000Z",
messageId: "event-2",
authorPubkey: AUTHOR,
acpSource: "session/prompt:user",
},
{
...second,
id: "msg:assistant2",
type: "message",
renderClass: "message",
role: "assistant",
title: "Test Agent",
text: "on it",
timestamp: "2026-06-14T19:05:01.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 }));
}
/**
* Same mount, but the caller can swap the list props afterwards. Needed for the
* contracts that are only visible across a rerender: a streaming thought
* folding once the turn moves on, and a plan mutating in place.
*/
async function renderRerenderableTranscript(variant, initialOverrides = {}) {
let applyProps;
const Harness = () => {
const [overrides, setOverrides] = useState(initialOverrides);
applyProps = setOverrides;
return createElement(AgentSessionTranscriptList, {
...AGENT,
emptyDescription: "nothing yet",
items: items(),
variant,
...overrides,
});
};
const rootRoute = createRootRoute({ component: Harness });
const router = createRouter({
history: createMemoryHistory({ initialEntries: ["/"] }),
routeTree: rootRoute,
});
await router.load();
const utils = render(createElement(RouterProvider, { router }));
return {
...utils,
async setOverrides(next) {
await act(async () => {
applyProps(next);
});
},
};
}
before(async () => {
({ act, cleanup, render } = await import("@testing-library/react"));
({ createElement, useState } = 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", {
profiles: AUTHOR_PROFILES,
});
const author = container.querySelector(
'[data-testid="transcript-user-message-author"]',
);
assert.ok(author, "conversation should label the prompt author");
assert.equal(author.textContent, "Ada Lovelace");
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 never shows the trigger title as the prompt author when the sender is unresolved", async () => {
// Regression guard. The label chain's last fallback used to be the prompt
// item's `title`, which is a description of the trigger ("@Mention",
// "Prompt", "Buzz event") rather than an identity. On the other variants that
// string only seeds avatar initials, so it was harmless; the conversation
// author row displays it, so an unresolved sender was rendered as "@Mention"
// — a trigger presented as a person's name. With no profile for the author,
// the row must fall back to the truncated pubkey instead.
const { container } = await renderTranscript("conversation");
const author = container.querySelector(
'[data-testid="transcript-user-message-author"]',
);
assert.ok(author, "the author row should still render without a profile");
assert.equal(author.textContent, AUTHOR_TRUNCATED);
assert.doesNotMatch(
author.textContent,
/@Mention|Prompt|Buzz event/,
"the trigger title must never be displayed as an author name",
);
});
test("conversation prefers the resolved profile over both the trigger title and the pubkey", async () => {
// The truncated-pubkey fallback must not shadow a real identity: with a
// profile present the row shows the display name, and with only a NIP-05
// handle it shows that.
const { container } = await renderTranscript("conversation", {
profiles: {
[AUTHOR]: {
displayName: null,
avatarUrl: null,
nip05Handle: "ada@example.com",
ownerPubkey: null,
},
},
});
assert.equal(
container.querySelector('[data-testid="transcript-user-message-author"]')
.textContent,
"ada@example.com",
);
});
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 folds the thought when the turn moves on, even after the browser echoes the auto-open toggle", async () => {
// Regression guard for the programmatic-toggle echo trap: a real browser fires
// `toggle` when React flips `open`, so the auto-open for a streaming thought
// arrives back at the component as if the reader had clicked. JSDOM does not
// fire that event itself, so the test injects it. If the handler records it as
// a reader choice, the disclosure stays pinned open forever and reasoning
// never recedes once the agent acts.
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 streaming = { channelId: "chan-1", items: items().slice(0, 2) };
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
streaming,
);
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, true, "streaming thought should auto-open");
// The browser echo: `open` already agrees with what React rendered, and the
// event follows rather than causes that state.
await act(async () => {
disclosure.open = true;
disclosure.dispatchEvent(new dom.window.Event("toggle"));
});
assert.equal(
disclosure.open,
true,
"the echo must not disturb the open tail",
);
// The turn produces its next item, so the thought is no longer the streaming
// tail and should recede.
await setOverrides({ ...streaming, items: items().slice(0, 3) });
const settled = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(
settled.open,
false,
"a thought the agent has acted on should fold again",
);
assert.match(
settled
.querySelector('[data-testid="transcript-thought-disclosure"]')
.textContent.trim(),
/^Thought for 5s/,
);
});
test("conversation keeps a reader-opened thought open after the turn moves on", async () => {
// The other half of the guard: a toggle that DISAGREES with the rendered state
// is a genuine reader choice and must win over the stream transition.
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,
},
]);
// Three items so the thought is not the streaming tail: it renders collapsed.
const settledItems = { channelId: "chan-1", items: items().slice(0, 3) };
const { container, setOverrides } = await renderRerenderableTranscript(
"conversation",
settledItems,
);
const disclosure = container.querySelector(
'[data-testid="transcript-thought-item"]',
);
assert.equal(disclosure.open, false, "a settled thought starts collapsed");
await act(async () => {
disclosure.open = true;
disclosure.dispatchEvent(new dom.window.Event("toggle"));
});
await setOverrides({ ...settledItems, items: items() });
assert.equal(
container.querySelector('[data-testid="transcript-thought-item"]').open,
true,
"the reader's choice should survive later transcript items",
);
});
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 updates the same plan card in place as entries advance", async () => {
// The transcript reducer mutates the plan item in place (same item id), so the
// card must be re-rendered rather than replaced: progress copy changes while
// the same DOM node is retained. A new node here would mean the reader's
// scroll position and any card-level state get thrown away on every update.
const { container, setOverrides } =
await renderRerenderableTranscript("conversation");
const before = container.querySelector(
'[data-testid="transcript-plan-item"]',
);
assert.equal(
before.querySelector('[data-testid="transcript-plan-progress"]')
.textContent,
"1/3 complete",
);
const advanced = items().map((item) =>
item.id === "plan:1"
? {
...item,
text: "- [x] read the transcript\n- [x] write the summary\n- [ ] ship it (in progress)",
}
: item,
);
await setOverrides({ items: advanced });
const after = container.querySelector('[data-testid="transcript-plan-item"]');
assert.equal(after, before, "the same plan card node should be reused");
assert.equal(
after.querySelector('[data-testid="transcript-plan-progress"]').textContent,
"2/3 complete",
);
assert.deepEqual(
[...after.querySelectorAll("[data-plan-entry-status]")].map((entry) =>
entry.getAttribute("data-plan-entry-status"),
),
["completed", "completed", "in_progress"],
);
});
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, {
items: baselineItems(),
});
assert.equal(
container.innerHTML,
BASELINE_MARKUP[variant],
`${variant} transcript markup drifted`,
);
cleanup();
}
});
test("the byte-for-byte baseline actually exercises every renderable item kind", async () => {
// The fixture embeds formatted dates and times, so a zone or locale other than
// the one it was captured in fails the comparison above for a reason that has
// nothing to do with markup. Fail loudly and specifically instead.
assert.equal(
new Intl.DateTimeFormat().resolvedOptions().timeZone,
"UTC",
"the baseline fixture is captured in UTC — see the TZ pin at the top of this file",
);
assert.equal(
new Intl.DateTimeFormat().resolvedOptions().locale,
FIXTURE_LOCALE,
`the baseline fixture is captured in ${FIXTURE_LOCALE} — see the locale pin at the top of this file`,
);
assert.equal(
new Date("2026-06-14T19:05:00.000Z").toLocaleString(),
"6/14/2026, 7:05:00 PM",
"Date.prototype.toLocaleString must be locale-pinned too — the session-boundary divider uses it",
);
// Guards the fixture itself: a baseline that silently stopped covering a kind
// would keep passing the comparison above while protecting nothing. Asserted
// against `default`, which renders every kind.
const { container } = await renderTranscript("default", {
items: baselineItems(),
});
const present = (selector) => container.querySelector(selector) !== null;
for (const [kind, selector] of [
["prompt", '[data-testid="transcript-user-message"]'],
["prompt setup", '[data-testid="transcript-turn-setup"]'],
["assistant message", '[data-testid="transcript-assistant-message"]'],
["thought", '[data-testid="transcript-thought-item"]'],
["plan", '[data-testid="transcript-plan-item"]'],
["tool", '[data-testid="transcript-tool-item"]'],
["permission", '[data-testid="transcript-permission-item"]'],
["lifecycle status/error", '[data-testid="transcript-lifecycle-item"]'],
["session boundary", '[data-testid="session-boundary-divider"]'],
]) {
assert.ok(present(selector), `baseline must cover ${kind}`);
}
// Status and error share the lifecycle testid, so check both are really there.
const lifecycleText = [
...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'),
]
.map((node) => node.textContent)
.join("\n");
assert.match(lifecycleText, /Context compacted/);
assert.match(lifecycleText, /Turn failed/);
});

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,108 @@ 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,
});
// `<details>` fires `toggle` for programmatic `open` changes as well as
// clicks. Without this guard the auto-open for a streaming thought would be
// recorded as a reader choice and would then pin the disclosure open forever,
// so reasoning would never fold once the agent acted on it. Only a toggle
// that DISAGREES with the state we last rendered can have come from the
// reader.
//
// Same trap, same guard as the tool-run card's disclosure in the tool-chain
// slice; once that lands and extracts a shared controlled-details hook this
// should consume it rather than keeping a second copy.
const renderedOpenRef = React.useRef(isOpen);
React.useLayoutEffect(() => {
renderedOpenRef.current = isOpen;
}, [isOpen]);
const handleToggle = React.useCallback(
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
const open = event.currentTarget.open;
if (open === renderedOpenRef.current) return;
setUserOpen(open);
},
[],
);
return (
<details
className="not-prose group w-full"
data-testid="transcript-thought-item"
onToggle={handleToggle}
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,24 +33,48 @@ 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
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
// The other variants seed the avatar from `resolveUserLabel(…, fallbackName:
// item.title)`, whose last resort is the prompt item's title. That title
// describes the *trigger* that started the turn ("@Mention", "Prompt", "Buzz
// event"), never a person — harmless when it only picks avatar initials, so
// this chain is left exactly as it was for `default`/`compactPreview`.
const triggerSeededLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
// Focus mode promotes the author to displayed text, where a trigger title
// would read as a false identity — an unresolved sender showing up as
// "@Mention". Identity resolution here therefore stops at the profile
// (display name, then NIP-05 handle, then the truncated pubkey): a truncated
// pubkey is a real, if terse, identity, and it keeps the utterance attributed
// in a full-cover view. `item.title` stays available as trigger chrome in the
// footer; it is never a name. Only when the item carries no author at all does
// the row fall back to a generic placeholder.
const authorLabel = isConversation
? item.authorPubkey
? resolveUserLabel({ pubkey: item.authorPubkey, profiles })
: "User"
: triggerSeededLabel;
const handleBubbleClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!messageLink || isNestedInteractiveTarget(event)) return;
@@ -130,9 +154,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 +176,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 +195,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,267 @@
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";
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 ----
/**
* Product ruling (ss-core-02, Slice B review): reasoning folds as soon as the
* agent ACTS on it — the next leaf item of any kind — not only when the next
* assistant message arrives. A thought pinned open across a long tool run is
* exactly the noise the focus view removes. This test exists so the rule is
* explicit rather than an accident of `items[index + 1]`.
*/
test("buildConversationTurnMeta ends a thought at the next tool call, not the next message", () => {
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: "tool:1",
type: "tool",
renderClass: "shell",
title: "Ran a command",
text: "",
descriptor: { label: "Ran a command", preview: "cargo test" },
timestamp: "2026-06-14T19:00:06.000Z",
}),
},
{
kind: "item",
item: item({
id: "msg:1",
type: "message",
renderClass: "message",
role: "assistant",
title: "Agent",
text: "tests pass",
timestamp: "2026-06-14T19:01:30.000Z",
}),
},
]),
],
{ isTurnLive: true, variant: "conversation" },
);
// 4s to the tool call, NOT 88s to the assistant message: the thought is done
// the moment the agent acts, so it folds instead of staying open for the run.
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 4 });
// The trailing message is the streaming tail, so the thought is not streaming.
assert.equal(meta.streamingItemId, "msg:1");
});
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);
});

View File

@@ -0,0 +1,120 @@
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
* ANY later leaf item — a tool call or plan update, not just the next
* assistant message. This is deliberate: reasoning recedes as soon as the
* agent *acts* on it, which is the moment the reader's attention should move
* to the action. Folding only on the next message would pin reasoning open
* across a long tool run, which is exactly the noise the focus view exists
* to remove. 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

@@ -16,6 +16,7 @@ import {
scopeByChannel,
} from "@/features/agents/ui/agentSessionPanelLayout";
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
@@ -87,6 +88,15 @@ type AgentSessionThreadPanelProps = {
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
/**
* Transcript presentation. Caller-pinned only: the panel keeps the dense
* activity feed unless a host explicitly asks for `conversation` (the
* full-cover focus view does). There is deliberately no width or layout
* heuristic here — an automatic wide-pane mode would be a separate product
* decision, and swapping the whole presentation as a reader drags a resize
* handle across a threshold is not one we want to make implicitly.
*/
transcriptVariant?: AgentSessionTranscriptVariant;
};
export function AgentSessionThreadPanel({
@@ -102,6 +112,7 @@ export function AgentSessionThreadPanel({
onClose,
widthPx,
transparentChrome = false,
transcriptVariant = "default",
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
@@ -503,6 +514,7 @@ export function AgentSessionThreadPanel({
rawLayout="exclusive"
showHeader={false}
showRaw={showRawFeed}
transcriptVariant={transcriptVariant}
/>
</div>
</AuxiliaryPanelBody>