mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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:
358
desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx
Normal file
358
desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx
Normal 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
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
120
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal file
120
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal 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`;
|
||||
}
|
||||
54
desktop/src/features/agents/ui/agentSessionPlanChecklist.ts
Normal file
54
desktop/src/features/agents/ui/agentSessionPlanChecklist.ts
Normal 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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user