mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
feat(desktop): add conversation transcript variant for focus-mode agent sessions
**Category:** feature **User Impact:** In the wide/standalone agent session panel the observer transcript now reads like a conversation — the human's prompt as a right-aligned bubble, the agent's reply as unboxed prose at a comfortable measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan as a live checklist, and session/status noise receded to quiet dividers. **Problem:** The transcript had two presentations — the dense `default` activity feed and `compactPreview` — both tuned for a narrow panel. Read at full width they are a scan surface, not something you read a turn in. **Solution:** A third, purely additive `conversation` variant, threaded through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an optional prop with a width-based default. Tool items deliberately route through the same `ToolActivity` presenter as `default` so the parallel tool-chain-card work merges cleanly, and the drawer/shell is untouched. The byte-for-byte guarantee for the existing variants is enforced by a captured-markup fixture, not by inspection: the baseline was produced by mounting the same transcript on unmodified code, and the test fails on any drift in `default` or `compactPreview` output. Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
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,387 @@
|
||||
/**
|
||||
* Rendering contract for the `conversation` transcript variant (focus mode).
|
||||
*
|
||||
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
|
||||
* (variant context + derived turn meta) is exercised end to end rather than
|
||||
* asserting against re-implemented render classes.
|
||||
*
|
||||
* The last test is the important one: `conversation` is purely additive, so the
|
||||
* `default` and `compactPreview` markup for the same transcript must be
|
||||
* byte-identical to the markup captured before the variant existed. That
|
||||
* snapshot lives in AgentSessionTranscriptList.conversation.baseline.json and
|
||||
* was produced by mounting this same transcript on the pre-change commit
|
||||
* (f79421673) — regenerate it only when a deliberate change to the other
|
||||
* variants is being made.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const BASELINE_MARKUP = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
"./AgentSessionTranscriptList.conversation.baseline.json",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
class NoopObserver {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
Object.assign(globalThis, {
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
IntersectionObserver: NoopObserver,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
ResizeObserver: NoopObserver,
|
||||
document: dom.window.document,
|
||||
getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
|
||||
localStorage: dom.window.localStorage,
|
||||
self: dom.window,
|
||||
window: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
|
||||
|
||||
let cleanup;
|
||||
let render;
|
||||
let createElement;
|
||||
let createMemoryHistory;
|
||||
let createRootRoute;
|
||||
let createRouter;
|
||||
let RouterProvider;
|
||||
let AgentSessionTranscriptList;
|
||||
let resetActiveAgentTurnsStore;
|
||||
let syncAgentTurnsFromEvents;
|
||||
|
||||
const AGENT = {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Test Agent",
|
||||
agentPubkey: "f".repeat(64),
|
||||
};
|
||||
const AUTHOR = "a".repeat(64);
|
||||
|
||||
function items() {
|
||||
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
|
||||
return [
|
||||
{
|
||||
...shared,
|
||||
id: "msg:user",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "user",
|
||||
title: "Ada",
|
||||
text: "please summarize the plan",
|
||||
timestamp: "2026-06-14T19:00:00.000Z",
|
||||
messageId: "event-1",
|
||||
authorPubkey: AUTHOR,
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "thought:1",
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "weighing the options",
|
||||
timestamp: "2026-06-14T19:00:02.000Z",
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "plan:1",
|
||||
type: "plan",
|
||||
renderClass: "plan",
|
||||
title: "Plan",
|
||||
text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
|
||||
timestamp: "2026-06-14T19:00:07.000Z",
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "msg:assistant",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Test Agent",
|
||||
text: "Here is the summary with `code`.",
|
||||
timestamp: "2026-06-14T19:00:09.000Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function renderTranscript(variant, overrides = {}) {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(AgentSessionTranscriptList, {
|
||||
...AGENT,
|
||||
emptyDescription: "nothing yet",
|
||||
items: items(),
|
||||
variant,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
return render(createElement(RouterProvider, { router }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
({ cleanup, render } = await import("@testing-library/react"));
|
||||
({ createElement } = await import("react"));
|
||||
({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router"));
|
||||
({ AgentSessionTranscriptList } = await import(
|
||||
"./AgentSessionTranscriptList.tsx"
|
||||
));
|
||||
({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import(
|
||||
"../activeAgentTurnsStore.ts"
|
||||
));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
resetActiveAgentTurnsStore?.();
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("conversation marks the transcript container and centers a reading column", async () => {
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const log = container.querySelector("[data-transcript-variant]");
|
||||
assert.ok(log, "conversation should tag its container for variant styling");
|
||||
assert.equal(log.getAttribute("data-transcript-variant"), "conversation");
|
||||
assert.match(log.className, /max-w-3xl/);
|
||||
assert.match(log.className, /gap-8/);
|
||||
});
|
||||
|
||||
test("conversation renders the prompt as a filled right-aligned bubble with an author label", async () => {
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const author = container.querySelector(
|
||||
'[data-testid="transcript-user-message-author"]',
|
||||
);
|
||||
assert.ok(author, "conversation should label the prompt author");
|
||||
assert.equal(author.textContent, "Ada");
|
||||
|
||||
const row = container.querySelector(
|
||||
'[data-testid="transcript-user-message"]',
|
||||
);
|
||||
assert.match(row.className, /justify-end/);
|
||||
const bubble = row.querySelector(".rounded-2xl");
|
||||
assert.match(bubble.className, /bg-muted\/60/);
|
||||
// Focus mode shows the whole prompt rather than clamping it.
|
||||
assert.doesNotMatch(bubble.className, /max-h-36/);
|
||||
});
|
||||
|
||||
test("conversation renders agent messages as unboxed prose at full fidelity", async () => {
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const message = container.querySelector(
|
||||
'[data-testid="transcript-assistant-message"]',
|
||||
);
|
||||
assert.ok(message, "assistant message should render");
|
||||
assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
|
||||
// The shared markdown renderer is reused, so inline code still renders as code.
|
||||
assert.ok(message.querySelector("code"), "markdown/code fidelity preserved");
|
||||
});
|
||||
|
||||
test("conversation collapses a finished thought into a Thought for Ns disclosure", async () => {
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const disclosure = container.querySelector(
|
||||
'[data-testid="transcript-thought-item"]',
|
||||
);
|
||||
assert.ok(disclosure, "thought should render");
|
||||
assert.equal(disclosure.tagName, "DETAILS");
|
||||
assert.equal(disclosure.open, false, "finished thoughts start collapsed");
|
||||
// thought at :02, next turn item (plan) at :07 → 5s.
|
||||
assert.equal(
|
||||
disclosure
|
||||
.querySelector('[data-testid="transcript-thought-disclosure"]')
|
||||
.textContent.trim(),
|
||||
"Thought for 5s",
|
||||
);
|
||||
});
|
||||
|
||||
test("conversation auto-opens the thought disclosure while it is streaming", async () => {
|
||||
// A live turn on this channel makes the trailing item the streaming one, which
|
||||
// is exactly the condition the disclosure auto-opens for.
|
||||
syncAgentTurnsFromEvents(AGENT.agentPubkey, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-06-14T19:00:02.000Z",
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
payload: null,
|
||||
},
|
||||
]);
|
||||
const streamingThought = items().slice(0, 2);
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
channelId: "chan-1",
|
||||
items: streamingThought,
|
||||
});
|
||||
|
||||
const disclosure = container.querySelector(
|
||||
'[data-testid="transcript-thought-item"]',
|
||||
);
|
||||
assert.equal(disclosure.open, true, "streaming thought should be open");
|
||||
const summary = disclosure.querySelector(
|
||||
'[data-testid="transcript-thought-disclosure"]',
|
||||
);
|
||||
// Shimmer paints a visual-only aria-hidden duplicate of the label, so match a
|
||||
// prefix rather than the whole text node.
|
||||
assert.match(summary.textContent, /^Thinking…/);
|
||||
assert.doesNotMatch(summary.textContent, /Thought for/);
|
||||
});
|
||||
|
||||
test("conversation renders the plan as a checklist card with in-place progress", async () => {
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const card = container.querySelector('[data-testid="transcript-plan-item"]');
|
||||
assert.ok(card, "plan should render");
|
||||
assert.equal(card.getAttribute("data-variant"), "conversation-plan-card");
|
||||
assert.equal(
|
||||
card.querySelector('[data-testid="transcript-plan-progress"]').textContent,
|
||||
"1/3 complete",
|
||||
);
|
||||
const statuses = [...card.querySelectorAll("[data-plan-entry-status]")].map(
|
||||
(entry) => entry.getAttribute("data-plan-entry-status"),
|
||||
);
|
||||
assert.deepEqual(statuses, ["completed", "in_progress", "pending"]);
|
||||
});
|
||||
|
||||
test("conversation quiets session boundaries and status rows into centered dividers", async () => {
|
||||
const shared = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
items: [
|
||||
...items(),
|
||||
{
|
||||
...shared,
|
||||
id: "msg:user2",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "user",
|
||||
title: "Ada",
|
||||
text: "next task",
|
||||
timestamp: "2026-06-14T19:05:01.000Z",
|
||||
messageId: "event-2",
|
||||
authorPubkey: AUTHOR,
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "life:status",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Context compacted",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:05:02.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// A second session id draws the boundary rule between the two runs.
|
||||
const boundary = container.querySelector(
|
||||
'[data-testid="session-boundary-divider"]',
|
||||
);
|
||||
assert.ok(boundary, "a second session should draw a boundary");
|
||||
assert.equal(boundary.getAttribute("data-variant"), "conversation-divider");
|
||||
|
||||
const status = container.querySelector(
|
||||
'[data-testid="transcript-lifecycle-item"]',
|
||||
);
|
||||
assert.ok(status, "status lifecycle row should render");
|
||||
assert.equal(status.getAttribute("data-variant"), "conversation-divider");
|
||||
assert.match(status.textContent, /Context compacted/);
|
||||
});
|
||||
|
||||
test("conversation keeps errors and permission gates loud", async () => {
|
||||
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
items: [
|
||||
...items(),
|
||||
{
|
||||
...shared,
|
||||
id: "life:permission",
|
||||
type: "lifecycle",
|
||||
renderClass: "permission",
|
||||
title: "Permission requested",
|
||||
text: "write src/main.rs\nOptions: Allow, Deny",
|
||||
outcome: "Approved (once)",
|
||||
timestamp: "2026-06-14T19:00:11.000Z",
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "life:error",
|
||||
type: "lifecycle",
|
||||
renderClass: "error",
|
||||
title: "Turn failed",
|
||||
text: "the harness exited",
|
||||
timestamp: "2026-06-14T19:00:12.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const permission = container.querySelector(
|
||||
'[data-testid="transcript-permission-item"]',
|
||||
);
|
||||
assert.ok(permission, "permission item should render");
|
||||
assert.match(permission.className, /amber/);
|
||||
assert.equal(
|
||||
permission.getAttribute("data-variant"),
|
||||
null,
|
||||
"permission gates never route through the quiet divider",
|
||||
);
|
||||
assert.equal(
|
||||
permission
|
||||
.querySelector('[data-testid="transcript-permission-outcome"]')
|
||||
.textContent.trim(),
|
||||
"Approved (once)",
|
||||
);
|
||||
|
||||
// The error shares the lifecycle testid, so pick it out by its copy.
|
||||
const error = [
|
||||
...container.querySelectorAll('[data-testid="transcript-lifecycle-item"]'),
|
||||
].find((node) => node.textContent.includes("Turn failed"));
|
||||
assert.ok(error, "error lifecycle item should still render");
|
||||
assert.match(error.className, /destructive/);
|
||||
assert.equal(
|
||||
error.getAttribute("data-variant"),
|
||||
null,
|
||||
"errors never route through the quiet divider",
|
||||
);
|
||||
});
|
||||
|
||||
test("default and compactPreview markup is unchanged by the conversation variant", async () => {
|
||||
for (const variant of ["default", "compactPreview"]) {
|
||||
const { container } = await renderTranscript(variant);
|
||||
assert.equal(
|
||||
container.innerHTML,
|
||||
BASELINE_MARKUP[variant],
|
||||
`${variant} transcript markup drifted`,
|
||||
);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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,87 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ThoughtItem item={props.item} />;
|
||||
}
|
||||
|
||||
function ThoughtItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
|
||||
}) {
|
||||
const variant = useAgentSessionTranscriptVariant();
|
||||
|
||||
if (variant === "conversation") {
|
||||
return <ConversationThought item={item} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-thought-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
title={formatTranscriptTimestampTitle(item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
|
||||
<ActivityRowLabel openToneScope="tool" verb={item.title} />
|
||||
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
|
||||
<Markdown
|
||||
className="leading-5"
|
||||
content={props.item.text.trim() || " "}
|
||||
/>
|
||||
<Markdown className="leading-5" content={item.text.trim() || " "} />
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus-mode reasoning disclosure: collapsed by default so the reader sees the
|
||||
* prompt/response rhythm, open while the thought is the streaming tail of a
|
||||
* live turn so reasoning can be watched as it arrives, and collapsed again once
|
||||
* the turn produces its next item (that is exactly when the item stops being
|
||||
* the streaming tail — no timers involved).
|
||||
*
|
||||
* A reader who opens or closes it takes over: `userOpen` pins the state so the
|
||||
* next stream transition cannot yank the panel out from under them.
|
||||
*/
|
||||
function ConversationThought({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
|
||||
}) {
|
||||
const turnMeta = useAgentSessionTranscriptTurnMeta();
|
||||
const isStreaming = turnMeta.streamingItemId === item.id;
|
||||
const [userOpen, setUserOpen] = React.useState<boolean | null>(null);
|
||||
const isOpen = userOpen ?? isStreaming;
|
||||
const label = formatThoughtDisclosureLabel({
|
||||
durationSeconds: turnMeta.thoughtDurationSecondsById[item.id],
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
return (
|
||||
<details
|
||||
className="not-prose group w-full"
|
||||
data-testid="transcript-thought-item"
|
||||
onToggle={(event) => {
|
||||
setUserOpen(event.currentTarget.open);
|
||||
}}
|
||||
open={isOpen}
|
||||
title={formatTranscriptTimestampTitle(item.timestamp)}
|
||||
>
|
||||
<summary
|
||||
className="flex w-full cursor-pointer list-none items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="transcript-thought-disclosure"
|
||||
>
|
||||
<Brain aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||
{isStreaming ? <Shimmer>{label}</Shimmer> : <span>{label}</span>}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
|
||||
"group-open:rotate-180",
|
||||
)}
|
||||
/>
|
||||
</summary>
|
||||
<div className="mt-2 border-l border-border/60 pl-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<Markdown
|
||||
className="leading-relaxed"
|
||||
content={item.text.trim() || " "}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@ export function UserMessageBubble({
|
||||
const { goChannel } = useAppNavigation();
|
||||
const { openProfilePanel } = useProfilePanel();
|
||||
const isCompactPreview = variant === "compactPreview";
|
||||
const shouldClampBubble = !isCompactPreview;
|
||||
const isConversation = variant === "conversation";
|
||||
// Focus mode shows the whole prompt: the reader is here to read the turn, and
|
||||
// the channel-context affordance already lives in the footer, so clamping the
|
||||
// bubble would hide the one thing they came for.
|
||||
const shouldClampBubble = !isCompactPreview && !isConversation;
|
||||
const [bubbleRef, hasBubbleOverflow] =
|
||||
useTranscriptBubbleOverflow(shouldClampBubble);
|
||||
const text = item.text.trim();
|
||||
// The bubble stays a link back to the originating channel message in both
|
||||
// polished variants; only the dense preview drops it.
|
||||
const messageLink =
|
||||
shouldClampBubble && item.channelId && item.messageId
|
||||
!isCompactPreview && item.channelId && item.messageId
|
||||
? { channelId: item.channelId, messageId: item.messageId }
|
||||
: null;
|
||||
const authorProfile = item.authorPubkey
|
||||
@@ -130,9 +136,21 @@ export function UserMessageBubble({
|
||||
className={cn(
|
||||
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
|
||||
isCompactPreview && "items-start",
|
||||
// Focus mode caps the prompt to a chat-bubble measure rather than the
|
||||
// full reading column, so the right-aligned turn opener reads as an
|
||||
// utterance against the agent's full-width prose.
|
||||
isConversation && "max-w-[85%] flex-initial",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isConversation ? (
|
||||
<span
|
||||
className="px-1 text-xs font-medium text-muted-foreground"
|
||||
data-testid="transcript-user-message-author"
|
||||
>
|
||||
{authorLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 rounded-2xl border border-border/70 bg-transparent p-3 text-sm leading-relaxed text-foreground",
|
||||
@@ -140,6 +158,9 @@ export function UserMessageBubble({
|
||||
messageLink &&
|
||||
"group/bubble cursor-pointer transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isCompactPreview && "p-2 text-xs leading-4",
|
||||
// A filled surface (not just a hairline) is what makes the prompt
|
||||
// read as the human's turn at a glance in focus mode.
|
||||
isConversation && "border-transparent bg-muted/60 px-4 py-2.5",
|
||||
bubbleClassName,
|
||||
)}
|
||||
ref={bubbleRef}
|
||||
@@ -156,7 +177,17 @@ export function UserMessageBubble({
|
||||
) : null}
|
||||
{messageLink ? <MessageLinkHoverCue /> : null}
|
||||
</div>
|
||||
{footer}
|
||||
{isConversation && footer ? (
|
||||
// Timestamp/context row is chrome, not content: focus mode keeps it
|
||||
// out of the reading rhythm until the row is hovered or
|
||||
// keyboard-focused. Other variants render `footer` bare so their
|
||||
// markup is unchanged.
|
||||
<div className="opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
{footer}
|
||||
</div>
|
||||
) : (
|
||||
footer
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildConversationTurnMeta,
|
||||
formatThoughtDisclosureLabel,
|
||||
} from "./agentSessionConversationMeta.ts";
|
||||
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext.ts";
|
||||
import {
|
||||
formatPlanChecklistProgress,
|
||||
parsePlanChecklist,
|
||||
} from "./agentSessionPlanChecklist.ts";
|
||||
import { resolveAgentSessionTranscriptVariant } from "./agentSessionTranscriptVariantChoice.ts";
|
||||
|
||||
function item(overrides) {
|
||||
return {
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turnBlock(segments) {
|
||||
return { kind: "turn", turnId: "turn-1", segments };
|
||||
}
|
||||
|
||||
// ---- buildConversationTurnMeta ----
|
||||
|
||||
test("buildConversationTurnMeta returns the shared empty value for other variants", () => {
|
||||
for (const variant of ["default", "compactPreview"]) {
|
||||
assert.equal(
|
||||
buildConversationTurnMeta([turnBlock([])], {
|
||||
isTurnLive: true,
|
||||
variant,
|
||||
}),
|
||||
EMPTY_TRANSCRIPT_TURN_META,
|
||||
`${variant} must allocate nothing so its render output is untouched`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta times a thought against the turn's next item", () => {
|
||||
const meta = buildConversationTurnMeta(
|
||||
[
|
||||
turnBlock([
|
||||
{
|
||||
kind: "item",
|
||||
item: item({
|
||||
id: "thought:1",
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "…",
|
||||
timestamp: "2026-06-14T19:00:02.000Z",
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
item: item({
|
||||
id: "msg:1",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Agent",
|
||||
text: "done",
|
||||
timestamp: "2026-06-14T19:00:14.000Z",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: false, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 12 });
|
||||
assert.equal(meta.streamingItemId, null);
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta leaves a trailing thought untimed and marks it streaming when live", () => {
|
||||
const blocks = [
|
||||
turnBlock([
|
||||
{
|
||||
kind: "item",
|
||||
item: item({
|
||||
id: "thought:tail",
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "…",
|
||||
timestamp: "2026-06-14T19:00:02.000Z",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const live = buildConversationTurnMeta(blocks, {
|
||||
isTurnLive: true,
|
||||
variant: "conversation",
|
||||
});
|
||||
assert.deepEqual(live.thoughtDurationSecondsById, {});
|
||||
assert.equal(live.streamingItemId, "thought:tail");
|
||||
|
||||
const idle = buildConversationTurnMeta(blocks, {
|
||||
isTurnLive: false,
|
||||
variant: "conversation",
|
||||
});
|
||||
assert.equal(idle.streamingItemId, null);
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta counts a prompt as an item and skips setup segments", () => {
|
||||
const meta = buildConversationTurnMeta(
|
||||
[
|
||||
turnBlock([
|
||||
{
|
||||
kind: "setup",
|
||||
items: [
|
||||
item({
|
||||
id: "life:1",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Turn started",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
item: item({
|
||||
id: "thought:1",
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "…",
|
||||
timestamp: "2026-06-14T19:00:01.000Z",
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "prompt",
|
||||
context: null,
|
||||
setup: [],
|
||||
user: item({
|
||||
id: "msg:steer",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "user",
|
||||
title: "Ada",
|
||||
text: "actually…",
|
||||
timestamp: "2026-06-14T19:00:04.000Z",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
// Setup contributes nothing, so the thought is timed against the steer prompt.
|
||||
assert.deepEqual(meta.thoughtDurationSecondsById, { "thought:1": 3 });
|
||||
assert.equal(meta.streamingItemId, "msg:steer");
|
||||
});
|
||||
|
||||
// ---- formatThoughtDisclosureLabel ----
|
||||
|
||||
test("formatThoughtDisclosureLabel says Thinking while streaming", () => {
|
||||
assert.equal(
|
||||
formatThoughtDisclosureLabel({ durationSeconds: 4, isStreaming: true }),
|
||||
"Thinking…",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatThoughtDisclosureLabel reports a duration once the turn moved on", () => {
|
||||
assert.equal(
|
||||
formatThoughtDisclosureLabel({ durationSeconds: 9, isStreaming: false }),
|
||||
"Thought for 9s",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatThoughtDisclosureLabel avoids a misleading 0s", () => {
|
||||
assert.equal(
|
||||
formatThoughtDisclosureLabel({ durationSeconds: 0, isStreaming: false }),
|
||||
"Thought for a moment",
|
||||
);
|
||||
assert.equal(formatThoughtDisclosureLabel({ isStreaming: false }), "Thought");
|
||||
});
|
||||
|
||||
// ---- parsePlanChecklist ----
|
||||
|
||||
test("parsePlanChecklist reads the checkbox markdown the transcript builds", () => {
|
||||
const checklist = parsePlanChecklist(
|
||||
[
|
||||
"- [x] read the transcript",
|
||||
"- [ ] write the summary (in progress)",
|
||||
"- [ ] ship it",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.deepEqual(checklist.entries, [
|
||||
{ label: "read the transcript", status: "completed" },
|
||||
{ label: "write the summary", status: "in_progress" },
|
||||
{ label: "ship it", status: "pending" },
|
||||
]);
|
||||
assert.equal(checklist.completedCount, 1);
|
||||
assert.equal(formatPlanChecklistProgress(checklist), "1/3 complete");
|
||||
});
|
||||
|
||||
test("parsePlanChecklist yields nothing for free-form plan text", () => {
|
||||
const checklist = parsePlanChecklist("We will read, then write, then ship.");
|
||||
assert.deepEqual(checklist.entries, []);
|
||||
assert.equal(formatPlanChecklistProgress(checklist), null);
|
||||
});
|
||||
|
||||
// ---- resolveAgentSessionTranscriptVariant ----
|
||||
|
||||
test("resolveAgentSessionTranscriptVariant opts a wide panel into conversation", () => {
|
||||
assert.equal(
|
||||
resolveAgentSessionTranscriptVariant({
|
||||
isSinglePanelView: false,
|
||||
widthPx: 720,
|
||||
}),
|
||||
"conversation",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAgentSessionTranscriptVariant keeps narrow and single-panel layouts on default", () => {
|
||||
assert.equal(
|
||||
resolveAgentSessionTranscriptVariant({
|
||||
isSinglePanelView: false,
|
||||
widthPx: 380,
|
||||
}),
|
||||
"default",
|
||||
);
|
||||
assert.equal(
|
||||
resolveAgentSessionTranscriptVariant({
|
||||
isSinglePanelView: true,
|
||||
widthPx: 1200,
|
||||
}),
|
||||
"default",
|
||||
);
|
||||
});
|
||||
115
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal file
115
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
AgentSessionTranscriptTurnMeta,
|
||||
AgentSessionTranscriptVariant,
|
||||
} from "./agentSessionTranscriptContext";
|
||||
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext";
|
||||
import type {
|
||||
TranscriptDisplayBlock,
|
||||
TranscriptTurnSegment,
|
||||
} from "./agentSessionTranscriptGrouping";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
|
||||
/**
|
||||
* Flatten a turn's segments into the leaf items that the reader actually sees
|
||||
* in the order they appear. Prompt segments contribute their user message;
|
||||
* setup segments contribute nothing (they render as a quiet divider, not work);
|
||||
* summary segments contribute their collapsed tool items.
|
||||
*/
|
||||
function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] {
|
||||
if (segment.kind === "prompt") return [segment.user];
|
||||
if (segment.kind === "setup") return [];
|
||||
if (segment.kind === "summary") return segment.summary.items;
|
||||
return [segment.item];
|
||||
}
|
||||
|
||||
function elapsedSeconds(from: string, to: string): number | null {
|
||||
const start = Date.parse(from);
|
||||
const end = Date.parse(to);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
const seconds = Math.round((end - start) / 1_000);
|
||||
return seconds >= 0 ? seconds : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the `conversation` variant's streaming/duration hints from the display
|
||||
* blocks.
|
||||
*
|
||||
* Two facts the individual render classes cannot see on their own:
|
||||
*
|
||||
* - **Which item is streaming.** Only the trailing leaf item of the final turn
|
||||
* can be mid-stream, and only when the agent's turn is actually live. The
|
||||
* thought disclosure auto-opens for exactly that item, so the reader watches
|
||||
* reasoning arrive rather than clicking to find it.
|
||||
* - **How long a thought ran.** A thought is finished once the turn produces a
|
||||
* later item; the gap between the thought's timestamp and that next item's
|
||||
* timestamp is the "Thought for Ns" duration. Thoughts with no following
|
||||
* item are still open, so they get no duration.
|
||||
*
|
||||
* Returns the shared empty value for non-conversation variants so the default
|
||||
* and compactPreview paths allocate nothing and stay byte-identical.
|
||||
*/
|
||||
export function buildConversationTurnMeta(
|
||||
displayBlocks: TranscriptDisplayBlock[],
|
||||
options: { isTurnLive: boolean; variant: AgentSessionTranscriptVariant },
|
||||
): AgentSessionTranscriptTurnMeta {
|
||||
if (options.variant !== "conversation") {
|
||||
return EMPTY_TRANSCRIPT_TURN_META;
|
||||
}
|
||||
|
||||
const thoughtDurationSecondsById: Record<string, number> = {};
|
||||
let streamingItemId: string | null = null;
|
||||
|
||||
for (const block of displayBlocks) {
|
||||
if (block.kind !== "turn") continue;
|
||||
|
||||
const items = block.segments.flatMap(turnSegmentItems);
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = items[index];
|
||||
if (item.type !== "thought") continue;
|
||||
const next = items[index + 1];
|
||||
if (!next) continue;
|
||||
const seconds = elapsedSeconds(item.timestamp, next.timestamp);
|
||||
if (seconds !== null) {
|
||||
thoughtDurationSecondsById[item.id] = seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isTurnLive) {
|
||||
const lastBlock = displayBlocks[displayBlocks.length - 1];
|
||||
if (lastBlock?.kind === "turn") {
|
||||
const items = lastBlock.segments.flatMap(turnSegmentItems);
|
||||
streamingItemId = items[items.length - 1]?.id ?? null;
|
||||
} else if (lastBlock?.kind === "single") {
|
||||
streamingItemId = lastBlock.item.id;
|
||||
}
|
||||
}
|
||||
|
||||
return { streamingItemId, thoughtDurationSecondsById };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disclosure label for a thought in the `conversation` variant.
|
||||
*
|
||||
* While the thought is the streaming tail of a live turn the reader is watching
|
||||
* reasoning arrive, so the label stays present tense. Once the turn has moved
|
||||
* on we know how long it ran and say so; a thought we cannot time (missing or
|
||||
* unparseable timestamps) still gets a stable past-tense label rather than a
|
||||
* misleading "0s".
|
||||
*/
|
||||
export function formatThoughtDisclosureLabel(options: {
|
||||
durationSeconds?: number;
|
||||
isStreaming: boolean;
|
||||
}): string {
|
||||
if (options.isStreaming) {
|
||||
return "Thinking…";
|
||||
}
|
||||
const seconds = options.durationSeconds;
|
||||
if (seconds === undefined) {
|
||||
return "Thought";
|
||||
}
|
||||
if (seconds < 1) {
|
||||
return "Thought for a moment";
|
||||
}
|
||||
return `Thought for ${seconds}s`;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
|
||||
|
||||
/**
|
||||
* Narrowest panel width that still reads as a conversation.
|
||||
*
|
||||
* The `conversation` variant lays the transcript out as a document: a centered
|
||||
* reading column with right-aligned prompt bubbles capped well inside it. Below
|
||||
* roughly this width the bubble/prose asymmetry collapses and the dense
|
||||
* `default` activity feed is the more legible presentation, so the wide layout
|
||||
* is the only one that opts in.
|
||||
*
|
||||
* Calibrated against `shared/layout/auxiliaryPanelLayout.ts`: the panel opens at
|
||||
* `AUXILIARY_PANEL_DEFAULT_WIDTH_PX` (380) and clamps to at least
|
||||
* `AUXILIARY_PANEL_MAX_WIDTH_PX` (720), so 640 sits deliberately in the top of
|
||||
* that range — a panel at its default or merely-widened size keeps the feed, and
|
||||
* only a reader who has pulled it near full width (or a full-cover host that
|
||||
* pins the variant explicitly) gets the reading layout.
|
||||
*/
|
||||
export const CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX = 640;
|
||||
|
||||
/**
|
||||
* Pick the transcript variant for an agent session panel from its layout.
|
||||
*
|
||||
* Callers may always override by passing an explicit `transcriptVariant`; this
|
||||
* is only the default when they don't. Kept as a pure function so the threshold
|
||||
* is testable without mounting the panel.
|
||||
*/
|
||||
export function resolveAgentSessionTranscriptVariant({
|
||||
isSinglePanelView,
|
||||
widthPx,
|
||||
}: {
|
||||
isSinglePanelView: boolean;
|
||||
widthPx: number;
|
||||
}): AgentSessionTranscriptVariant {
|
||||
// Single-panel view stacks the panel over the channel on a narrow viewport
|
||||
// and renders it at 100% width, so `widthPx` (the stored/resizable width)
|
||||
// does not describe what the reader sees. Keep the default feed there.
|
||||
if (isSinglePanelView) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return widthPx >= CONVERSATION_TRANSCRIPT_MIN_WIDTH_PX
|
||||
? "conversation"
|
||||
: "default";
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
scopeByChannel,
|
||||
} from "@/features/agents/ui/agentSessionPanelLayout";
|
||||
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
|
||||
import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
|
||||
import { resolveAgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptVariantChoice";
|
||||
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
|
||||
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
|
||||
import {
|
||||
@@ -81,6 +83,11 @@ type AgentSessionThreadPanelProps = {
|
||||
onClose: () => void;
|
||||
widthPx: number;
|
||||
transparentChrome?: boolean;
|
||||
/**
|
||||
* Overrides the transcript presentation. Omit to let the panel pick from its
|
||||
* own layout — see `resolveAgentSessionTranscriptVariant`.
|
||||
*/
|
||||
transcriptVariant?: AgentSessionTranscriptVariant;
|
||||
};
|
||||
|
||||
export function AgentSessionThreadPanel({
|
||||
@@ -95,9 +102,16 @@ export function AgentSessionThreadPanel({
|
||||
onClose,
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
transcriptVariant,
|
||||
}: AgentSessionThreadPanelProps) {
|
||||
const isLive = isManagedAgentActive(agent);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
// Wide/standalone reading layouts get the conversation transcript; narrow and
|
||||
// single-panel layouts keep the dense activity feed. An explicit prop always
|
||||
// wins so callers can pin a presentation.
|
||||
const resolvedTranscriptVariant =
|
||||
transcriptVariant ??
|
||||
resolveAgentSessionTranscriptVariant({ isSinglePanelView, widthPx });
|
||||
const sessionChannelId = channelId ?? channel?.id ?? null;
|
||||
// Unified working signal, scoped to this panel's channel (or all channels
|
||||
// when the panel is unscoped) — observer turns primary, typing fallback.
|
||||
@@ -495,6 +509,7 @@ export function AgentSessionThreadPanel({
|
||||
rawLayout="exclusive"
|
||||
showHeader={false}
|
||||
showRaw={showRawFeed}
|
||||
transcriptVariant={resolvedTranscriptVariant}
|
||||
/>
|
||||
</div>
|
||||
</AuxiliaryPanelBody>
|
||||
|
||||
Reference in New Issue
Block a user