mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
preview: merge tool-chain cards (Slice C, carries B + restyle) onto main
* commit '91a5754ad': fix(desktop): keep a reader's work block open when a later answer regroups it test(desktop): pin the plain next-turn gap and stop the 5-minute suite hang fix(desktop): guard the work-block pulse and stop a finished block going live test(desktop): split the work-block suite and prune the conversation harness fix(desktop): settle a work block whose running step has no live session feat(desktop): fold focus-mode agent work into one transcript block fix(desktop): hide the decorative identity avatar and split the conversation suite fix(desktop): resolve the focus identity row agent through the profiles lookup fix(desktop): apply the focus code recipe to fenced human prompts too style(desktop): bring the conversation variant closer to berd's recipes fix(desktop): never show a trigger title as the prompt author fix(desktop): pin the transcript baseline fixture's locale too fix(desktop): pin the transcript baseline fixture to UTC fix(desktop): address review on the conversation transcript variant feat(desktop): add conversation transcript variant for focus-mode agent sessions Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
This commit is contained in:
18
AGENTS.md
18
AGENTS.md
@@ -256,6 +256,24 @@ scripts build the required E2E bridge before running Playwright.
|
||||
|
||||
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
||||
|
||||
### Never assert a DOM element against `null` with `assert.equal`
|
||||
|
||||
In the desktop jsdom tests (`desktop/src/**/*.test.mjs`), write
|
||||
|
||||
```js
|
||||
assert.ok(container.querySelector(sel) === null, "no header row");
|
||||
```
|
||||
|
||||
**not** `assert.equal(container.querySelector(sel), null, ...)`. Both pass
|
||||
identically, but when the `assert.equal` form *fails*, node serializes the
|
||||
matched element and its entire subtree to build a diff. On a real transcript
|
||||
that exhausts memory and the runner dies with SIGKILL after ~100s instead of
|
||||
printing the assertion message — so a genuine regression is unreadable and
|
||||
looks like a hang or an OOM in unrelated code. The `assert.ok(x === null)` form
|
||||
fails in milliseconds with the message you wrote.
|
||||
|
||||
Comparing `getAttribute(...)` to `null` is fine — attributes are strings.
|
||||
|
||||
### PR Screenshots
|
||||
|
||||
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TranscriptItem } from "../agentSessionTypes";
|
||||
import { getBuzzToolInfo } from "../agentSessionToolCatalog";
|
||||
import { buildCompactToolSummary } from "../agentSessionToolSummary";
|
||||
import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types";
|
||||
import { useIsInsideWorkBlockRail } from "../agentSessionTranscriptContext";
|
||||
import {
|
||||
formatTranscriptTimestampTitle,
|
||||
getToolDurationDisplay,
|
||||
@@ -34,6 +35,7 @@ export function ToolItem({
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const insideWorkBlockRail = useIsInsideWorkBlockRail();
|
||||
const hasArgs = Object.keys(item.args).length > 0;
|
||||
const hasResult = item.result.trim().length > 0;
|
||||
const canonicalToolName = item.buzzToolName ?? item.toolName;
|
||||
@@ -57,7 +59,10 @@ export function ToolItem({
|
||||
[],
|
||||
);
|
||||
|
||||
if (compactSummary.presentation === "message") {
|
||||
// On a work block's rail a posted message is a step the agent took, so it
|
||||
// takes the same muted, expandable row as every other step. Everywhere else
|
||||
// it keeps the bubble: see `useIsInsideWorkBlockRail`.
|
||||
if (compactSummary.presentation === "message" && !insideWorkBlockRail) {
|
||||
return (
|
||||
<div
|
||||
className="not-prose w-full"
|
||||
|
||||
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,506 @@
|
||||
/**
|
||||
* Rendering contract for the `conversation` transcript variant (focus mode):
|
||||
* layout, prompt authorship, plans, lifecycle chrome, and the byte-for-byte
|
||||
* guarantee for the other variants.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Reasoning is no longer a per-thought disclosure on this path — thoughts,
|
||||
* tool steps and interim notes fold into one work block, whose contract lives in
|
||||
* `AgentSessionWorkBlock.test.mjs`. The identity row and code-block chrome live
|
||||
* in `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom
|
||||
* setup, ambient-formatting pins, and render helpers live in the harness they
|
||||
* all import.
|
||||
*
|
||||
* The byte-for-byte tests at the bottom are the important ones: `conversation`
|
||||
* is purely additive, so `default` and `compactPreview` markup for the same
|
||||
* transcript must be byte-identical to the markup captured before the variant
|
||||
* existed. See the harness for how that fixture was produced.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
act,
|
||||
AUTHOR,
|
||||
AUTHOR_PROFILES,
|
||||
AUTHOR_TRUNCATED,
|
||||
BASELINE_MARKUP,
|
||||
baselineItems,
|
||||
cleanup,
|
||||
FIXTURE_LOCALE,
|
||||
items,
|
||||
renderRerenderableTranscript,
|
||||
renderTranscript,
|
||||
} from "./AgentSessionTranscriptList.conversationHarness.mjs";
|
||||
|
||||
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/);
|
||||
// berd's user-turn recipe: soft tint, no border, `px-4 py-2`, and a 12px
|
||||
// radius (berd's `rounded-sm` on its own scale = Buzz's `rounded-xl`).
|
||||
const bubble = row.querySelector(".rounded-xl");
|
||||
assert.ok(bubble, "the prompt bubble should take berd's 12px radius");
|
||||
assert.match(bubble.className, /bg-muted\/60/);
|
||||
assert.match(bubble.className, /px-4/);
|
||||
assert.match(bubble.className, /py-2(?!\.)/);
|
||||
assert.match(
|
||||
bubble.className,
|
||||
/border-0/,
|
||||
"berd never draws a border on the user turn",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
bubble.className,
|
||||
/rounded-2xl/,
|
||||
"the old 16px pill radius should be gone",
|
||||
);
|
||||
// Focus mode shows the whole prompt rather than clamping it.
|
||||
assert.doesNotMatch(bubble.className, /max-h-36/);
|
||||
});
|
||||
|
||||
test("conversation caps the prompt bubble at a fixed measure, not a percentage", async () => {
|
||||
// berd caps the user turn with `--chat-user-message-max-width: 640px`. A
|
||||
// percentage cap re-wraps the prompt every time the cover view is resized;
|
||||
// a fixed measure holds one stable line length, which is the point of the
|
||||
// recipe. Guards against a silent revert to `max-w-[85%]`.
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const column = container.querySelector(
|
||||
'[data-testid="transcript-user-message-author"]',
|
||||
).parentElement;
|
||||
assert.match(column.className, /max-w-prompt-bubble/);
|
||||
assert.doesNotMatch(column.className, /max-w-\[\d+%\]/);
|
||||
});
|
||||
|
||||
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 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/);
|
||||
});
|
||||
|
||||
/**
|
||||
* A reader's expansion survives the work blocks being regrouped underneath it.
|
||||
*
|
||||
* Work blocks are derived, not stored: `groupConversationWorkBlocks` rebuilds
|
||||
* them every render and ids each one after its first step
|
||||
* (`work-block:${items[0].id}`). `findFinalAnswerId` exempts only the LAST
|
||||
* assistant message from the block, so a second assistant message demotes the
|
||||
* first — the earlier answer becomes work, and the runs on either side of it
|
||||
* merge into one block:
|
||||
*
|
||||
* frame 2 work-block:thought:1[...] msg:1 work-block:thought:2[...]
|
||||
* frame 3 work-block:thought:1[thought:1,tool:1,msg:1,thought:2,tool:2] msg:2
|
||||
*
|
||||
* `work-block:thought:2` stops existing, so React unmounts it. With the fold
|
||||
* state held in that component, a reader who had opened the second block to read
|
||||
* its steps was folded shut by the agent posting a second message — an event
|
||||
* they did not cause and cannot predict. This drives the real list so the actual
|
||||
* regrouping runs, rather than asserting against hand-built segments.
|
||||
*/
|
||||
test("conversation keeps a reader's expanded work block open when a later answer regroups it", async () => {
|
||||
const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
|
||||
const at = (seconds) =>
|
||||
`2026-06-14T19:00:${String(seconds).padStart(2, "0")}.000Z`;
|
||||
const thought = (id, seconds) => ({
|
||||
...shared,
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: `reasoning ${id}`,
|
||||
timestamp: at(seconds),
|
||||
});
|
||||
const tool = (id, seconds) => ({
|
||||
...shared,
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
title: id,
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: at(seconds),
|
||||
startedAt: at(seconds),
|
||||
completedAt: at(seconds),
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: id,
|
||||
source: "shell",
|
||||
groupKey: "shell:command",
|
||||
},
|
||||
});
|
||||
const answer = (id, seconds) => ({
|
||||
...shared,
|
||||
id,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Test Agent",
|
||||
text: `answer ${id}`,
|
||||
timestamp: at(seconds),
|
||||
});
|
||||
|
||||
const firstRun = [thought("thought:1", 1), tool("tool:1", 2)];
|
||||
const frame1 = [...firstRun, answer("msg:1", 3)];
|
||||
const frame2 = [...frame1, thought("thought:2", 4), tool("tool:2", 5)];
|
||||
const frame3 = [...frame2, answer("msg:2", 6)];
|
||||
|
||||
const { container, setOverrides } = await renderRerenderableTranscript(
|
||||
"conversation",
|
||||
{ items: frame1 },
|
||||
);
|
||||
const blocks = () => [
|
||||
...container.querySelectorAll('[data-testid="transcript-work-block"]'),
|
||||
];
|
||||
const summaries = () => [
|
||||
...container.querySelectorAll(
|
||||
'[data-testid="transcript-work-block-summary"]',
|
||||
),
|
||||
];
|
||||
const openCount = () =>
|
||||
summaries().filter((node) => node.getAttribute("aria-expanded") === "true")
|
||||
.length;
|
||||
|
||||
await setOverrides({ items: frame2 });
|
||||
assert.equal(
|
||||
blocks().length,
|
||||
2,
|
||||
"the demoted-answer frame should show two separate work blocks",
|
||||
);
|
||||
assert.equal(openCount(), 0, "both blocks start folded once work finished");
|
||||
|
||||
// The reader opens the SECOND block — the one the merge destroys.
|
||||
await act(async () => {
|
||||
summaries()[1].click();
|
||||
});
|
||||
assert.equal(openCount(), 1, "the reader's click should open that block");
|
||||
|
||||
await setOverrides({ items: frame3 });
|
||||
assert.equal(
|
||||
blocks().length,
|
||||
1,
|
||||
"the second answer should merge the runs into one block",
|
||||
);
|
||||
assert.equal(
|
||||
openCount(),
|
||||
1,
|
||||
"the merged block must stay open — the reader asked to see those steps, and an agent posting again is not a reason to fold them away",
|
||||
);
|
||||
// The steps they were reading are actually on screen, not merely a block
|
||||
// reporting itself open.
|
||||
const stepText = [
|
||||
...container.querySelectorAll('[data-testid="transcript-work-block-step"]'),
|
||||
]
|
||||
.map((node) => node.textContent)
|
||||
.join("\n");
|
||||
assert.match(
|
||||
stepText,
|
||||
/tool:2/,
|
||||
"the reader's steps should still be visible",
|
||||
);
|
||||
|
||||
// ...and the merged block is still theirs to fold. Recording the choice
|
||||
// against only the block's first step would leave the absorbed block's stale
|
||||
// `open` entry behind, and since an open choice wins the read, the merged
|
||||
// block could never be folded again — the reader's click would do nothing.
|
||||
await act(async () => {
|
||||
summaries()[0].click();
|
||||
});
|
||||
assert.equal(
|
||||
openCount(),
|
||||
0,
|
||||
"a reader who folds the merged block must actually fold it",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Presentation contract for the `conversation` transcript variant's *chrome*:
|
||||
* the agent identity row above each reply, and the focus code-block recipe.
|
||||
*
|
||||
* Split out of `AgentSessionTranscriptList.conversation.test.mjs` to stay under
|
||||
* the repo's hard 1000-line/file ceiling (AGENTS.md). The shared jsdom setup,
|
||||
* ambient-formatting pins, and render helpers live in the harness so the two
|
||||
* suites cannot drift apart.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
AGENT,
|
||||
AGENT_AVATAR_URL,
|
||||
AGENT_PROFILES,
|
||||
cleanup,
|
||||
fencedCodeItems,
|
||||
fencedCodePromptItems,
|
||||
renderTranscript,
|
||||
renderTranscriptWithCodeChrome,
|
||||
} from "./AgentSessionTranscriptList.conversationHarness.mjs";
|
||||
|
||||
test("the conversation identity row announces the agent exactly once", async () => {
|
||||
// `UserAvatar` names itself — either an `<img alt="${displayName} avatar">` or
|
||||
// its fallback initials — and the row puts the agent's name in visible text
|
||||
// immediately after it. Left unhidden, assistive tech reads the same identity
|
||||
// twice for every single agent turn in the transcript. The visible name is
|
||||
// the row's one accessible identity; the avatar is decorative.
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
agentAvatarUrl: null,
|
||||
profiles: AGENT_PROFILES,
|
||||
});
|
||||
const identity = container.querySelector(
|
||||
'[data-testid="transcript-assistant-identity"]',
|
||||
);
|
||||
assert.ok(identity, "the identity row should render");
|
||||
|
||||
// The avatar is present and still shows the resolved image ...
|
||||
const decorative = identity.querySelector('[aria-hidden="true"]');
|
||||
assert.ok(decorative, "the avatar must be hidden from the accessible tree");
|
||||
const image = decorative.querySelector("img");
|
||||
assert.ok(image, "hiding the avatar must not stop it rendering visually");
|
||||
assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
|
||||
|
||||
// ... but every name-bearing node it contains is inside the hidden subtree,
|
||||
// so the accessible tree is left with exactly one copy of the name.
|
||||
const named = [...identity.querySelectorAll("img[alt], [aria-label]")];
|
||||
assert.ok(named.length > 0, "the avatar should still expose a raw alt/label");
|
||||
for (const node of named) {
|
||||
assert.ok(
|
||||
node.closest('[aria-hidden="true"]') !== null,
|
||||
`${node.tagName} carrying an accessible name escapes the hidden avatar subtree`,
|
||||
);
|
||||
}
|
||||
|
||||
// The one remaining accessible identity is the visible name text.
|
||||
assert.equal(identity.textContent, "Test Agent");
|
||||
});
|
||||
|
||||
test("conversation labels the agent turn with a berd-style identity row", async () => {
|
||||
// The single biggest divergence from berd was that agent prose carried no
|
||||
// attribution at all. berd puts a 20px round avatar + the agent name at
|
||||
// `text-xs` above every reply (MessageBubble.tsx:961-981).
|
||||
const { container } = await renderTranscript("conversation");
|
||||
const identity = container.querySelector(
|
||||
'[data-testid="transcript-assistant-identity"]',
|
||||
);
|
||||
assert.ok(identity, "conversation should label the agent turn");
|
||||
assert.match(identity.textContent, /Test Agent/);
|
||||
assert.match(identity.className, /text-xs/);
|
||||
assert.match(identity.className, /gap-1(?!\d)/);
|
||||
// 20px avatar, berd's size (UserAvatar `size="xs"` → `h-5 w-5`).
|
||||
assert.ok(
|
||||
identity.querySelector(".h-5.w-5"),
|
||||
"identity row should carry a 20px avatar",
|
||||
);
|
||||
// The prose itself stays unboxed and full-width.
|
||||
const message = container.querySelector(
|
||||
'[data-testid="transcript-assistant-message"]',
|
||||
);
|
||||
assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
|
||||
});
|
||||
|
||||
test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => {
|
||||
// Regression guard for the primary channel flow. `ChannelAgentSessionAgent`
|
||||
// (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the
|
||||
// focus conversation is opened from a channel the panel passes
|
||||
// `agentAvatarUrl: null` — which is exactly this mount. The row must still
|
||||
// show the configured avatar by resolving it out of the `profiles` lookup the
|
||||
// panel already hands down, the same way the ToolItem row
|
||||
// (ToolItem.tsx:45-52) and the panel header above it already do. Before the
|
||||
// fix, every channel-opened session fell back to initials.
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
agentAvatarUrl: null,
|
||||
profiles: AGENT_PROFILES,
|
||||
});
|
||||
const identity = container.querySelector(
|
||||
'[data-testid="transcript-assistant-identity"]',
|
||||
);
|
||||
assert.ok(identity, "the identity row should render");
|
||||
const image = identity.querySelector("img");
|
||||
assert.ok(
|
||||
image,
|
||||
"the profile avatar must win over the caller's null agent record avatar",
|
||||
);
|
||||
assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
|
||||
});
|
||||
|
||||
test("the conversation identity row resolves the agent name the same way the header does", async () => {
|
||||
// The row sits directly under the panel header, which labels the same agent
|
||||
// through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading
|
||||
// the raw `agentName` prop instead let the two disagree whenever the relay
|
||||
// profile's display name differed from the caller's agent record.
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
agentName: "stale-record-name",
|
||||
profiles: {
|
||||
[AGENT.agentPubkey]: {
|
||||
displayName: "Profile Display Name",
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const identity = container.querySelector(
|
||||
'[data-testid="transcript-assistant-identity"]',
|
||||
);
|
||||
assert.equal(identity.textContent, "Profile Display Name");
|
||||
});
|
||||
|
||||
test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => {
|
||||
// The managed-agent path is the other direction: a locally managed agent can
|
||||
// carry an avatar its relay profile never published. The prop stays the
|
||||
// fallback, so resolving profile-first must not drop it.
|
||||
const localAvatar = "https://cdn.example.test/local-managed.png";
|
||||
const { container } = await renderTranscript("conversation", {
|
||||
agentAvatarUrl: localAvatar,
|
||||
profiles: {
|
||||
[AGENT.agentPubkey]: {
|
||||
displayName: "Test Agent",
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const image = container
|
||||
.querySelector('[data-testid="transcript-assistant-identity"]')
|
||||
.querySelector("img");
|
||||
assert.ok(image, "the caller-supplied avatar should still render");
|
||||
assert.equal(image.getAttribute("src"), localAvatar);
|
||||
});
|
||||
|
||||
test("conversation frames fenced code with berd's header row", async () => {
|
||||
// berd puts the language in a real header row above the frame, with the copy
|
||||
// action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code
|
||||
// itself in a 10px-radius, page-background, borderless-shadow frame
|
||||
// (:528-529). Buzz's `rounded-lg` (`--radius: 0.625rem`) is exactly berd's
|
||||
// `rounded-[0.625rem]`.
|
||||
const { container } = await renderTranscriptWithCodeChrome("conversation", {
|
||||
items: fencedCodeItems(),
|
||||
});
|
||||
const header = container.querySelector(
|
||||
'[data-testid="markdown-code-block-header"]',
|
||||
);
|
||||
assert.ok(header, "focus mode should render a code-block header row");
|
||||
// Language sits in the header, not inside the frame.
|
||||
assert.match(header.textContent, /^ts/);
|
||||
assert.match(header.className, /justify-between/);
|
||||
assert.match(header.className, /items-end/);
|
||||
assert.match(header.className, /min-h-7/);
|
||||
assert.ok(
|
||||
header.querySelector('[aria-label="Copy code block"]'),
|
||||
"the copy action is a flow sibling of the language label",
|
||||
);
|
||||
|
||||
const frame = container.querySelector("pre");
|
||||
assert.ok(frame, "the code frame should render");
|
||||
assert.match(frame.className, /rounded-lg/);
|
||||
assert.match(frame.className, /bg-background/);
|
||||
assert.match(frame.className, /border-border\/80/);
|
||||
assert.doesNotMatch(
|
||||
frame.className,
|
||||
/shadow/,
|
||||
"berd's code frame carries no shadow",
|
||||
);
|
||||
// Guards against the default recipe leaking in: it uses a 16px radius, a
|
||||
// muted fill, `pr-12` to clear an absolutely-positioned copy button, and an
|
||||
// inline `borderRadius` style.
|
||||
assert.doesNotMatch(frame.className, /rounded-2xl/);
|
||||
assert.doesNotMatch(frame.className, /bg-muted/);
|
||||
assert.doesNotMatch(frame.className, /pr-12/);
|
||||
assert.equal(frame.style.borderRadius, "");
|
||||
// Line numbers come from `.code-block-lines [data-line]` in markdown.css, so
|
||||
// the frame only has to keep emitting per-line elements under that class.
|
||||
const code = frame.querySelector("code.code-block-lines");
|
||||
assert.ok(code, "the code element keeps the line-number class");
|
||||
assert.equal(code.querySelectorAll("[data-line]").length, 2);
|
||||
});
|
||||
|
||||
test("conversation applies the code recipe to a fenced human prompt too", async () => {
|
||||
// Regression guard for a real bug quality caught. The provider was first
|
||||
// mounted inside `MessageActivity`, which only handles assistant items — the
|
||||
// user bubble returns before it, so a fence inside a prompt kept the legacy
|
||||
// 16px muted frame nested inside the new 12px bubble. The recipe is a
|
||||
// property of the *surface*, not of a role, so the provider now sits at the
|
||||
// transcript boundary and both roles inherit it.
|
||||
const { container } = await renderTranscriptWithCodeChrome("conversation", {
|
||||
items: fencedCodePromptItems(),
|
||||
});
|
||||
const bubble = container.querySelector(
|
||||
'[data-testid="transcript-user-message"]',
|
||||
);
|
||||
assert.ok(bubble, "the prompt should render");
|
||||
assert.ok(
|
||||
bubble.querySelector('[data-testid="markdown-code-block-header"]'),
|
||||
"a fence inside the prompt gets berd's header row",
|
||||
);
|
||||
const frame = bubble.querySelector("pre");
|
||||
assert.match(frame.className, /rounded-lg/);
|
||||
assert.doesNotMatch(
|
||||
frame.className,
|
||||
/rounded-2xl/,
|
||||
"the legacy 16px frame must not nest inside the 12px bubble",
|
||||
);
|
||||
assert.doesNotMatch(frame.className, /pr-12/);
|
||||
});
|
||||
|
||||
test("the default transcript variant keeps the legacy code chrome", async () => {
|
||||
// The markdown renderer is shared with channel messages, so `focusProse` is
|
||||
// opt-in per surface. Rendering the same fenced block through the `default`
|
||||
// transcript variant must still produce the original chrome: no header row,
|
||||
// 16px radius, muted fill, and the absolutely-positioned copy button.
|
||||
//
|
||||
// This proves the *variant gate*, not the channel-message row itself — those
|
||||
// rows are covered by the markdown tests in `shared/ui/markdown`.
|
||||
const { container } = await renderTranscriptWithCodeChrome("default", {
|
||||
items: fencedCodeItems(),
|
||||
});
|
||||
// `assert.ok(x === null)` rather than `assert.equal(x, null)`: on failure the
|
||||
// latter serializes the whole matched jsdom element (and its ancestors) to
|
||||
// build a diff, which exhausts memory instead of printing the message.
|
||||
assert.ok(
|
||||
container.querySelector('[data-testid="markdown-code-block-header"]') ===
|
||||
null,
|
||||
"the default recipe has no header row",
|
||||
);
|
||||
const frame = container.querySelector("pre");
|
||||
assert.match(frame.className, /rounded-2xl/);
|
||||
assert.match(frame.className, /bg-muted\/60/);
|
||||
assert.match(frame.className, /pr-12/);
|
||||
assert.match(frame.className, /shadow-xs/);
|
||||
const copy = container.querySelector('[aria-label="Copy code block"]');
|
||||
assert.ok(copy, "the default copy button still renders");
|
||||
assert.match(copy.className, /absolute/);
|
||||
});
|
||||
|
||||
test("the identity row is conversation-only", async () => {
|
||||
// `default`/`compactPreview` markup is pinned byte-for-byte, so the identity
|
||||
// row must not leak into them. The fixture comparison would catch this too;
|
||||
// this asserts it directly so the failure names the cause.
|
||||
for (const variant of ["default", "compactPreview"]) {
|
||||
const { container } = await renderTranscript(variant);
|
||||
assert.ok(
|
||||
container.querySelector(
|
||||
'[data-testid="transcript-assistant-identity"]',
|
||||
) === null,
|
||||
`${variant} must not render the identity row`,
|
||||
);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Shared test infrastructure for the `conversation` transcript-variant suites.
|
||||
*
|
||||
* Lives in a non-test file for two reasons. The `src/**\/*.test.mjs` glob would
|
||||
* otherwise pick it up as a suite of its own, and — more importantly — the
|
||||
* ambient-formatting pins below are order-sensitive and easy to get subtly
|
||||
* wrong, so the two suites that need them must share one copy rather than
|
||||
* maintain two. See `AgentSessionTranscriptList.conversation.test.mjs` (layout
|
||||
* and lifecycle contracts) and
|
||||
* `AgentSessionTranscriptList.conversationChrome.test.mjs` (identity row and
|
||||
* code-block chrome).
|
||||
*
|
||||
* Importing this module installs the jsdom globals and registers the
|
||||
* `before`/`afterEach`/`after` hooks for the importing suite. Import it before
|
||||
* anything that reaches for React or the DOM.
|
||||
*
|
||||
* The byte-for-byte contract 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 `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 { readFileSync } from "node:fs";
|
||||
import { after, afterEach, before } 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";
|
||||
|
||||
export 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";
|
||||
|
||||
export 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;
|
||||
|
||||
/**
|
||||
* Radix's `AvatarImage` renders nothing until its own preloader reports
|
||||
* `loaded` (`react-avatar/dist/index.mjs` `useImageLoadingStatus`), and jsdom
|
||||
* never fetches, so a real avatar URL would otherwise stay in `loading` forever
|
||||
* and every avatar assertion would see only the initials fallback — the exact
|
||||
* bug under test, passing vacuously. This stub reports a decoded image as soon
|
||||
* as `src` is assigned. It only affects avatars that HAVE a url: with
|
||||
* `avatarUrl: null` radix resolves to `error` and skips the preloader entirely,
|
||||
* so the byte-for-byte baseline (whose agent and author carry no avatar) is
|
||||
* untouched.
|
||||
*/
|
||||
class LoadedImageStub {
|
||||
constructor() {
|
||||
this._src = "";
|
||||
this.complete = false;
|
||||
this.naturalWidth = 0;
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
set src(value) {
|
||||
this._src = value;
|
||||
this.complete = true;
|
||||
this.naturalWidth = 1;
|
||||
}
|
||||
}
|
||||
dom.window.Image = LoadedImageStub;
|
||||
|
||||
/**
|
||||
* Assigned in `before`. Exported as `let` so importers see the resolved values
|
||||
* through ES module live bindings rather than a snapshot taken at import time.
|
||||
*/
|
||||
export let act;
|
||||
export let cleanup;
|
||||
export let render;
|
||||
let createElement;
|
||||
let useState;
|
||||
let createMemoryHistory;
|
||||
let createRootRoute;
|
||||
let createRouter;
|
||||
let RouterProvider;
|
||||
let AgentSessionTranscriptList;
|
||||
let ThemeProvider;
|
||||
let TooltipProvider;
|
||||
export let resetActiveAgentTurnsStore;
|
||||
export let syncAgentTurnsFromEvents;
|
||||
|
||||
export const AGENT = {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Test Agent",
|
||||
agentPubkey: "f".repeat(64),
|
||||
};
|
||||
export const AUTHOR = "a".repeat(64);
|
||||
export 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";
|
||||
export const AUTHOR_PROFILES = {
|
||||
[AUTHOR]: {
|
||||
displayName: "Ada Lovelace",
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
};
|
||||
/**
|
||||
* A relay-resolved profile for the *agent*. Deliberately not a `/media/<hex>`
|
||||
* relay URL: `UserAvatar` routes those through the localhost media proxy
|
||||
* (`rewriteRelayUrl`), which would make the rendered `src` a moving target.
|
||||
*/
|
||||
export const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png";
|
||||
export const AGENT_PROFILES = {
|
||||
[AGENT.agentPubkey]: {
|
||||
displayName: "Test Agent",
|
||||
avatarUrl: AGENT_AVATAR_URL,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
};
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export 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, wrapped in the providers a fenced code block needs.
|
||||
*
|
||||
* `MarkdownCodeBlock` reaches for the theme (shiki highlighting) and a Radix
|
||||
* tooltip provider for its copy action, so a transcript containing a fenced
|
||||
* block throws without them. Kept as a separate helper rather than folded into
|
||||
* `renderTranscript` so the byte-for-byte fixture keeps rendering through the
|
||||
* exact tree it was captured with.
|
||||
*/
|
||||
export async function renderTranscriptWithCodeChrome(variant, overrides = {}) {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
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 }));
|
||||
}
|
||||
|
||||
/** One assistant turn whose body is a fenced code block. */
|
||||
export function fencedCodeItems() {
|
||||
return [
|
||||
{
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
id: "msg:assistant",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Test Agent",
|
||||
text: "before\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
|
||||
timestamp: "2026-06-14T19:00:09.000Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** One *human prompt* whose body is a fenced code block. */
|
||||
export function fencedCodePromptItems() {
|
||||
return [
|
||||
{
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
id: "msg:user",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "user",
|
||||
title: TRIGGER_TITLE,
|
||||
text: "fix this\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
|
||||
timestamp: "2026-06-14T19:00:00.000Z",
|
||||
messageId: "event-1",
|
||||
authorPubkey: AUTHOR,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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"
|
||||
));
|
||||
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx"));
|
||||
({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
resetActiveAgentTurnsStore?.();
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
@@ -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,32 @@ 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 { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock";
|
||||
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 { AgentSessionWorkBlockSegment } from "./AgentSessionWorkBlock";
|
||||
import { AgentSessionWorkBlockDisclosureProvider } from "./agentSessionWorkBlockDisclosure";
|
||||
import {
|
||||
conversationSegmentsForBlock,
|
||||
type TranscriptConversationSegment,
|
||||
} from "./agentSessionWorkBlockGrouping";
|
||||
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
|
||||
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
|
||||
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
||||
@@ -48,10 +56,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 +64,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 +86,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 +158,7 @@ export function AgentSessionTranscriptList({
|
||||
});
|
||||
|
||||
const isCompactPreview = variant === "compactPreview";
|
||||
const isConversation = variant === "conversation";
|
||||
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const animationsDisabled =
|
||||
@@ -195,6 +173,14 @@ 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, items, variant }),
|
||||
[displayBlocks, isTurnLive, items, variant],
|
||||
);
|
||||
|
||||
const scrollContainerClassNames = cn(
|
||||
"w-full",
|
||||
@@ -240,46 +226,66 @@ 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
|
||||
{/* The berd code-block recipe is a property of the *surface*, not of a
|
||||
role: a fenced block in a human prompt must get the same chrome as
|
||||
one in an agent reply. Provided at the transcript boundary so every
|
||||
descendant markdown render inherits it. A context provider emits no
|
||||
DOM, so `default`/`compactPreview` markup is unaffected. */}
|
||||
<CodeBlockVariantContext.Provider
|
||||
value={isConversation ? "focusProse" : "default"}
|
||||
>
|
||||
<AgentSessionTranscriptVariantProvider value={variant}>
|
||||
<AgentSessionTranscriptTurnMetaProvider value={turnMeta}>
|
||||
<AgentSessionWorkBlockDisclosureProvider>
|
||||
{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}
|
||||
</AgentSessionTranscriptVariantProvider>
|
||||
<div className="content-visibility-auto">
|
||||
<TranscriptDisplayBlockView
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
block={block}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
{isTurnLive && !isCompactPreview ? (
|
||||
<TurnLivenessIndicator />
|
||||
) : null}
|
||||
</AgentSessionWorkBlockDisclosureProvider>
|
||||
</AgentSessionTranscriptTurnMetaProvider>
|
||||
</AgentSessionTranscriptVariantProvider>
|
||||
</CodeBlockVariantContext.Provider>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -337,18 +343,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 +355,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
|
||||
@@ -392,13 +387,25 @@ function TranscriptDisplayBlockView({
|
||||
);
|
||||
}
|
||||
|
||||
// Variant in, presentation out: the conversation variant folds a turn's
|
||||
// thinking/tools/notes into work blocks, and every other variant renders the
|
||||
// shared segments untouched. Keeping the choice here — rather than inside the
|
||||
// segment components — is what keeps `default`/`compactPreview` byte-identical
|
||||
// to their pre-work-block markup.
|
||||
const segments: TranscriptConversationSegment[] = isConversation
|
||||
? conversationSegmentsForBlock(block)
|
||||
: block.segments;
|
||||
|
||||
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}
|
||||
>
|
||||
{block.segments.map((segment) => (
|
||||
{segments.map((segment) => (
|
||||
<motion.div
|
||||
animate={ROW_ENTER_TO}
|
||||
initial={
|
||||
@@ -422,7 +429,13 @@ function TranscriptDisplayBlockView({
|
||||
);
|
||||
}
|
||||
|
||||
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
|
||||
function getTurnSegmentKey(
|
||||
turnId: string,
|
||||
segment: TranscriptConversationSegment,
|
||||
) {
|
||||
if (segment.kind === "work-block") {
|
||||
return segment.block.id;
|
||||
}
|
||||
if (segment.kind === "setup") {
|
||||
return `turn:${turnId}:setup`;
|
||||
}
|
||||
@@ -445,8 +458,20 @@ function TranscriptTurnSegmentView({
|
||||
segment,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
profiles?: UserProfileLookup;
|
||||
segment: TranscriptTurnSegment;
|
||||
segment: TranscriptConversationSegment;
|
||||
}) {
|
||||
if (segment.kind === "work-block") {
|
||||
return (
|
||||
<AgentSessionWorkBlockSegment
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
block={segment.block}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (segment.kind === "prompt") {
|
||||
return (
|
||||
<TurnPromptBlock
|
||||
@@ -652,198 +677,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 +739,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,609 @@
|
||||
/**
|
||||
* Work-block behaviour for work that was abandoned mid-flight, the rail's
|
||||
* per-kind presentation, and the re-render cost a streaming block must not pay.
|
||||
* The live/finished contracts live in `AgentSessionWorkBlock.test.mjs`; the
|
||||
* shared rig is `AgentSessionWorkBlockTestRig.mjs`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
noteStep,
|
||||
relayStep,
|
||||
renderBlock,
|
||||
step,
|
||||
thoughtStep,
|
||||
} from "./AgentSessionWorkBlockTestRig.mjs";
|
||||
|
||||
// ── Orphaned work ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reopened history must not present as work in progress.
|
||||
*
|
||||
* A tool's `executing` status is written when the step starts and never revised
|
||||
* if the agent dies first, so an abandoned step keeps it forever. In this block
|
||||
* that status is not a per-row detail — one `running` entry makes the whole
|
||||
* block active, which suppresses the folded summary line, keeps the rail
|
||||
* expanded and pulses a bullet. Scrolling back to a crashed turn therefore
|
||||
* showed the reader live work indefinitely (Codex P2 on #6536).
|
||||
*/
|
||||
test("a block whose running step has no live session folds like finished work", async () => {
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
|
||||
const summary = view.summary();
|
||||
assert.ok(
|
||||
summary,
|
||||
"history gets its folded summary line — the orphaned status must not suppress it",
|
||||
);
|
||||
assert.match(
|
||||
summary.textContent,
|
||||
/2 steps$/,
|
||||
"an abandoned step is not known to have failed, so the count stays neutral",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0, "the rail folds away");
|
||||
// Then OPEN it and look. Asserting "nothing pulses" on the folded block would
|
||||
// be vacuous: the fold unmounts every row, so no glyph exists to carry a
|
||||
// pulse class and the assertion would hold against a build where abandoned
|
||||
// steps pulse forever — the exact Codex finding. The reader's complaint is
|
||||
// about what they see when they scroll back and expand, so that is where the
|
||||
// assertion belongs.
|
||||
await view.expand();
|
||||
assert.equal(view.stepCount(), 2, "the expanded rail has rows to inspect");
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "settled"],
|
||||
"the abandoned step reads as settled, not running",
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.pulseStates(),
|
||||
[],
|
||||
"nothing pulses when nothing is running",
|
||||
);
|
||||
});
|
||||
|
||||
test("the same items still hold the block open while the turn is live", async () => {
|
||||
// The paired half: this is what makes the gate meaningful rather than a
|
||||
// blanket "never trust executing". Identical items, live turn.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-1", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.equal(view.summary(), null, "a live block has no folded line");
|
||||
assert.equal(view.stepCount(), 2, "the rail stays open");
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "running"]);
|
||||
assert.deepEqual(view.pulseStates(), ["running"], "live work pulses");
|
||||
});
|
||||
|
||||
test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => {
|
||||
// The reason the signal is a turn id and not a boolean: a restarted agent is
|
||||
// live, but not on this turn, and a global flag would keep this step spinning.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-2", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"this turn is history even though the agent is busy",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
// Expanded, for the same reason as above: a folded rail has no glyph to pulse,
|
||||
// so the negative has to be taken on rows that exist.
|
||||
await view.expand();
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
|
||||
assert.deepEqual(view.pulseStates(), []);
|
||||
});
|
||||
|
||||
test("a block live when the session ends folds instead of spinning forever", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
// The bug as the reader meets it live: the agent dies mid-step, so the item
|
||||
// never reaches a terminal status and the only thing that changes is that no
|
||||
// turn is live any more.
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, {
|
||||
liveTurnId: "turn-1",
|
||||
streamingItemId: "b",
|
||||
});
|
||||
assert.equal(view.stepCount(), 2, "live: the rail is open");
|
||||
assert.equal(view.summary(), null);
|
||||
|
||||
// Session gone. The items are BYTE-IDENTICAL — only liveness changed.
|
||||
await act(async () => {
|
||||
view.stream(live, null, null);
|
||||
});
|
||||
|
||||
assert.ok(view.summary(), "the block settles when its session goes away");
|
||||
assert.match(view.summary().textContent, /2 steps$/);
|
||||
// Taken HERE, before the fold finishes: the rail is still mounted for the
|
||||
// settle frame, so the glyph that was pulsing a moment ago still exists and
|
||||
// can be asked whether it stopped. Once `settleToStepCount(0)` has run there
|
||||
// are no rows left and the same assertion proves nothing.
|
||||
assert.equal(view.stepCount(), 2, "the rail is still mounted to inspect");
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
|
||||
assert.deepEqual(
|
||||
view.pulseStates(),
|
||||
[],
|
||||
"the pulse stops the moment the session goes away, not when the fold finishes",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
});
|
||||
|
||||
// ── The gap between two turns ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A finished block must stay folded through the gap before the next turn shows
|
||||
* anything.
|
||||
*
|
||||
* The rendered half of the `buildConversationTurnMeta` gap contract (see
|
||||
* `agentSessionConversationMeta.test.mjs`). The meta test proves the hints are
|
||||
* right; this proves the reader sees the consequence, because the symptom was
|
||||
* never a wrong id — it was a settled 6-step block re-opening, dropping to its
|
||||
* last three steps behind a "previous steps" disclosure, and then folding back,
|
||||
* on every single turn.
|
||||
*
|
||||
* Six steps rather than two on purpose: the live window only applies above
|
||||
* three, so a smaller block would hide the loudest part of the regression.
|
||||
*/
|
||||
test("a finished block stays folded while the next turn has started but shown nothing", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const items = ["a", "b", "c", "d", "e", "f"].map((id) => step(id));
|
||||
|
||||
// Finished: nothing live, so the block is folded to its summary line.
|
||||
const view = await renderBlock(items, {
|
||||
liveTurnId: null,
|
||||
streamingItemId: null,
|
||||
});
|
||||
assert.match(view.summary().textContent, /6 steps$/);
|
||||
assert.equal(await view.settleToStepCount(0), 0, "it starts folded");
|
||||
|
||||
// The next turn starts. It owns liveness (turn-2) and, having emitted nothing
|
||||
// renderable, contributes no streaming item — which is exactly what the fixed
|
||||
// `latestTurnId`/`streamingIdForTail` pair reports for this frame.
|
||||
await act(async () => {
|
||||
view.stream(items, null, "turn-2");
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"the folded summary line survives the next turn starting",
|
||||
);
|
||||
assert.match(view.summary().textContent, /6 steps$/);
|
||||
assert.equal(view.stepCount(), 0, "the rail does not re-open");
|
||||
assert.equal(
|
||||
view.previousSteps(),
|
||||
null,
|
||||
"and the live window's previous-steps disclosure never appears",
|
||||
);
|
||||
assert.deepEqual(view.pulseStates(), []);
|
||||
});
|
||||
|
||||
test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => {
|
||||
// Deliberate: we do not know what happened to the step, so it renders as the
|
||||
// neutral step it is with whatever it recorded. A visible "interrupted"
|
||||
// treatment would be a design addition, not part of this fix.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
await view.expand();
|
||||
await view.settleToStepCount(2);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "settled"],
|
||||
"no third state was invented for an abandoned step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-testid="transcript-tool-item"]').length,
|
||||
2,
|
||||
"both steps still render through the normal tool presenter",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet masks the spine with the drawer surface colour", async () => {
|
||||
// The bullet has to mask the spine passing behind it, and the mask must match
|
||||
// the surface the transcript is drawn on. A mask in any other colour shows as
|
||||
// a disc of the wrong shade around every bullet (berd's BOT-1599).
|
||||
const view = await renderBlock([step("a"), step("b")]);
|
||||
await view.expand();
|
||||
const bullet = view.q("[data-step-state]");
|
||||
assert.match(bullet.className, /\bbg-background\b/);
|
||||
assert.match(bullet.className, /\bring-background\b/);
|
||||
assert.match(bullet.className, /\brounded-full\b/);
|
||||
});
|
||||
|
||||
test("the spine is drawn for every step except the last", async () => {
|
||||
const view = await renderBlock([step("a"), step("b"), step("c")]);
|
||||
await view.expand();
|
||||
const spines = view.qa(".w-px");
|
||||
assert.equal(
|
||||
spines.length,
|
||||
2,
|
||||
"three steps means two connecting segments; a trailing spine would dangle",
|
||||
);
|
||||
});
|
||||
|
||||
test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => {
|
||||
const view = await renderBlock([thoughtStep("thought:1"), step("a")]);
|
||||
await view.expand();
|
||||
const thought = view.q('[data-testid="transcript-work-block-thought"]');
|
||||
assert.ok(thought, "reasoning renders on the rail");
|
||||
assert.match(thought.textContent, /weighing the options/);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-thought-disclosure"]'),
|
||||
null,
|
||||
"the block is already one disclosure — a thought must not add a second",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* An interim note is progress, not a second reply.
|
||||
*
|
||||
* #6720 gives every conversation-variant assistant message a 20px avatar + name
|
||||
* identity row, which is right for the turn's answer. A rail note is the same
|
||||
* item type, so routing it through that presenter would render a fully
|
||||
* attributed agent turn nested inside a muted step row — the reader would see
|
||||
* the agent apparently reply twice, once inside the work it was doing. berd
|
||||
* draws the same line: its `progress` entry is a plain rail row.
|
||||
*
|
||||
* The suppression is done on this side (a dedicated prose body) rather than by
|
||||
* reaching into the message presenter, so #6720 keeps one rule for what a
|
||||
* message looks like.
|
||||
*/
|
||||
test("an interim note renders as rail prose with no identity row", async () => {
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
noteStep("msg:interim", "checked the three call sites"),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const note = view.q('[data-testid="transcript-work-block-note"]');
|
||||
assert.ok(note, "the note renders on the rail");
|
||||
assert.match(note.textContent, /checked the three call sites/);
|
||||
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-identity"]') === null,
|
||||
"an avatar + name row inside a muted step reads as a second reply",
|
||||
);
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-message"]') === null,
|
||||
"the note must not go through the message presenter at all",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A relay post is a step, not a reply — the same rule as an interim note,
|
||||
* reached by a different route.
|
||||
*
|
||||
* A note is an assistant *message* the block re-presents as prose. A relay post
|
||||
* is a *tool call* that classifies as `renderClass: "message"`, so it renders
|
||||
* through `CompactMessageSummary`: 28px avatar, bordered speech bubble,
|
||||
* timestamp, delivery-receipt button. That is right in the activity feed, where
|
||||
* a posted message is a destination to open; on the rail it makes the agent
|
||||
* appear to reply in the middle of its own work — and it did, in the seeded
|
||||
* browser preview, which is where this was caught.
|
||||
*
|
||||
* Suppressing it needs the presentation signal rather than the transcript
|
||||
* variant: the same relay step OUTSIDE a block in this variant keeps its
|
||||
* bubble, which the next test pins.
|
||||
*/
|
||||
test("a relay post on the rail is a plain step, with no bubble or avatar", async () => {
|
||||
const view = await renderBlock([step("a"), relayStep("relay:1")]);
|
||||
await view.expand();
|
||||
|
||||
assert.equal(
|
||||
view.stepCount(),
|
||||
2,
|
||||
"the relay post takes its own rail row, like any other step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-work-block-entry="tool"]').length,
|
||||
2,
|
||||
"a relay post is a tool step — it is something the agent did",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-tool-message-preview"]'),
|
||||
null,
|
||||
"a speech bubble inside a muted step reads as the agent replying mid-work",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-agent-sent-avatar"]'),
|
||||
null,
|
||||
"no identity avatar on the rail",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-sent-message-context-button"]'),
|
||||
null,
|
||||
"no delivery receipt on the rail",
|
||||
);
|
||||
|
||||
// It is still a real, expandable tool row carrying its command.
|
||||
const rows = view.qa('[data-testid="transcript-tool-item"]');
|
||||
assert.equal(rows.length, 2, "both steps render as tool rows");
|
||||
assert.ok(
|
||||
rows[1].querySelector("details"),
|
||||
"the relay step keeps the ordinary step disclosure so its args stay reachable",
|
||||
);
|
||||
assert.match(
|
||||
rows[1].textContent,
|
||||
/Sent|posted the findings/,
|
||||
"the row still says what the step was",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the branch: outside a block the bubble is correct and must
|
||||
* survive. Without this, suppressing the bubble everywhere in the conversation
|
||||
* variant would pass the test above.
|
||||
*/
|
||||
test("the same relay post outside a work block keeps its message bubble", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptVariantProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { TranscriptActivityItem } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
|
||||
// `gcTime: 0`: React Query's default is 300000ms, and this is the one test
|
||||
// that actually drives the bubble presenter's `useQuery`, so its query arms a
|
||||
// five-minute gc timer at teardown. node:test waits that timer out before
|
||||
// exiting — this file's tests sum to ~2s but the wall was ~303s, all passing,
|
||||
// with no failing assertion to point at the cause.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { gcTime: 0, retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(
|
||||
AgentSessionTranscriptVariantProvider,
|
||||
{ value: "conversation" },
|
||||
createElement(TranscriptActivityItem, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
item: relayStep("relay:1"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
|
||||
assert.ok(
|
||||
view.container.querySelector(
|
||||
'[data-testid="transcript-tool-message-preview"]',
|
||||
),
|
||||
"outside a block a posted message is a destination the reader can open — the bubble stays",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => {
|
||||
// The exhaustive switch is the point: a note that fell through to the tool
|
||||
// branch would wear a wrench and read as something the agent ran.
|
||||
const view = await renderBlock([
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const glyphClass = (kind, index = 0) => {
|
||||
const rows = view.qa(`[data-work-block-entry="${kind}"]`);
|
||||
const icon = rows[index].querySelector("svg");
|
||||
return icon.getAttribute("class") ?? "";
|
||||
};
|
||||
|
||||
// lucide stamps each icon with a `lucide-<kebab-name>` class, so the glyph
|
||||
// identity is readable from the DOM without reaching into the icon modules.
|
||||
assert.match(glyphClass("thought"), /lucide-message-circle/);
|
||||
assert.match(
|
||||
glyphClass("note"),
|
||||
/lucide-message-circle/,
|
||||
"prose is the agent talking, whether it is reasoning or a note",
|
||||
);
|
||||
assert.match(glyphClass("tool", 0), /lucide-wrench/);
|
||||
assert.match(
|
||||
glyphClass("tool", 1),
|
||||
/lucide-circle/,
|
||||
"a failed step is a filled dot, not a wrench",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet is never red, whatever the step's outcome", async () => {
|
||||
// A failure is carried by glyph shape and by the folded line's count. Tinting
|
||||
// the bullet would make one bad step read as an alarm across the whole run.
|
||||
//
|
||||
// The running step keeps this block live, so the rail is already open — which
|
||||
// is also the only state in which a running bullet can be observed at all.
|
||||
const view = await renderBlock(
|
||||
[
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
{ streamingItemId: "c" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "failed", "running"],
|
||||
"all three outcomes are on screen",
|
||||
);
|
||||
for (const bullet of view.qa("[data-step-state]")) {
|
||||
assert.ok(
|
||||
!/\b(text|bg|ring)-(destructive|red)/.test(bullet.className),
|
||||
`rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`,
|
||||
);
|
||||
assert.match(bullet.className, /\btext-muted-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* berd brightens rail prose with `usePrimaryText={open}`. Here the brightening
|
||||
* is unconditional, and this test records why that is not a divergence: a closed
|
||||
* block unmounts its rows rather than dimming them, so there is no state in
|
||||
* which rail prose is on screen and NOT in an open block. A `primaryText` prop
|
||||
* would have an unreachable false branch.
|
||||
*/
|
||||
test("rail prose is primary text, and a closed block has no prose on screen at all", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, { streamingItemId: "b" });
|
||||
|
||||
const prose = () =>
|
||||
view.qa(
|
||||
'[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]',
|
||||
);
|
||||
|
||||
assert.equal(prose().length, 2, "both prose rows are on the live rail");
|
||||
for (const node of prose()) {
|
||||
assert.match(
|
||||
node.className,
|
||||
/\btext-foreground\b/,
|
||||
"prose the reader can see is primary, not muted",
|
||||
);
|
||||
assert.ok(
|
||||
!/\btext-muted-foreground\b/.test(node.className),
|
||||
"the row must not carry both colours",
|
||||
);
|
||||
}
|
||||
|
||||
// Finish the turn: the block folds and takes its prose with it.
|
||||
await act(async () => {
|
||||
view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]);
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(0), 0, "it folded");
|
||||
assert.equal(
|
||||
prose().length,
|
||||
0,
|
||||
"a folded block renders no prose, so there is no dimmed state to test",
|
||||
);
|
||||
|
||||
// And the reader reopening it brings the same primary prose back.
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it");
|
||||
assert.equal(prose().length, 2);
|
||||
for (const node of prose()) {
|
||||
assert.match(node.className, /\btext-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Streaming cost ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A block re-renders on every append while work streams. Unchanged steps must
|
||||
* not re-render with it: each step's presenter rebuilds compact tool summaries,
|
||||
* parses diffs and renders markdown/images, so an unmemoized step row makes a
|
||||
* long block cost O(n) of that work per appended step.
|
||||
*
|
||||
* Counted at the presenter boundary — `TranscriptActivityItem` looks its
|
||||
* presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
|
||||
* swapping in a counting presenter observes exactly the work a step row
|
||||
* triggers, without reaching into React internals.
|
||||
*/
|
||||
async function countStepRenders(initialItems, nextItems) {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const renders = [];
|
||||
const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
|
||||
renders.push(props.item.id);
|
||||
return createElement("div", null, props.item.id);
|
||||
};
|
||||
|
||||
try {
|
||||
const element = (items) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: items[items.length - 1].turnId,
|
||||
streamingItemId: items[items.length - 1].id,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: "work-block:a",
|
||||
items,
|
||||
timestamp: items[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const view = render(element(initialItems));
|
||||
renders.length = 0;
|
||||
view.rerender(element(nextItems));
|
||||
return renders;
|
||||
} finally {
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("appending a step does not re-render the steps already on the rail", async () => {
|
||||
// The block is expanded (a live block with ≤3 steps shows them all), and the
|
||||
// prior steps are the SAME objects across both renders, as the transcript
|
||||
// store replaces items rather than mutating them.
|
||||
const settled = [step("a"), step("b")];
|
||||
const appended = [
|
||||
...settled,
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
];
|
||||
|
||||
const rendered = await countStepRenders(settled, appended);
|
||||
|
||||
assert.deepEqual(rendered, ["c"]);
|
||||
});
|
||||
|
||||
test("a step that actually changed does re-render", async () => {
|
||||
// Guards the memo from being too aggressive: an executing step settling is a
|
||||
// new object for that id, and it must re-render to drop its running glyph.
|
||||
const a = step("a");
|
||||
const executing = step("b", { status: "executing", completedAt: null });
|
||||
const settled = step("b");
|
||||
|
||||
const rendered = await countStepRenders([a, executing], [a, settled]);
|
||||
|
||||
assert.deepEqual(rendered, ["b"]);
|
||||
});
|
||||
402
desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs
Normal file
402
desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Work-block rendering while a turn is live and when it finishes: the rail, the
|
||||
* live window, the fold animation, and the reader's disclosure choice.
|
||||
* Orphaned work and streaming cost live in
|
||||
* `AgentSessionWorkBlock.orphaned.test.mjs`; the shared rig is
|
||||
* `AgentSessionWorkBlockTestRig.mjs`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
renderBlock,
|
||||
setPrefersReducedMotion,
|
||||
step,
|
||||
} from "./AgentSessionWorkBlockTestRig.mjs";
|
||||
|
||||
// ── Live ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("a live block shows no header line — the rail is the status", async () => {
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
]);
|
||||
assert.equal(
|
||||
view.summary(),
|
||||
null,
|
||||
"a header while live would only restate what the arriving steps show",
|
||||
);
|
||||
assert.equal(view.stepCount(), 2);
|
||||
});
|
||||
|
||||
test("a live block windows to the last three steps with the rest behind a disclosure", async () => {
|
||||
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
|
||||
items[4] = step("e", { status: "executing", completedAt: null });
|
||||
const view = await renderBlock(items, { streamingItemId: "e" });
|
||||
|
||||
assert.equal(view.stepCount(), 3, "only the live window renders on the rail");
|
||||
const disclosure = view.previousSteps();
|
||||
assert.ok(disclosure, "older steps sit behind a disclosure");
|
||||
assert.match(disclosure.textContent, /2 previous steps/);
|
||||
});
|
||||
|
||||
test("expanding previous steps reveals the older steps in place", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
|
||||
items[4] = step("e", { status: "executing", completedAt: null });
|
||||
const view = await renderBlock(items, { streamingItemId: "e" });
|
||||
|
||||
assert.equal(view.stepCount(), 3);
|
||||
await act(async () => {
|
||||
view.previousSteps().click();
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(5),
|
||||
5,
|
||||
"all five steps are now on the rail",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Finished ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("a block that was already finished on mount folds to an N steps line", async () => {
|
||||
const view = await renderBlock([step("a"), step("b"), step("c")]);
|
||||
const summary = view.summary();
|
||||
assert.ok(summary, "a finished block gets its summary line");
|
||||
assert.match(summary.textContent, /3 steps/);
|
||||
assert.equal(summary.getAttribute("aria-expanded"), "false");
|
||||
assert.equal(view.stepCount(), 0, "the rail is collapsed away");
|
||||
});
|
||||
|
||||
test("a finished block containing a failure names the failure in its folded line", async () => {
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
step("c"),
|
||||
]);
|
||||
assert.match(
|
||||
view.summary().textContent,
|
||||
/3 steps · 1 failed/,
|
||||
"a failure must never hide behind a neutral count",
|
||||
);
|
||||
});
|
||||
|
||||
test("clicking the folded line expands the whole rail", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const view = await renderBlock([step("a"), step("b"), step("c")]);
|
||||
assert.equal(view.stepCount(), 0);
|
||||
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
|
||||
assert.equal(await view.settleToStepCount(3), 3);
|
||||
assert.equal(view.summary().getAttribute("aria-expanded"), "true");
|
||||
});
|
||||
|
||||
test("a block expanded by the reader while live shows every step, not just the window", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
|
||||
items[4] = step("e", { status: "executing", completedAt: null });
|
||||
const view = await renderBlock(items, { streamingItemId: "e" });
|
||||
|
||||
assert.equal(view.stepCount(), 3);
|
||||
await act(async () => {
|
||||
view.previousSteps().click();
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(5),
|
||||
5,
|
||||
"a reader who asked to see the work sees all of it",
|
||||
);
|
||||
|
||||
// And the window does NOT come back as more work streams in: the reader's
|
||||
// choice is not re-decided on every append.
|
||||
await act(async () => {
|
||||
view.stream(
|
||||
[...items, step("f", { status: "executing", completedAt: null })],
|
||||
"f",
|
||||
);
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(6),
|
||||
6,
|
||||
"a reader-expanded live block keeps showing everything as it grows",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fold animation ───────────────────────────────────────────────────────────
|
||||
|
||||
test("a block that finishes while mounted stays open for a paint so the collapse is visible", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live);
|
||||
assert.equal(view.summary(), null, "live: no header");
|
||||
assert.equal(view.stepCount(), 2);
|
||||
|
||||
// The turn finishes: the same block id re-renders with settled steps.
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b")]);
|
||||
});
|
||||
|
||||
// Still open on the commit right after finishing — that open state is what
|
||||
// gives the height animation something to collapse FROM. A block that jumped
|
||||
// straight to closed would swap a rail for a one-line summary between frames.
|
||||
assert.equal(
|
||||
view.stepCount(),
|
||||
2,
|
||||
"the rail is still mounted for the settle frame",
|
||||
);
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"the summary line appears as soon as work finishes",
|
||||
);
|
||||
|
||||
// After the settle frames it closes — once the collapse animation has run.
|
||||
assert.equal(await view.settleToStepCount(0), 0, "the block settles closed");
|
||||
});
|
||||
|
||||
test("under reduced motion a finishing block folds immediately, with no settle frames", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
setPrefersReducedMotion(true);
|
||||
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
]);
|
||||
assert.equal(view.stepCount(), 2);
|
||||
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b")]);
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
view.stepCount(),
|
||||
0,
|
||||
"reduced motion skips the animation, so there is nothing to hold open for",
|
||||
);
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"it still folds to a summary line — only the animation is skipped",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Reader choice ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The echo trap, and why this block is structurally immune to it.
|
||||
*
|
||||
* `<details>` fires `toggle` for programmatic `open` changes as well as for
|
||||
* clicks, indistinguishably — so a policy-driven open echoes back looking like
|
||||
* a reader choice and pins the row to its first policy state forever. That trap
|
||||
* cost time on the tool-run card.
|
||||
*
|
||||
* This block cannot hit it, because its disclosure is a `<button>` whose only
|
||||
* state change is the click handler firing: there is no browser-generated echo
|
||||
* to mistake for intent. That is a real invariant and not an accident of the
|
||||
* current markup — switching the trigger to `<details>` would reintroduce the
|
||||
* trap — so it is asserted rather than assumed. An earlier version of this test
|
||||
* dispatched a synthetic `toggle` at the block and asserted it changed nothing,
|
||||
* which passed for the wrong reason: nothing was listening for `toggle` at all.
|
||||
*/
|
||||
test("the block's disclosure has no toggle echo to mistake for a reader choice", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live);
|
||||
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b")]);
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(0), 0, "policy folds it");
|
||||
|
||||
const trigger = view.summary();
|
||||
assert.equal(
|
||||
trigger.tagName,
|
||||
"BUTTON",
|
||||
"a button has no programmatic-toggle echo; <details> would need the hook's guard",
|
||||
);
|
||||
assert.equal(
|
||||
view.q("details"),
|
||||
null,
|
||||
"no <details> anywhere in the block, so no echo can be generated",
|
||||
);
|
||||
|
||||
// And the fold is genuinely repeatable: policy opens it for the next live
|
||||
// phase and folds it again, which is the exact behaviour a recorded echo
|
||||
// would have disabled.
|
||||
await act(async () => {
|
||||
view.stream(
|
||||
[
|
||||
step("a"),
|
||||
step("b"),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
"c",
|
||||
);
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(3),
|
||||
3,
|
||||
"policy re-opens for new work",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b"), step("c")]);
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(0),
|
||||
0,
|
||||
"and folds again — policy transitions still work",
|
||||
);
|
||||
});
|
||||
|
||||
test("a reader who opens a finished block keeps it open as the transcript moves on", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const view = await renderBlock([step("a"), step("b")]);
|
||||
assert.equal(view.stepCount(), 0);
|
||||
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(2), 2);
|
||||
|
||||
// A later append re-renders the block; the reader's choice must survive it.
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b"), step("c")]);
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(3),
|
||||
3,
|
||||
"the reader's choice outlives later policy transitions",
|
||||
);
|
||||
});
|
||||
|
||||
test("a reader who folds a live block keeps it folded while work continues", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live);
|
||||
assert.equal(view.stepCount(), 2, "live blocks open themselves");
|
||||
|
||||
// While live there is no summary trigger, so the reader's route to folding is
|
||||
// the block's own disclosure change handler. Drive it the way a click would.
|
||||
await act(async () => {
|
||||
view.stream([step("a"), step("b")]);
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(0), 0, "policy folded it");
|
||||
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(2), 2, "reader opened it");
|
||||
|
||||
await act(async () => {
|
||||
view.stream(
|
||||
[
|
||||
step("a"),
|
||||
step("b"),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
"c",
|
||||
);
|
||||
});
|
||||
assert.equal(
|
||||
await view.settleToStepCount(3),
|
||||
3,
|
||||
"the reader's open choice persists into the next live phase",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Rail ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("the rail marks running, failed and settled steps with distinct glyph states", async () => {
|
||||
const view = await renderBlock(
|
||||
[
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
{ streamingItemId: "c" },
|
||||
);
|
||||
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "failed", "running"]);
|
||||
assert.equal(view.summary(), null, "a running step keeps the block live");
|
||||
});
|
||||
|
||||
test("a running step pulses and a failed one does not", async () => {
|
||||
const view = await renderBlock(
|
||||
[
|
||||
step("a", { isError: true, status: "failed" }),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
],
|
||||
{ streamingItemId: "b" },
|
||||
);
|
||||
const [failed, running] = view.qa("[data-step-state]");
|
||||
assert.deepEqual(
|
||||
view.pulseStates(),
|
||||
["running"],
|
||||
"work in flight pulses and a settled failure does not",
|
||||
);
|
||||
assert.equal(
|
||||
failed.classList.contains("motion-safe:animate-pulse"),
|
||||
false,
|
||||
"a settled failure is not in flight",
|
||||
);
|
||||
assert.equal(
|
||||
running.classList.contains("motion-safe:animate-pulse"),
|
||||
true,
|
||||
"the running step is the one that pulses",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Reduced motion has to reach this pulse through the CLASS, not the hook.
|
||||
*
|
||||
* `useWorkBlockMotionEnabled` skips the fold's height animation, but the running
|
||||
* glyph's pulse is a CSS keyframe animation applied by a Tailwind utility, which
|
||||
* no hook can switch off — so the guard has to be `motion-safe:animate-pulse`.
|
||||
* Compiled with this repo's Tailwind (4.3.0), `animate-pulse` is
|
||||
* `animation: var(--animate-pulse)` with no guard, while
|
||||
* `motion-safe:animate-pulse` wraps exactly that declaration in
|
||||
* `@media (prefers-reduced-motion: no-preference)`. jsdom does not evaluate
|
||||
* media queries, so the assertion is on the class: the bare form would keep
|
||||
* pulsing forever for a reader who asked for no motion, and none of the app's 20
|
||||
* `prefers-reduced-motion: reduce` blocks matches `.animate-pulse` (all are
|
||||
* scoped to `buzz-*`/`motion-*`/`t-skel-*` classes), so nothing else would catch
|
||||
* it.
|
||||
*
|
||||
* The preference is irrelevant to which class is emitted, and that is the point:
|
||||
* asserting under BOTH settings is what proves the guard is in the class rather
|
||||
* than in a conditional that reduced motion could flip.
|
||||
*/
|
||||
test("a running glyph's pulse is guarded so reduced motion silences it", async () => {
|
||||
for (const reduced of [false, true]) {
|
||||
setPrefersReducedMotion(reduced);
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ streamingItemId: "b" },
|
||||
);
|
||||
const [, running] = view.qa("[data-step-state]");
|
||||
assert.equal(
|
||||
running.classList.contains("motion-safe:animate-pulse"),
|
||||
true,
|
||||
`the pulse is reduced-motion-safe (prefersReducedMotion=${reduced})`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.unguardedPulseStates(),
|
||||
[],
|
||||
`no glyph pulses unconditionally (prefersReducedMotion=${reduced})`,
|
||||
);
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
677
desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx
Normal file
677
desktop/src/features/agents/ui/AgentSessionWorkBlock.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ChevronRight, Circle, MessageCircle, Wrench } from "lucide-react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { CodeBlockVariantContext } from "@/shared/ui/markdown/CodeBlock";
|
||||
import {
|
||||
AgentSessionWorkBlockRailProvider,
|
||||
useAgentSessionTranscriptTurnMeta,
|
||||
} from "./agentSessionTranscriptContext";
|
||||
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
|
||||
import {
|
||||
readWorkBlockChoice,
|
||||
useWorkBlockDisclosureState,
|
||||
useWorkBlockDisclosureStore,
|
||||
} from "./agentSessionWorkBlockDisclosure";
|
||||
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
|
||||
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
||||
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
|
||||
import {
|
||||
formatPreviousStepsLabel,
|
||||
formatWorkBlockSummaryLabel,
|
||||
projectWorkBlockEntries,
|
||||
summarizeWorkBlock,
|
||||
windowWorkBlockEntries,
|
||||
type TranscriptWorkBlock,
|
||||
type WorkBlockEntry,
|
||||
type WorkBlockStatus,
|
||||
} from "./agentSessionWorkBlockGrouping";
|
||||
|
||||
const FADE_TRANSITION = {
|
||||
duration: 0.18,
|
||||
ease: [0.215, 0.61, 0.355, 1],
|
||||
} as const;
|
||||
const COLLAPSE_TRANSITION = {
|
||||
duration: 0.22,
|
||||
ease: [0.215, 0.61, 0.355, 1],
|
||||
} as const;
|
||||
|
||||
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
/**
|
||||
* The OS reduced-motion preference, read through `matchMedia` the way the rest
|
||||
* of the app reads it (see `TerminalSubstrate`) rather than through motion's
|
||||
* `useReducedMotion`.
|
||||
*
|
||||
* That helper resolves the query once per process and caches the answer, so it
|
||||
* reports whatever the first render happened to see for the lifetime of the
|
||||
* process. Here that would mean the reduced-motion path could never be
|
||||
* exercised under test — the assertion would pass or fail on module load order
|
||||
* rather than on the preference — and a preference change mid-session would be
|
||||
* ignored. Subscribing to the query keeps both honest.
|
||||
*/
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = React.useState(
|
||||
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const preference = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
const update = () => setReduced(preference.matches);
|
||||
update();
|
||||
preference.addEventListener("change", update);
|
||||
return () => preference.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the reader wants motion at all: the transcript's own animation
|
||||
* preference AND the OS reduced-motion setting. Both are respected, so a
|
||||
* height animation is skipped rather than shortened when either says no.
|
||||
*/
|
||||
function useWorkBlockMotionEnabled() {
|
||||
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
return animationPreferenceEnabled && !prefersReducedMotion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a disclosure's height between 0 and its natural height.
|
||||
*
|
||||
* `<details>` cannot do this — its content is either laid out or not, with no
|
||||
* intermediate height to tween — so the collapse would simply snap. Height is
|
||||
* animated to and from `auto`, which motion resolves by measuring, so the block
|
||||
* never needs a hard-coded height that could disagree with its content.
|
||||
*/
|
||||
function DisclosurePanel({
|
||||
children,
|
||||
motionEnabled,
|
||||
open,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
motionEnabled: boolean;
|
||||
open: boolean;
|
||||
}) {
|
||||
if (!motionEnabled) {
|
||||
return open ? <div>{children}</div> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
className="overflow-hidden"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
transition={COLLAPSE_TRANSITION}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a finished block should play its collapse on first paint.
|
||||
*
|
||||
* A turn that finishes while the reader is watching should be *seen* to fold —
|
||||
* otherwise the rail they were reading is simply replaced by a one-line summary
|
||||
* between frames, and it reads as content disappearing rather than as work
|
||||
* settling. So a block that was live when it mounted renders open once, then
|
||||
* closes after a paint, giving the height animation a real open→closed
|
||||
* transition to play.
|
||||
*
|
||||
* A block that was already finished when it mounted (history load, scrollback)
|
||||
* never had a rail on screen, so there is nothing to fold: it renders closed
|
||||
* immediately. Distinguishing the two is what keeps the animation meaningful
|
||||
* instead of a spurious flourish on every mount.
|
||||
*/
|
||||
function useSettleOnFinish(isActive: boolean, motionEnabled: boolean) {
|
||||
const [settling, setSettling] = React.useState(false);
|
||||
const wasActiveRef = React.useRef(isActive);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive) {
|
||||
wasActiveRef.current = true;
|
||||
setSettling(false);
|
||||
return;
|
||||
}
|
||||
if (!wasActiveRef.current) return;
|
||||
wasActiveRef.current = false;
|
||||
|
||||
if (!motionEnabled) {
|
||||
setSettling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Two frames: the first commits the still-open render, the second flips it
|
||||
// closed, so the animation has a start state that was actually painted.
|
||||
setSettling(true);
|
||||
let second: number | null = null;
|
||||
const first = requestAnimationFrame(() => {
|
||||
second = requestAnimationFrame(() => setSettling(false));
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(first);
|
||||
if (second !== null) cancelAnimationFrame(second);
|
||||
};
|
||||
}, [isActive, motionEnabled]);
|
||||
|
||||
return settling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the block renders open, and how the reader takes that decision over.
|
||||
*
|
||||
* The policy is "open while work is in flight, folded once it finishes". The
|
||||
* reader's own click wins from then on.
|
||||
*
|
||||
* The reader's choice is held ABOVE this component, keyed by the step ids it was
|
||||
* taken on, because a work block does not outlive its own membership: blocks are
|
||||
* regrouped from scratch each render and merge when a second assistant message
|
||||
* demotes the first from final answer, so a `useState` here is destroyed by the
|
||||
* agent posting again. See `agentSessionWorkBlockDisclosure`.
|
||||
*
|
||||
* The trigger is deliberately button-native. An earlier version used a shared
|
||||
* `useControlledDisclosure` hook whose whole reason to exist was the
|
||||
* `<details>` echo trap: `<details>` fires `toggle` for programmatic `open`
|
||||
* changes as well as for clicks, indistinguishably, so a policy-driven open
|
||||
* echoes back looking like reader intent and pins the row to its first policy
|
||||
* state forever. This block's trigger is a `<button>`, so the only thing that
|
||||
* can call `onToggle` is a real click — there is no echo to discriminate, and a
|
||||
* guard against it would be dead code that reads as though it were load-bearing.
|
||||
*/
|
||||
function useWorkBlockDisclosure(
|
||||
policyOpen: boolean,
|
||||
itemIds: readonly string[],
|
||||
) {
|
||||
// Local state is the fallback for a block mounted outside a transcript, where
|
||||
// nothing regroups it and per-component state is correct. Both hooks always
|
||||
// run; only one of them is read.
|
||||
const localStore = useWorkBlockDisclosureState();
|
||||
const { choices, choose } = useWorkBlockDisclosureStore() ?? localStore;
|
||||
const readerChoice = readWorkBlockChoice(choices, itemIds);
|
||||
const open = readerChoice ?? policyOpen;
|
||||
|
||||
return {
|
||||
chosenByReader: readerChoice !== null,
|
||||
open,
|
||||
toggle: React.useCallback(
|
||||
() => choose(itemIds, !open),
|
||||
[choose, itemIds, open],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentSessionWorkBlockSegment({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
block,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
block: TranscriptWorkBlock;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const turnMeta = useAgentSessionTranscriptTurnMeta();
|
||||
// One projection per render, shared by the summary counts and the rail, so
|
||||
// "is this step failed?" has exactly one answer in this component.
|
||||
const entries = React.useMemo(
|
||||
() =>
|
||||
projectWorkBlockEntries(block.items, {
|
||||
liveTurnId: turnMeta.liveTurnId,
|
||||
}),
|
||||
[block.items, turnMeta.liveTurnId],
|
||||
);
|
||||
const status = React.useMemo(
|
||||
() =>
|
||||
summarizeWorkBlock(entries, {
|
||||
streamingItemId: turnMeta.streamingItemId,
|
||||
}),
|
||||
[entries, turnMeta.streamingItemId],
|
||||
);
|
||||
const motionEnabled = useWorkBlockMotionEnabled();
|
||||
const settling = useSettleOnFinish(status.isActive, motionEnabled);
|
||||
|
||||
// The steps the reader's fold choice is recorded against. Memoized on the
|
||||
// items so `toggle`'s identity is stable between renders that did not change
|
||||
// membership.
|
||||
const itemIds = React.useMemo(
|
||||
() => block.items.map((item) => item.id),
|
||||
[block.items],
|
||||
);
|
||||
const disclosure = useWorkBlockDisclosure(
|
||||
status.isActive || settling,
|
||||
itemIds,
|
||||
);
|
||||
const isLive = status.isActive;
|
||||
// A reader who expanded the block asked to see all of it, so windowing stops
|
||||
// applying. The test is the reader's *choice*, not `open`: policy already
|
||||
// holds every live block open, so keying this off `open` would switch
|
||||
// windowing off in exactly the case it exists for.
|
||||
const readerExpanded = disclosure.chosenByReader && disclosure.open;
|
||||
const { hiddenEntries, visibleEntries } = React.useMemo(
|
||||
() =>
|
||||
windowWorkBlockEntries(entries, {
|
||||
isActive: isLive && !readerExpanded,
|
||||
}),
|
||||
[entries, isLive, readerExpanded],
|
||||
);
|
||||
|
||||
return (
|
||||
// Declared once at the block root rather than per row: every tool item
|
||||
// rendered anywhere inside a block — windowed rail, previous-steps
|
||||
// disclosure — is a rail step, and the value is constant so it adds no
|
||||
// re-render of its own.
|
||||
<AgentSessionWorkBlockRailProvider value={true}>
|
||||
<div
|
||||
className="w-full min-w-0"
|
||||
data-role="agent-work-block"
|
||||
data-testid="transcript-work-block"
|
||||
title={formatTranscriptTimestampTitle(block.timestamp)}
|
||||
>
|
||||
{/* While live the rail IS the status: a header would only restate what
|
||||
the arriving steps already show. */}
|
||||
{isLive ? null : (
|
||||
<WorkBlockSummaryTrigger
|
||||
onToggle={disclosure.toggle}
|
||||
open={disclosure.open}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
<DisclosurePanel
|
||||
motionEnabled={motionEnabled}
|
||||
open={isLive || disclosure.open}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
{hiddenEntries.length > 0 ? (
|
||||
<PreviousStepsDisclosure
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
entries={hiddenEntries}
|
||||
motionEnabled={motionEnabled}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
<WorkBlockRail
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
animateEnter={motionEnabled && isLive}
|
||||
entries={visibleEntries}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</div>
|
||||
</AgentSessionWorkBlockRailProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkBlockSummaryTrigger({
|
||||
onToggle,
|
||||
open,
|
||||
status,
|
||||
}: {
|
||||
onToggle: () => void;
|
||||
open: boolean;
|
||||
status: WorkBlockStatus;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group/row flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md py-1 text-left text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="transcript-work-block-summary"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm tabular-nums">
|
||||
{formatWorkBlockSummaryLabel(status)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover/row:text-foreground",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousStepsDisclosure({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
entries,
|
||||
motionEnabled,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
entries: WorkBlockEntry[];
|
||||
motionEnabled: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
initial={motionEnabled ? { opacity: 0 } : false}
|
||||
transition={motionEnabled ? FADE_TRANSITION : { duration: 0 }}
|
||||
>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group/row mb-1 flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md py-1 text-left text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="transcript-work-block-previous-steps"
|
||||
onClick={() => setOpen((previous) => !previous)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm tabular-nums">
|
||||
{formatPreviousStepsLabel(entries.length)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover/row:text-foreground",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<DisclosurePanel motionEnabled={motionEnabled} open={open}>
|
||||
<WorkBlockRail
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
animateEnter={false}
|
||||
entries={entries}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkBlockRail({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
animateEnter,
|
||||
entries,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
animateEnter: boolean;
|
||||
entries: WorkBlockEntry[];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div data-role="agent-work-block-rail">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={animateEnter ? { opacity: 0, y: -4 } : undefined}
|
||||
initial={animateEnter ? { opacity: 0, y: 4 } : false}
|
||||
key={entry.item.id}
|
||||
transition={animateEnter ? FADE_TRANSITION : { duration: 0 }}
|
||||
>
|
||||
<WorkBlockStepRow
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
entry={entry}
|
||||
isLast={index === entries.length - 1}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkBlockStepRow({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
entry,
|
||||
isLast,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
entry: WorkBlockEntry;
|
||||
isLast: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2.5"
|
||||
data-testid="transcript-work-block-step"
|
||||
data-work-block-entry={entry.kind}
|
||||
>
|
||||
<WorkBlockRailGlyph entry={entry} isLast={isLast} />
|
||||
<WorkBlockStepBody
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
profiles={profiles}
|
||||
{...entry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A step's content: one exhaustive switch over the entry kind, so a new kind
|
||||
* cannot silently inherit another kind's presentation.
|
||||
*
|
||||
* The entry arrives SPREAD into props rather than as an `entry` object, because
|
||||
* this component is memoized and the projection is rebuilt whenever the block's
|
||||
* item array changes — i.e. on every append — so entry objects are fresh each
|
||||
* time and a memo keyed on one would never hit. Spread, the compared props are
|
||||
* `item` (reference-stable: the transcript store replaces items rather than
|
||||
* mutating them) plus two strings, so shallow comparison is a sound "did not
|
||||
* change" test where comparison on the entry wrapper is not.
|
||||
*
|
||||
* Spreading keeps the discriminated union intact — `kind` and `item` stay paired
|
||||
* in the props type — so the switch below narrows `item` to the kind's own item
|
||||
* type. That is what removes the previous `item.type === "thought" ? ... : ""`
|
||||
* re-checks: a mismatch is no longer representable, so there is no mismatch
|
||||
* branch to render an empty body for.
|
||||
*
|
||||
* The memo boundary is the body rather than the whole row because the glyph
|
||||
* depends on the row's *position* (`isLast` decides whether the spine
|
||||
* continues), and that changes for the previous last row on every append.
|
||||
* Passing position into the memoized part would invalidate that row's body on
|
||||
* each append for a one-pixel spine segment; splitting keeps the cheap,
|
||||
* position-dependent half outside and the expensive, item-dependent half in.
|
||||
*/
|
||||
const WorkBlockStepBody = React.memo(function WorkBlockStepBody(
|
||||
props: WorkBlockEntryBodyProps,
|
||||
) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 pb-2">
|
||||
<WorkBlockEntryBody {...props} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type WorkBlockEntryBodyProps = AgentTranscriptIdentityProps & {
|
||||
profiles?: UserProfileLookup;
|
||||
} & WorkBlockEntry;
|
||||
|
||||
/**
|
||||
* One exhaustive switch over the entry kind, so a new kind cannot silently
|
||||
* inherit another kind's presentation.
|
||||
*
|
||||
* Because the props carry the whole discriminated entry, narrowing on `kind`
|
||||
* also narrows `item` to that kind's item type. The previous version took
|
||||
* `kind` and `item` as independent fields and so had to re-check
|
||||
* `item.type === "thought" ? item.text : ""` — a mismatch branch that rendered
|
||||
* an empty body and could only ever be reached by a projection bug.
|
||||
*/
|
||||
function WorkBlockEntryBody(props: WorkBlockEntryBodyProps) {
|
||||
switch (props.kind) {
|
||||
case "thought":
|
||||
return (
|
||||
<WorkBlockProseBody
|
||||
testId="transcript-work-block-thought"
|
||||
text={props.item.text}
|
||||
/>
|
||||
);
|
||||
case "note":
|
||||
return (
|
||||
<WorkBlockProseBody
|
||||
testId="transcript-work-block-note"
|
||||
text={props.item.text}
|
||||
/>
|
||||
);
|
||||
case "tool":
|
||||
return (
|
||||
<TranscriptActivityItem
|
||||
agentAvatarUrl={props.agentAvatarUrl}
|
||||
agentName={props.agentName}
|
||||
agentPubkey={props.agentPubkey}
|
||||
item={props.item}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prose on the rail: reasoning, or an interim note the agent addressed to the
|
||||
* reader. No disclosure of its own — the whole block is already one, and
|
||||
* nesting a second would mean two clicks to read something the reader has just
|
||||
* chosen to reveal.
|
||||
*
|
||||
* A note deliberately does NOT go through the message presenter. That presenter
|
||||
* gives every conversation-variant assistant message an avatar + name identity
|
||||
* row, which is right for the turn's answer and wrong here: a fully attributed
|
||||
* agent turn nested inside a muted rail step reads as a second reply rather
|
||||
* than as progress. berd draws the same distinction — its `progress` entry is a
|
||||
* plain rail row, not a message bubble. The focus code-block recipe is kept by
|
||||
* providing the same `CodeBlockVariantContext` value the presenter would.
|
||||
*
|
||||
* berd brightens rail prose with `usePrimaryText={open}`. Here it is
|
||||
* unconditional, because the rail only ever exists inside the open disclosure
|
||||
* panel — a closed block unmounts its rows entirely rather than rendering them
|
||||
* dimmed. Threading an `open` flag down to this component would be a prop whose
|
||||
* false branch is unreachable, which is the same shape of dead-code-that-looks-
|
||||
* load-bearing as the disclosure echo guard this block also dropped. If the
|
||||
* block ever renders a peek of its rows while closed, the flag comes back with
|
||||
* a test that can actually reach both branches.
|
||||
*/
|
||||
function WorkBlockProseBody({
|
||||
testId,
|
||||
text,
|
||||
}: {
|
||||
testId: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="text-foreground text-sm leading-relaxed"
|
||||
data-testid={testId}
|
||||
>
|
||||
<CodeBlockVariantContext.Provider value="focusProse">
|
||||
<Markdown className="leading-relaxed" content={text.trim() || " "} />
|
||||
</CodeBlockVariantContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spine and this row's bullet.
|
||||
*
|
||||
* The bullet masks the spine passing behind it, which is what makes the rail
|
||||
* read as a series of stops rather than a line with icons floating over it.
|
||||
* The mask has to match the surface the transcript is actually drawn on — the
|
||||
* cover drawer's `bg-background` — because a mask in any other colour shows up
|
||||
* as a visible disc of the wrong shade around every bullet.
|
||||
*
|
||||
* (berd's equivalent uses `bg-card` and warns against `bg-background`; that is
|
||||
* the same rule, not a different one. In berd the transcript sits on a card, so
|
||||
* `bg-card` is its surface. Buzz's drawer surface is `bg-background`, and the
|
||||
* two tokens are NOT interchangeable here: they share a value in the base
|
||||
* themes, but in Buzz Dark the drawer sits inside `[data-buzz-content-surface]`,
|
||||
* which locally overrides `--background` to `--buzz-content-dark` while
|
||||
* `--card` keeps the theme value. Measured in a seeded browser:
|
||||
*
|
||||
* | theme | bullet | drawer surface | spine |
|
||||
* | ------------ | --------------- | --------------- | --------------- |
|
||||
* | github-light | rgb(255,255,255)| rgb(255,255,255)| rgb(229,229,230)|
|
||||
* | buzz-dark | rgb(26,26,26) | rgb(26,26,26) | rgb(64,69,74) |
|
||||
*
|
||||
* `bg-card` paints the bullet `rgb(36,41,46)` over that `rgb(26,26,26)` drawer
|
||||
* — a visible disc of the wrong shade, which is exactly the BOT-1599 failure.
|
||||
* Light mode alone matches under either class, so light-mode evidence is not
|
||||
* sufficient here. Following berd's class literally would be following the
|
||||
* letter of its note against its point.)
|
||||
*/
|
||||
function WorkBlockRailGlyph({
|
||||
entry,
|
||||
isLast,
|
||||
}: {
|
||||
entry: WorkBlockEntry;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative flex w-5 shrink-0 justify-center self-stretch"
|
||||
>
|
||||
{isLast ? null : (
|
||||
<div className="absolute top-5 bottom-0 left-1/2 w-px -translate-x-1/2 bg-border" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 mt-0.5 flex size-5 items-center justify-center rounded-full bg-background text-muted-foreground ring-2 ring-background",
|
||||
// `motion-safe:`, not bare `animate-pulse`: the guard has to be in the
|
||||
// class because this animation is CSS, not motion. The block's height
|
||||
// animation is skipped through `useWorkBlockMotionEnabled`, but that
|
||||
// hook cannot reach a keyframe animation applied by a utility — and no
|
||||
// reduced-motion rule in the app's CSS matches `.animate-pulse` (every
|
||||
// one of them is scoped to a `buzz-*`/`motion-*` class), so an
|
||||
// unguarded pulse would keep pulsing forever for a reader who asked
|
||||
// for no motion. Tailwind compiles `motion-safe:` to
|
||||
// `@media (prefers-reduced-motion: no-preference)`, which is the only
|
||||
// thing that actually stops it. Matches every other pulse in this
|
||||
// feature (`AgentStatusBadge`, `ManagedAgentRow`).
|
||||
entry.state === "running" && "motion-safe:animate-pulse",
|
||||
)}
|
||||
data-step-state={entry.state}
|
||||
>
|
||||
<WorkBlockRailGlyphIcon entry={entry} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The glyph itself: shape carries kind, and for a tool step, outcome. */
|
||||
function WorkBlockRailGlyphIcon({ entry }: { entry: WorkBlockEntry }) {
|
||||
switch (entry.kind) {
|
||||
// Prose — reasoning or an interim note — is the agent talking, so both get
|
||||
// the speech bubble. berd gives its `progress` entry the same glyph as a
|
||||
// thought for the same reason.
|
||||
case "thought":
|
||||
case "note":
|
||||
return <MessageCircle className="size-3.5" />;
|
||||
case "tool":
|
||||
return entry.state === "failed" ? (
|
||||
// Failure is a glyph shape, not a colour: the rail stays uniformly
|
||||
// muted so one failed step does not read as an alarm across the whole
|
||||
// run. The tinted output block carries the red when the step is
|
||||
// expanded.
|
||||
<Circle className="size-2.5 fill-current" />
|
||||
) : (
|
||||
<Wrench className="size-3.5" />
|
||||
);
|
||||
}
|
||||
}
|
||||
343
desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs
Normal file
343
desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Shared jsdom lifecycle, item fixtures and the `renderBlock` view helper for
|
||||
* the work-block suites. One owner for the order-sensitive globals: the two
|
||||
* suites run in separate processes, so a second copy would not collide -- it
|
||||
* would drift, which is the failure this rig exists to prevent.
|
||||
*/
|
||||
|
||||
import { after, afterEach, before } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
class NoopObserver {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
let prefersReducedMotion = false;
|
||||
|
||||
/**
|
||||
* ESM bindings are read-only in importers, so the reduced-motion test cannot
|
||||
* assign this directly -- it goes through the setter, and `afterEach` below
|
||||
* resets it for every file that imports this rig.
|
||||
*/
|
||||
export function setPrefersReducedMotion(value) {
|
||||
prefersReducedMotion = value;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
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,
|
||||
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,
|
||||
});
|
||||
// `motion`'s useReducedMotion reads this query, so the reduced-motion test
|
||||
// drives it through the same surface the component does.
|
||||
dom.window.matchMedia = (query) => ({
|
||||
matches:
|
||||
prefersReducedMotion && String(query).includes("prefers-reduced-motion"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
});
|
||||
globalThis.matchMedia = dom.window.matchMedia;
|
||||
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
prefersReducedMotion = false;
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
export const START = "2026-06-18T00:00:00.000Z";
|
||||
export const SHARED = {
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
|
||||
export function step(id, overrides = {}) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: id,
|
||||
source: "shell",
|
||||
groupKey: "shell:command",
|
||||
},
|
||||
title: id,
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: START,
|
||||
startedAt: START,
|
||||
completedAt: "2026-06-18T00:00:01.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function thoughtStep(id) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "weighing the options",
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
export function noteStep(id, text = "posted the summary to the channel") {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Agent",
|
||||
text,
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay post the agent made mid-turn.
|
||||
*
|
||||
* The distinguishing property is `descriptor.renderClass: "message"`, which is
|
||||
* what `buildCompactToolSummary` turns into `presentation: "message"` and what
|
||||
* routes the item to the avatar + speech-bubble presenter outside a block. The
|
||||
* result carries an `event_id` so `getSentMessageLink` resolves too — that is
|
||||
* the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one
|
||||
* that goes most wrong on a muted rail.
|
||||
*/
|
||||
export function relayStep(id, overrides = {}) {
|
||||
return step(id, {
|
||||
renderClass: "message",
|
||||
descriptor: {
|
||||
renderClass: "message",
|
||||
label: "Send Message",
|
||||
preview: "posted the findings to the channel",
|
||||
action: { verb: "Sent", object: "posted the findings to the channel" },
|
||||
source: "shell",
|
||||
groupKey: "buzz-cli:messages.send",
|
||||
operation: "messages.send",
|
||||
},
|
||||
title: "Send Message",
|
||||
toolName: "buzz_dev_mcp__shell",
|
||||
args: {
|
||||
command:
|
||||
"buzz messages send --channel chan-1 --content 'posted the findings to the channel'",
|
||||
},
|
||||
result: '{"event_id":"ev-1","accepted":true}',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Let every pending animation finish and the DOM settle.
|
||||
*
|
||||
* A collapsing block keeps its rail mounted until the height animation's exit
|
||||
* completes, which is the point of the fold — so "has it closed yet?" can only
|
||||
* be asked after the exit runs, not on the commit that started it. Polling to a
|
||||
* stable answer keeps these assertions about the end state the reader sees
|
||||
* rather than about motion's internal frame schedule.
|
||||
*/
|
||||
export async function settle(read, expected, { timeout = 2000 } = {}) {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const deadline = Date.now() + timeout;
|
||||
let last = read();
|
||||
while (Date.now() < deadline) {
|
||||
if (last === expected) return last;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
last = read();
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a work block under a router.
|
||||
*
|
||||
* Rail rows render agent content through `Markdown`, which resolves in-app links
|
||||
* via `useAppNavigation` and therefore needs router context — the same reason
|
||||
* `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory
|
||||
* router. Without it a thought row throws while a tool row happens not to.
|
||||
*
|
||||
* `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on
|
||||
* this turn right now", because that is the situation nearly every case here is
|
||||
* about. Pass `liveTurnId: null` for reopened history — see the orphaned-step
|
||||
* tests, where the same items must read as finished.
|
||||
*/
|
||||
export async function renderBlock(
|
||||
items,
|
||||
{ liveTurnId = SHARED.turnId, streamingItemId = null } = {},
|
||||
) {
|
||||
const { createElement, useState } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const element = (blockItems, streaming, live) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: `work-block:${blockItems[0].id}`,
|
||||
items: blockItems,
|
||||
timestamp: blockItems[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let applyState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState({ items, liveTurnId, streamingItemId });
|
||||
applyState = setState;
|
||||
return element(state.items, state.streamingItemId, state.liveTurnId);
|
||||
};
|
||||
// The bubble presenter reads the posted message through `useQuery`. Provided
|
||||
// unconditionally so that if a rail step ever DID reach that presenter, the
|
||||
// relay test below would fail on its bubble assertion rather than on a
|
||||
// missing provider — a test must fail for the reason it claims.
|
||||
// `gcTime: 0` because React Query's default is 300000ms, and an un-collected
|
||||
// query arms a timer that long at teardown — node:test then waits it out
|
||||
// before exiting, turning a 2s suite into a 5-minute wall with no failing
|
||||
// assertion. Dormant here today (only the relay-bubble test reaches a
|
||||
// `useQuery`, and it builds its own client), but a silent five-minute hang is
|
||||
// an expensive thing to leave armed for the next rig-based test that renders
|
||||
// a bubble.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { gcTime: 0, retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(Harness),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
const q = (selector) => view.container.querySelector(selector);
|
||||
const qa = (selector) => [...view.container.querySelectorAll(selector)];
|
||||
const stepCount = () =>
|
||||
qa('[data-testid="transcript-work-block-step"]').length;
|
||||
return {
|
||||
...view,
|
||||
// Re-render the SAME block id, exactly as the transcript does as work
|
||||
// streams and then finishes. `liveTurnId` is carried through so a test can
|
||||
// model the session going away under a still-running step (agent death),
|
||||
// not just the step reaching a terminal status.
|
||||
stream: (nextItems, streaming = null, live = liveTurnId) =>
|
||||
applyState({
|
||||
items: nextItems,
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
}),
|
||||
summary: () => q('[data-testid="transcript-work-block-summary"]'),
|
||||
previousSteps: () =>
|
||||
q('[data-testid="transcript-work-block-previous-steps"]'),
|
||||
stepCount,
|
||||
// A finished block renders folded, so any assertion about what is ON the
|
||||
// rail has to open it first — exactly as a reader would.
|
||||
expand: async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
await act(async () => {
|
||||
q('[data-testid="transcript-work-block-summary"]').click();
|
||||
});
|
||||
},
|
||||
// Wait for the rail to reach `expected` rows once animations have run.
|
||||
settleToStepCount: (expected) => settle(stepCount, expected),
|
||||
glyphStates: () =>
|
||||
qa("[data-step-state]").map((node) =>
|
||||
node.getAttribute("data-step-state"),
|
||||
),
|
||||
// Which rail glyphs pulse, reported as their step states, and the same
|
||||
// narrowed to pulses that are NOT reduced-motion-safe.
|
||||
//
|
||||
// Both read `classList.contains`, which matches a whole class token, rather
|
||||
// than a `.animate-pulse` CSS selector or `className.includes` —
|
||||
// `animate-pulse` is a SUBSTRING of `motion-safe:animate-pulse`, so a
|
||||
// substring check cannot tell the guarded class from the bare one and would
|
||||
// report the reduced-motion regression as fixed.
|
||||
//
|
||||
// They return step-state STRINGS rather than the elements: a failing
|
||||
// `deepEqual` over jsdom nodes makes node:test serialize the whole DOM tree
|
||||
// and the runner dies on SIGKILL instead of printing an assertion, which
|
||||
// silently turns "the mutant was caught" into an unreadable crash.
|
||||
pulseStates: () =>
|
||||
qa("[data-step-state]")
|
||||
.filter(
|
||||
(node) =>
|
||||
node.classList.contains("motion-safe:animate-pulse") ||
|
||||
node.classList.contains("animate-pulse"),
|
||||
)
|
||||
.map((node) => node.getAttribute("data-step-state")),
|
||||
unguardedPulseStates: () =>
|
||||
qa("[data-step-state]")
|
||||
.filter((node) => node.classList.contains("animate-pulse"))
|
||||
.map((node) => node.getAttribute("data-step-state")),
|
||||
q,
|
||||
qa,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
@@ -16,21 +21,55 @@ export function MessageActivity(props: ActivityRenderClassItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MessageItem item={props.item} profiles={props.profiles} />;
|
||||
return (
|
||||
<MessageItem
|
||||
agentAvatarUrl={props.agentAvatarUrl}
|
||||
agentName={props.agentName}
|
||||
agentPubkey={props.agentPubkey}
|
||||
item={props.item}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageItem({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
agentAvatarUrl: string | null;
|
||||
agentName: string;
|
||||
agentPubkey: string;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
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);
|
||||
// The identity row must resolve the agent through the profiles lookup first,
|
||||
// exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header
|
||||
// (AgentSessionThreadPanel.tsx:243-249) already do for the same agent on the
|
||||
// same surface. The `agentAvatarUrl`/`agentName` props only carry what the
|
||||
// *caller's* agent record holds, and the primary channel flow's record
|
||||
// (`ChannelAgentSessionAgent`) has no avatar field at all — so relying on the
|
||||
// prop alone showed initials for every channel-opened session while the
|
||||
// managed-agent panel showed the real avatar. Resolving here keeps the row
|
||||
// agreeing with the header directly above it; the props stay as the fallback
|
||||
// for callers that have an avatar the lookup does not (a locally managed
|
||||
// agent whose avatar was never published to a relay profile).
|
||||
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
|
||||
const resolvedAgentAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
|
||||
const resolvedAgentName = resolveUserLabel({
|
||||
pubkey: agentPubkey,
|
||||
fallbackName: agentName,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
|
||||
if (!isAssistant) {
|
||||
return (
|
||||
@@ -54,6 +93,44 @@ function MessageItem({
|
||||
data-testid="transcript-assistant-message"
|
||||
>
|
||||
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
|
||||
{isConversation ? (
|
||||
// berd labels every agent turn with a small identity row above the
|
||||
// prose — 20px round avatar + name at `text-xs`, `mb-0.5`, `gap-1`
|
||||
// (MessageBubble.tsx:961-981). Without it the reply reads as
|
||||
// unattributed body text in a full-cover view, which was the largest
|
||||
// single divergence from berd. Only the conversation variant gets it:
|
||||
// the other variants' markup is pinned by the byte-for-byte fixture.
|
||||
<div
|
||||
className="mb-0.5 flex items-center gap-1 text-xs"
|
||||
data-testid="transcript-assistant-identity"
|
||||
>
|
||||
{/*
|
||||
* The avatar is decorative here: `UserAvatar` exposes either an
|
||||
* image named `${displayName} avatar` or its fallback initials, and
|
||||
* the agent's name already follows as visible text, so an
|
||||
* unhidden avatar makes a screen reader announce the same identity
|
||||
* twice for every agent turn. The visible name is the row's single
|
||||
* accessible identity. Hidden at this call site rather than by
|
||||
* teaching the shared `UserAvatar` a decorative mode: other rows
|
||||
* that pair an avatar with adjacent name text (for example
|
||||
* `ForumPostCard.tsx:91-99`) have the same shape and would want the
|
||||
* same treatment, but changing the shared component's accessible
|
||||
* name affects all 45 of its call sites and is not this PR's scope.
|
||||
*
|
||||
* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar.
|
||||
*/}
|
||||
<span aria-hidden="true" className="flex shrink-0">
|
||||
<UserAvatar
|
||||
avatarUrl={resolvedAgentAvatarUrl}
|
||||
displayName={resolvedAgentName}
|
||||
size="xs"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-normal text-foreground">
|
||||
{resolvedAgentName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={
|
||||
isCompactPreview
|
||||
@@ -63,7 +140,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 (
|
||||
<>
|
||||
|
||||
@@ -16,17 +16,30 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ThoughtItem item={props.item} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `default`/`compactPreview` thought row.
|
||||
*
|
||||
* The `conversation` variant does not reach this presenter: focus mode renders
|
||||
* reasoning as a row on the work block's rail (`AgentSessionWorkBlock`), so the
|
||||
* separate per-thought disclosure that used to live here is gone rather than
|
||||
* duplicated. Its "Thought for Ns" label logic moved with it.
|
||||
*/
|
||||
function ThoughtItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ActivityRenderClassItemProps["item"], { type: "thought" }>;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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,23 @@ export function UserMessageBubble({
|
||||
className={cn(
|
||||
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
|
||||
isCompactPreview && "items-start",
|
||||
// berd caps the user turn at a fixed measure, not a percentage of the
|
||||
// column (`--chat-user-message-max-width: 640px`,
|
||||
// MessageBubble.tsx:956): a percentage keeps re-wrapping the prompt as
|
||||
// the cover width changes, while a fixed measure holds one stable
|
||||
// reading line length. `max-w-prompt-bubble` carries the 640px token.
|
||||
isConversation && "max-w-prompt-bubble 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,13 +178,28 @@ 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",
|
||||
// berd's user-turn recipe (MessageBubble.tsx:990): a soft tint, no
|
||||
// border at all, `px-4 py-2`, and a tighter radius than a chat
|
||||
// "pill". berd's `rounded-sm` is 12px on its own scale
|
||||
// (globals.css `--radius-sm: 12px`), NOT Tailwind's stock 2px —
|
||||
// Buzz's `rounded-xl` is the exact 12px equivalent here.
|
||||
// `leading-normal` overrides the `leading-relaxed` base, as berd
|
||||
// does, so the prompt sits tighter than the agent's prose.
|
||||
isConversation &&
|
||||
"rounded-xl border-0 bg-muted/60 px-4 py-2 leading-normal",
|
||||
bubbleClassName,
|
||||
)}
|
||||
ref={bubbleRef}
|
||||
{...bubbleLinkProps}
|
||||
>
|
||||
<Markdown
|
||||
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
|
||||
className={cn(
|
||||
isCompactPreview
|
||||
? "text-xs leading-4"
|
||||
: isConversation
|
||||
? "leading-normal"
|
||||
: "leading-5",
|
||||
)}
|
||||
content={text || " "}
|
||||
mediaInset
|
||||
/>
|
||||
@@ -156,7 +209,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,549 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildConversationTurnMeta } from "./agentSessionConversationMeta.ts";
|
||||
import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext.ts";
|
||||
import { buildTranscriptDisplayBlocks } from "./agentSessionTranscriptGrouping.ts";
|
||||
import {
|
||||
formatPlanChecklistProgress,
|
||||
parsePlanChecklist,
|
||||
} from "./agentSessionPlanChecklist.ts";
|
||||
|
||||
function item(overrides) {
|
||||
return {
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_TIMESTAMP = "2026-06-14T19:00:00.000Z";
|
||||
|
||||
/**
|
||||
* Raw transcript items, for the tests that go through the real grouping rather
|
||||
* than asserting on hand-written blocks. `acpSource` is what decides whether an
|
||||
* item is setup lifecycle, a prompt, or work, so these carry it.
|
||||
*/
|
||||
function lifecycleItem(id, acpSource, turnId) {
|
||||
return item({
|
||||
id,
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: acpSource,
|
||||
text: "",
|
||||
timestamp: RAW_TIMESTAMP,
|
||||
acpSource,
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
|
||||
function promptItem(id, turnId) {
|
||||
return item({
|
||||
id,
|
||||
type: "message",
|
||||
role: "user",
|
||||
renderClass: "message",
|
||||
title: "Buzz event",
|
||||
text: "please look into this",
|
||||
timestamp: RAW_TIMESTAMP,
|
||||
acpSource: "session/prompt:user",
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
|
||||
function toolItem(id, turnId) {
|
||||
return item({
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
title: id,
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: RAW_TIMESTAMP,
|
||||
startedAt: RAW_TIMESTAMP,
|
||||
completedAt: RAW_TIMESTAMP,
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: id,
|
||||
source: "shell",
|
||||
groupKey: "shell:command",
|
||||
},
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
|
||||
function systemPromptItem(id) {
|
||||
return item({
|
||||
id,
|
||||
type: "metadata",
|
||||
renderClass: "metadata",
|
||||
title: "System prompt",
|
||||
sections: [],
|
||||
timestamp: RAW_TIMESTAMP,
|
||||
acpSource: "session/new",
|
||||
turnId: null,
|
||||
});
|
||||
}
|
||||
|
||||
function turnBlock(segments) {
|
||||
return { kind: "turn", turnId: "turn-1", segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaf items a block is built from, in wire order — including setup
|
||||
* lifecycle items, which carry a turn id in the real stream even though they
|
||||
* contribute nothing to the streaming tail.
|
||||
*
|
||||
* `buildConversationTurnMeta` reads the item stream as well as the blocks (it
|
||||
* has to: a turn that has only emitted setup rows produces no block at all), so
|
||||
* a test that passed blocks alone would be describing a transcript that cannot
|
||||
* exist. This keeps the two arguments consistent by construction.
|
||||
*/
|
||||
function blockItems(blocks) {
|
||||
return blocks.flatMap((block) => {
|
||||
if (block.kind === "single") return [block.item];
|
||||
if (block.kind !== "turn") return [];
|
||||
return block.segments.flatMap((segment) => {
|
||||
if (segment.kind === "prompt") return [segment.user];
|
||||
if (segment.kind === "setup") return segment.items;
|
||||
if (segment.kind === "summary") return segment.summary.items;
|
||||
return [segment.item];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** `buildConversationTurnMeta` with the item stream derived from the blocks. */
|
||||
function metaFor(blocks, { items, ...options }) {
|
||||
return buildConversationTurnMeta(blocks, {
|
||||
...options,
|
||||
items: items ?? blockItems(blocks),
|
||||
});
|
||||
}
|
||||
|
||||
function itemSegment(id, overrides) {
|
||||
return {
|
||||
kind: "item",
|
||||
item: item({
|
||||
id,
|
||||
timestamp: "2026-06-14T19:00:02.000Z",
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const thoughtSegment = (id, timestamp) =>
|
||||
itemSegment(id, {
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "…",
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const messageSegment = (id, timestamp, role = "assistant") =>
|
||||
itemSegment(id, {
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role,
|
||||
title: role === "user" ? "Ada" : "Agent",
|
||||
text: "done",
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const toolSegment = (id, timestamp) =>
|
||||
itemSegment(id, {
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
title: "Ran a command",
|
||||
text: "",
|
||||
descriptor: { label: "Ran a command", preview: "cargo test" },
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// ---- buildConversationTurnMeta ----
|
||||
|
||||
test("buildConversationTurnMeta returns the shared empty value for other variants", () => {
|
||||
for (const variant of ["default", "compactPreview"]) {
|
||||
assert.equal(
|
||||
metaFor([turnBlock([])], {
|
||||
isTurnLive: true,
|
||||
variant,
|
||||
}),
|
||||
EMPTY_TRANSCRIPT_TURN_META,
|
||||
`${variant} must allocate nothing so its render output is untouched`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta reports nothing streaming when the turn is idle", () => {
|
||||
// The hint exists to tell the work block it is still working. A finished turn
|
||||
// has no tail, and reporting one would pin the block open forever.
|
||||
assert.equal(
|
||||
metaFor(
|
||||
[
|
||||
turnBlock([
|
||||
thoughtSegment("thought:1", "2026-06-14T19:00:02.000Z"),
|
||||
messageSegment("msg:1", "2026-06-14T19:00:14.000Z"),
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: false, variant: "conversation" },
|
||||
),
|
||||
EMPTY_TRANSCRIPT_TURN_META,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta names the trailing item of a live turn", () => {
|
||||
const blocks = [
|
||||
turnBlock([thoughtSegment("thought:tail", "2026-06-14T19:00:02.000Z")]),
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
metaFor(blocks, {
|
||||
isTurnLive: true,
|
||||
variant: "conversation",
|
||||
}).streamingItemId,
|
||||
"thought:tail",
|
||||
"a thought carries no status of its own, so the hint is how the block knows it is live",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta skips setup segments when finding the tail", () => {
|
||||
// Setup renders as a quiet divider, not as work, so it must never be reported
|
||||
// as the streaming item — a lifecycle row would hold the block open.
|
||||
const meta = metaFor(
|
||||
[
|
||||
turnBlock([
|
||||
thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
|
||||
{
|
||||
kind: "setup",
|
||||
items: [
|
||||
item({
|
||||
id: "life:1",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Turn started",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.streamingItemId, "thought:1");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta counts a mid-turn steer prompt as the tail", () => {
|
||||
const meta = metaFor(
|
||||
[
|
||||
turnBlock([
|
||||
thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
|
||||
messageSegment("msg:steer", "2026-06-14T19:00:04.000Z", "user"),
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.streamingItemId, "msg:steer");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta expands a summary segment to reach its last tool", () => {
|
||||
// A summary segment holds several leaf items; the tail is the last of them,
|
||||
// not the summary itself, which has no item id the work block could match.
|
||||
const meta = metaFor(
|
||||
[
|
||||
turnBlock([
|
||||
{
|
||||
kind: "summary",
|
||||
summary: {
|
||||
id: "summary:shell:tool:1",
|
||||
label: "Ran 2 commands",
|
||||
count: 2,
|
||||
items: [
|
||||
toolSegment("tool:1", "2026-06-14T19:00:04.000Z").item,
|
||||
toolSegment("tool:2", "2026-06-14T19:00:05.000Z").item,
|
||||
],
|
||||
renderClass: "shell",
|
||||
variant: "same-kind",
|
||||
timestamp: "2026-06-14T19:00:04.000Z",
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.streamingItemId, "tool:2");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta reads a trailing single block directly", () => {
|
||||
const meta = metaFor(
|
||||
[
|
||||
turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
|
||||
{
|
||||
kind: "single",
|
||||
item: item({
|
||||
id: "life:orphan",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Context compacted",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:20.000Z",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.streamingItemId, "life:orphan");
|
||||
});
|
||||
|
||||
// ---- buildConversationTurnMeta: liveTurnId ----
|
||||
|
||||
/**
|
||||
* The work block cannot tell an abandoned step from a running one on its own: a
|
||||
* tool keeps its `executing` status forever if the agent dies mid-step. Turn
|
||||
* ownership is knowledge only the list has, so it is published here.
|
||||
*/
|
||||
test("buildConversationTurnMeta names the live turn so the block can gate running steps", () => {
|
||||
const meta = metaFor(
|
||||
[turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.liveTurnId, "turn-1");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta reports no live turn when the turn is idle", () => {
|
||||
// This is the case the orphaned-step bug lived in: history reopened, nothing
|
||||
// live, but an item still claiming `executing`.
|
||||
const meta = metaFor(
|
||||
[turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
|
||||
{ isTurnLive: false, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.liveTurnId, null);
|
||||
assert.equal(
|
||||
meta,
|
||||
EMPTY_TRANSCRIPT_TURN_META,
|
||||
"an idle turn still allocates nothing",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta names the live turn even when a lifecycle row trails it", () => {
|
||||
// A compaction notice arrives as a `single` block AFTER the turn it belongs
|
||||
// to, so "the last block" is not always the live turn — but the last turn is.
|
||||
// Reading the last block's kind alone would report no live turn and gate every
|
||||
// running step off, which is the mirror image of the bug being fixed.
|
||||
const meta = metaFor(
|
||||
[
|
||||
turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
|
||||
{
|
||||
kind: "single",
|
||||
item: item({
|
||||
id: "life:compact",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Context compacted",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:20.000Z",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.liveTurnId, "turn-1");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta reports no live turn when there is no turn at all", () => {
|
||||
// `turnId: null` explicitly: a session-started row arrives before any turn
|
||||
// exists, so it belongs to nobody. The shared `item()` helper defaults to
|
||||
// `turn-1`, which would have made this fixture describe a transcript that
|
||||
// does have a turn — and the assertion would then be checking that a turn
|
||||
// with no BLOCK reports no live turn, which is the opposite of the rule.
|
||||
const meta = metaFor(
|
||||
[
|
||||
{
|
||||
kind: "single",
|
||||
item: item({
|
||||
id: "life:1",
|
||||
type: "lifecycle",
|
||||
renderClass: "status",
|
||||
title: "Session started",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:00.000Z",
|
||||
turnId: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ isTurnLive: true, variant: "conversation" },
|
||||
);
|
||||
|
||||
assert.equal(meta.liveTurnId, null);
|
||||
});
|
||||
|
||||
// ---- buildConversationTurnMeta: the gap between two turns ----
|
||||
|
||||
/**
|
||||
* The next turn owns liveness the moment it appears, even before it has anything
|
||||
* to show.
|
||||
*
|
||||
* A turn that has only emitted setup lifecycle rows (`turn_started`,
|
||||
* `session_resolved`) classifies to zero segments and therefore produces NO
|
||||
* block — `agentSessionTranscriptGrouping` only pushes a turn block
|
||||
* `if (segments.length > 0)`. So "the newest turn with a block" is the turn that
|
||||
* ALREADY ENDED for the whole gap between `turn_started` and the new turn's
|
||||
* first prompt or thought, and the ended turn's own trailing item then gets
|
||||
* reported as the streaming item. Its finished work block reads as live again:
|
||||
* the folded summary is replaced by the open rail, a 6-step block drops to its
|
||||
* last three with a previous-steps disclosure, and then it all folds back.
|
||||
*
|
||||
* That gap is real observer-stream latency, and `turn_started` fires on every
|
||||
* turn (`activeAgentTurnsStore`), so this was not an edge case — it was every
|
||||
* turn, for as long as the agent took to emit its first renderable item.
|
||||
*
|
||||
* Built from a raw item stream through the real grouping rather than from
|
||||
* hand-written blocks, because the whole bug is which turns DO and DO NOT
|
||||
* produce a block: hand-built blocks would assume the answer away.
|
||||
*
|
||||
* The frames pin the PLAIN next turn (`turn_started`, then `session_resolved`,
|
||||
* with no `session/new` card) because that is the common path and the only one
|
||||
* that pins the bug. On a session restart the `session/new` card lands as a
|
||||
* trailing `single` block, which moves `streamingItemId` off turn-1's work block
|
||||
* on its own — so a restart-only fixture would pass against the old
|
||||
* block-walking code. Both sequences are covered; only the plain one is
|
||||
* load-bearing.
|
||||
*/
|
||||
test("buildConversationTurnMeta hands liveness to a next turn that has no block yet", () => {
|
||||
const stream = (...extra) => [
|
||||
lifecycleItem("life:start:1", "turn_started", "turn-1"),
|
||||
promptItem("prompt:1", "turn-1"),
|
||||
toolItem("tool:1", "turn-1"),
|
||||
// turn-1's last act is a completed tool: the agent answered by posting
|
||||
// through buzz-cli, so no assistant message trails its work.
|
||||
toolItem("tool:2", "turn-1"),
|
||||
...extra,
|
||||
];
|
||||
|
||||
const started = lifecycleItem("life:start:2", "turn_started", "turn-2");
|
||||
const resolved = lifecycleItem(
|
||||
"life:resolved:2",
|
||||
"session_resolved",
|
||||
"turn-2",
|
||||
);
|
||||
const sysPrompt = systemPromptItem("meta:sysprompt");
|
||||
|
||||
const frames = [
|
||||
// The plain next turn — no session restart, so no `session/new` card. This
|
||||
// is both the common path and the only one that pins the bug: a
|
||||
// `session/new` card renders as a trailing `single` block, which moves the
|
||||
// streaming item off turn-1's work block by itself and so lets the old
|
||||
// block-walking code pass for the wrong reason.
|
||||
{
|
||||
label: "turn-2 has only started",
|
||||
items: stream(started),
|
||||
},
|
||||
{
|
||||
label: "the session has resolved with no restart card",
|
||||
items: stream(started, resolved),
|
||||
},
|
||||
// The restarting next turn, where a system-prompt card lands between the
|
||||
// lifecycle rows. A different route through the grouping, kept because it
|
||||
// has to settle too.
|
||||
{
|
||||
label: "the restarting turn's system prompt has landed",
|
||||
items: stream(started, sysPrompt),
|
||||
},
|
||||
{
|
||||
label: "the restarted session has resolved",
|
||||
items: stream(started, sysPrompt, resolved),
|
||||
},
|
||||
];
|
||||
|
||||
for (const frame of frames) {
|
||||
const meta = buildConversationTurnMeta(
|
||||
buildTranscriptDisplayBlocks(frame.items),
|
||||
{ isTurnLive: true, items: frame.items, variant: "conversation" },
|
||||
);
|
||||
assert.equal(
|
||||
meta.liveTurnId,
|
||||
"turn-2",
|
||||
`the newest turn owns liveness once ${frame.label}`,
|
||||
);
|
||||
assert.equal(
|
||||
meta.streamingItemId,
|
||||
null,
|
||||
`turn-1's tail must not read as streaming once ${frame.label}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Final frame: turn-2 finally has something renderable, so it gets a block
|
||||
// and its own prompt becomes the tail. Included so the sequence ends in the
|
||||
// steady state rather than stopping at the gap.
|
||||
const renderable = stream(
|
||||
started,
|
||||
resolved,
|
||||
promptItem("prompt:2", "turn-2"),
|
||||
);
|
||||
const meta = buildConversationTurnMeta(
|
||||
buildTranscriptDisplayBlocks(renderable),
|
||||
{ isTurnLive: true, items: renderable, variant: "conversation" },
|
||||
);
|
||||
assert.equal(meta.liveTurnId, "turn-2");
|
||||
assert.equal(meta.streamingItemId, "prompt:2");
|
||||
});
|
||||
|
||||
test("buildConversationTurnMeta still names the live turn's own trailing item", () => {
|
||||
// The other half of the ownership check: when the newest turn IS the turn that
|
||||
// owns the trailing item, that item is the streaming one. Without this the
|
||||
// gate would be a blanket "never report a tail" and every live block would
|
||||
// fold while the reader watched it arrive.
|
||||
const items = [
|
||||
lifecycleItem("life:start:1", "turn_started", "turn-1"),
|
||||
promptItem("prompt:1", "turn-1"),
|
||||
toolItem("tool:1", "turn-1"),
|
||||
];
|
||||
const meta = buildConversationTurnMeta(buildTranscriptDisplayBlocks(items), {
|
||||
isTurnLive: true,
|
||||
items,
|
||||
variant: "conversation",
|
||||
});
|
||||
|
||||
assert.equal(meta.liveTurnId, "turn-1");
|
||||
assert.equal(meta.streamingItemId, "tool:1");
|
||||
});
|
||||
|
||||
// ---- 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);
|
||||
});
|
||||
153
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal file
153
desktop/src/features/agents/ui/agentSessionConversationMeta.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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.
|
||||
*
|
||||
* This walks the SHARED segment union deliberately. The conversation variant's
|
||||
* additive work-block transform runs later, at the list's render boundary
|
||||
* (`TranscriptDisplayBlockView`), so no `work-block` segment can reach here —
|
||||
* and the streaming tail must be computed from the true item order regardless
|
||||
* of how the variant later groups those items for presentation.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the `conversation` variant's live-turn hints from the display blocks.
|
||||
*
|
||||
* Two things the work block cannot see for itself:
|
||||
*
|
||||
* - **`streamingItemId`** — the trailing leaf item of the final turn, and only
|
||||
* when the agent's turn is actually live. The block reads this to decide
|
||||
* whether it is still working: a thought or note streaming in carries no
|
||||
* status of its own, so without this hint a block whose last step is prose
|
||||
* would fold while the reader was still watching it arrive.
|
||||
* - **`liveTurnId`** — which turn a session currently owns. A tool item keeps
|
||||
* its `executing` status forever if the agent dies mid-step, so status alone
|
||||
* cannot tell a running step from an abandoned one; the block needs to know
|
||||
* whose turn is live to decide (see `AgentSessionTranscriptTurnMeta`).
|
||||
*
|
||||
* 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;
|
||||
items: TranscriptItem[];
|
||||
variant: AgentSessionTranscriptVariant;
|
||||
},
|
||||
): AgentSessionTranscriptTurnMeta {
|
||||
if (options.variant !== "conversation" || !options.isTurnLive) {
|
||||
return EMPTY_TRANSCRIPT_TURN_META;
|
||||
}
|
||||
|
||||
const lastBlock = displayBlocks[displayBlocks.length - 1];
|
||||
// The live turn is the newest turn in the ITEM stream, not the newest turn
|
||||
// that produced a block. See `latestTurnId` for why the distinction is
|
||||
// load-bearing. Read from the transcript rather than from the active-turn
|
||||
// store because the store's turn ids and the transcript's are populated by
|
||||
// different paths, and a mismatch would silently gate every step off.
|
||||
const liveTurnId = latestTurnId(options.items, displayBlocks);
|
||||
|
||||
if (lastBlock?.kind === "single") {
|
||||
return {
|
||||
liveTurnId,
|
||||
streamingItemId: streamingIdForTail(lastBlock.item, liveTurnId),
|
||||
};
|
||||
}
|
||||
if (lastBlock?.kind !== "turn") {
|
||||
return { liveTurnId, streamingItemId: null };
|
||||
}
|
||||
|
||||
const items = lastBlock.segments.flatMap(turnSegmentItems);
|
||||
const tail = items[items.length - 1];
|
||||
return {
|
||||
liveTurnId,
|
||||
streamingItemId: tail ? streamingIdForTail(tail, liveTurnId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing item counts as streaming only if the LIVE turn owns it.
|
||||
*
|
||||
* Without this check the newest block's tail is reported as streaming no matter
|
||||
* whose turn it belongs to, so a finished turn's last step would hold that
|
||||
* turn's work block open while a *different* turn is the live one. That is the
|
||||
* same class of bug as an abandoned `executing` step: presenting settled history
|
||||
* as work in flight.
|
||||
*/
|
||||
function streamingIdForTail(
|
||||
tail: TranscriptItem,
|
||||
liveTurnId: string | null,
|
||||
): string | null {
|
||||
// Compared rather than tested for truthiness, for the same reason as
|
||||
// `toolEntryState`: an item with no turn id is owned by nobody, and
|
||||
// `null === null` must not read as ownership.
|
||||
if (liveTurnId === null || tail.turnId !== liveTurnId) return null;
|
||||
return tail.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the newest turn the transcript has seen — including a turn that has
|
||||
* arrived but has nothing renderable in it yet.
|
||||
*
|
||||
* Read from the items rather than the blocks because a turn that has only
|
||||
* emitted setup lifecycle rows (`turn_started`, `session_resolved`) classifies
|
||||
* to zero segments and so produces NO block at all
|
||||
* (`agentSessionTranscriptGrouping`: `if (segments.length > 0)`). Walking the
|
||||
* blocks backwards for the last `turn` therefore skipped straight past the new
|
||||
* turn and returned the turn that had already ended — which then made its own
|
||||
* trailing item the streaming item, and a settled block visibly re-opened,
|
||||
* dropped to its last three steps and re-expanded for the whole gap between
|
||||
* `turn_started` and the new turn's first prompt or thought. That gap is real
|
||||
* observer-stream latency and happens on every turn, so this was not an edge
|
||||
* case.
|
||||
*
|
||||
* Items are in wire order, so the last one carrying a turn id names the newest
|
||||
* turn. Falls back to the last turn block for an item stream that carries no
|
||||
* turn ids at all.
|
||||
*/
|
||||
function latestTurnId(
|
||||
items: TranscriptItem[],
|
||||
displayBlocks: TranscriptDisplayBlock[],
|
||||
): string | null {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const turnId = items[index]?.turnId;
|
||||
if (turnId) return turnId;
|
||||
}
|
||||
return lastTurnBlockId(displayBlocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the last turn block, ignoring anything that trails it.
|
||||
*
|
||||
* A lifecycle row (a compaction notice, a session boundary) can land after the
|
||||
* turn it belongs to and arrives as a `single` block, so the last block is not
|
||||
* always the live turn — but the last *turn* is.
|
||||
*/
|
||||
function lastTurnBlockId(
|
||||
displayBlocks: TranscriptDisplayBlock[],
|
||||
): string | null {
|
||||
for (let index = displayBlocks.length - 1; index >= 0; index -= 1) {
|
||||
const block = displayBlocks[index];
|
||||
if (block.kind === "turn") return block.turnId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
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,78 @@ 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). 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;
|
||||
/**
|
||||
* The turn that is actually live, or null when no turn is.
|
||||
*
|
||||
* A tool item's `executing`/`pending` status is not evidence that work is
|
||||
* happening: an agent that dies after emitting a tool start leaves that
|
||||
* status on the item permanently, so reopened history still claims to be
|
||||
* mid-step. Whether a session owns that step is knowledge only the list has,
|
||||
* which is why it is published here rather than re-derived per row.
|
||||
*
|
||||
* A turn id rather than a boolean, because "some turn is live" is not the
|
||||
* question. An agent that crashed during turn 1 and is now working on turn 2
|
||||
* is live, yet turn 1's abandoned step is no more running than before — a
|
||||
* global flag would keep it spinning in exactly the case a restarted agent
|
||||
* makes common.
|
||||
*/
|
||||
liveTurnId: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
|
||||
liveTurnId: null,
|
||||
streamingItemId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the surrounding subtree is a work block's rail.
|
||||
*
|
||||
* The rail's job is to show *what the agent did*, one muted row per step. Two
|
||||
* tool presentations disagree with that: a relay `messages send` classifies as
|
||||
* `renderClass: "message"` and therefore renders through
|
||||
* `CompactMessageSummary` — a 28px avatar, a bordered speech bubble, a
|
||||
* timestamp and a delivery-receipt button. That treatment is right in the
|
||||
* activity feed, where a posted message is a destination the reader may want to
|
||||
* open, and wrong on the rail, where the same markup reads as the agent
|
||||
* *replying* in the middle of its own work.
|
||||
*
|
||||
* This is the same failure the interim-note case avoids (see
|
||||
* `WorkBlockProseBody`), reached by a different route: there the item is an
|
||||
* assistant message, here it is a tool call that merely classifies as one. The
|
||||
* signal is explicit and additive rather than inferred from the transcript
|
||||
* variant, because `conversation` alone is not the condition — the same relay
|
||||
* step rendered outside a block in that variant should keep its bubble.
|
||||
*
|
||||
* Defaults to `false`, so `default` and `compactPreview` cannot observe it and
|
||||
* their markup stays byte-identical.
|
||||
*/
|
||||
const AgentSessionWorkBlockRailContext = React.createContext(false);
|
||||
|
||||
export const AgentSessionWorkBlockRailProvider =
|
||||
AgentSessionWorkBlockRailContext.Provider;
|
||||
|
||||
export function useIsInsideWorkBlockRail() {
|
||||
return React.useContext(AgentSessionWorkBlockRailContext);
|
||||
}
|
||||
|
||||
const AgentSessionTranscriptTurnMetaContext =
|
||||
React.createContext<AgentSessionTranscriptTurnMeta>(
|
||||
EMPTY_TRANSCRIPT_TURN_META,
|
||||
);
|
||||
|
||||
export const AgentSessionTranscriptTurnMetaProvider =
|
||||
AgentSessionTranscriptTurnMetaContext.Provider;
|
||||
|
||||
export function useAgentSessionTranscriptTurnMeta() {
|
||||
return React.useContext(AgentSessionTranscriptTurnMetaContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* The reader's fold/unfold choices for focus-mode work blocks, keyed by the
|
||||
* STEP ids a choice was taken on rather than by block id.
|
||||
*
|
||||
* ## Why this cannot be `useState` inside the block
|
||||
*
|
||||
* A work block's id is derived from its first step (`work-block:${items[0].id}`,
|
||||
* see `groupConversationWorkBlocks`), and block membership is recomputed from
|
||||
* scratch on every render. `findFinalAnswerId` exempts only the LAST assistant
|
||||
* message from the block, so when a second assistant message arrives the first
|
||||
* one stops being the answer, becomes work, and the run of steps around it
|
||||
* merges: two blocks become one.
|
||||
*
|
||||
* frame 2 work-block:th:1[th:1,tool:1] msg:1 work-block:th:2[th:2,tool:2]
|
||||
* frame 3 work-block:th:1[th:1,tool:1,msg:1,th:2,tool:2] msg:2
|
||||
*
|
||||
* `work-block:th:2` ceases to exist, so React unmounts it and any state it
|
||||
* owned goes with it. A reader who had opened it to read the steps was folded
|
||||
* shut by an event they did not cause — the agent posting another message.
|
||||
*
|
||||
* Keying on step ids survives that, because the steps are what the reader's
|
||||
* intent was actually about: they are still on screen after the merge, just
|
||||
* inside a different block.
|
||||
*/
|
||||
export type WorkBlockDisclosureChoices = ReadonlyMap<string, boolean>;
|
||||
|
||||
/**
|
||||
* The reader's choice for a block, or `null` when they have not taken one and
|
||||
* policy still owns the fold.
|
||||
*
|
||||
* An open choice wins over a folded one. After a merge the block can carry both
|
||||
* — one constituent opened, another folded — and the two are not symmetric:
|
||||
* showing steps a reader asked to see costs them a scroll, while hiding steps a
|
||||
* reader asked to see loses the thing they were reading. Same reason the
|
||||
* absorbed block's own choice cannot simply be dropped.
|
||||
*/
|
||||
export function readWorkBlockChoice(
|
||||
choices: WorkBlockDisclosureChoices,
|
||||
itemIds: readonly string[],
|
||||
): boolean | null {
|
||||
let folded: boolean | null = null;
|
||||
for (const itemId of itemIds) {
|
||||
const choice = choices.get(itemId);
|
||||
if (choice === true) return true;
|
||||
if (choice === false) folded = false;
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one choice against every step the block currently holds.
|
||||
*
|
||||
* Written across all of them, not just the first, because the block this choice
|
||||
* was taken on may later be absorbed into a block that begins with a different
|
||||
* step — and the reader's intent has to be findable from whichever step the
|
||||
* merged block happens to start with.
|
||||
*/
|
||||
export function recordWorkBlockChoice(
|
||||
choices: WorkBlockDisclosureChoices,
|
||||
itemIds: readonly string[],
|
||||
choice: boolean,
|
||||
): WorkBlockDisclosureChoices {
|
||||
const next = new Map(choices);
|
||||
for (const itemId of itemIds) next.set(itemId, choice);
|
||||
return next;
|
||||
}
|
||||
|
||||
const EMPTY_CHOICES: WorkBlockDisclosureChoices = new Map();
|
||||
|
||||
export type WorkBlockDisclosureStore = {
|
||||
choices: WorkBlockDisclosureChoices;
|
||||
choose: (itemIds: readonly string[], choice: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* `null` when no transcript is providing a store.
|
||||
*
|
||||
* Deliberately not a no-op store: a work block rendered on its own — which is
|
||||
* how most of its tests mount it — would then swallow every click silently and
|
||||
* look like a component whose disclosure is broken. `useWorkBlockDisclosure`
|
||||
* falls back to component-local state instead, which is correct in isolation
|
||||
* (nothing is regrouping the block) and is the behaviour a reader of that
|
||||
* component would expect. A provider emits no DOM, so `default` and
|
||||
* `compactPreview` markup is unaffected either way.
|
||||
*/
|
||||
const WorkBlockDisclosureContext =
|
||||
React.createContext<WorkBlockDisclosureStore | null>(null);
|
||||
|
||||
export function AgentSessionWorkBlockDisclosureProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const store = useWorkBlockDisclosureState();
|
||||
|
||||
return (
|
||||
<WorkBlockDisclosureContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkBlockDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The store's state and updater, so the fallback path can reuse it verbatim. */
|
||||
export function useWorkBlockDisclosureState(): WorkBlockDisclosureStore {
|
||||
const [choices, setChoices] =
|
||||
React.useState<WorkBlockDisclosureChoices>(EMPTY_CHOICES);
|
||||
const choose = React.useCallback(
|
||||
(itemIds: readonly string[], choice: boolean) => {
|
||||
setChoices((current) => recordWorkBlockChoice(current, itemIds, choice));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return React.useMemo(() => ({ choices, choose }), [choices, choose]);
|
||||
}
|
||||
|
||||
export function useWorkBlockDisclosureStore() {
|
||||
return React.useContext(WorkBlockDisclosureContext);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Work-block grouping: which of a turn's items fold onto the rail, where the
|
||||
* block splits, and what its folded line says.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatPreviousStepsLabel,
|
||||
formatWorkBlockSummaryLabel,
|
||||
groupConversationWorkBlocks,
|
||||
projectWorkBlockEntries,
|
||||
summarizeWorkBlock,
|
||||
windowWorkBlockEntries,
|
||||
WORK_BLOCK_LIVE_WINDOW_SIZE,
|
||||
} from "./agentSessionWorkBlockGrouping.ts";
|
||||
|
||||
const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
|
||||
|
||||
/**
|
||||
* Project the block's items with the block's own turn live — i.e. the agent is
|
||||
* working on `turn-1` right now, which is the turn every fixture here belongs
|
||||
* to. Most projection cases are about what a step looks like while its own turn
|
||||
* is being worked, so this is the default lens.
|
||||
*/
|
||||
const projectLive = (items) =>
|
||||
projectWorkBlockEntries(items, { liveTurnId: "turn-1" });
|
||||
|
||||
/**
|
||||
* Project with no live turn — reopened history, or a session that has ended.
|
||||
* The distinction matters because a tool item's `executing` status is written
|
||||
* once at the start and never revised if the agent dies, so it is only evidence
|
||||
* of work in flight when a session still owns the turn.
|
||||
*/
|
||||
const projectHistory = (items) =>
|
||||
projectWorkBlockEntries(items, { liveTurnId: null });
|
||||
|
||||
function thought(id, timestamp = "2026-06-14T19:00:02.000Z") {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "weighing the options",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function tool(id, overrides = {}) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
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",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function message(id, role, overrides = {}) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role,
|
||||
title: role === "user" ? "Ada" : "Test Agent",
|
||||
text: "hello",
|
||||
timestamp: "2026-06-14T19:00:09.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(id) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "plan",
|
||||
renderClass: "plan",
|
||||
title: "Plan",
|
||||
text: "- [ ] ship it",
|
||||
timestamp: "2026-06-14T19:00:07.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycle(id, renderClass) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "lifecycle",
|
||||
renderClass,
|
||||
title:
|
||||
renderClass === "permission"
|
||||
? "Permission requested"
|
||||
: "Context compacted",
|
||||
text: "",
|
||||
timestamp: "2026-06-14T19:00:06.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
const itemSegments = (...items) =>
|
||||
items.map((item) => ({ kind: "item", item }));
|
||||
const kinds = (segments) => segments.map((segment) => segment.kind);
|
||||
const blockItemIds = (segment) => segment.block.items.map((item) => item.id);
|
||||
|
||||
// ---- membership ----
|
||||
|
||||
test("thinking, tool steps and the prompt/answer split fold into one block", () => {
|
||||
const prompt = {
|
||||
kind: "prompt",
|
||||
user: message("msg:user", "user"),
|
||||
context: null,
|
||||
setup: [],
|
||||
};
|
||||
const answer = message("msg:answer", "assistant");
|
||||
const grouped = groupConversationWorkBlocks([
|
||||
prompt,
|
||||
...itemSegments(thought("thought:1"), tool("tool:1"), tool("tool:2")),
|
||||
{ kind: "item", item: answer },
|
||||
]);
|
||||
|
||||
assert.deepEqual(kinds(grouped), ["prompt", "work-block", "item"]);
|
||||
assert.deepEqual(blockItemIds(grouped[1]), ["thought:1", "tool:1", "tool:2"]);
|
||||
assert.equal(
|
||||
grouped[2].item.id,
|
||||
"msg:answer",
|
||||
"the turn's answer stays outside the block as prose",
|
||||
);
|
||||
});
|
||||
|
||||
test("a single work item still becomes a block", () => {
|
||||
// No minimum: a lone step must not render through a different mechanism than
|
||||
// a run of steps, or the rail would appear and disappear by count.
|
||||
const grouped = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
|
||||
assert.deepEqual(kinds(grouped), ["work-block"]);
|
||||
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
|
||||
});
|
||||
|
||||
test("an interim agent note is work but the final answer is not", () => {
|
||||
const grouped = groupConversationWorkBlocks(
|
||||
itemSegments(
|
||||
tool("tool:1"),
|
||||
message("msg:interim", "assistant"),
|
||||
tool("tool:2"),
|
||||
message("msg:answer", "assistant"),
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(kinds(grouped), ["work-block", "item"]);
|
||||
assert.deepEqual(
|
||||
blockItemIds(grouped[0]),
|
||||
["tool:1", "msg:interim", "tool:2"],
|
||||
"a mid-turn note reads as progress and belongs on the rail",
|
||||
);
|
||||
assert.equal(grouped[1].item.id, "msg:answer");
|
||||
});
|
||||
|
||||
test("only the LAST assistant message is treated as the answer", () => {
|
||||
// Two trailing notes: the earlier one is still work.
|
||||
const grouped = groupConversationWorkBlocks(
|
||||
itemSegments(
|
||||
tool("tool:1"),
|
||||
message("msg:first", "assistant"),
|
||||
message("msg:last", "assistant"),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(kinds(grouped), ["work-block", "item"]);
|
||||
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "msg:first"]);
|
||||
assert.equal(grouped[1].item.id, "msg:last");
|
||||
});
|
||||
|
||||
test("a turn with no answer folds all of its work", () => {
|
||||
// Mid-turn: nothing has been answered yet, so every item is work and the
|
||||
// block must not hold back its most recent step waiting for an answer.
|
||||
const grouped = groupConversationWorkBlocks(
|
||||
itemSegments(thought("thought:1"), tool("tool:1")),
|
||||
);
|
||||
assert.deepEqual(kinds(grouped), ["work-block"]);
|
||||
assert.deepEqual(blockItemIds(grouped[0]), ["thought:1", "tool:1"]);
|
||||
});
|
||||
|
||||
// ---- what stays out ----
|
||||
|
||||
test("the plan stays a sibling and splits the work around it", () => {
|
||||
const grouped = groupConversationWorkBlocks(
|
||||
itemSegments(tool("tool:1"), plan("plan:1"), tool("tool:2")),
|
||||
);
|
||||
|
||||
assert.deepEqual(kinds(grouped), ["work-block", "item", "work-block"]);
|
||||
assert.equal(grouped[1].item.id, "plan:1");
|
||||
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
|
||||
assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
|
||||
});
|
||||
|
||||
test("permission gates and errors stay loud, in position", () => {
|
||||
for (const renderClass of ["permission", "error", "status"]) {
|
||||
const grouped = groupConversationWorkBlocks(
|
||||
itemSegments(
|
||||
tool("tool:1"),
|
||||
lifecycle(`life:${renderClass}`, renderClass),
|
||||
tool("tool:2"),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
kinds(grouped),
|
||||
["work-block", "item", "work-block"],
|
||||
`${renderClass} must never fold into a collapsed block`,
|
||||
);
|
||||
assert.equal(
|
||||
grouped[1].item.id,
|
||||
`life:${renderClass}`,
|
||||
`${renderClass} must stay where it happened, not be lifted out of order`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the user's prompt is never work", () => {
|
||||
const grouped = groupConversationWorkBlocks([
|
||||
{
|
||||
kind: "prompt",
|
||||
user: message("msg:user", "user"),
|
||||
context: null,
|
||||
setup: [],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(kinds(grouped), ["prompt"]);
|
||||
});
|
||||
|
||||
test("setup segments neither join a block nor split one", () => {
|
||||
// Setup renders as a quiet divider, not as work, and contributes no items —
|
||||
// so it must not sit between two blocks that ought to be one.
|
||||
const grouped = groupConversationWorkBlocks([
|
||||
{ kind: "item", item: tool("tool:1") },
|
||||
{ kind: "setup", items: [] },
|
||||
{ kind: "item", item: tool("tool:2") },
|
||||
]);
|
||||
assert.deepEqual(kinds(grouped), ["work-block", "setup", "work-block"]);
|
||||
});
|
||||
|
||||
test("a default-variant summary is expanded back to its leaf steps", () => {
|
||||
// The other variants' "Read 3 files" summaries are a competing answer to the
|
||||
// same grouping problem. Nesting one inside the block would cost the reader a
|
||||
// second click to reach a step.
|
||||
const grouped = groupConversationWorkBlocks([
|
||||
{
|
||||
kind: "summary",
|
||||
summary: {
|
||||
id: "summary:shell:tool:1",
|
||||
label: "Ran 2 commands",
|
||||
count: 2,
|
||||
items: [tool("tool:1"), tool("tool:2")],
|
||||
renderClass: "shell",
|
||||
variant: "same-kind",
|
||||
timestamp: "2026-06-14T19:00:04.000Z",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(kinds(grouped), ["work-block"]);
|
||||
assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "tool:2"]);
|
||||
});
|
||||
|
||||
// ---- identity ----
|
||||
|
||||
test("the block id is derived from its first step so appends do not remount it", () => {
|
||||
const first = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
|
||||
const grown = groupConversationWorkBlocks(
|
||||
itemSegments(tool("tool:1"), tool("tool:2"), tool("tool:3")),
|
||||
);
|
||||
assert.equal(
|
||||
grown[0].block.id,
|
||||
first[0].block.id,
|
||||
"a growing block must keep its identity or the reader's disclosure choice is lost",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- projection ----
|
||||
|
||||
test("every item projects to exactly one rail kind, paired with its own item type", () => {
|
||||
// The closed set is the point: glyph and body render off a switch over these
|
||||
// three, so an item that projected to nothing (or to two things) is what let
|
||||
// a note pick up a wrench on the abandoned card.
|
||||
//
|
||||
// The pairing half is what makes the body switch able to read `item.text`
|
||||
// directly. `WorkBlockEntry` is a discriminated union, so `{ kind: "note",
|
||||
// item: <thought> }` does not type-check — but a hand-written swap between the
|
||||
// two prose branches is the easy mistake, and TypeScript is stripped at
|
||||
// runtime, so the pairing is asserted here as well.
|
||||
const entries = projectLive([
|
||||
thought("thought:1"),
|
||||
message("msg:interim", "assistant"),
|
||||
tool("tool:1"),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.kind),
|
||||
["thought", "note", "tool"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => [entry.kind, entry.item.type]),
|
||||
[
|
||||
["thought", "thought"],
|
||||
["note", "message"],
|
||||
["tool", "tool"],
|
||||
],
|
||||
"a kind always carries its own item type — the body switch reads the item without re-checking",
|
||||
);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.item.id),
|
||||
["thought:1", "msg:interim", "tool:1"],
|
||||
"projection preserves arrival order",
|
||||
);
|
||||
});
|
||||
|
||||
test("only a tool step can be running or failed", () => {
|
||||
const stateOf = (item) => projectLive([item])[0].state;
|
||||
|
||||
assert.equal(stateOf(tool("t", { status: "executing" })), "running");
|
||||
assert.equal(stateOf(tool("t", { status: "pending" })), "running");
|
||||
assert.equal(
|
||||
stateOf(tool("t", { status: "failed" })),
|
||||
"failed",
|
||||
"a failed status is a failure even without isError",
|
||||
);
|
||||
assert.equal(
|
||||
stateOf(tool("t", { isError: true })),
|
||||
"failed",
|
||||
"an error result is a failure even when the status reads completed",
|
||||
);
|
||||
assert.equal(stateOf(tool("t")), "settled");
|
||||
|
||||
// Prose has no outcome of its own. A thought whose text happens to mention a
|
||||
// failure, or a note, must not colour the rail or the folded count.
|
||||
assert.equal(stateOf(thought("thought:1")), "settled");
|
||||
assert.equal(stateOf(message("msg:1", "assistant")), "settled");
|
||||
});
|
||||
|
||||
test("a step that is both running and errored still reads as running", () => {
|
||||
// Order matters: a tool can carry a stale isError from a retry while the new
|
||||
// attempt executes. Reporting it as failed would fold a live block's count to
|
||||
// "N steps · 1 failed" while the work is still in flight.
|
||||
assert.equal(
|
||||
projectLive([tool("t", { status: "executing", isError: true })])[0].state,
|
||||
"running",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- liveness ----
|
||||
|
||||
/**
|
||||
* An abandoned step is not a running step.
|
||||
*
|
||||
* `executing` is written when a step starts and never revised if the agent dies
|
||||
* first, so reopened history keeps that status forever. In this block `running`
|
||||
* is not just a glyph — one running entry makes `summarizeWorkBlock` report
|
||||
* `isActive`, which suppresses the folded summary line and holds the rail open.
|
||||
* Ungated, a single orphaned step therefore renders finished history as work in
|
||||
* progress, pulsing indefinitely.
|
||||
*/
|
||||
test("an executing step whose turn is not live reads as settled, not running", () => {
|
||||
const orphan = tool("t", { status: "executing", completedAt: null });
|
||||
|
||||
assert.equal(
|
||||
projectLive([orphan])[0].state,
|
||||
"running",
|
||||
"the same item IS running while a session owns its turn",
|
||||
);
|
||||
assert.equal(
|
||||
projectHistory([orphan])[0].state,
|
||||
"settled",
|
||||
"with no live turn the status only says the step began, not that it is happening",
|
||||
);
|
||||
assert.equal(
|
||||
projectWorkBlockEntries([orphan], { liveTurnId: "turn-2" })[0].state,
|
||||
"settled",
|
||||
"an agent live on a LATER turn does not resurrect an earlier turn's abandoned step",
|
||||
);
|
||||
});
|
||||
|
||||
test("a pending step whose turn is not live reads as settled too", () => {
|
||||
// Both in-flight statuses go through the same gate; `pending` is the one a
|
||||
// crash between queue and start leaves behind.
|
||||
const queued = tool("t", { status: "pending", completedAt: null });
|
||||
assert.equal(projectLive([queued])[0].state, "running");
|
||||
assert.equal(projectHistory([queued])[0].state, "settled");
|
||||
});
|
||||
|
||||
test("an abandoned step is settled rather than failed, so it never inflates the failure count", () => {
|
||||
// We do not know an abandoned step failed — only that nobody finished it.
|
||||
// Counting it as a failure would put "1 failed" on the folded line for work
|
||||
// that may well have succeeded without its terminal update being recorded.
|
||||
const status = summarizeWorkBlock(
|
||||
projectHistory([
|
||||
tool("tool:1"),
|
||||
tool("tool:2", { status: "executing", completedAt: null }),
|
||||
]),
|
||||
{ streamingItemId: null },
|
||||
);
|
||||
assert.deepEqual(status, { count: 2, failedCount: 0, isActive: false });
|
||||
assert.equal(
|
||||
formatWorkBlockSummaryLabel(status),
|
||||
"2 steps",
|
||||
"reopened history folds to a neutral count",
|
||||
);
|
||||
});
|
||||
|
||||
test("a genuinely failed step is still failed when its turn is not live", () => {
|
||||
// The gate is only about the in-flight statuses. A step that recorded a
|
||||
// failure recorded a fact, and history must keep reporting it.
|
||||
const failed = () => tool("t", { isError: true, status: "failed" });
|
||||
assert.equal(projectHistory([failed()])[0].state, "failed");
|
||||
assert.equal(
|
||||
summarizeWorkBlock(projectHistory([failed()]), { streamingItemId: null })
|
||||
.failedCount,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("a step with no turn id is not owned by a live turn", () => {
|
||||
// `null === null` must not read as ownership: an item that never recorded a
|
||||
// turn cannot be shown to belong to the live one.
|
||||
assert.equal(
|
||||
projectWorkBlockEntries(
|
||||
[tool("t", { status: "executing", completedAt: null, turnId: null })],
|
||||
{ liveTurnId: null },
|
||||
)[0].state,
|
||||
"settled",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- status ----
|
||||
|
||||
test("summarizeWorkBlock counts steps and failures", () => {
|
||||
const status = summarizeWorkBlock(
|
||||
projectLive([
|
||||
tool("tool:1"),
|
||||
tool("tool:2", { isError: true, status: "failed" }),
|
||||
thought("thought:1"),
|
||||
]),
|
||||
{ streamingItemId: null },
|
||||
);
|
||||
assert.deepEqual(status, { count: 3, failedCount: 1, isActive: false });
|
||||
});
|
||||
|
||||
test("a block is active when a step is running OR when it holds the streaming item", () => {
|
||||
assert.equal(
|
||||
summarizeWorkBlock(
|
||||
projectLive([tool("tool:1", { status: "executing", completedAt: null })]),
|
||||
{ streamingItemId: null },
|
||||
).isActive,
|
||||
true,
|
||||
"a step reporting itself as executing is enough",
|
||||
);
|
||||
|
||||
// A thought streaming in carries no tool status, so status alone would miss it.
|
||||
const streamingThought = projectLive([thought("thought:1")]);
|
||||
assert.equal(
|
||||
summarizeWorkBlock(streamingThought, { streamingItemId: "thought:1" })
|
||||
.isActive,
|
||||
true,
|
||||
"the list's streaming hint covers work that carries no status of its own",
|
||||
);
|
||||
assert.equal(
|
||||
summarizeWorkBlock(streamingThought, { streamingItemId: "other" }).isActive,
|
||||
false,
|
||||
"a streaming item in a DIFFERENT block must not make this one live",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- labels ----
|
||||
|
||||
test("the folded line names a failure rather than hiding it behind a count", () => {
|
||||
assert.equal(
|
||||
formatWorkBlockSummaryLabel({ count: 6, failedCount: 0, isActive: false }),
|
||||
"6 steps",
|
||||
);
|
||||
assert.equal(
|
||||
formatWorkBlockSummaryLabel({ count: 6, failedCount: 1, isActive: false }),
|
||||
"6 steps · 1 failed",
|
||||
);
|
||||
assert.equal(
|
||||
formatWorkBlockSummaryLabel({ count: 6, failedCount: 2, isActive: false }),
|
||||
"6 steps · 2 failed",
|
||||
);
|
||||
assert.equal(
|
||||
formatWorkBlockSummaryLabel({ count: 1, failedCount: 0, isActive: false }),
|
||||
"1 step",
|
||||
"a single step must not read as '1 steps'",
|
||||
);
|
||||
});
|
||||
|
||||
test("the previous-steps label is singular for one step", () => {
|
||||
assert.equal(formatPreviousStepsLabel(1), "1 previous step");
|
||||
assert.equal(formatPreviousStepsLabel(4), "4 previous steps");
|
||||
});
|
||||
|
||||
// ---- live window ----
|
||||
|
||||
const entryIds = (entries) => entries.map((entry) => entry.item.id);
|
||||
|
||||
test("a live block shows the last N steps in true order and hides the rest", () => {
|
||||
const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
|
||||
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
assert.equal(visibleEntries.length, WORK_BLOCK_LIVE_WINDOW_SIZE);
|
||||
assert.deepEqual(
|
||||
entryIds(visibleEntries),
|
||||
["c", "d", "e"],
|
||||
"the window is chronological — arrival order, not reversed",
|
||||
);
|
||||
assert.deepEqual(entryIds(hiddenEntries), ["a", "b"]);
|
||||
});
|
||||
|
||||
test("a finished block shows every step", () => {
|
||||
const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
|
||||
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
|
||||
isActive: false,
|
||||
});
|
||||
assert.equal(hiddenEntries.length, 0);
|
||||
assert.equal(visibleEntries.length, 5);
|
||||
});
|
||||
|
||||
test("a live block at or under the window size hides nothing", () => {
|
||||
const entries = projectLive(["a", "b", "c"].map((id) => tool(id)));
|
||||
const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
|
||||
isActive: true,
|
||||
});
|
||||
assert.equal(
|
||||
hiddenEntries.length,
|
||||
0,
|
||||
"no disclosure until there is a step to hide",
|
||||
);
|
||||
assert.equal(visibleEntries.length, 3);
|
||||
});
|
||||
428
desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
Normal file
428
desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import type {
|
||||
TranscriptTurnSegment,
|
||||
TranscriptDisplayBlock,
|
||||
} from "./agentSessionTranscriptGrouping";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
|
||||
/** Reasoning. */
|
||||
type WorkBlockThoughtItem = Extract<TranscriptItem, { type: "thought" }>;
|
||||
/**
|
||||
* An interim agent note: an assistant message that is not the turn's answer.
|
||||
* The `role` intersection is what makes a *user* message unrepresentable as a
|
||||
* note, so the rail cannot present the reader's own prompt as agent progress.
|
||||
*/
|
||||
type WorkBlockNoteItem = Extract<TranscriptItem, { type: "message" }> & {
|
||||
role: "assistant";
|
||||
};
|
||||
/** A step the agent ran. */
|
||||
type WorkBlockToolItem = Extract<TranscriptItem, { type: "tool" }>;
|
||||
|
||||
/**
|
||||
* The items a work block admits, as a closed union.
|
||||
*
|
||||
* This is the type-level half of `isWorkItem`: a block cannot hold a plan, a
|
||||
* lifecycle row or the turn's answer, so no downstream code has to consider
|
||||
* what those would look like on the rail. Adding a `TranscriptItem` variant to
|
||||
* this union is a deliberate act that forces a projection decision (see
|
||||
* `projectWorkBlockEntry`) rather than letting the new type inherit another
|
||||
* kind's presentation.
|
||||
*/
|
||||
export type WorkBlockItem =
|
||||
| WorkBlockThoughtItem
|
||||
| WorkBlockNoteItem
|
||||
| WorkBlockToolItem;
|
||||
|
||||
/**
|
||||
* One turn's work block: the thinking, tool steps and interim agent notes that
|
||||
* happened between the reader's prompt and the agent's answer, presented as a
|
||||
* single rail rather than as a row per item.
|
||||
*/
|
||||
export type TranscriptWorkBlock = {
|
||||
/**
|
||||
* Stable identity, derived from the FIRST item in the block. A block grows in
|
||||
* place as later steps stream in, so keying on the first item keeps the id
|
||||
* append-stable — anything derived from the last item would remount the block
|
||||
* on every append and throw away the reader's disclosure choice.
|
||||
*/
|
||||
id: string;
|
||||
/** Steps in true arrival order. */
|
||||
items: WorkBlockItem[];
|
||||
/** Timestamp of the block's first step. */
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A turn's segments as the `conversation` variant renders them: the shared
|
||||
* segment union plus the work block, which only this variant produces.
|
||||
*/
|
||||
export type TranscriptConversationSegment =
|
||||
| TranscriptTurnSegment
|
||||
| { kind: "work-block"; block: TranscriptWorkBlock };
|
||||
|
||||
/**
|
||||
* Which items are *work* — the material the block absorbs.
|
||||
*
|
||||
* - **thoughts** — reasoning is a step on the rail, not a sibling disclosure.
|
||||
* - **tool steps**, including failed ones. A failure belongs to the work it
|
||||
* happened in; the folded line reports it (`N steps · 1 failed`) so it is
|
||||
* never hidden behind a neutral count.
|
||||
* - **interim agent notes** — an assistant message that is not the turn's
|
||||
* answer. A relay post the agent makes mid-turn is a tool step and lands
|
||||
* here for the same reason: it reads as progress, not as the reply.
|
||||
*
|
||||
* Everything else stays outside and keeps its own row. Plans stay a sibling
|
||||
* (Buzz's checklist is a first-class surface, not a step), and lifecycle rows —
|
||||
* permission gates, errors, status/compaction notices — are exactly the rows a
|
||||
* reader may need to act on or reinterpret the rest of the turn through, so
|
||||
* they never fold into a collapsed block.
|
||||
*
|
||||
* A type guard rather than a predicate, so the admitted set is checked once
|
||||
* here and every later stage receives `WorkBlockItem` — the reason the entry
|
||||
* projection can be exhaustive at all.
|
||||
*/
|
||||
function isWorkItem(
|
||||
item: TranscriptItem,
|
||||
finalAnswerId: string | null,
|
||||
): item is WorkBlockItem {
|
||||
if (item.type === "thought" || item.type === "tool") return true;
|
||||
return (
|
||||
item.type === "message" &&
|
||||
item.role === "assistant" &&
|
||||
item.id !== finalAnswerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's answer: its LAST assistant message.
|
||||
*
|
||||
* Deliberately positional rather than liveness-derived. While a turn streams,
|
||||
* the message currently arriving is already the presumptive answer, so the same
|
||||
* rule holds mid-turn and after it settles — the block does not reshuffle its
|
||||
* membership at the moment a turn completes.
|
||||
*/
|
||||
function findFinalAnswerId(segments: TranscriptTurnSegment[]): string | null {
|
||||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||
const segment = segments[index];
|
||||
if (
|
||||
segment.kind === "item" &&
|
||||
segment.item.type === "message" &&
|
||||
segment.item.role === "assistant"
|
||||
) {
|
||||
return segment.item.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaf items a segment contributes as candidate work, in reading order.
|
||||
*
|
||||
* Summary segments are expanded back to their leaf tool rows: the default
|
||||
* variant's "Read 3 files" summaries are a *different* answer to the same
|
||||
* grouping problem, and nesting them inside the block would give the reader two
|
||||
* collapsed layers to open before reaching a step. The block is the one
|
||||
* grouping in this variant.
|
||||
*/
|
||||
function segmentWorkCandidates(
|
||||
segment: TranscriptTurnSegment,
|
||||
): TranscriptItem[] {
|
||||
if (segment.kind === "item") return [segment.item];
|
||||
if (segment.kind === "summary") return segment.summary.items;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The segment's candidates as admitted work items, or `null` when the segment
|
||||
* is not (entirely) work and therefore keeps its own row.
|
||||
*
|
||||
* All-or-nothing per segment, as before: a summary segment is one grouping
|
||||
* decision the shared code already made, so admitting half of it would emit its
|
||||
* remaining rows outside the block they belong to. Returning the narrowed array
|
||||
* rather than a boolean is what carries `WorkBlockItem` into the block — an
|
||||
* `.every()` guard cannot narrow the array it tested.
|
||||
*/
|
||||
function admittedWorkItems(
|
||||
segment: TranscriptTurnSegment,
|
||||
finalAnswerId: string | null,
|
||||
): WorkBlockItem[] | null {
|
||||
const candidates = segmentWorkCandidates(segment);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const admitted: WorkBlockItem[] = [];
|
||||
for (const item of candidates) {
|
||||
if (!isWorkItem(item, finalAnswerId)) return null;
|
||||
admitted.push(item);
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse each maximal run of consecutive work items in a turn into one work
|
||||
* block.
|
||||
*
|
||||
* ## Why runs, and not "everything between the prompt and the answer"
|
||||
*
|
||||
* A turn is normally one block: prompt → (thinking, tools, notes) → answer, with
|
||||
* the plan checklist as a sibling. The span reading only differs when a
|
||||
* non-work row lands *inside* the work — a permission gate, an error, a
|
||||
* mid-turn plan update — and there the two readings disagree about order:
|
||||
* producing a single block would mean lifting that row out and re-emitting it
|
||||
* somewhere it did not happen.
|
||||
*
|
||||
* Splitting instead keeps every row where it occurred. That matters most for
|
||||
* exactly the rows this is about: a permission gate is a question asked at a
|
||||
* moment, and an error is the reason the steps after it look the way they do.
|
||||
* Moving either away from its position would cost the reader the thing that
|
||||
* makes it legible. So an interruption genuinely splits the work, and the seam
|
||||
* is the interruption itself.
|
||||
*/
|
||||
export function groupConversationWorkBlocks(
|
||||
segments: TranscriptTurnSegment[],
|
||||
): TranscriptConversationSegment[] {
|
||||
const finalAnswerId = findFinalAnswerId(segments);
|
||||
const grouped: TranscriptConversationSegment[] = [];
|
||||
|
||||
let pending: WorkBlockItem[] = [];
|
||||
const flush = () => {
|
||||
if (pending.length === 0) return;
|
||||
grouped.push({
|
||||
kind: "work-block",
|
||||
block: {
|
||||
id: `work-block:${pending[0].id}`,
|
||||
items: pending,
|
||||
timestamp: pending[0].timestamp,
|
||||
},
|
||||
});
|
||||
pending = [];
|
||||
};
|
||||
|
||||
for (const segment of segments) {
|
||||
const work = admittedWorkItems(segment, finalAnswerId);
|
||||
|
||||
if (work !== null) {
|
||||
pending.push(...work);
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
grouped.push(segment);
|
||||
}
|
||||
|
||||
flush();
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/** Apply work-block grouping to every turn in a display block. */
|
||||
export function conversationSegmentsForBlock(
|
||||
block: Extract<TranscriptDisplayBlock, { kind: "turn" }>,
|
||||
): TranscriptConversationSegment[] {
|
||||
return groupConversationWorkBlocks(block.segments);
|
||||
}
|
||||
|
||||
/** A tool step's rail state. Only a tool step can be in flight or have failed. */
|
||||
export type WorkBlockEntryState = "running" | "failed" | "settled";
|
||||
|
||||
/**
|
||||
* One projected rail row: what a row *is*, as a closed set.
|
||||
*
|
||||
* - `thought` — reasoning.
|
||||
* - `note` — an interim agent message: prose the agent addressed to the
|
||||
* reader mid-turn, which is not the turn's answer. berd models this as a
|
||||
* distinct `progress` entry and gives it the same speech-bubble glyph as a
|
||||
* thought, because it reads as the agent talking, not as the agent acting.
|
||||
* - `tool` — a step the agent ran.
|
||||
*
|
||||
* Projection happens ONCE per item, and everything downstream — the glyph, the
|
||||
* body, the folded line's counts — reads this rather than re-deriving it. Two
|
||||
* independent classifications of the same item is exactly how the headline and
|
||||
* the chain eligibility drifted apart on the abandoned tool-chain card, and
|
||||
* asking `item.type === ...` at each render site is what let a note fall
|
||||
* through to the tool branch and pick up a wrench.
|
||||
*
|
||||
* A discriminated union rather than a product of independent
|
||||
* `{ item, kind, state }` fields, so the model itself rules out the
|
||||
* combinations a product type leaves representable — and which every render
|
||||
* site would otherwise have to defend against:
|
||||
*
|
||||
* - a `note` kind carrying a thought item (or vice versa), which is why the
|
||||
* body switch previously had to re-check `item.type` and had a silent
|
||||
* empty-string branch for the mismatch it could not otherwise handle;
|
||||
* - prose that claims to be `running` or `failed`. Only a tool step has an
|
||||
* outcome, so `state` is fixed to `settled` on the prose kinds — an edit
|
||||
* that let a thought report a failure would not compile, rather than
|
||||
* quietly adding to the folded line's `N failed`.
|
||||
*/
|
||||
export type WorkBlockEntry =
|
||||
| { kind: "thought"; item: WorkBlockThoughtItem; state: "settled" }
|
||||
| { kind: "note"; item: WorkBlockNoteItem; state: "settled" }
|
||||
| { kind: "tool"; item: WorkBlockToolItem; state: WorkBlockEntryState };
|
||||
|
||||
/**
|
||||
* A tool step's outcome.
|
||||
*
|
||||
* Order matters: a tool can carry a stale `isError` from a retry while the new
|
||||
* attempt executes, and reporting that as failed would fold a live block's
|
||||
* count to "N steps · 1 failed" while the work is still in flight.
|
||||
*
|
||||
* `executing`/`pending` only counts as *running* when a session actually owns
|
||||
* the step's turn. That status is written when the step starts and is never
|
||||
* revised if the agent dies first, so on its own it says "this step began", not
|
||||
* "this step is happening" — reopened history full of abandoned steps would
|
||||
* otherwise present as work in progress forever. See `liveTurnId`.
|
||||
*
|
||||
* An abandoned step is reported as `settled`, not as a new state: we do not
|
||||
* know that it failed, so it must not count toward the folded line's
|
||||
* `N failed`, and inventing a third outcome would put a marker on the rail for
|
||||
* something the reader cannot act on. It renders as the neutral step it is,
|
||||
* with whatever detail it managed to record.
|
||||
*/
|
||||
function toolEntryState(
|
||||
item: WorkBlockToolItem,
|
||||
liveTurnId: string | null,
|
||||
): WorkBlockEntryState {
|
||||
if (item.status === "executing" || item.status === "pending") {
|
||||
// Compared rather than tested for truthiness: an item with no turn id
|
||||
// cannot be owned by a live turn, and `null === null` must not read as
|
||||
// ownership.
|
||||
return liveTurnId !== null && item.turnId === liveTurnId
|
||||
? "running"
|
||||
: "settled";
|
||||
}
|
||||
if (item.isError || item.status === "failed") return "failed";
|
||||
return "settled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one admitted item into its rail entry.
|
||||
*
|
||||
* The switch is exhaustive over `WorkBlockItem` with no default: adding a
|
||||
* variant to that union without deciding what it looks like on the rail leaves
|
||||
* this function without an ending return, which is a type error. That is the
|
||||
* whole point of the closed union — a new item type must fail loudly here
|
||||
* instead of falling through to the tool branch and wearing a wrench.
|
||||
*/
|
||||
function projectWorkBlockEntry(
|
||||
item: WorkBlockItem,
|
||||
liveTurnId: string | null,
|
||||
): WorkBlockEntry {
|
||||
switch (item.type) {
|
||||
case "thought":
|
||||
return { item, kind: "thought", state: "settled" };
|
||||
case "message":
|
||||
return { item, kind: "note", state: "settled" };
|
||||
case "tool":
|
||||
return { item, kind: "tool", state: toolEntryState(item, liveTurnId) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a block's items into rail entries, in true arrival order.
|
||||
*
|
||||
* `liveTurnId` is required rather than optional: whether a step is running is
|
||||
* not a property of the step alone, and a default would let a caller that has
|
||||
* not thought about liveness get the old spins-forever behaviour silently.
|
||||
*/
|
||||
export function projectWorkBlockEntries(
|
||||
items: WorkBlockItem[],
|
||||
options: { liveTurnId: string | null },
|
||||
): WorkBlockEntry[] {
|
||||
return items.map((item) => projectWorkBlockEntry(item, options.liveTurnId));
|
||||
}
|
||||
|
||||
export type WorkBlockStatus = {
|
||||
/** Total steps on the rail. */
|
||||
count: number;
|
||||
/** Steps that failed. */
|
||||
failedCount: number;
|
||||
/**
|
||||
* Whether work is still happening: a step is pending/executing, or the turn
|
||||
* is live and its streaming item belongs to this block.
|
||||
*/
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate state of a block, read off the SAME projection the rail renders.
|
||||
*
|
||||
* `isActive` is what decides live-vs-finished presentation, so it accepts both
|
||||
* evidence sources: an entry projected as `running`, and the list's
|
||||
* streaming-item hint. Either alone leaves a real gap — a thought streaming in
|
||||
* carries no tool status, and a tool left executing when the observer stream
|
||||
* drops would otherwise pin the block open forever if we trusted status alone.
|
||||
*/
|
||||
export function summarizeWorkBlock(
|
||||
entries: WorkBlockEntry[],
|
||||
options: { streamingItemId: string | null },
|
||||
): WorkBlockStatus {
|
||||
let failedCount = 0;
|
||||
let isActive = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.state === "failed") failedCount += 1;
|
||||
if (entry.state === "running") isActive = true;
|
||||
if (
|
||||
options.streamingItemId !== null &&
|
||||
entry.item.id === options.streamingItemId
|
||||
) {
|
||||
isActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { count: entries.length, failedCount, isActive };
|
||||
}
|
||||
|
||||
function pluralSteps(count: number) {
|
||||
return count === 1 ? "1 step" : `${count} steps`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folded line for a finished block.
|
||||
*
|
||||
* berd's is a bare count. The failure clause is a deliberate departure: a count
|
||||
* alone is the one thing a reader cannot tell a clean run from a broken one by,
|
||||
* and a fold that hides a failure behind a neutral number invites them not to
|
||||
* open it.
|
||||
*/
|
||||
export function formatWorkBlockSummaryLabel(status: WorkBlockStatus): string {
|
||||
const steps = pluralSteps(status.count);
|
||||
if (status.failedCount === 0) return steps;
|
||||
return `${steps} · ${status.failedCount} failed`;
|
||||
}
|
||||
|
||||
/** Label for the older steps tucked above the live window. */
|
||||
export function formatPreviousStepsLabel(count: number): string {
|
||||
return count === 1 ? "1 previous step" : `${count} previous steps`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many steps stay on the rail while work is in flight. The rest go behind
|
||||
* the "N previous steps" disclosure, so a long run does not push the answer off
|
||||
* screen while the reader is watching it arrive.
|
||||
*/
|
||||
export const WORK_BLOCK_LIVE_WINDOW_SIZE = 3;
|
||||
|
||||
export type WorkBlockWindow = {
|
||||
/** Entries rendered on the rail, in true order. */
|
||||
visibleEntries: WorkBlockEntry[];
|
||||
/** Older entries behind the disclosure, in true order. */
|
||||
hiddenEntries: WorkBlockEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Chronological window over a live block: the last N steps in true arrival
|
||||
* order, with everything older behind the disclosure. A finished (or reader-
|
||||
* expanded) block shows every step, so windowing only applies while live.
|
||||
*/
|
||||
export function windowWorkBlockEntries(
|
||||
entries: WorkBlockEntry[],
|
||||
options: { isActive: boolean },
|
||||
): WorkBlockWindow {
|
||||
if (!options.isActive || entries.length <= WORK_BLOCK_LIVE_WINDOW_SIZE) {
|
||||
return { hiddenEntries: [], visibleEntries: entries };
|
||||
}
|
||||
const splitAt = entries.length - WORK_BLOCK_LIVE_WINDOW_SIZE;
|
||||
return {
|
||||
hiddenEntries: entries.slice(0, splitAt),
|
||||
visibleEntries: entries.slice(splitAt),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -81,6 +82,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({
|
||||
@@ -95,6 +105,7 @@ export function AgentSessionThreadPanel({
|
||||
onClose,
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
transcriptVariant = "default",
|
||||
}: AgentSessionThreadPanelProps) {
|
||||
const isLive = isManagedAgentActive(agent);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
@@ -495,6 +506,7 @@ export function AgentSessionThreadPanel({
|
||||
rawLayout="exclusive"
|
||||
showHeader={false}
|
||||
showRaw={showRawFeed}
|
||||
transcriptVariant={transcriptVariant}
|
||||
/>
|
||||
</div>
|
||||
</AuxiliaryPanelBody>
|
||||
|
||||
@@ -19,6 +19,31 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
import { getReactNodeText } from "./utils";
|
||||
|
||||
/**
|
||||
* Code-block presentation, supplied by the rendering surface.
|
||||
*
|
||||
* `focusProse` applies berd's recipe (`code-block.tsx` CodeBlockHeader:388-402,
|
||||
* viewport:528-529): the language in a real header row above the frame with the
|
||||
* copy action opposite it, a 10px radius, page-background fill behind a subtle
|
||||
* border, and no shadow.
|
||||
*
|
||||
* It is opt-in because this renderer is shared with channel messages —
|
||||
* restyling the default would change every code block in the app. The variant
|
||||
* is a property of the *surface*, not of a role: `AgentSessionTranscriptList`
|
||||
* provides it once at the transcript boundary, so a fence in a human prompt
|
||||
* gets the same chrome as one in an agent reply.
|
||||
*
|
||||
* Delivered through context rather than a `MarkdownCodeBlock` prop for the same
|
||||
* reason as `VideoReviewMarkdownContext`: the component map handed to
|
||||
* react-markdown must stay module-stable. A prop would have to be threaded
|
||||
* through `createMarkdownComponents`, which would also mean partitioning the
|
||||
* parsed-node cache that identifies that map by variant string
|
||||
* (`nodeCache.ts`). A context read at render time needs neither.
|
||||
*/
|
||||
export const CodeBlockVariantContext = React.createContext<
|
||||
"default" | "focusProse"
|
||||
>("default");
|
||||
|
||||
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
|
||||
null;
|
||||
let shikiInitPromise: Promise<void> | null = null;
|
||||
@@ -75,6 +100,8 @@ export function MarkdownCodeBlock({
|
||||
const [isCopying, setIsCopying] = React.useState(false);
|
||||
const codeBlockRef = React.useRef<HTMLPreElement | null>(null);
|
||||
const code = React.useMemo(() => getCodeBlockText(children), [children]);
|
||||
const isFocusProse =
|
||||
React.useContext(CodeBlockVariantContext) === "focusProse";
|
||||
useSmoothCorners(codeBlockRef);
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
@@ -96,6 +123,52 @@ export function MarkdownCodeBlock({
|
||||
[code],
|
||||
);
|
||||
|
||||
const focusProseCopyButton = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy code block"
|
||||
// In berd's header row the action is a flow sibling of the language
|
||||
// label, so it needs no absolute placement, backdrop, or ring to stay
|
||||
// legible over the code.
|
||||
className="h-5 w-5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
|
||||
disabled={isCopying}
|
||||
onClick={handleCopy}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Copy code block</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
if (isFocusProse) {
|
||||
return (
|
||||
<div className="group relative w-full min-w-0" data-code-block="">
|
||||
<div
|
||||
className="flex min-h-7 items-end justify-between gap-2 text-2xs leading-4 text-muted-foreground"
|
||||
data-testid="markdown-code-block-header"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono">{language}</span>
|
||||
{focusProseCopyButton}
|
||||
</div>
|
||||
<pre
|
||||
className="relative max-h-[400px] min-w-0 max-w-full overflow-auto rounded-lg border border-border/80 bg-background px-3 py-1.5"
|
||||
ref={codeBlockRef}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Unchanged default path: this renderer is shared with channel messages, so
|
||||
// its markup stays byte-identical (including class order) to keep every other
|
||||
// code block in the app exactly as it was.
|
||||
return (
|
||||
<div className="group relative" data-code-block="">
|
||||
<pre
|
||||
|
||||
@@ -63,6 +63,14 @@ export default {
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
maxWidth: {
|
||||
// Focus-mode agent prompt bubble. Mirrors berd's
|
||||
// `--chat-user-message-max-width: 640px` (shared/styles/globals.css):
|
||||
// a fixed measure, not a percentage of the column, so the prompt keeps
|
||||
// one stable line length as the full-cover view is resized. Not a text
|
||||
// size, so the px-text guard does not apply.
|
||||
"prompt-bubble": "640px",
|
||||
},
|
||||
spacing: {
|
||||
4.5: "1.125rem",
|
||||
"conversation-body": "var(--conversation-body-gap)",
|
||||
|
||||
Reference in New Issue
Block a user