feat(desktop): rebuild agent activity feed around a classifier registry and twelve render classes (#1381)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-30 12:19:06 -07:00
committed by GitHub
parent 42ec17f137
commit 5cc09e6989
62 changed files with 6695 additions and 838 deletions

65
VISION_ACTIVITY.md Normal file
View File

@@ -0,0 +1,65 @@
# Vision: The Agent Activity Feed
## The Problem
When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*.
We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line.
## Who It Serves
A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions:
- **Comprehension** — *what is it doing, and why?*
- **Confidence** — *is it going well, or is it stuck or wrong?*
- **Control** — *do I need to step in, and where?*
A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar.
## The Governing Frame: Verb, Object, Outcome
Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].**
> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed."
The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to.
## The Render Classes
Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry:
**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed.
**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign).
**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render).
These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor.
## Design Principles
- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract.
- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline.
- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines.
- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen.
- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed.
- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes.
- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace.
- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are.
- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed.
## What This Earns
The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible.
A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one.
Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window.

View File

@@ -73,6 +73,8 @@ const overrides = new Map([
// unify refactor followup. +26 for resolve_effective_prompt_model_provider // unify refactor followup. +26 for resolve_effective_prompt_model_provider
// re-introduced after 826d735fe removal (config-bridge caller still needs it). // re-introduced after 826d735fe removal (config-bridge caller still needs it).
// PGID resolution helper + PID-recycling safety guard added for orphan sweep. // PGID resolution helper + PID-recycling safety guard added for orphan sweep.
// activity-feed threads avatar_url into build_managed_agent_summary for the
// assistant-bubble pinned snapshot.
["src-tauri/src/managed_agents/runtime.rs", 2150], ["src-tauri/src/managed_agents/runtime.rs", 2150],
// applyWorkspace reposDir parameter plus the validateReposDir binding, // applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the // threaded through Tauri invokes for configurable repos_dir, plus the
@@ -121,7 +123,7 @@ const overrides = new Map([
["src/features/channels/readState/readStateManager.ts", 1030], ["src/features/channels/readState/readStateManager.ts", 1030],
// Shared UI was added to this guard after splitting globals/markdown so // Shared UI was added to this guard after splitting globals/markdown so
// large shared renderers cannot grow further while follow-up splits land. // large shared renderers cannot grow further while follow-up splits land.
["src/shared/ui/markdown.tsx", 2082], ["src/shared/ui/markdown.tsx", 2119],
["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/VideoPlayer.tsx", 2199],
["src/shared/ui/sidebar.tsx", 1042], ["src/shared/ui/sidebar.tsx", 1042],
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent // Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
@@ -129,6 +131,9 @@ const overrides = new Map([
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules. // fallback. Queued to split into dedicated discovery modules.
["src-tauri/src/commands/agent_models.rs", 1066], ["src-tauri/src/commands/agent_models.rs", 1066],
// Kept activity-feed design fixture: realistic prompt context and tool-heavy
// chatter for render-class test/reference coverage. Queued to split with the
// rest of this list if it grows further.
]); ]);
await runFileSizeCheck({ await runFileSizeCheck({

View File

@@ -1539,6 +1539,7 @@ pub fn build_managed_agent_summary(
max_turn_duration_seconds: record.max_turn_duration_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds,
parallelism: record.parallelism, parallelism: record.parallelism,
system_prompt: record.system_prompt.clone(), system_prompt: record.system_prompt.clone(),
avatar_url: record.avatar_url.clone(),
model: record.model.clone(), model: record.model.clone(),
provider: record.provider.clone(), provider: record.provider.clone(),
persona_out_of_date, persona_out_of_date,

View File

@@ -268,6 +268,7 @@ pub struct ManagedAgentSummary {
pub max_turn_duration_seconds: Option<u64>, pub max_turn_duration_seconds: Option<u64>,
pub parallelism: u32, pub parallelism: u32,
pub system_prompt: Option<String>, pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub model: Option<String>, pub model: Option<String>,
/// LLM inference provider, from the agent's pinned record snapshot. /// LLM inference provider, from the agent's pinned record snapshot.
pub provider: Option<String>, pub provider: Option<String>,

View File

@@ -1,428 +1 @@
import * as React from "react"; export { ToolItem } from "./AgentSessionToolItem/ToolItem";
import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { TranscriptItem } from "./agentSessionTypes";
import {
formatToolTitle,
getBuzzToolInfo,
getToolStatusDisplay,
} from "./agentSessionToolCatalog";
import {
asRecord,
formatCodeValue,
formatDuration,
formatTranscriptTime,
getResultArray,
getToolString,
getToolStringList,
shortenMiddle,
} from "./agentSessionUtils";
export function ToolItem({
item,
}: {
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const status = getToolStatusDisplay(item.status, item.isError);
const hasArgs = Object.keys(item.args).length > 0;
const hasResult = item.result.trim().length > 0;
const canonicalToolName = item.buzzToolName ?? item.toolName;
const buzzTool = getBuzzToolInfo(canonicalToolName);
const ToolIcon = buzzTool?.icon ?? Wrench;
const showStatus = status.state !== "output-available";
const toolTitle = formatToolTitle(canonicalToolName, item.title);
const handleToggle = React.useCallback(
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
setIsExpanded(event.currentTarget.open);
},
[],
);
return (
<div className="not-prose w-full px-1">
<details
className="group w-full"
onToggle={handleToggle}
open={isExpanded}
>
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px">
{ToolIcon ? (
<ToolIcon
className={cn(
"h-4 w-4 shrink-0",
buzzTool ? "text-primary" : "text-muted-foreground",
)}
/>
) : null}
<span className="min-w-0 truncate text-sm font-medium">
{toolTitle}
</span>
{buzzTool ? (
<BuzzToolInlineAction args={item.args} result={item.result} />
) : null}
{showStatus ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<status.Icon
className={cn(
"h-4 w-4",
item.status === "executing" && "animate-pulse",
)}
/>
{status.label}
</span>
) : null}
<ToolTimestamp item={item} />
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<ToolDetailBlocks
args={item.args}
description={buzzTool?.label}
hasArgs={hasArgs}
hasResult={hasResult}
isError={item.isError}
result={item.result}
/>
</details>
</div>
);
}
function ToolDetailBlocks({
args,
description,
hasArgs,
hasResult,
isError,
result,
}: {
args: Record<string, unknown>;
description?: string;
hasArgs: boolean;
hasResult: boolean;
isError: boolean;
result: string;
}) {
return (
<div className="space-y-4 py-2 pl-5 text-popover-foreground outline-hidden">
{description ? (
<p className="max-w-2xl text-xs leading-5 text-muted-foreground">
{description}
</p>
) : null}
{hasArgs ? (
<ToolCodeBlock
label="Parameters"
tone="muted"
value={JSON.stringify(args, null, 2)}
/>
) : null}
{hasResult ? (
<ToolCodeBlock
label={isError ? "Error" : "Result"}
tone={isError ? "error" : "muted"}
value={result}
/>
) : null}
{!hasArgs && !hasResult ? (
<p className="text-sm text-muted-foreground/80">
Waiting for tool details.
</p>
) : null}
</div>
);
}
function ToolCodeBlock({
label,
tone,
value,
}: {
label: string;
tone: "muted" | "error";
value: string;
}) {
return (
<div className="space-y-2 overflow-hidden">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h4>
<pre
className={cn(
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md px-3 py-2 font-mono text-xs leading-5",
tone === "error"
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground",
)}
>
{formatCodeValue(value)}
</pre>
</div>
);
}
const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
function ToolTimestamp({
item,
}: {
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const time = formatTranscriptTime(item.timestamp);
if (!time) return null;
const duration =
item.startedAt && item.completedAt
? formatDuration(item.startedAt, item.completedAt)
: null;
const date = new Date(item.timestamp);
const fullDateTime = Number.isNaN(date.getTime())
? item.timestamp
: toolFullDateTimeFormat.format(date);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 cursor-default text-2xs text-muted-foreground/60">
{time}
{duration ? ` · ${duration}` : null}
</span>
</TooltipTrigger>
<TooltipContent side="top">{fullDateTime}</TooltipContent>
</Tooltip>
);
}
function BuzzToolInlineAction({
args,
result,
}: {
args: Record<string, unknown>;
result: string;
}) {
const { channels } = useChannelNavigation();
const { goChannel } = useAppNavigation();
const resultValue = React.useMemo(
() => parseToolResultValue(result),
[result],
);
const resultRecord = asRecord(resultValue);
const channelId =
getToolString(args, ["channel_id", "channelId"]) ??
getToolString(resultRecord, ["channel_id", "channelId"]);
const pubkeys = React.useMemo(
() => getToolStringList(args, ["pubkeys", "pubkey"]),
[args],
);
const profilesQuery = useUsersBatchQuery(pubkeys, {
enabled: pubkeys.length > 0,
});
const profiles = profilesQuery.data?.profiles;
const openChannel = React.useCallback(
(messageId?: string) => {
if (!channelId) return;
void goChannel(channelId, messageId ? { messageId } : undefined);
},
[channelId, goChannel],
);
const action = React.useMemo(
() =>
getBuzzToolInlineAction({
args,
channelId,
channels,
openChannel,
profiles,
resultValue,
}),
[args, channelId, channels, openChannel, profiles, resultValue],
);
if (!action) {
return null;
}
if (action.onClick) {
return (
<button
className="inline-flex max-w-[14rem] shrink min-w-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/[0.05] px-1.5 py-0.5 text-2xs font-normal leading-none text-primary/90 transition-colors hover:border-primary/35 hover:bg-primary/10 hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
action.onClick?.();
}}
title={action.title}
type="button"
>
{action.avatar}
<span className="shrink-0">{action.label}</span>
<span className="truncate">{action.value}</span>
<ArrowUpRight className="h-4 w-4 shrink-0" />
</button>
);
}
return (
<span
className="inline-flex max-w-[14rem] shrink min-w-0 items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-1.5 py-0.5 text-2xs font-normal leading-none text-muted-foreground"
title={action.title}
>
{action.avatar}
<span className="shrink-0">{action.label}</span>
<span className="truncate">{action.value}</span>
</span>
);
}
type BuzzToolInlineActionModel = {
avatar?: React.ReactNode;
label: string;
value: string;
title: string;
onClick?: () => void;
};
function getBuzzToolInlineAction({
args,
channelId,
channels,
openChannel,
profiles,
resultValue,
}: {
args: Record<string, unknown>;
channelId: string | null;
channels: Channel[];
openChannel: (messageId?: string) => void;
profiles: Record<string, UserProfileSummary> | undefined;
resultValue: unknown;
}): BuzzToolInlineActionModel | null {
const resultRecord = asRecord(resultValue);
const eventId =
getToolString(args, ["event_id", "eventId"]) ??
getToolString(resultRecord, ["event_id", "eventId", "id"]);
if (eventId && channelId) {
return {
label: resultRecord.accepted === true ? "posted" : "event",
onClick: () => openChannel(eventId),
title: eventId,
value: getChannelChipLabel(channels, channelId),
};
}
const messages = getResultArray(resultValue, resultRecord, "messages");
if (messages) {
return {
label: "read",
onClick: channelId ? () => openChannel() : undefined,
title: `${messages.length} messages`,
value: `${messages.length} message${messages.length === 1 ? "" : "s"}`,
};
}
if (channelId) {
return {
label: "channel",
onClick: () => openChannel(),
title: channelId,
value: getChannelChipLabel(channels, channelId),
};
}
const workflowId =
getToolString(args, ["workflow_id", "workflowId"]) ??
getToolString(resultRecord, ["workflow_id", "workflowId"]);
if (workflowId) {
return {
label: "workflow",
title: workflowId,
value: shortenMiddle(workflowId, 26),
};
}
const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
if (pubkeys.length > 0) {
if (pubkeys.length === 1) {
const pk = pubkeys[0];
const displayName = resolveUserLabel({ pubkey: pk, profiles });
const profile = profiles?.[pk.toLowerCase()];
return {
avatar: (
<UserAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="shrink-0"
displayName={displayName}
size="xs"
/>
),
label: "user",
title: pk,
value: displayName,
};
}
return {
label: "users",
title: pubkeys
.map((pk) => resolveUserLabel({ pubkey: pk, profiles }))
.join(", "),
value: `${pubkeys.length} users`,
};
}
const query = getToolString(args, ["query"]);
if (query) {
return {
label: "query",
title: query,
value: shortenMiddle(query, 30),
};
}
if (typeof resultRecord.accepted === "boolean") {
return {
label: "relay",
title: resultRecord.accepted ? "accepted" : "rejected",
value: resultRecord.accepted ? "accepted" : "rejected",
};
}
return null;
}
function parseToolResultValue(result: string): unknown {
const trimmed = result.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "string") return parsed;
try {
return JSON.parse(parsed);
} catch {
return parsed;
}
} catch {
return null;
}
}
function getChannelChipLabel(channels: Channel[], channelId: string) {
const channel = channels.find((candidate) => candidate.id === channelId);
return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`;
}

View File

@@ -0,0 +1,102 @@
import * as React from "react";
import { CheckCheck } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp";
import { compactSummaryTone } from "./CompactToolSummaryRow";
import type { SentMessageLink } from "./messageLinks";
import { SentMessageContextDialog } from "./SentMessageContextDialog";
export function CompactMessageSummary({
args,
avatarUrl,
description,
displayName,
duration,
hasArgs,
hasResult,
isError,
label,
messageLink,
preview,
result,
timestamp,
}: {
args: Record<string, unknown>;
avatarUrl: string | null;
description?: string;
displayName: string;
duration: string | null;
hasArgs: boolean;
hasResult: boolean;
isError: boolean;
label: string;
messageLink: SentMessageLink | null;
preview: string | null;
result: string;
timestamp: string;
}) {
const [detailsOpen, setDetailsOpen] = React.useState(false);
const mutedTone = compactSummaryTone();
return (
<>
<div className="flex max-w-full flex-row items-start justify-start">
<UserAvatar
avatarUrl={avatarUrl}
className="mr-2 mt-1 shrink-0"
displayName={displayName}
size="xs"
testId="transcript-agent-sent-avatar"
/>
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
<div
className={cn(
"min-w-0 rounded-2xl border px-3 py-2 text-sm leading-relaxed shadow-sm",
isError
? "border-destructive/25 bg-destructive/10 text-destructive"
: "border-primary/15 bg-primary/6 text-foreground",
)}
data-testid="transcript-tool-message-preview"
>
<p className="whitespace-pre-wrap wrap-break-word">
{preview || "Message content unavailable."}
</p>
</div>
<div className="inline-flex max-w-full items-center gap-1.5 px-1">
<TranscriptTimestamp
messageLink={messageLink}
timestamp={timestamp}
/>
<button
aria-label="Show sent message context"
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
mutedTone,
)}
data-testid="transcript-sent-message-context-button"
onClick={() => setDetailsOpen(true)}
title="Show sent message context"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<SentMessageContextDialog
args={args}
description={description}
duration={duration}
hasArgs={hasArgs}
hasResult={hasResult}
isError={isError}
label={label}
onOpenChange={setDetailsOpen}
open={detailsOpen}
preview={preview}
result={result}
/>
</>
);
}

View File

@@ -0,0 +1,144 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { AgentActivityAction } from "../agentSessionTypes";
import type { CompactFileEditSummary } from "../agentSessionToolSummary";
import { isInlineImageData } from "../agentSessionUtils";
import {
ActivityRowLabel,
splitActivityRowLabel,
type ActivityRowLabelParts,
} from "../activityRenderClasses/ActivityRow";
export function compactSummaryTone() {
return "text-muted-foreground/60 group-open:text-foreground";
}
export function CompactToolSummaryRow({
action,
duration,
fileEditSummary,
label,
preview,
thumbnailSrc,
}: {
action: AgentActivityAction | null;
duration: string | null;
fileEditSummary: CompactFileEditSummary | null;
label: string;
preview: string | null;
thumbnailSrc: string | null;
}) {
const [thumbnailFailed, setThumbnailFailed] = React.useState(false);
const mutedTone = compactSummaryTone();
const resolvedThumbnail = React.useMemo(() => {
if (!thumbnailSrc || thumbnailFailed) return null;
return resolveImageSrc(thumbnailSrc);
}, [thumbnailFailed, thumbnailSrc]);
const actionLabel = fileEditSummary
? null
: getCompactToolActionLabel(action, label, preview);
return (
<>
{fileEditSummary ? (
<CompactFileEditSummaryView summary={fileEditSummary} />
) : actionLabel ? (
<ActivityRowLabel
object={actionLabel.object}
openToneScope="tool"
title={actionLabel.title}
verb={actionLabel.verb}
/>
) : (
<span className={cn("shrink-0 text-sm font-semibold", mutedTone)}>
{label}
</span>
)}
{!fileEditSummary && resolvedThumbnail ? (
<img
alt=""
className="h-5 w-auto max-w-12 shrink-0 rounded-sm object-cover"
decoding="async"
loading="lazy"
onError={() => setThumbnailFailed(true)}
src={resolvedThumbnail}
title={preview ?? undefined}
/>
) : !fileEditSummary && !actionLabel && preview ? (
<span
className={cn("min-w-0 max-w-48 truncate text-sm", mutedTone)}
title={preview}
>
{preview}
</span>
) : null}
{duration ? (
<span className={cn("shrink-0 text-xs", mutedTone)}>{duration}</span>
) : null}
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180",
mutedTone,
)}
/>
</>
);
}
function getCompactToolActionLabel(
action: AgentActivityAction | null,
label: string,
preview: string | null,
): (ActivityRowLabelParts & { title?: string }) | null {
if (action) {
const object = action.object ?? preview ?? undefined;
return {
verb: action.verb,
object,
title: typeof object === "string" ? object : undefined,
};
}
const parts = splitActivityRowLabel(label);
if (!parts) return null;
if (!preview) return parts;
if (
label === "Ran command" ||
label === "Read file" ||
label === "Updated todos" ||
label === "Viewed image"
) {
return { verb: parts.verb, object: preview, title: preview };
}
return parts;
}
function CompactFileEditSummaryView({
summary,
}: {
summary: CompactFileEditSummary;
}) {
return (
<ActivityRowLabel
className="max-w-72"
object={summary.filename}
openToneScope="tool"
stats={{
additions: summary.additions,
deletions: summary.deletions,
}}
title={summary.path}
verb="Edited"
/>
);
}
function resolveImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}

View File

@@ -0,0 +1,186 @@
import * as React from "react";
import { CheckCheck, ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { formatCodeValue } from "../agentSessionUtils";
export function SentMessageContextDialog({
args,
description,
duration,
hasArgs,
hasResult,
isError,
label,
onOpenChange,
open,
preview,
result,
}: {
args: Record<string, unknown>;
description?: string;
duration: string | null;
hasArgs: boolean;
hasResult: boolean;
isError: boolean;
label: string;
onOpenChange: (open: boolean) => void;
open: boolean;
preview: string | null;
result: string;
}) {
const sections = buildSentMessageContextSections({
args,
description,
hasArgs,
hasResult,
isError,
preview,
result,
});
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Sent message context</DialogTitle>
<DialogDescription className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0" />
<span>{label}</span>
{duration ? <span className="shrink-0">{duration}</span> : null}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<SentMessageContextSections sections={sections} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
type SentMessageContextSection = {
body: string;
title: string;
};
function buildSentMessageContextSections({
args,
description,
hasArgs,
hasResult,
isError,
preview,
result,
}: {
args: Record<string, unknown>;
description?: string;
hasArgs: boolean;
hasResult: boolean;
isError: boolean;
preview: string | null;
result: string;
}): SentMessageContextSection[] {
const sections: SentMessageContextSection[] = [];
if (preview) {
sections.push({ title: "Message", body: preview });
}
if (description) {
sections.push({ title: "Tool", body: description });
}
if (hasArgs) {
sections.push({
title: "Parameters",
body: JSON.stringify(args, null, 2),
});
}
if (hasResult) {
sections.push({
title: isError ? "Error" : "Result",
body: formatCodeValue(result),
});
}
if (sections.length === 0) {
sections.push({
title: "Status",
body: "Waiting for tool details.",
});
}
return sections;
}
function SentMessageContextSections({
sections,
}: {
sections: SentMessageContextSection[];
}) {
return (
<div className="space-y-3" data-testid="transcript-sent-message-context">
{sections.map((section) => (
<SentMessageContextSectionAccordion
key={`${section.title}:${section.body.slice(0, 48)}`}
section={section}
/>
))}
</div>
);
}
function SentMessageContextSectionAccordion({
section,
}: {
section: SentMessageContextSection;
}) {
const [open, setOpen] = React.useState(false);
const body = section.body.trim();
return (
<article className="overflow-hidden rounded-2xl bg-muted/40">
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
>
{section.title}
</div>
<div
className={cn(
"mt-1 whitespace-pre-wrap break-all text-xs leading-5 text-foreground/70",
!open && "line-clamp-2",
)}
>
{body.length > 0 ? (
body
) : (
<span className="italic text-foreground/50">No metadata.</span>
)}
</div>
</div>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
</article>
);
}

View File

@@ -0,0 +1,31 @@
import { Terminal } from "lucide-react";
import { parseShellToolOutput } from "../agentSessionUtils";
export function ShellCommandBlock({
command,
result,
}: {
command: string;
result: string;
}) {
const output = parseShellToolOutput(result);
const stdout = output.stdout.trimEnd();
return (
<div
className="rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs leading-5"
data-testid="transcript-shell-command"
>
<p className="whitespace-pre-wrap wrap-break-word text-muted-foreground/70">
<Terminal className="mr-2 inline h-3.5 w-3.5 align-[-0.1875rem] text-accent-foreground" />
{command}
</p>
{stdout ? (
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word text-foreground">
{stdout}
</pre>
) : null}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import type { TranscriptItem } from "../agentSessionTypes";
import type { CompactToolSummary } from "../agentSessionToolSummary";
import {
asRecord,
formatTranscriptTimestampTitle,
getToolString,
parseToolResultValue,
} from "../agentSessionUtils";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "../activityRenderClasses/ActivityRow";
type TodoDisplayItem = {
checked: boolean;
text: string;
};
export function TodoToolSummary({
duration,
fallbackPreview,
item,
}: {
duration: string | null;
fallbackPreview: string | null;
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview);
const actionLabel = {
verb: "Updated",
object: fallbackPreview ?? "todos",
};
return (
<ActivityRow title={formatTranscriptTimestampTitle(item.timestamp)}>
<ActivityRowLabel
object={actionLabel.object}
openToneScope="tool"
title={actionLabel.object}
verb={actionLabel.verb}
/>
{duration ? (
<span className="shrink-0 text-xs text-muted-foreground/60 group-open:text-foreground">
{duration}
</span>
) : null}
<ActivityRowContent className="pt-1 pb-1.5">
{todos.length > 0 ? (
<div className="space-y-1">
{todos.map((todo, index) => (
<TodoCheckboxRow
// biome-ignore lint/suspicious/noArrayIndexKey: todo snapshots are static transcript content
key={index}
todo={todo}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground/80">No todos.</p>
)}
</ActivityRowContent>
</ActivityRow>
);
}
export function isTodoSummary(summary: CompactToolSummary) {
return (
summary.descriptor.groupKey === "plan:todo" ||
summary.descriptor.operation === "todo"
);
}
function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) {
return (
<div className="flex min-w-0 items-start gap-2 text-sm leading-5 text-muted-foreground">
<input
checked={todo.checked}
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-default accent-primary"
disabled
readOnly
type="checkbox"
/>
<span className="min-w-0 wrap-break-word">{todo.text}</span>
</div>
);
}
function buildTodoDisplayItems(
args: Record<string, unknown>,
result: string,
fallbackPreview: string | null,
): TodoDisplayItem[] {
const argTodos = extractTodoItemsFromArgs(args);
if (argTodos.length > 0) {
return argTodos;
}
const resultTodos = extractTodoItemsFromResult(result);
if (resultTodos.length > 0) {
return resultTodos;
}
return fallbackPreview && fallbackPreview !== "empty list"
? [{ checked: false, text: fallbackPreview }]
: [];
}
function extractTodoItemsFromArgs(
args: Record<string, unknown>,
): TodoDisplayItem[] {
const todos = args.todos;
if (!Array.isArray(todos)) {
return [];
}
return todos.flatMap((todo) => {
if (!todo || typeof todo !== "object") {
return [];
}
const record = asRecord(todo);
const text = getToolString(record, ["text", "content", "label", "title"]);
if (!text) {
return [];
}
return [
{
checked: getTodoChecked(record),
text,
},
];
});
}
function extractTodoItemsFromResult(result: string): TodoDisplayItem[] {
const resultText = getTodoResultText(result);
if (!resultText) {
return [];
}
return resultText.split(/\r?\n/).flatMap((line) => {
const match = line.match(/^\s*[-*]\s+\[([ xX])]\s+(.+?)\s*$/);
if (!match) {
return [];
}
return [
{
checked: match[1].toLowerCase() === "x",
text: match[2],
},
];
});
}
function getTodoResultText(result: string): string {
const parsed = parseToolResultValue(result);
if (typeof parsed === "string") {
return parsed;
}
const record = asRecord(parsed);
return getToolString(record, ["stdout", "result", "text"]) ?? result;
}
function getTodoChecked(record: Record<string, unknown>) {
if (typeof record.done === "boolean") {
return record.done;
}
if (typeof record.checked === "boolean") {
return record.checked;
}
const status = getToolString(record, ["status"])?.toLowerCase();
return status === "completed" || status === "done";
}

View File

@@ -0,0 +1,102 @@
import { cn } from "@/shared/lib/cn";
import type { FileEditDiff } from "../agentSessionFileEditDiff";
import { FileEditDiffBlock, hasFileEditLineDiff } from "../FileEditDiffView";
import { formatCodeValue } from "../agentSessionUtils";
import { ShellCommandBlock } from "./ShellCommandBlock";
import { ViewImageToolPreview } from "./ViewImageToolPreview";
export function ToolDetailBlocks({
args,
description,
fileEditDiff,
hasArgs,
hasResult,
imagePreview,
isError,
result,
shellCommand,
}: {
args: Record<string, unknown>;
description?: string;
fileEditDiff: FileEditDiff | null;
hasArgs: boolean;
hasResult: boolean;
imagePreview: { src: string | null; title: string | null } | null;
isError: boolean;
result: string;
shellCommand: string | null;
}) {
const showFileEditDiff =
fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError;
const showShellCommand = shellCommand != null && !showFileEditDiff;
const showParameters = hasArgs && !showFileEditDiff;
return (
<div className="space-y-4 py-2 text-popover-foreground outline-hidden">
{description ? (
<p className="max-w-2xl text-xs leading-5 text-muted-foreground">
{description}
</p>
) : null}
{imagePreview?.src ? (
<ViewImageToolPreview
src={imagePreview.src}
title={imagePreview.title}
/>
) : null}
{showShellCommand ? (
<ShellCommandBlock command={shellCommand} result={result} />
) : showParameters ? (
<ToolCodeBlock
label="Parameters"
tone="muted"
value={JSON.stringify(args, null, 2)}
/>
) : null}
{!showShellCommand && hasResult ? (
showFileEditDiff ? (
<FileEditDiffBlock diff={fileEditDiff} />
) : (
<ToolCodeBlock
label={isError ? "Error" : "Result"}
tone={isError ? "error" : "muted"}
value={result}
/>
)
) : null}
{!showShellCommand && !showParameters && !hasResult ? (
<p className="text-sm text-muted-foreground/80">
Waiting for tool details.
</p>
) : null}
</div>
);
}
function ToolCodeBlock({
label,
tone,
value,
}: {
label: string;
tone: "muted" | "error";
value: string;
}) {
return (
<div className="space-y-2 overflow-hidden">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h4>
<pre
className={cn(
"max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md px-3 py-2 font-mono text-xs leading-5",
tone === "error"
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground",
)}
>
{formatCodeValue(value)}
</pre>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import * as React from "react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { TranscriptItem } from "../agentSessionTypes";
import { getBuzzToolInfo } from "../agentSessionToolCatalog";
import { buildCompactToolSummary } from "../agentSessionToolSummary";
import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types";
import {
formatTranscriptTimestampTitle,
getToolDurationDisplay,
getToolString,
} from "../agentSessionUtils";
import { CompactMessageSummary } from "./CompactMessageSummary";
import {
CompactToolSummaryRow,
compactSummaryTone,
} from "./CompactToolSummaryRow";
import { getSentMessageLink } from "./messageLinks";
import { isTodoSummary, TodoToolSummary } from "./TodoToolSummary";
import { ToolDetailBlocks } from "./ToolDetailBlocks";
export function ToolItem({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
item: Extract<TranscriptItem, { type: "tool" }>;
profiles?: UserProfileLookup;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const hasArgs = Object.keys(item.args).length > 0;
const hasResult = item.result.trim().length > 0;
const canonicalToolName = item.buzzToolName ?? item.toolName;
const buzzTool = getBuzzToolInfo(canonicalToolName);
const compactSummary = buildCompactToolSummary(item);
const duration = getToolDurationDisplay(item);
const messageLink = getSentMessageLink(item);
const timestampTitle = formatTranscriptTimestampTitle(item.timestamp);
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
const agentLabel = resolveUserLabel({
pubkey: agentPubkey,
fallbackName: agentName,
profiles,
preferResolvedSelfLabel: true,
});
const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
const handleToggle = React.useCallback(
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
setIsExpanded(event.currentTarget.open);
},
[],
);
if (compactSummary.presentation === "message") {
return (
<div
className="not-prose w-full"
data-testid="transcript-tool-item"
title={timestampTitle}
>
<CompactMessageSummary
args={item.args}
avatarUrl={agentResolvedAvatarUrl}
description={buzzTool?.label}
displayName={agentLabel}
duration={duration}
hasArgs={hasArgs}
hasResult={hasResult}
isError={item.isError || item.status === "failed"}
label={compactSummary.label}
messageLink={messageLink}
preview={compactSummary.preview}
result={item.result}
timestamp={item.timestamp}
/>
</div>
);
}
if (isTodoSummary(compactSummary)) {
return (
<div
className="not-prose w-full"
data-testid="transcript-tool-item"
title={timestampTitle}
>
<TodoToolSummary
duration={duration}
fallbackPreview={compactSummary.preview}
item={item}
/>
</div>
);
}
return (
<div
className="not-prose w-full"
data-testid="transcript-tool-item"
title={timestampTitle}
>
<details
className="group w-full"
onToggle={handleToggle}
open={isExpanded}
>
<summary
className={cn(
"max-w-full cursor-pointer list-none",
compactSummary.presentation === "inline" &&
"inline-flex items-center gap-1.5",
compactSummaryTone(),
)}
>
<CompactToolSummaryRow
action={compactSummary.action}
duration={duration}
fileEditSummary={compactSummary.fileEditSummary}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
/>
</summary>
<ToolDetailBlocks
args={item.args}
description={buzzTool?.label}
fileEditDiff={compactSummary.fileEditDiff}
hasArgs={hasArgs}
hasResult={hasResult}
imagePreview={
compactSummary.thumbnailSrc != null && isExpanded
? {
src: compactSummary.thumbnailSrc,
title: compactSummary.preview,
}
: null
}
isError={item.isError}
result={item.result}
shellCommand={
compactSummary.kind === "shell"
? (getToolString(item.args, ["command"]) ??
compactSummary.preview ??
"command")
: null
}
/>
</details>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { isInlineImageData } from "../agentSessionUtils";
export function ViewImageToolPreview({
src,
title,
}: {
src: string;
title: string | null;
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const [imageFailed, setImageFailed] = React.useState(false);
const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]);
const alt = title ?? "Viewed image";
if (imageFailed) {
return null;
}
return (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */}
<img
alt={alt}
className="ml-1.5 block max-h-64 max-w-[min(24rem,calc(100%-0.375rem))] cursor-pointer rounded-lg object-contain"
decoding="async"
loading="lazy"
onClick={() => setLightboxOpen(true)}
onError={() => setImageFailed(true)}
src={resolvedSrc}
title={title ?? undefined}
/>
<ImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={resolvedSrc}
/>
</>
);
}
function ImageLightbox({
alt,
onOpenChange,
open,
src,
}: {
alt: string;
onOpenChange: (open: boolean) => void;
open: boolean;
src: string;
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the image to
close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
/>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={src}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
function resolveImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}

View File

@@ -0,0 +1,76 @@
import type { TranscriptItem } from "../agentSessionTypes";
import {
asRecord,
getToolString,
parseToolResultValue,
} from "../agentSessionUtils";
export type SentMessageLink = {
channelId: string;
messageId: string;
};
export function getSentMessageLink(
item: Extract<TranscriptItem, { type: "tool" }>,
): SentMessageLink | null {
if (item.status !== "completed" || item.isError) {
return null;
}
if (item.descriptor?.renderClass !== "message") {
return null;
}
const channelId =
item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]);
if (!channelId) {
return null;
}
const resultRecord = getMessageSendResultRecord(item.result);
if (!resultRecord || resultRecord.accepted === false) {
return null;
}
const messageId = getToolString(resultRecord, [
"event_id",
"eventId",
"message_id",
"messageId",
]);
if (!messageId) {
return null;
}
return {
channelId,
messageId,
};
}
function getMessageSendResultRecord(
result: string,
): Record<string, unknown> | null {
const parsed = parseToolResultValue(result);
const directRecord = asRecord(parsed);
if (getMessageEventId(directRecord)) {
return directRecord;
}
const stdout = getToolString(directRecord, ["stdout"]);
if (!stdout) {
return null;
}
const stdoutRecord = asRecord(parseToolResultValue(stdout));
return getMessageEventId(stdoutRecord) ? stdoutRecord : null;
}
function getMessageEventId(record: Record<string, unknown>) {
return getToolString(record, [
"event_id",
"eventId",
"message_id",
"messageId",
]);
}

View File

@@ -1,32 +1,88 @@
import * as React from "react"; import * as React from "react";
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react"; import { CheckCheck, ChevronDown, Radio } from "lucide-react";
import { import type { UserProfileLookup } from "@/features/profile/lib/identity";
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown"; import {
import { UserAvatar } from "@/shared/ui/UserAvatar"; Dialog,
import type { TranscriptItem } from "./agentSessionTypes"; DialogContent,
import { ToolItem } from "./AgentSessionToolItem"; DialogDescription,
import { formatTranscriptTime } from "./agentSessionUtils"; DialogHeader,
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
type ActivityRowStats,
splitActivityRowLabel,
} from "./activityRenderClasses/ActivityRow";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
import type { FileEditDiff } from "./agentSessionFileEditDiff";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
turnSetupDetail,
turnSetupTimestamp,
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { hasFileEditLineDiff } from "./FileEditDiffView";
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.
*/
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
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({ export function AgentSessionTranscriptList({
agentAvatarUrl,
agentName, agentName,
agentPubkey,
emptyDescription, emptyDescription,
items, items,
profiles, profiles,
}: { }: AgentTranscriptIdentityProps & {
agentName: string;
emptyDescription: string; emptyDescription: string;
items: TranscriptItem[]; items: TranscriptItem[];
profiles?: UserProfileLookup; profiles?: UserProfileLookup;
}) { }) {
const displayBlocks = React.useMemo(
() => buildTranscriptDisplayBlocks(items),
[items],
);
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="flex min-h-56 flex-col items-center justify-center px-6 py-10 text-center"> <div className="flex min-h-40 flex-col items-center justify-center px-6 py-10 text-center">
<Radio className="mx-auto h-4 w-4 text-muted-foreground" /> <Radio className="mx-auto h-4 w-4 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No ACP activity yet</p> <p className="mt-3 text-sm font-medium">No ACP activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p> <p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
@@ -35,224 +91,598 @@ export function AgentSessionTranscriptList({
} }
return ( return (
<div className="w-full">
<div <div
aria-label="Live ACP transcript" aria-label="Live ACP transcript"
aria-live="polite" aria-live="polite"
className="w-full py-1" className="flex w-full flex-col gap-2.5"
role="log" role="log"
> >
{items.map((item) => ( {displayBlocks.map((block) => (
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}> <div
<TranscriptItemView className="content-visibility-auto"
key={getDisplayBlockKey(block)}
>
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName} agentName={agentName}
item={item} agentPubkey={agentPubkey}
block={block}
profiles={profiles} profiles={profiles}
/> />
</div> </div>
))} ))}
</div> </div>
</div>
);
}
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 getDisplayBlockKey(block: TranscriptDisplayBlock) {
if (block.kind === "single") {
return block.item.id;
}
return `turn:${block.turnId}`;
}
function TranscriptDisplayBlockView({
agentAvatarUrl,
agentName,
agentPubkey,
block,
profiles,
}: AgentTranscriptIdentityProps & {
block: TranscriptDisplayBlock;
profiles?: UserProfileLookup;
}) {
if (block.kind === "single") {
return (
<TranscriptItemRow
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={block.item}
profiles={profiles}
/>
);
}
return (
<div
className="flex flex-col gap-2.5"
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
<TranscriptTurnSegmentView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
key={getTurnSegmentKey(block.turnId, segment)}
profiles={profiles}
segment={segment}
/>
))}
</div>
);
}
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
}
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
}
if (segment.kind === "summary") {
return segment.summary.id;
}
return segment.item.id;
}
function TranscriptTurnSegmentView({
agentAvatarUrl,
agentName,
agentPubkey,
profiles,
segment,
}: AgentTranscriptIdentityProps & {
profiles?: UserProfileLookup;
segment: TranscriptTurnSegment;
}) {
if (segment.kind === "prompt") {
return (
<TurnPromptBlock
context={segment.context}
profiles={profiles}
setup={segment.setup}
user={segment.user}
/>
);
}
if (segment.kind === "setup") {
return <TurnSetupStatus items={segment.items} />;
}
if (segment.kind === "summary") {
return (
<SameKindSummaryItem
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
profiles={profiles}
summary={segment.summary}
/>
);
}
return (
<TranscriptItemRow
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={segment.item}
profiles={profiles}
/>
);
}
function SameKindSummaryItem({
agentAvatarUrl,
agentName,
agentPubkey,
profiles,
summary,
}: AgentTranscriptIdentityProps & {
profiles?: UserProfileLookup;
summary: Extract<TranscriptTurnSegment, { kind: "summary" }>["summary"];
}) {
const groupedFileEditDiffs = React.useMemo(
() =>
summary.renderClass === "file-edit"
? getGroupedFileEditDiffs(summary.items)
: [],
[summary.items, summary.renderClass],
);
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
const expandsToToolItems = summary.items.every(
(item) => item.type === "tool",
);
return (
<ActivityRow
className="flex flex-col gap-0.5"
openToneScope="summary"
testId="transcript-same-kind-summary"
title={formatTranscriptTimestampTitle(summary.timestamp)}
>
<ToolRunSummaryLabel label={summary.label} stats={groupedFileEditStats} />
<ActivityRowContent
className={cn(
"flex flex-col",
expandsToToolItems ? "gap-0.5" : "gap-1 pl-5",
)}
>
{expandsToToolItems
? summary.items.map((item) => (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
key={item.id}
profiles={profiles}
/>
))
: summary.items.map((item) => (
<p
className="truncate text-xs text-muted-foreground"
key={item.id}
>
{item.type === "tool"
? item.descriptor.preview || item.descriptor.label
: item.title}
</p>
))}
</ActivityRowContent>
</ActivityRow>
);
}
function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] {
return items.flatMap((item) => {
if (item.type !== "tool" || item.isError) {
return [];
}
const diff = buildCompactToolSummary(item).fileEditDiff;
return diff && hasFileEditLineDiff(diff) ? [diff] : [];
});
}
function summarizeFileEditDiffs(
diffs: FileEditDiff[],
): ActivityRowStats | null {
if (diffs.length === 0) {
return null;
}
return diffs.reduce(
(stats, diff) => ({
additions: stats.additions + diff.additions,
deletions: stats.deletions + diff.deletions,
}),
{ additions: 0, deletions: 0 },
);
}
function ToolRunSummaryLabel({
label,
stats,
}: {
label: string;
stats?: ActivityRowStats | null;
}) {
const parts = splitActivityRowLabel(label);
if (!parts) {
return <span className="truncate text-sm font-medium">{label}</span>;
}
return (
<ActivityRowLabel
object={parts.object}
openToneScope="summary"
stats={stats}
verb={parts.verb}
/>
);
}
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);
return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
context={context}
contextOpen={contextOpen}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
context={context}
onOpenChange={setContextOpen}
open={contextOpen}
setup={setup}
/>
</>
);
}
function PromptContextSections({
className,
sections,
}: {
className?: string;
sections: PromptSection[];
}) {
return (
<div
className={cn("space-y-3", className)}
data-testid="transcript-prompt-context-sections"
>
{sections.map((section) => (
<PromptContextSectionAccordion
key={`${section.title}:${section.body.slice(0, 48)}`}
section={section}
/>
))}
</div>
);
}
function PromptContextSectionAccordion({
section,
}: {
section: PromptSection;
}) {
const [open, setOpen] = React.useState(false);
const body = section.body.trim();
return (
<article className="overflow-hidden rounded-2xl bg-muted/40">
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
>
{section.title}
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
>
{body.length > 0 ? (
body
) : (
<span className="italic text-foreground/50">No metadata.</span>
)}
</div>
</div>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
</article>
);
}
function PromptContextDialog({
context,
onOpenChange,
open,
setup,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
onOpenChange: (open: boolean) => void;
open: boolean;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || !context || context.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 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={context.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({
context = null,
contextOpen = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
contextOpen?: 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 = context != null && context.sections.length > 0;
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
const contextToggle = showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
data-testid="transcript-prompt-context-toggle"
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : null;
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext && showSetup ? contextToggle : null}
{!showContext && showSetup ? (
<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>
) : null}
{showContext && !showSetup ? contextToggle : null}
{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,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
return (
<div key={item.id}>
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
<TranscriptAcpSourceBadge source={item.acpSource} />
) : null}
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
profiles={profiles}
/>
</div>
);
}
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>
); );
} }
const TranscriptItemView = React.memo(function TranscriptItemView({ const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName, agentName,
agentPubkey,
item, item,
profiles, profiles,
}: { }: AgentTranscriptIdentityProps & {
agentName: string;
item: TranscriptItem; item: TranscriptItem;
profiles?: UserProfileLookup; profiles?: UserProfileLookup;
}) { }) {
if (item.type === "message") {
return ( return (
<MessageItem agentName={agentName} item={item} profiles={profiles} /> <TranscriptActivityItem
); agentAvatarUrl={agentAvatarUrl}
} agentName={agentName}
if (item.type === "tool") { agentPubkey={agentPubkey}
return <ToolItem item={item} />; item={item}
} profiles={profiles}
if (item.type === "thought") {
return <ThoughtItem item={item} />;
}
if (item.type === "metadata") {
return <MetadataItem item={item} />;
}
return <LifecycleItem item={item} />;
});
function MessageItem({
agentName,
item,
profiles,
}: {
agentName: string;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
return (
<div
className={cn(
"flex flex-row px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
)}
data-role={isAssistant ? "assistant-message" : "user-message"}
>
{!isAssistant ? (
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="mr-2 mt-1 h-5 w-5 shrink-0 text-3xs"
displayName={authorLabel}
size="xs"
/> />
) : null}
<div
className={cn(
"group relative min-w-0 flex flex-col items-start gap-1",
isAssistant ? "w-full" : "max-w-[85%]",
)}
>
{isAssistant ? (
<div className="mb-0.5 flex items-center gap-1 text-xs">
<span className="flex h-5 w-5 items-center justify-center">
<Bot className="h-4 w-4 text-muted-foreground" />
</span>
<span className="font-normal text-foreground">{agentName}</span>
<TranscriptTimestamp timestamp={item.timestamp} />
</div>
) : null}
<div
className={cn(
"w-full min-w-0 text-sm leading-relaxed",
!isAssistant && "rounded-2xl bg-muted p-3 text-foreground",
)}
>
{isAssistant ? (
<Markdown content={text || " "} />
) : (
<>
<p className="whitespace-pre-wrap break-words">{text}</p>
<TranscriptTimestamp timestamp={item.timestamp} />
</>
)}
</div>
</div>
</div>
); );
}
function ThoughtItem({
item,
}: {
item: Extract<TranscriptItem, { type: "thought" }>;
}) {
return (
<details className="group not-prose w-full px-1">
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
<Brain className="h-4 w-4" />
<span className="truncate text-sm font-medium">{item.title}</span>
<TranscriptTimestamp timestamp={item.timestamp} />
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
</summary>
<div className="py-2 pl-5 text-sm leading-6 text-muted-foreground">
<Markdown content={item.text.trim() || " "} />
</div>
</details>
);
}
function MetadataItem({
item,
}: {
item: Extract<TranscriptItem, { type: "metadata" }>;
}) {
return (
<details className="group not-prose w-full px-1">
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
<TerminalSquare className="h-4 w-4" />
<span className="truncate text-sm font-medium">{item.title}</span>
<span className="shrink-0 text-xs">
{item.sections.length} section{item.sections.length === 1 ? "" : "s"}
</span>
<TranscriptTimestamp timestamp={item.timestamp} />
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-3 py-2 pl-5">
{item.sections.map((section) => (
<details
className="group/section"
key={`${section.title}:${section.body.slice(0, 48)}`}
>
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-foreground/80">
<span className="truncate">{section.title}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open/section:rotate-180" />
</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/50 px-3 py-2 font-mono text-2xs leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))}
</div>
</details>
);
}
function LifecycleItem({
item,
}: {
item: Extract<TranscriptItem, { type: "lifecycle" }>;
}) {
const isError = item.title.toLowerCase().includes("error");
return (
<div
className={cn(
"flex items-center justify-start gap-1.5 px-1 py-2 text-left text-xs",
isError ? "text-destructive" : "text-muted-foreground",
)}
>
<span className="font-medium">{item.title}</span>
{item.text ? <span> - {item.text}</span> : null}
<TranscriptTimestamp timestamp={item.timestamp} />
</div>
);
}
const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
}); });
function TranscriptTimestamp({ timestamp }: { timestamp: string }) {
const formatted = formatTranscriptTime(timestamp);
if (!formatted) return null;
const date = new Date(timestamp);
const fullDateTime = Number.isNaN(date.getTime())
? timestamp
: fullDateTimeFormat.format(date);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 cursor-default text-2xs text-muted-foreground/60">
{formatted}
</span>
</TooltipTrigger>
<TooltipContent side="top">{fullDateTime}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,56 @@
import { cn } from "@/shared/lib/cn";
import type {
FileEditDiff,
FileEditDiffLine,
} from "./agentSessionFileEditDiff";
export function hasFileEditLineDiff(diff: FileEditDiff) {
return diff.lines.some(
(line) => line.kind === "add" || line.kind === "remove",
);
}
export function FileEditDiffBlock({ diff }: { diff: FileEditDiff }) {
return (
<div className="flex max-h-64 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/35 text-xs leading-5 text-foreground">
<pre className="min-h-0 flex-1 overflow-auto py-2 font-mono">
<FileEditDiffLines diff={diff} />
</pre>
<div
className="truncate border-t border-border/50 px-3 py-1.5 text-xs font-normal text-muted-foreground/70"
title={diff.path}
>
{diff.path}
</div>
</div>
);
}
function FileEditDiffLines({ diff }: { diff: FileEditDiff }) {
return diff.lines
.filter((line) => line.kind !== "meta")
.map((line, index) => (
<FileEditDiffLineView
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines are positional
key={index}
line={line}
/>
));
}
function FileEditDiffLineView({ line }: { line: FileEditDiffLine }) {
return (
<span
className={cn(
"block min-w-full whitespace-pre-wrap wrap-break-word px-3",
line.kind === "add" &&
"border-l-2 border-green-500/50 bg-green-500/12 text-foreground dark:bg-green-500/10",
line.kind === "remove" &&
"border-l-2 border-red-500/50 bg-red-500/12 text-foreground dark:bg-red-500/10",
line.kind === "meta" && "text-muted-foreground/70",
)}
>
{line.text || " "}
</span>
);
}

View File

@@ -21,17 +21,28 @@ import type {
ObserverEvent, ObserverEvent,
TranscriptItem, TranscriptItem,
} from "./agentSessionTypes"; } from "./agentSessionTypes";
import {
deriveLatestSessionId,
resolveDisplayEvents,
resolveRawRailLayout,
scopeByChannel,
} from "./agentSessionPanelLayout";
import { shorten } from "./agentSessionUtils"; import { shorten } from "./agentSessionUtils";
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
type ManagedAgentSessionPanelProps = { type ManagedAgentSessionPanelProps = {
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">; agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
avatarUrl?: string | null;
};
channelId?: string | null; channelId?: string | null;
className?: string; className?: string;
emptyDescription?: string; emptyDescription?: string;
rawLayout?: "responsive" | "exclusive";
showHeader?: boolean; showHeader?: boolean;
showRaw?: boolean; showRaw?: boolean;
profiles?: UserProfileLookup; profiles?: UserProfileLookup;
rawEventsOverride?: ObserverEvent[];
transcriptOverride?: TranscriptItem[];
}; };
export function ManagedAgentSessionPanel({ export function ManagedAgentSessionPanel({
@@ -39,9 +50,12 @@ export function ManagedAgentSessionPanel({
channelId = null, channelId = null,
className, className,
emptyDescription = "Mention this agent in a channel to watch the next turn.", emptyDescription = "Mention this agent in a channel to watch the next turn.",
rawLayout = "responsive",
showHeader = true, showHeader = true,
showRaw = true, showRaw = true,
profiles, profiles,
rawEventsOverride,
transcriptOverride,
}: ManagedAgentSessionPanelProps) { }: ManagedAgentSessionPanelProps) {
const hasObserver = isManagedAgentActive(agent); const hasObserver = isManagedAgentActive(agent);
const { connectionState, errorMessage, events } = useObserverEvents( const { connectionState, errorMessage, events } = useObserverEvents(
@@ -51,27 +65,25 @@ export function ManagedAgentSessionPanel({
const transcript = useAgentTranscript(hasObserver, agent.pubkey); const transcript = useAgentTranscript(hasObserver, agent.pubkey);
const scopedTranscript = React.useMemo( const scopedTranscript = React.useMemo(
() => () => scopeByChannel(transcript, channelId),
channelId
? transcript.filter((item) => item.channelId === channelId)
: transcript,
[channelId, transcript], [channelId, transcript],
); );
const displayTranscript = transcriptOverride ?? scopedTranscript;
const scopedEvents = React.useMemo( const scopedEvents = React.useMemo(
() => () => scopeByChannel(events, channelId),
channelId
? events.filter((event) => event.channelId === channelId)
: events,
[channelId, events], [channelId, events],
); );
const displayEvents = React.useMemo(
() => resolveDisplayEvents(scopedEvents, rawEventsOverride),
[rawEventsOverride, scopedEvents],
);
const latestSessionId = React.useMemo(() => { const latestSessionId = React.useMemo(
for (let i = scopedEvents.length - 1; i >= 0; i--) { () => deriveLatestSessionId(displayEvents),
if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId; [displayEvents],
} );
return null;
}, [scopedEvents]);
return ( return (
<section <section
@@ -83,22 +95,26 @@ export function ManagedAgentSessionPanel({
{showHeader ? ( {showHeader ? (
<SessionHeader <SessionHeader
connectionState={connectionState} connectionState={connectionState}
eventCount={scopedEvents.length} eventCount={displayEvents.length}
hasObserver={hasObserver} hasObserver={hasObserver}
latestSessionId={latestSessionId} latestSessionId={latestSessionId}
/> />
) : null} ) : null}
<SessionBody <SessionBody
agentAvatarUrl={agent.avatarUrl ?? null}
agentName={agent.name} agentName={agent.name}
agentPubkey={agent.pubkey}
connectionState={connectionState} connectionState={connectionState}
emptyDescription={emptyDescription} emptyDescription={emptyDescription}
errorMessage={errorMessage} errorMessage={errorMessage}
events={scopedEvents} events={displayEvents}
hasObserver={hasObserver} hasObserver={hasObserver}
hasTranscriptOverride={transcriptOverride != null}
profiles={profiles} profiles={profiles}
rawLayout={rawLayout}
showRaw={showRaw} showRaw={showRaw}
transcript={scopedTranscript} transcript={displayTranscript}
/> />
</section> </section>
); );
@@ -140,47 +156,76 @@ function SessionHeader({
} }
function SessionBody({ function SessionBody({
agentAvatarUrl,
agentName, agentName,
agentPubkey,
connectionState, connectionState,
emptyDescription, emptyDescription,
errorMessage, errorMessage,
events, events,
hasObserver, hasObserver,
hasTranscriptOverride,
profiles, profiles,
rawLayout,
showRaw, showRaw,
transcript, transcript,
}: { }: {
agentAvatarUrl: string | null;
agentName: string; agentName: string;
agentPubkey: string;
connectionState: ConnectionState; connectionState: ConnectionState;
emptyDescription: string; emptyDescription: string;
errorMessage: string | null; errorMessage: string | null;
events: ObserverEvent[]; events: ObserverEvent[];
hasObserver: boolean; hasObserver: boolean;
hasTranscriptOverride: boolean;
profiles?: UserProfileLookup; profiles?: UserProfileLookup;
rawLayout: "responsive" | "exclusive";
showRaw: boolean; showRaw: boolean;
transcript: TranscriptItem[]; transcript: TranscriptItem[];
}) { }) {
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
if (rawRail.mode === "exclusive") {
return ( return (
<> <>
{!hasObserver ? ( <RawEventRail events={events} />
{errorMessage ? (
<p className="mt-4 inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<CircleAlert className="h-4 w-4" />
{errorMessage}
</p>
) : null}
</>
);
}
return (
<>
{!hasObserver && !hasTranscriptOverride ? (
<EmptyObserverState /> <EmptyObserverState />
) : connectionState === "connecting" && events.length === 0 ? ( ) : connectionState === "connecting" &&
events.length === 0 &&
!hasTranscriptOverride ? (
<SessionLoadingSkeleton /> <SessionLoadingSkeleton />
) : ( ) : (
<div <div
className={cn( className={cn(
showRaw rawRail.mode === "side"
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]" ? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
: "mt-0", : "mt-0",
)} )}
> >
<AgentSessionTranscriptList <AgentSessionTranscriptList
agentAvatarUrl={agentAvatarUrl}
agentName={agentName} agentName={agentName}
agentPubkey={agentPubkey}
emptyDescription={emptyDescription} emptyDescription={emptyDescription}
items={transcript} items={transcript}
profiles={profiles} profiles={profiles}
/> />
{showRaw ? <RawEventRail events={events} /> : null} {rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
</div> </div>
)} )}

View File

@@ -1,46 +1,28 @@
import * as React from "react";
import { Button } from "@/shared/ui/button";
import type { ObserverEvent } from "./agentSessionTypes"; import type { ObserverEvent } from "./agentSessionTypes";
import { describeRawEvent } from "./agentSessionTranscript"; import { describeRawEvent } from "./agentSessionTranscript";
export function RawEventRail({ events }: { events: ObserverEvent[] }) { export function RawEventRail({ events }: { events: ObserverEvent[] }) {
const [expanded, setExpanded] = React.useState(false);
const visible = expanded ? events : events.slice(-18);
return ( return (
<aside className="rounded-lg border border-border/70 bg-[#17171d] text-zinc-100"> <section className="flex min-h-0 w-full flex-col text-foreground">
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2"> <div className="min-h-0 flex-1">
<div> {events.length === 0 ? (
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-zinc-400"> <p className="py-8 text-center text-sm text-muted-foreground">
Raw ACP No raw events yet.
</p> </p>
<p className="text-xs text-zinc-500">JSON-RPC payloads</p>
</div>
<Button
className="h-7 text-zinc-300 hover:text-zinc-50"
onClick={() => setExpanded((current) => !current)}
size="sm"
variant="ghost"
>
{expanded ? "Latest" : "All"}
</Button>
</div>
<div className="max-h-[34rem] overflow-auto px-3 py-3">
{visible.length === 0 ? (
<p className="text-xs text-zinc-500">No raw events yet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{visible.map((event) => ( {events.map((event) => (
<details <details
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-1.5" className="group rounded-md border border-border/55 bg-muted/25 px-2.5 py-1.5 transition-colors open:bg-muted/35"
key={event.seq} key={event.seq}
> >
<summary className="cursor-pointer select-none text-xs text-zinc-300"> <summary className="cursor-pointer select-none text-xs text-muted-foreground transition-colors group-open:text-foreground">
<span className="font-mono text-zinc-500">#{event.seq}</span>{" "} <span className="font-mono text-muted-foreground/70">
#{event.seq}
</span>{" "}
{describeRawEvent(event)} {describeRawEvent(event)}
</summary> </summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-2xs leading-5 text-zinc-300"> <pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/40 bg-background/45 p-2 font-mono text-xs leading-5 text-muted-foreground">
{JSON.stringify(event.payload, null, 2)} {JSON.stringify(event.payload, null, 2)}
</pre> </pre>
</details> </details>
@@ -48,6 +30,6 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
</div> </div>
)} )}
</div> </div>
</aside> </section>
); );
} }

View File

@@ -0,0 +1,187 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
export type ActivityRowLabelParts = {
verb: string;
object?: React.ReactNode;
};
export type ActivityRowStats = {
additions: number;
deletions: number;
};
export type ActivityRowToneScope = "none" | "tool" | "summary";
type ActivityRowProps = {
children: React.ReactNode;
className?: string;
openToneScope?: Exclude<ActivityRowToneScope, "none">;
testId?: string;
title?: string;
};
type ActivityRowContentProps = {
children: React.ReactNode;
className?: string;
};
const ACTIVITY_ROW_CONTENT_MARKER = Symbol("ActivityRowContent");
type ActivityRowContentComponent = React.FC<ActivityRowContentProps> & {
marker: typeof ACTIVITY_ROW_CONTENT_MARKER;
};
export function ActivityRow({
children,
className,
openToneScope = "tool",
testId,
title,
}: ActivityRowProps) {
const childArray = React.Children.toArray(children);
const summaryChildren = childArray.filter(
(child) => !isActivityRowContent(child),
);
const contentChildren = childArray.filter(isActivityRowContent);
if (contentChildren.length === 0) {
return (
<div
className={cn("not-prose flex min-h-6 items-center gap-1.5", className)}
data-testid={testId}
title={title}
>
{children}
</div>
);
}
return (
<details
className={cn(
openToneScope === "summary" ? "group/summary" : "group",
"not-prose w-full",
className,
)}
data-testid={testId}
title={title}
>
<summary
className={cn(
"inline-flex min-h-6 max-w-full cursor-pointer list-none items-center gap-1.5 text-muted-foreground",
openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
)}
>
{summaryChildren}
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
openToneScope === "summary"
? "group-open/summary:rotate-180 group-open/summary:text-foreground"
: "group-open:rotate-180 group-open:text-foreground",
)}
/>
</summary>
{contentChildren.map((child, index) => (
<div
className={child.props.className}
// biome-ignore lint/suspicious/noArrayIndexKey: content regions are static children
key={index}
>
{child.props.children}
</div>
))}
</details>
);
}
export function ActivityRowLabel({
className,
object,
openToneScope,
stats,
title,
verb,
}: ActivityRowLabelParts & {
className?: string;
openToneScope: ActivityRowToneScope;
stats?: ActivityRowStats | null;
title?: string;
}) {
return (
<span
className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
title={title}
>
<span
className={cn(
"shrink-0 text-sm font-semibold text-muted-foreground/50",
openToneScope === "none"
? null
: openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
)}
>
{verb}
</span>
{object ? (
<span
className={cn(
"min-w-0 truncate text-sm font-normal text-muted-foreground/60",
openToneScope === "none"
? null
: openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
)}
>
{object}
</span>
) : null}
{stats ? <ActivityRowStatsView stats={stats} /> : null}
</span>
);
}
export const ActivityRowContent = (({ children }: ActivityRowContentProps) => (
<>{children}</>
)) as ActivityRowContentComponent;
ActivityRowContent.marker = ACTIVITY_ROW_CONTENT_MARKER;
function ActivityRowStatsView({ stats }: { stats: ActivityRowStats }) {
return (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold leading-5 tabular-nums">
<span className="text-status-added">+{stats.additions}</span>
<span className="text-status-deleted">-{stats.deletions}</span>
</span>
);
}
function isActivityRowContent(
child: React.ReactNode,
): child is React.ReactElement<
ActivityRowContentProps,
ActivityRowContentComponent
> {
return (
React.isValidElement(child) &&
typeof child.type !== "string" &&
"marker" in child.type &&
child.type.marker === ACTIVITY_ROW_CONTENT_MARKER
);
}
export function splitActivityRowLabel(
label: string,
): ActivityRowLabelParts | null {
const match = label.match(
/^(Added|Archived|Captured|Checked|Compacted|Created|Deleted|Edited|Ran|Read|Removed|Searched|Sent|Unarchived|Updated|Viewed)\s+(.+)$/,
);
return match ? { verb: match[1], object: match[2] } : null;
}

View File

@@ -0,0 +1,63 @@
import { AlertCircle, ShieldCheck } from "lucide-react";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import type { ActivityRenderClassItemProps } from "./types";
export function LifecycleActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "lifecycle") {
return null;
}
const isError =
props.item.renderClass === "error" ||
props.item.title.toLowerCase().includes("error");
const isPermission = props.item.renderClass === "permission";
const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp);
if (isPermission) {
return (
<div
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
data-testid="transcript-permission-item"
title={timestampTitle}
>
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{props.item.text ? (
<span className="opacity-80"> · {props.item.text}</span>
) : null}
</div>
);
}
if (isError) {
return (
<div
className="rounded-md border border-destructive/20 bg-destructive/5 px-2 py-1.5 text-left text-xs text-destructive"
data-testid="transcript-lifecycle-item"
title={timestampTitle}
>
<AlertCircle className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{props.item.text ? (
<span className="opacity-80"> · {props.item.text}</span>
) : null}
</div>
);
}
return (
<ActivityRow testId="transcript-lifecycle-item" title={timestampTitle}>
<ActivityRowLabel
object={[props.item.title, props.item.text].filter(Boolean).join(" · ")}
openToneScope="none"
verb="Status"
/>
</ActivityRow>
);
}

View File

@@ -0,0 +1,112 @@
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 type { TranscriptItem } from "../agentSessionTypes";
import { ToolActivity } from "./ToolActivity";
import { TranscriptTimestamp } from "./TranscriptTimestamp";
import type {
ActivityRenderClassItemProps,
AgentTranscriptIdentityProps,
} from "./types";
import { UserMessageBubble } from "./UserMessageBubble";
export function MessageActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "message") {
return null;
}
return (
<MessageItem
agentAvatarUrl={props.agentAvatarUrl}
agentName={props.agentName}
agentPubkey={props.agentPubkey}
item={props.item}
profiles={props.profiles}
/>
);
}
function MessageItem({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const messageLink = getTranscriptMessageLink(item);
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
const assistantLabel = resolveUserLabel({
pubkey: agentPubkey,
fallbackName: agentName,
profiles,
preferResolvedSelfLabel: true,
});
const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
if (!isAssistant) {
return (
<UserMessageBubble
footer={
<TranscriptTimestamp
messageLink={messageLink}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
);
}
return (
<div
className="flex flex-row animate-in fade-in duration-200 motion-reduce:animate-none"
data-role="assistant-message"
data-testid="transcript-assistant-message"
>
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
<div className="mb-0.5 flex items-center gap-1.5 text-xs">
<UserAvatar
avatarUrl={assistantAvatarUrl}
className="shrink-0"
displayName={assistantLabel}
size="xs"
testId="transcript-assistant-avatar"
/>
<span className="text-sm font-semibold text-foreground">
{assistantLabel}
</span>
<TranscriptTimestamp
messageLink={messageLink}
timestamp={item.timestamp}
/>
</div>
<div className="w-full min-w-0 text-sm">
<Markdown content={text || " "} tight />
</div>
</div>
</div>
);
}
function getTranscriptMessageLink(
item: Extract<TranscriptItem, { type: "message" }>,
) {
if (!item.channelId || !item.messageId) return null;
return {
channelId: item.channelId,
messageId: item.messageId,
};
}

View File

@@ -0,0 +1,62 @@
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
export function PlanActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "plan") {
return null;
}
if (props.item.isUpdate) {
return (
<ActivityRow
testId="transcript-plan-update-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel
object={<PlanUpdateLabelObject text={props.item.text} />}
openToneScope="none"
verb="Updated"
/>
</ActivityRow>
);
}
return (
<ActivityRow
testId="transcript-plan-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel object="plan" openToneScope="tool" verb="Updated" />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
content={props.item.text.trim() || "No plan details."}
tight
/>
</ActivityRowContent>
</ActivityRow>
);
}
function PlanUpdateLabelObject({ text }: { text: string }) {
return (
<>
plan
{text ? (
<>
{" · "}
<span className="text-foreground">{text}</span>
</>
) : null}
</>
);
}

View File

@@ -0,0 +1,50 @@
import { ChevronDown } from "lucide-react";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
export function RawRailActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "metadata") {
return null;
}
return (
<ActivityRow
testId="transcript-metadata-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel
object={`${props.item.sections.length} raw section${
props.item.sections.length === 1 ? "" : "s"
}`}
openToneScope="tool"
verb="Captured"
/>
<ActivityRowContent className="flex flex-col gap-3 py-2">
{props.item.sections.map((section) => (
<details
className="group/section"
key={`${section.title}:${section.body.slice(0, 48)}`}
>
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground/60 group-open/section:text-foreground">
<span className="truncate">{section.title}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))}
</ActivityRowContent>
</ActivityRow>
);
}

View File

@@ -0,0 +1,39 @@
import {
formatTranscriptTimestampTitle,
getToolDurationDisplay,
} from "../agentSessionUtils";
import {
ActivityRow,
ActivityRowLabel,
splitActivityRowLabel,
} from "./ActivityRow";
import type { ActivityRenderClassItemProps } from "./types";
export function SuppressedActivity(props: ActivityRenderClassItemProps) {
if (props.item.type !== "tool") {
return null;
}
const action = props.item.descriptor.action;
const labelParts =
action ?? splitActivityRowLabel(props.item.descriptor.label);
const duration = getToolDurationDisplay(props.item);
return (
<ActivityRow
testId="transcript-suppressed-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel
object={labelParts?.object ?? props.item.descriptor.preview}
openToneScope="none"
verb={labelParts?.verb ?? props.item.descriptor.label}
/>
{duration ? (
<span className="shrink-0 text-xs text-muted-foreground/60">
{duration}
</span>
) : null}
</ActivityRow>
);
}

View File

@@ -0,0 +1,30 @@
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
ActivityRowContent,
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
export function ThoughtActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "thought") {
return null;
}
return (
<ActivityRow
testId="transcript-thought-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-6 text-muted-foreground">
<Markdown compact content={props.item.text.trim() || " "} />
</ActivityRowContent>
</ActivityRow>
);
}

View File

@@ -0,0 +1,11 @@
import { ToolItem } from "../AgentSessionToolItem";
import type { ActivityRenderClassItemProps } from "./types";
export function ToolActivity(props: ActivityRenderClassItemProps) {
const { item } = props;
if (item.type !== "tool") {
return null;
}
return <ToolItem {...props} item={item} />;
}

View File

@@ -0,0 +1,34 @@
import type { AgentActivityRenderClass } from "../agentSessionTypes";
import { LifecycleActivity } from "./LifecycleActivity";
import { MessageActivity } from "./MessageActivity";
import { PlanActivity } from "./PlanActivity";
import { RawRailActivity } from "./RawRailActivity";
import { SuppressedActivity } from "./SuppressedActivity";
import { ThoughtActivity } from "./ThoughtActivity";
import { ToolActivity } from "./ToolActivity";
import type {
ActivityRenderClassItemProps,
ActivityRenderClassPresenter,
} from "./types";
// Exhaustive render-class routing. Several semantic classes intentionally share
// a presenter when their row treatment is the same.
export const ACTIVITY_RENDER_CLASS_PRESENTERS = {
message: MessageActivity,
"relay-op": ToolActivity,
"file-edit": ToolActivity,
shell: ToolActivity,
status: LifecycleActivity,
thought: ThoughtActivity,
plan: PlanActivity,
permission: LifecycleActivity,
error: LifecycleActivity,
generic: ToolActivity,
"raw-rail": RawRailActivity,
suppressed: SuppressedActivity,
} satisfies Record<AgentActivityRenderClass, ActivityRenderClassPresenter>;
export function TranscriptActivityItem(props: ActivityRenderClassItemProps) {
const Presenter = ACTIVITY_RENDER_CLASS_PRESENTERS[props.item.renderClass];
return <Presenter {...props} />;
}

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { cn } from "@/shared/lib/cn";
import {
formatTranscriptTime,
formatTranscriptTimestampTitle,
} from "../agentSessionUtils";
export type TranscriptTimestampMessageLink = {
channelId: string;
messageId: string;
};
export function TranscriptTimestamp({
className,
messageLink = null,
timestamp,
}: {
className?: string;
messageLink?: TranscriptTimestampMessageLink | null;
timestamp: string;
}) {
const formatted = formatTranscriptTime(timestamp);
const { goChannel } = useAppNavigation();
const href = messageLink ? buildMessageLink(messageLink) : null;
const openMessage = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (!messageLink) return;
event.preventDefault();
event.stopPropagation();
void goChannel(messageLink.channelId, {
messageId: messageLink.messageId,
});
},
[goChannel, messageLink],
);
if (!formatted) return null;
const fullDateTime = formatTranscriptTimestampTitle(timestamp);
if (href) {
return (
<a
className={cn(
"shrink-0 rounded-sm text-xs text-muted-foreground/60 transition-colors hover:text-muted-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
data-testid="transcript-open-message-link"
href={href}
onClick={openMessage}
title={fullDateTime}
>
{formatted}
</a>
);
}
return (
<span
className={cn(
"shrink-0 cursor-default text-xs text-muted-foreground/60",
className,
)}
title={fullDateTime}
>
{formatted}
</span>
);
}

View File

@@ -0,0 +1,70 @@
import type * as React from "react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { TranscriptItem } from "../agentSessionTypes";
export function UserMessageBubble({
bubbleClassName,
children,
className,
footer,
item,
profiles,
}: {
bubbleClassName?: string;
children?: React.ReactNode;
className?: string;
footer?: React.ReactNode;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const text = item.text.trim();
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
return (
<div
className="flex flex-row items-start justify-end animate-in fade-in duration-200 motion-reduce:animate-none"
data-role="user-message"
data-testid="transcript-user-message"
>
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="order-last ml-2 mt-1 shrink-0"
displayName={authorLabel}
size="xs"
/>
<div
className={cn(
"group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1",
className,
)}
>
<div
className={cn(
"w-full min-w-0 rounded-2xl bg-muted p-3 text-sm leading-relaxed text-foreground",
bubbleClassName,
)}
>
<Markdown content={text || " "} mediaInset tight />
{children}
</div>
{footer}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type * as React from "react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { TranscriptItem } from "../agentSessionTypes";
export type AgentTranscriptIdentityProps = {
agentAvatarUrl: string | null;
agentName: string;
agentPubkey: string;
};
export type ActivityRenderClassItemProps = AgentTranscriptIdentityProps & {
item: TranscriptItem;
profiles?: UserProfileLookup;
};
export type ActivityRenderClassPresenter =
React.ComponentType<ActivityRenderClassItemProps>;

View File

@@ -0,0 +1,203 @@
import type {
AgentActivityDescriptor,
TranscriptItem,
} from "./agentSessionTypes";
import {
asRecord,
getToolString,
parseToolResultValue,
} from "./agentSessionUtils";
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
export type FileEditDiffLineKind = "add" | "remove" | "context" | "meta";
export type FileEditDiffLine = {
kind: FileEditDiffLineKind;
text: string;
};
export type FileEditDiffSummary = {
path: string;
filename: string;
additions: number;
deletions: number;
};
export type FileEditDiff = FileEditDiffSummary & {
lines: FileEditDiffLine[];
};
const SHIKI_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
const SHIKI_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
export function buildFileEditDiff(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): FileEditDiff | null {
if (descriptor.renderClass !== "file-edit") {
return null;
}
const resultText = getResultText(item.result);
const path =
getToolString(item.args, ["path", "file", "file_path", "target_file"]) ??
descriptor.object ??
descriptor.preview ??
getDiffPath(resultText);
if (!path) {
return null;
}
const lines = getDiffLines(resultText);
const stats = getDiffStats(resultText, lines);
if (!stats) {
return null;
}
return {
path,
filename: basename(path),
additions: stats.additions,
deletions: stats.deletions,
lines,
};
}
function getResultText(result: string): string {
const parsed = parseToolResultValue(result);
if (typeof parsed === "string") {
return parsed;
}
const record = asRecord(parsed);
const output = [
getToolString(record, ["stdout", "output", "text"]),
getToolString(record, ["stderr"]),
]
.filter((value): value is string => value != null)
.join("\n");
return output || result;
}
function getDiffPath(text: string): string | null {
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\+\+\+\s+(?:b\/)?(.+)$/);
if (match?.[1] && match[1] !== "/dev/null") {
return match[1].trim();
}
}
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^---\s+(?:a\/)?(.+)$/);
if (match?.[1] && match[1] !== "/dev/null") {
return match[1].trim();
}
}
return null;
}
function getDiffLines(text: string): FileEditDiffLine[] {
const rawLines = text.split(/\r?\n/);
const hasUnifiedDiff = rawLines.some((line) =>
/^(diff --git|--- |\+\+\+ |@@)/.test(line),
);
const lines: FileEditDiffLine[] = [];
let inUnifiedDiff = false;
for (const rawLine of rawLines) {
const shikiKind = getShikiDiffKind(rawLine);
if (shikiKind) {
lines.push({
kind: shikiKind,
text: rawLine.replace(
shikiKind === "add" ? SHIKI_ADD_RE : SHIKI_REMOVE_RE,
"",
),
});
continue;
}
if (hasUnifiedDiff && !inUnifiedDiff) {
if (!/^(diff --git|--- |\+\+\+ |@@)/.test(rawLine)) {
continue;
}
inUnifiedDiff = true;
}
if (hasUnifiedDiff) {
lines.push(classifyUnifiedDiffLine(rawLine));
continue;
}
lines.push({ kind: "context", text: rawLine });
}
return trimTrailingEmptyContextLines(
lines.filter((line) => line.text.length > 0 || line.kind !== "meta"),
);
}
function trimTrailingEmptyContextLines(
lines: FileEditDiffLine[],
): FileEditDiffLine[] {
let end = lines.length;
while (end > 0) {
const line = lines[end - 1];
if (line.kind !== "context" || line.text.length > 0) {
break;
}
end -= 1;
}
return end === lines.length ? lines : lines.slice(0, end);
}
function getShikiDiffKind(line: string): "add" | "remove" | null {
if (SHIKI_ADD_RE.test(line)) return "add";
if (SHIKI_REMOVE_RE.test(line)) return "remove";
return null;
}
function classifyUnifiedDiffLine(line: string): FileEditDiffLine {
if (/^(diff --git|--- |\+\+\+ |@@)/.test(line)) {
return { kind: "meta", text: line };
}
if (line.startsWith("+")) {
return { kind: "add", text: line };
}
if (line.startsWith("-")) {
return { kind: "remove", text: line };
}
return { kind: "context", text: line };
}
function getDiffStats(
text: string,
lines: FileEditDiffLine[],
): Pick<FileEditDiffSummary, "additions" | "deletions"> | null {
const additions = lines.filter((line) => line.kind === "add").length;
const deletions = lines.filter((line) => line.kind === "remove").length;
if (additions > 0 || deletions > 0) {
return { additions, deletions };
}
const statAdditions = text.match(/(\d+)\s+insertions?\(\+\)/);
const statDeletions = text.match(/(\d+)\s+deletions?\(-\)/);
if (statAdditions || statDeletions) {
return {
additions: statAdditions ? Number(statAdditions[1]) : 0,
deletions: statDeletions ? Number(statDeletions[1]) : 0,
};
}
return null;
}
function basename(path: string) {
const parts = path.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || path;
}

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
deriveLatestSessionId,
resolveDisplayEvents,
resolveRawRailLayout,
scopeByChannel,
} from "./agentSessionPanelLayout.ts";
// ---- scopeByChannel ----
const items = [
{ id: "a", channelId: "channel-1" },
{ id: "b", channelId: "channel-2" },
{ id: "c", channelId: "channel-1" },
];
test("scopeByChannel returns the input unchanged when channelId is null", () => {
assert.equal(scopeByChannel(items, null), items);
});
test("scopeByChannel returns the input unchanged when channelId is undefined", () => {
assert.equal(scopeByChannel(items, undefined), items);
});
test("scopeByChannel filters items down to the requested channel", () => {
const scoped = scopeByChannel(items, "channel-1");
assert.deepEqual(
scoped.map((item) => item.id),
["a", "c"],
);
});
test("scopeByChannel returns an empty array when no item matches", () => {
assert.deepEqual(scopeByChannel(items, "channel-99"), []);
});
// ---- deriveLatestSessionId ----
test("deriveLatestSessionId returns null for an empty list", () => {
assert.equal(deriveLatestSessionId([]), null);
});
test("deriveLatestSessionId returns the last event's sessionId", () => {
const events = [
{ seq: 1, sessionId: "sess-1" },
{ seq: 2, sessionId: "sess-2" },
];
assert.equal(deriveLatestSessionId(events), "sess-2");
});
test("deriveLatestSessionId skips trailing events without a sessionId", () => {
const events = [
{ seq: 1, sessionId: "sess-1" },
{ seq: 2, sessionId: null },
{ seq: 3, sessionId: undefined },
];
assert.equal(deriveLatestSessionId(events), "sess-1");
});
test("deriveLatestSessionId returns null when no event carries a sessionId", () => {
const events = [{ seq: 1, sessionId: null }, { seq: 2 }];
assert.equal(deriveLatestSessionId(events), null);
});
// ---- resolveDisplayEvents ----
test("resolveDisplayEvents returns raw override events unchanged", () => {
const scopedEvents = [{ seq: 1, channelId: "channel-1" }];
const rawEventsOverride = [{ seq: 2, channelId: "debug-channel" }];
assert.equal(
resolveDisplayEvents(scopedEvents, rawEventsOverride),
rawEventsOverride,
);
});
test("resolveDisplayEvents falls back to scoped live events", () => {
const scopedEvents = [{ seq: 1, channelId: "channel-1" }];
assert.equal(resolveDisplayEvents(scopedEvents, undefined), scopedEvents);
});
// ---- resolveRawRailLayout (raw-ACP view toggle) ----
test("resolveRawRailLayout hides the rail when showRaw is off", () => {
assert.deepEqual(resolveRawRailLayout(false, "responsive"), {
mode: "hidden",
});
assert.deepEqual(resolveRawRailLayout(false, "exclusive"), {
mode: "hidden",
});
});
test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => {
assert.deepEqual(resolveRawRailLayout(true, "exclusive"), {
mode: "exclusive",
});
});
test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => {
assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" });
});

View File

@@ -0,0 +1,55 @@
import type { ObserverEvent } from "./agentSessionTypes";
/**
* Filter transcript items or raw observer events down to a single channel.
* A null `channelId` means "no scoping" — the input is returned as-is.
*/
export function scopeByChannel<T extends { channelId?: string | null }>(
items: readonly T[],
channelId: string | null | undefined,
): T[] {
if (!channelId) return items as T[];
return items.filter((item) => item.channelId === channelId);
}
/**
* Derive the most recent session id from a list of observer events by
* scanning from the end. Returns null when no event carries a sessionId.
*/
export function deriveLatestSessionId(
events: readonly ObserverEvent[],
): string | null {
for (let i = events.length - 1; i >= 0; i--) {
const sessionId = events[i]?.sessionId;
if (sessionId) return sessionId;
}
return null;
}
export function resolveDisplayEvents(
scopedEvents: ObserverEvent[],
rawEventsOverride: ObserverEvent[] | undefined,
): ObserverEvent[] {
return rawEventsOverride ?? scopedEvents;
}
export type RawRailLayout =
| { mode: "hidden" }
| { mode: "exclusive" }
| { mode: "side" };
/**
* Decide how the raw-ACP event rail should be rendered relative to the
* transcript:
* - `hidden` — raw view is off
* - `exclusive` — raw rail replaces the transcript entirely
* - `side` — raw rail renders alongside the transcript (responsive)
*/
export function resolveRawRailLayout(
showRaw: boolean,
rawLayout: "responsive" | "exclusive",
): RawRailLayout {
if (!showRaw) return { mode: "hidden" };
if (rawLayout === "exclusive") return { mode: "exclusive" };
return { mode: "side" };
}

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
classifyTool,
extractSimpleEchoPipeContent,
parseBuzzCliCommand,
tokenizeShellCommand,
} from "./agentSessionToolClassifier.ts";
test("tokenizeShellCommand preserves quoted strings and command separators", () => {
assert.deepEqual(
tokenizeShellCommand(
'echo "hello world" | buzz messages send --content - --channel agents; buzz feed get',
),
[
"echo",
"hello world",
"|",
"buzz",
"messages",
"send",
"--content",
"-",
"--channel",
"agents",
";",
"buzz",
"feed",
"get",
],
);
});
test("extractSimpleEchoPipeContent reads the simple echo before a buzz pipe", () => {
const tokens = tokenizeShellCommand(
'echo -n "Done. Eat my shorts." | buzz messages send --content - --channel agents',
);
assert.equal(
extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")),
"Done. Eat my shorts.",
);
});
test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => {
const descriptor = parseBuzzCliCommand(
'echo "Permission wired" | buzz messages send --channel agents --content -',
);
assert.equal(descriptor?.renderClass, "message");
assert.equal(descriptor?.label, "Send Message");
assert.equal(descriptor?.preview, "Permission wired");
assert.equal(descriptor?.operation, "messages.send");
});
test("classifyTool promotes buzz CLI shell commands to relay operations", () => {
const descriptor = classifyTool({
title: "Shell",
toolName: "dev__shell",
buzzToolName: null,
args: { command: "buzz channels get --channel buzz-agent-observability" },
result: "{}",
isError: false,
});
assert.equal(descriptor.renderClass, "relay-op");
assert.equal(descriptor.label, "Channels Get");
assert.equal(descriptor.preview, "buzz-agent-observability");
assert.equal(descriptor.groupKey, "buzz-cli:channels.get");
});
test("classifyTool falls back once to a generic descriptor", () => {
const descriptor = classifyTool({
title: "Mystery",
toolName: "mcp__mystery",
buzzToolName: null,
args: { path: "notes.md" },
result: "",
isError: false,
});
assert.equal(descriptor.renderClass, "generic");
assert.equal(descriptor.label, "Ran tool");
assert.equal(descriptor.preview, "notes.md");
assert.equal(descriptor.source, "fallback");
});

View File

@@ -0,0 +1,639 @@
import type {
AgentActivityAction,
AgentActivityDescriptor,
AgentActivityRenderClass,
AgentActivityTone,
TranscriptItem,
} from "./agentSessionTypes";
import {
formatToolTitle,
getBuzzToolInfo,
normalizeToolNameText,
} from "./agentSessionToolCatalog";
import {
asRecord,
getToolString,
getToolStringList,
} from "./agentSessionUtils";
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
export type ToolClassificationInput = {
title: string;
toolName: string;
buzzToolName: string | null;
args: Record<string, unknown>;
result: string;
isError: boolean;
};
type ToolClassifierProvider = (
input: ToolClassificationInput,
) => AgentActivityDescriptor | null;
const DEVELOPER_TOOL_BASES = new Set([
"shell",
"read_file",
"view_image",
"str_replace",
"todo",
"stop",
"postcompact",
]);
const BUZZ_CLI_GROUPS = new Set([
"messages",
"channels",
"dms",
"reactions",
"canvas",
"feed",
"users",
"workflows",
"social",
"repos",
"upload",
"mem",
"notes",
"patches",
"pr",
"issues",
"emoji",
"pack",
]);
const BUZZ_CLI_ADMIN_VERBS = new Set([
"archive",
"unarchive",
"create",
"delete",
"remove",
"add-channel-member",
"remove-channel-member",
"set-channel-add-policy",
]);
const BUZZ_CLI_READ_VERBS = new Set([
"get",
"list",
"thread",
"search",
"members",
"runs",
"notes",
]);
const TOOL_CLASS_LABELS: Record<AgentActivityRenderClass, string> = {
message: "Message",
"relay-op": "Buzz relay op",
"file-edit": "File edit",
shell: "Shell command",
status: "Status",
thought: "Thought",
plan: "Plan",
permission: "Permission",
error: "Error",
generic: "Tool",
"raw-rail": "Raw event",
suppressed: "Suppressed",
};
const providers: ToolClassifierProvider[] = [
classifyDeveloperHarnessTool,
classifyBuzzTool,
];
export function classifyTool(
input: ToolClassificationInput,
): AgentActivityDescriptor {
for (const provider of providers) {
const descriptor = provider(input);
if (descriptor) {
return input.isError || descriptor.renderClass === "error"
? {
...descriptor,
renderClass: "error",
label: descriptor.label.endsWith("failed")
? descriptor.label
: `${descriptor.label} failed`,
}
: descriptor;
}
}
return genericDescriptor(input);
}
export function classifyToolItem(item: ToolItem): AgentActivityDescriptor {
return classifyTool({
title: item.title,
toolName: item.toolName,
buzzToolName: item.buzzToolName,
args: item.args,
result: item.result,
isError: item.isError,
});
}
export function renderClassLabel(renderClass: AgentActivityRenderClass) {
return TOOL_CLASS_LABELS[renderClass];
}
function classifyDeveloperHarnessTool(
input: ToolClassificationInput,
): AgentActivityDescriptor | null {
const kind = resolveDeveloperToolKind(input);
if (!kind) return null;
if (kind === "shell") {
const command = getToolString(input.args, ["command"]);
const buzzCli = command ? parseBuzzCliCommand(command) : null;
if (buzzCli) {
return buzzCli;
}
return {
renderClass: "shell",
label: "Ran command",
preview: command,
action: { verb: "Ran", object: command ?? "command" },
source: "harness",
groupKey: "shell:command",
};
}
if (kind === "read_file") {
const path = getToolString(input.args, ["path"]);
return {
renderClass: "generic",
label: "Read file",
preview: path,
action: { verb: "Read", object: path ?? "file" },
source: "harness",
groupKey: "read_file",
};
}
if (kind === "view_image") {
const source = getToolString(input.args, ["source"]);
return {
renderClass: "generic",
label: "Viewed image",
preview: source ? basenameOrUrl(source) : null,
action: {
verb: "Viewed",
object: source ? basenameOrUrl(source) : "image",
},
source: "harness",
groupKey: "view_image",
};
}
if (kind === "str_replace") {
const path = getToolString(input.args, ["path"]);
return {
renderClass: "file-edit",
label: "Edited file",
preview: path,
action: { verb: "Edited", object: path ?? "file" },
source: "harness",
groupKey: "file-edit:str_replace",
};
}
if (kind === "todo") {
const preview = getTodoPreview(input.args);
return {
renderClass: "plan",
label: "Updated todos",
preview,
action: { verb: "Updated", object: preview },
source: "harness",
groupKey: "plan:todo",
};
}
if (kind === "stop_hook") {
return {
renderClass: "suppressed",
label: "Checked todos",
preview: null,
action: { verb: "Checked", object: "todos" },
source: "harness",
groupKey: "suppressed:stop-hook",
};
}
if (kind === "post_compact_hook") {
return {
renderClass: "status",
label: "Context compacted",
preview: null,
action: { verb: "Compacted", object: "context" },
source: "harness",
groupKey: "status:post-compact",
};
}
const preview = genericPreview(input);
return {
renderClass: "generic",
label: "Ran tool",
preview,
action: { verb: "Ran", object: preview ?? "tool" },
source: "harness",
groupKey: "generic:dev-mcp",
};
}
function classifyBuzzTool(
input: ToolClassificationInput,
): AgentActivityDescriptor | null {
const name = [input.buzzToolName, input.toolName, input.title].find(
(value) => value && getBuzzToolInfo(value),
);
if (!name) return null;
const info = getBuzzToolInfo(name);
if (!info) return null;
const operation = normalizeToolNameText(name);
const label = formatToolTitle(name, input.title);
const preview = extractBuzzToolPreview(input.args);
return {
renderClass: isBuzzMessageSend(operation) ? "message" : "relay-op",
label,
preview,
action: actionForBuzzOperation(operation, preview, info.tone),
tone: info.tone,
operation,
object: preview,
source: "mcp",
groupKey: `buzz:${operation}`,
};
}
function genericDescriptor(
input: ToolClassificationInput,
): AgentActivityDescriptor {
const preview = genericPreview(input);
return {
renderClass: "generic",
label: "Ran tool",
preview,
action: { verb: "Ran", object: preview ?? "tool" },
source: "fallback",
groupKey: `generic:${normalizeToolNameText(input.toolName || input.title)}`,
};
}
function resolveDeveloperToolKind(
input: ToolClassificationInput,
):
| "shell"
| "read_file"
| "view_image"
| "str_replace"
| "todo"
| "stop_hook"
| "post_compact_hook"
| "dev_mcp"
| null {
for (const value of [input.toolName, input.title, input.buzzToolName]) {
const kind = classifyDeveloperToolName(value);
if (kind) return kind;
}
return null;
}
function classifyDeveloperToolName(value: string | null | undefined) {
if (!value) return null;
const normalized = normalizeToolNameText(value);
const base = normalized.replace(/^buzz_dev_mcp_/, "");
if (base === "shell" || normalized.endsWith("_shell")) return "shell";
if (base === "read_file" || normalized.endsWith("_read_file"))
return "read_file";
if (base === "view_image" || normalized.endsWith("_view_image"))
return "view_image";
if (base === "str_replace" || normalized.endsWith("_str_replace"))
return "str_replace";
if (base === "todo") return "todo";
if (base === "stop") return "stop_hook";
if (base === "postcompact") return "post_compact_hook";
if (DEVELOPER_TOOL_BASES.has(base) || normalized.includes("buzz_dev_mcp")) {
return "dev_mcp";
}
return null;
}
export function parseBuzzCliCommand(
command: string,
): AgentActivityDescriptor | null {
const tokens = tokenizeShellCommand(command);
const range = findBuzzCommand(tokens);
if (!range) return null;
const group = tokens[range.groupIndex];
const verb = tokens[range.verbIndex] ?? "run";
const operation = `${group}.${verb}`;
const content =
group === "messages" && verb === "send"
? extractBuzzCliSendMessageContent(tokens, range)
: null;
const preview = content ?? extractBuzzCliObjectPreview(tokens, range);
const tone = buzzCliTone(group, verb);
return {
renderClass:
group === "messages" && verb === "send" ? "message" : "relay-op",
label: titleForBuzzCli(group, verb),
preview,
action: actionForBuzzOperation(operation, preview, tone),
tone,
operation,
object: preview,
source: "shell",
groupKey: `buzz-cli:${operation}`,
};
}
function titleForBuzzCli(group: string, verb: string) {
if (group === "messages" && verb === "send") return "Send Message";
return [group, verb]
.map((part) =>
part
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "),
)
.filter(Boolean)
.join(" ");
}
function actionForBuzzOperation(
operation: string,
object: string | null,
tone: AgentActivityTone,
): AgentActivityAction {
const verb = buzzOperationVerbToken(operation);
return {
verb: buzzOperationVerb(verb, tone),
object: object ?? buzzOperationObject(operation),
};
}
function buzzOperationVerbToken(operation: string) {
if (operation.includes(".")) {
return operation.split(".")[1] ?? "run";
}
return operation.split("_")[0] ?? "run";
}
function buzzOperationVerb(verb: string, tone: AgentActivityTone) {
if (verb === "add") return "Added";
if (verb === "archive") return "Archived";
if (verb === "create") return "Created";
if (verb === "delete") return "Deleted";
if (verb === "get" || verb === "list" || verb === "members") return "Read";
if (verb === "remove") return "Removed";
if (verb === "runs") return "Read";
if (verb === "search") return "Searched";
if (verb === "send") return "Sent";
if (verb === "thread") return "Read";
if (verb === "unarchive") return "Unarchived";
if (tone === "read") return "Read";
return "Updated";
}
function buzzOperationObject(operation: string) {
if (isBuzzMessageSend(operation)) return "message";
if (operation.includes(".")) {
const [group] = operation.split(".");
return group ? group.replace(/[-_]+/g, " ") : "Buzz";
}
const object = operation.replace(
/^(add|approve|archive|create|delete|edit|get|hide|join|leave|list|open|publish|remove|search|send|set|trigger|unarchive|update|vote)_/,
"",
);
return object ? object.replace(/[-_]+/g, " ") : "Buzz";
}
function buzzCliTone(group: string, verb: string): AgentActivityTone {
if (BUZZ_CLI_ADMIN_VERBS.has(verb)) return "admin";
if (BUZZ_CLI_READ_VERBS.has(verb)) return "read";
if (group === "feed" && verb === "get") return "read";
return "write";
}
function extractBuzzCliSendMessageContent(
tokens: string[],
range: BuzzCommandRange,
): string | null {
const content = getFlagValue(tokens, range.verbIndex + 1, "--content");
if (!content) return null;
if (content !== "-") return content;
return extractSimpleEchoPipeContent(tokens, range.buzzIndex) ?? null;
}
function extractBuzzCliObjectPreview(
tokens: string[],
range: BuzzCommandRange,
): string | null {
const flagPreview =
getFlagValue(tokens, range.verbIndex + 1, "--channel") ??
getFlagValue(tokens, range.verbIndex + 1, "--event") ??
getFlagValue(tokens, range.verbIndex + 1, "--query") ??
getFlagValue(tokens, range.verbIndex + 1, "--name") ??
getFlagValue(tokens, range.verbIndex + 1, "--file");
if (flagPreview) return flagPreview;
const next = tokens[range.verbIndex + 1];
return next && !isCommandSeparator(next) && !next.startsWith("-")
? next
: null;
}
type BuzzCommandRange = {
buzzIndex: number;
groupIndex: number;
verbIndex: number;
};
function findBuzzCommand(tokens: string[]): BuzzCommandRange | null {
for (let i = 0; i < tokens.length; i++) {
if (!isBuzzExecutable(tokens[i])) continue;
for (let j = i + 1; j < tokens.length; j++) {
if (isCommandSeparator(tokens[j])) break;
if (tokens[j].startsWith("-")) {
if (
!tokens[j].includes("=") &&
tokens[j + 1]?.startsWith("-") === false
) {
j += 1;
}
continue;
}
if (!BUZZ_CLI_GROUPS.has(tokens[j])) continue;
const verbIndex = j + 1;
if (!tokens[verbIndex] || isCommandSeparator(tokens[verbIndex])) {
return null;
}
return { buzzIndex: i, groupIndex: j, verbIndex };
}
}
return null;
}
export function tokenizeShellCommand(command: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
let escaping = false;
const pushCurrent = () => {
if (current.length > 0) {
tokens.push(current);
current = "";
}
};
for (const char of command) {
if (escaping) {
current += char;
escaping = false;
continue;
}
if (char === "\\" && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (char === quote) quote = null;
else current += char;
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (/\s/.test(char)) {
pushCurrent();
continue;
}
if (char === "|" || char === ";" || char === "&") {
pushCurrent();
tokens.push(char);
continue;
}
current += char;
}
if (escaping) current += "\\";
pushCurrent();
return tokens;
}
function isBuzzExecutable(token: string) {
return token === "buzz" || token.split(/[\\/]/).pop() === "buzz";
}
function isCommandSeparator(token: string) {
return token === "|" || token === ";" || token === "&";
}
function getFlagValue(tokens: string[], start: number, flag: string) {
for (let i = start; i < tokens.length; i++) {
const token = tokens[i];
if (isCommandSeparator(token)) return null;
if (token === flag) {
return tokens[i + 1] && !isCommandSeparator(tokens[i + 1])
? tokens[i + 1]
: null;
}
if (token.startsWith(`${flag}=`)) return token.slice(flag.length + 1);
}
return null;
}
export function extractSimpleEchoPipeContent(
tokens: string[],
buzzIndex: number,
): string | null {
const pipeIndex = tokens.lastIndexOf("|", buzzIndex);
if (pipeIndex <= 0) return null;
const echoStart = findSegmentStart(tokens, pipeIndex - 1);
const leftSegment = tokens.slice(echoStart, pipeIndex);
if (leftSegment[0] !== "echo") return null;
const contentTokens = leftSegment
.slice(1)
.filter((token) => !token.startsWith("-"));
return contentTokens.length > 0 ? contentTokens.join(" ") : null;
}
function findSegmentStart(tokens: string[], beforeIndex: number) {
for (let i = beforeIndex; i >= 0; i--) {
if (isCommandSeparator(tokens[i])) return i + 1;
}
return 0;
}
function extractBuzzToolPreview(args: Record<string, unknown>): string | null {
const content = getToolString(args, ["content", "message", "text", "body"]);
if (content) return content;
const query = getToolString(args, ["query", "search"]);
if (query) return query;
const channelId = getToolString(args, ["channel_id", "channelId"]);
if (channelId) return channelId;
const workflowId = getToolString(args, ["workflow_id", "workflowId"]);
if (workflowId) return workflowId;
const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
if (pubkeys.length === 1) return pubkeys[0];
if (pubkeys.length > 1) return `${pubkeys.length} users`;
return getToolString(args, ["event_id", "eventId", "name"]);
}
function genericPreview(input: ToolClassificationInput): string | null {
return (
getToolString(input.args, [
"command",
"path",
"source",
"query",
"name",
"content",
"message",
]) ?? (input.title ? input.title : null)
);
}
function isBuzzMessageSend(operation: string) {
return operation === "send_message" || operation === "messages_send";
}
function basenameOrUrl(source: string): string {
const trimmed = source.trim();
if (
trimmed.startsWith("data:image/") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("https://")
) {
return trimmed;
}
return trimmed.split(/[/\\]/).pop() ?? trimmed;
}
function getTodoPreview(args: Record<string, unknown>): string | null {
const todos = args.todos;
if (!Array.isArray(todos)) return "todo list";
if (todos.length === 0) return "empty list";
const first = todos[0];
const firstText =
first && typeof first === "object"
? getToolString(asRecord(first), ["text"])
: null;
if (firstText)
return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText;
return `${todos.length} item${todos.length === 1 ? "" : "s"}`;
}

View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatDurationMs,
formatTranscriptTime,
getToolDurationDisplay,
isInlineImageData,
parseShellToolOutput,
parseToolResultValue,
} from "./agentSessionUtils.ts";
// ---- isInlineImageData (dual-layer image-scheme security guard) ----
test("isInlineImageData accepts data:image/ URIs", () => {
const dataUri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC";
assert.equal(isInlineImageData(dataUri), true);
});
test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => {
// A non-image data: URI must NOT be treated as a safe inline image —
// it has to fall through to the relay rewrite path.
assert.equal(
isInlineImageData(
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
),
false,
);
assert.equal(isInlineImageData("data:application/json;base64,e30="), false);
});
test("isInlineImageData rejects relay-relative and absolute media URLs", () => {
assert.equal(isInlineImageData("/media/abc123.png"), false);
assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false);
});
// ---- formatDurationMs ----
test("formatDurationMs returns null for negative input", () => {
assert.equal(formatDurationMs(-1), null);
});
test("formatDurationMs renders sub-10s with one decimal", () => {
assert.equal(formatDurationMs(400), "0.4s");
assert.equal(formatDurationMs(9900), "9.9s");
});
test("formatDurationMs rounds 10s..60s to whole seconds", () => {
assert.equal(formatDurationMs(12300), "12s");
assert.equal(formatDurationMs(59400), "59s");
});
test("formatDurationMs renders minutes and seconds", () => {
assert.equal(formatDurationMs(90000), "1m 30s");
assert.equal(formatDurationMs(120000), "2m");
});
test("formatDurationMs carries a rounded 60s into the next minute", () => {
// 89.7s rounds the seconds component to 60, which must carry to 1m 30s
assert.equal(formatDurationMs(89700), "1m 30s");
});
test("formatTranscriptTime renders a short 12-hour time without seconds", () => {
assert.equal(formatTranscriptTime("2026-06-30T17:00:02.000"), "5:00 PM");
});
// ---- parseToolResultValue (JSON double-parse) ----
test("parseToolResultValue returns null for empty/whitespace", () => {
assert.equal(parseToolResultValue(""), null);
assert.equal(parseToolResultValue(" "), null);
});
test("parseToolResultValue parses a JSON object", () => {
assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), {
duration_ms: 123,
});
});
test("parseToolResultValue unwraps a double-encoded JSON string", () => {
// The result is a JSON string that itself contains JSON.
const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true }));
assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true });
});
test("parseToolResultValue returns the inner string when it is not JSON", () => {
const wrapped = JSON.stringify("plain text");
assert.equal(parseToolResultValue(wrapped), "plain text");
});
test("parseToolResultValue returns null for invalid JSON", () => {
assert.equal(parseToolResultValue("not json {"), null);
});
test("parseShellToolOutput extracts stdout from a shell result envelope", () => {
assert.deepEqual(
parseShellToolOutput(
JSON.stringify({
exit_code: 0,
stdout: "4 files changed\n",
stderr: "",
timed_out: false,
}),
),
{
exitCode: 0,
raw: "",
stderr: "",
stdout: "4 files changed\n",
timedOut: false,
},
);
});
test("parseShellToolOutput preserves non-envelope output as raw text", () => {
assert.deepEqual(parseShellToolOutput(JSON.stringify("plain output")), {
exitCode: null,
raw: "plain output",
stderr: "",
stdout: "",
timedOut: false,
});
});
// ---- getToolDurationDisplay (fallback chain) ----
const startedAt = "2026-06-14T19:00:00.000Z";
const completedAt = "2026-06-14T19:00:02.000Z";
test("getToolDurationDisplay prefers start/complete timestamps", () => {
assert.equal(
getToolDurationDisplay({ startedAt, completedAt, result: "" }),
"2.0s",
);
});
test("getToolDurationDisplay falls back to duration_ms in the result payload", () => {
assert.equal(
getToolDurationDisplay({
startedAt: null,
completedAt: null,
result: JSON.stringify({ duration_ms: 3500 }),
}),
"3.5s",
);
});
test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => {
assert.equal(
getToolDurationDisplay({
result: JSON.stringify({ elapsed_ms: 65000 }),
}),
"1m 5s",
);
});
test("getToolDurationDisplay returns null when no duration is available", () => {
assert.equal(getToolDurationDisplay({ result: "" }), null);
assert.equal(
getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }),
null,
);
});

View File

@@ -0,0 +1,334 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCompactToolSummary } from "./agentSessionToolSummary.ts";
const baseTimestamp = "2026-06-14T19:00:00.000Z";
function makeTool(overrides = {}) {
return {
id: "tool:1",
type: "tool",
title: "Tool call",
toolName: "shell",
buzzToolName: null,
status: "completed",
args: {},
result: "",
isError: false,
timestamp: baseTimestamp,
startedAt: baseTimestamp,
completedAt: "2026-06-14T19:00:01.000Z",
...overrides,
};
}
test("buildCompactToolSummary formats Buzz send_message preview", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "send_message",
buzzToolName: "send_message",
title: "Send Message",
args: { content: "Hello team" },
}),
);
assert.equal(summary.kind, "message");
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "Hello team");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary treats buzz messages send commands as messages", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "buzz-dev-mcp__shell",
args: {
command:
'buzz --format compact messages send --channel channel-1 --content "@Ned are you working"',
},
}),
);
assert.equal(summary.kind, "message");
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "@Ned are you working");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary extracts simple piped buzz message content", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "shell",
args: {
command:
'echo "hello from stdin" | ./target/release/buzz messages send --channel channel-1 --content -',
},
}),
);
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "hello from stdin");
assert.equal(summary.presentation, "message");
});
test("buildCompactToolSummary formats shell command preview", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "buzz-dev-mcp__shell",
args: { command: "git status" },
}),
);
assert.equal(summary.label, "Ran command");
assert.equal(summary.preview, "git status");
assert.deepEqual(summary.action, { verb: "Ran", object: "git status" });
assert.equal(summary.presentation, "inline");
});
test("buildCompactToolSummary formats view_image thumbnail source", () => {
const source =
"https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png";
const summary = buildCompactToolSummary(
makeTool({
toolName: "buzz-dev-mcp__view_image",
args: { source },
}),
);
assert.equal(summary.label, "Viewed image");
assert.equal(summary.thumbnailSrc, source);
assert.equal(summary.preview, source);
});
test("buildCompactToolSummary uses basename for local view_image paths", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "view_image",
args: { source: "desktop/assets/screenshot.png" },
}),
);
assert.equal(summary.thumbnailSrc, null);
assert.equal(summary.preview, "screenshot.png");
});
test("buildCompactToolSummary formats read_file path preview", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "read_file",
args: { path: "desktop/src/app/App.tsx" },
}),
);
assert.equal(summary.label, "Read file");
assert.equal(summary.preview, "desktop/src/app/App.tsx");
assert.deepEqual(summary.action, {
verb: "Read",
object: "desktop/src/app/App.tsx",
});
});
test("buildCompactToolSummary formats todo list preview", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "todo",
args: {
todos: [
{ text: "Ship compact summaries", done: false },
{ text: "Verify UI", done: false },
],
},
}),
);
assert.equal(summary.label, "Updated todos");
assert.equal(summary.preview, "Ship compact summaries (+1)");
});
test("buildCompactToolSummary uses running and failed labels", () => {
assert.equal(
buildCompactToolSummary(
makeTool({ toolName: "str_replace", status: "executing" }),
).label,
"Editing file",
);
assert.equal(
buildCompactToolSummary(
makeTool({ toolName: "str_replace", status: "failed", isError: true }),
).label,
"Edit failed",
);
});
test("buildCompactToolSummary promotes non-send buzz CLI commands to relay ops", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "shell",
args: {
command: "buzz channels get --channel channel-1",
},
}),
);
assert.equal(summary.kind, "relay-op");
assert.equal(summary.label, "Channels Get");
assert.equal(summary.preview, "channel-1");
assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" });
assert.equal(summary.presentation, "inline");
});
test("buildCompactToolSummary derives structured actions for native Buzz MCP tools", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "get_channel",
buzzToolName: "get_channel",
args: {
channel_id: "channel-1",
},
}),
);
assert.equal(summary.kind, "relay-op");
assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" });
});
test("buildCompactToolSummary promotes file edits and todos to first-class classes", () => {
assert.equal(
buildCompactToolSummary(
makeTool({ toolName: "str_replace", args: { path: "src/app.ts" } }),
).kind,
"file-edit",
);
assert.equal(
buildCompactToolSummary(makeTool({ toolName: "todo", args: { todos: [] } }))
.kind,
"plan",
);
});
test("buildCompactToolSummary formats file edits as filename plus diff stats", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "str_replace",
args: { path: "desktop/src/app/App.tsx" },
result: [
"Replaced 1 occurrence.",
"",
"--- a/desktop/src/app/App.tsx",
"+++ b/desktop/src/app/App.tsx",
"@@",
"-<Switch />",
"+<DropdownMenuCheckboxItem />",
"+<DropdownMenuSeparator />",
].join("\n"),
}),
);
assert.equal(summary.kind, "file-edit");
assert.equal(summary.preview, "App.tsx");
assert.deepEqual(summary.fileEditSummary, {
path: "desktop/src/app/App.tsx",
filename: "App.tsx",
additions: 2,
deletions: 1,
});
assert.deepEqual(summary.fileEditDiff?.lines, [
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
{ kind: "meta", text: "@@" },
{ kind: "remove", text: "-<Switch />" },
{ kind: "add", text: "+<DropdownMenuCheckboxItem />" },
{ kind: "add", text: "+<DropdownMenuSeparator />" },
]);
});
test("buildCompactToolSummary counts Shiki diff markers for file edit stats", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "str_replace",
args: { path: "desktop/src/app/App.tsx" },
result: [
"const keep = true;",
"const next = true; // [!code ++]",
"const old = true; // [!code --]",
].join("\n"),
}),
);
assert.deepEqual(summary.fileEditSummary, {
path: "desktop/src/app/App.tsx",
filename: "App.tsx",
additions: 1,
deletions: 1,
});
assert.deepEqual(summary.fileEditDiff?.lines, [
{ kind: "context", text: "const keep = true;" },
{ kind: "add", text: "const next = true;" },
{ kind: "remove", text: "const old = true;" },
]);
});
test("buildCompactToolSummary parses file edit stats from shell JSON stdout", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "str_replace",
args: { path: "desktop/src/app/App.tsx" },
result: JSON.stringify({
stdout: [
"diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx",
"--- a/desktop/src/app/App.tsx",
"+++ b/desktop/src/app/App.tsx",
"@@",
"-old",
"+new",
].join("\n"),
}),
}),
);
assert.deepEqual(summary.fileEditSummary, {
path: "desktop/src/app/App.tsx",
filename: "App.tsx",
additions: 1,
deletions: 1,
});
assert.deepEqual(summary.fileEditDiff?.lines, [
{
kind: "meta",
text: "diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx",
},
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
{ kind: "meta", text: "@@" },
{ kind: "remove", text: "-old" },
{ kind: "add", text: "+new" },
]);
});
test("buildCompactToolSummary trims only trailing blank diff lines", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "str_replace",
args: { path: "desktop/src/app/App.tsx" },
result: [
"--- a/desktop/src/app/App.tsx",
"+++ b/desktop/src/app/App.tsx",
"@@",
" const before = true;",
"",
"+const after = true;",
"",
].join("\n"),
}),
);
assert.deepEqual(summary.fileEditDiff?.lines, [
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
{ kind: "meta", text: "@@" },
{ kind: "context", text: " const before = true;" },
{ kind: "context", text: "" },
{ kind: "add", text: "+const after = true;" },
]);
});

View File

@@ -0,0 +1,112 @@
import type {
AgentActivityAction,
ToolStatus,
TranscriptItem,
} from "./agentSessionTypes";
import type { AgentActivityDescriptor } from "./agentSessionTypes";
import { getToolString } from "./agentSessionUtils";
import { classifyToolItem } from "./agentSessionToolClassifier";
import {
buildFileEditDiff,
type FileEditDiff,
type FileEditDiffSummary,
} from "./agentSessionFileEditDiff";
export type CompactToolKind =
| "message"
| "relay-op"
| "file-edit"
| "shell"
| "status"
| "thought"
| "plan"
| "permission"
| "error"
| "generic"
| "raw-rail"
| "suppressed";
export type CompactToolSummary = {
action: AgentActivityAction | null;
kind: CompactToolKind;
label: string;
preview: string | null;
fileEditSummary: FileEditDiffSummary | null;
fileEditDiff: FileEditDiff | null;
/** When set, the compact row renders a tiny image instead of text preview. */
thumbnailSrc: string | null;
presentation: "inline" | "message";
descriptor: AgentActivityDescriptor;
};
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
export type CompactFileEditSummary = FileEditDiffSummary;
/** Build the muted compact summary label and preview for any tool row. */
export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
const descriptor = item.descriptor ?? classifyToolItem(item);
const fileEditDiff = buildFileEditDiff(item, descriptor);
const fileEditSummary = fileEditDiff
? {
path: fileEditDiff.path,
filename: fileEditDiff.filename,
additions: fileEditDiff.additions,
deletions: fileEditDiff.deletions,
}
: null;
const thumbnailSrc = getThumbnailSrc(item, descriptor);
const failed = item.isError || item.status === "failed";
const running = item.status === "executing" || item.status === "pending";
return {
action: descriptor.action ?? null,
kind: descriptor.renderClass,
label: labelForStatus(descriptor, item.status, failed, running),
preview: fileEditSummary?.filename ?? descriptor.preview,
fileEditSummary,
fileEditDiff,
thumbnailSrc,
presentation: descriptor.renderClass === "message" ? "message" : "inline",
descriptor,
};
}
function labelForStatus(
descriptor: AgentActivityDescriptor,
status: ToolStatus,
failed: boolean,
running: boolean,
) {
const label = descriptor.label;
if (descriptor.groupKey === "file-edit:str_replace") {
if (failed) return "Edit failed";
if (running) return "Editing file";
return "Edited file";
}
if (failed) {
return label.endsWith("failed") ? label : `${label} failed`;
}
if (running) return label;
if (status === "completed") return label;
return label;
}
function getThumbnailSrc(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): string | null {
const operation =
descriptor.operation ?? descriptor.groupKey ?? item.toolName;
if (!operation.includes("view_image") && item.toolName !== "view_image") {
return null;
}
const source = getToolString(item.args, ["source"]);
if (!source) return null;
const trimmed = source.trim();
return trimmed.startsWith("data:image/") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("https://")
? trimmed
: null;
}

View File

@@ -13,6 +13,7 @@ const baseEvent = {
sessionId: "sess-1", sessionId: "sess-1",
turnId: "turn-1", turnId: "turn-1",
}; };
const PROMPT_EVENT_ID = "c".repeat(64);
function acpToolUpdate(seq, update) { function acpToolUpdate(seq, update) {
return { return {
@@ -75,7 +76,7 @@ test("buildTranscript renders Prompt context + user message for a multi-block se
{ type: "text", text: "[Context]\nScope: thread" }, { type: "text", text: "[Context]\nScope: thread" },
{ {
type: "text", type: "text",
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, text: `[Buzz event: @mention]\nEvent ID: ${PROMPT_EVENT_ID.toUpperCase()}\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
}, },
], ],
}, },
@@ -94,6 +95,40 @@ test("buildTranscript renders Prompt context + user message for a multi-block se
["Agent Memory — core", "Context", "Buzz event: @mention"], ["Agent Memory — core", "Context", "Buzz event: @mention"],
"every section header is counted", "every section header is counted",
); );
const userMessage = items.find((i) => i.type === "message");
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
});
test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => {
const promptEvent = {
...baseEvent,
seq: 2,
payload: {
method: "session/prompt",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
},
],
},
},
};
const [userMessage] = buildTranscript([
{
...baseEvent,
seq: 1,
kind: "turn_started",
payload: {
triggeringEventIds: [PROMPT_EVENT_ID],
},
},
promptEvent,
]).filter((candidate) => candidate.type === "message");
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
}); });
test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => { test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => {
@@ -194,3 +229,413 @@ test("buildTranscript categorizes explicit Buzz tool calls for the activity bar"
assert.deepEqual(item.args, { limit: 20 }); assert.deepEqual(item.args, { limit: 20 });
assert.equal(item.status, "completed"); assert.equal(item.status, "completed");
}); });
function sessionUpdate(seq, update, overrides = {}) {
return {
...baseEvent,
...overrides,
seq,
kind: "acp_read",
payload: {
method: "session/update",
params: {
sessionId: overrides.sessionId ?? baseEvent.sessionId,
update,
},
},
};
}
function assistantChunk(seq, messageId, text, overrides = {}) {
return sessionUpdate(
seq,
{
sessionUpdate: "agent_message_chunk",
messageId,
content: { type: "text", text },
},
overrides,
);
}
test("buildTranscript preserves author pubkeys on user message chunks", () => {
const authorPubkey = "b".repeat(64);
const [item] = buildTranscript([
sessionUpdate(25, {
sessionUpdate: "user_message_chunk",
messageId: "user-chunk-1",
authorPubkey,
content: { type: "text", text: "please keep this visible" },
}),
]).filter((candidate) => candidate.type === "message");
assert.equal(item.role, "user");
assert.equal(item.authorPubkey, authorPubkey);
assert.equal(item.messageId, null);
});
test("buildTranscript preserves real event ids on user message chunks", () => {
const authorPubkey = "b".repeat(64);
const messageId = "d".repeat(64);
const [item] = buildTranscript([
sessionUpdate(26, {
sessionUpdate: "user_message_chunk",
messageId,
authorPubkey,
content: { type: "text", text: "this came from a channel message" },
}),
]).filter((candidate) => candidate.type === "message");
assert.equal(item.role, "user");
assert.equal(item.messageId, messageId);
});
test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => {
const items = toolItems([
acpToolUpdate(40, {
sessionUpdate: "tool_call",
toolCallId: "call-dupe",
status: "executing",
title: "shell",
kind: "shell",
rawInput: { command: "echo hi" },
}),
acpToolUpdate(41, {
sessionUpdate: "tool_call_update",
toolCallId: "call-dupe",
status: "completed",
title: "shell",
kind: "shell",
rawOutput: "hi",
}),
acpToolUpdate(42, {
sessionUpdate: "tool_call_update",
toolCallId: "call-dupe",
status: "completed",
title: "shell",
kind: "shell",
rawOutput: "hi",
}),
]);
assert.equal(items.length, 1);
assert.equal(items[0].id, `tool:${baseEvent.channelId}:call-dupe`);
assert.equal(items[0].status, "completed");
assert.equal(items[0].result, "hi");
});
test("buildTranscript keeps a completed tool terminal when a late executing call arrives", () => {
const [item] = toolItems([
acpToolUpdate(50, {
sessionUpdate: "tool_call_update",
toolCallId: "call-regression",
status: "completed",
title: "shell",
kind: "shell",
rawOutput: "done",
}),
acpToolUpdate(51, {
sessionUpdate: "tool_call",
toolCallId: "call-regression",
status: "executing",
title: "shell",
kind: "shell",
rawInput: { command: "echo done" },
}),
]);
assert.equal(item.status, "completed");
assert.equal(item.completedAt, baseEvent.timestamp);
assert.deepEqual(item.args, { command: "echo done" });
assert.equal(item.result, "done");
});
test("buildTranscript rebuilds out-of-order tool frames as one canonical row with retained ids", () => {
const [item] = toolItems([
sessionUpdate(
60,
{
sessionUpdate: "tool_call_update",
toolCallId: "call-out-of-order",
status: "completed",
title: "read_file",
kind: "read_file",
rawOutput: "file contents",
},
{
channelId: "22222222-2222-2222-2222-222222222222",
sessionId: "sess-2",
turnId: "turn-2",
timestamp: "2026-06-18T00:00:05Z",
},
),
sessionUpdate(
61,
{
sessionUpdate: "tool_call",
toolCallId: "call-out-of-order",
status: "executing",
title: "read_file",
kind: "read_file",
rawInput: { path: "AGENTS.md" },
},
{
channelId: "22222222-2222-2222-2222-222222222222",
sessionId: "sess-2",
turnId: "turn-2",
timestamp: "2026-06-18T00:00:04Z",
},
),
]);
assert.equal(
item.id,
"tool:22222222-2222-2222-2222-222222222222:call-out-of-order",
);
assert.equal(item.status, "completed");
assert.deepEqual(item.args, { path: "AGENTS.md" });
assert.equal(item.channelId, "22222222-2222-2222-2222-222222222222");
assert.equal(item.turnId, "turn-2");
assert.equal(item.sessionId, "sess-2");
});
test("buildTranscript coalesces assistant chunks until the message is sealed", () => {
const messages = buildTranscript([
assistantChunk(70, "msg-1", "Hello "),
assistantChunk(71, "msg-1", "world"),
]).filter((item) => item.type === "message" && item.role === "assistant");
assert.equal(messages.length, 1);
assert.equal(messages[0].text, "Hello world");
assert.equal(messages[0].id, `assistant:${baseEvent.channelId}:msg-1`);
});
test("buildTranscript starts a continuation for same-message chunks after sealing", () => {
const messages = buildTranscript([
assistantChunk(80, "msg-2", "First"),
acpToolUpdate(81, {
sessionUpdate: "tool_call",
toolCallId: "call-seal",
status: "executing",
title: "shell",
kind: "shell",
}),
assistantChunk(82, "msg-2", "Second"),
]).filter((item) => item.type === "message" && item.role === "assistant");
assert.equal(messages.length, 2);
assert.equal(messages[0].text, "First");
assert.equal(messages[1].text, "Second");
assert.match(messages[1].id, /:c\d+$/);
});
test("buildTranscript preserves channel, turn, and session ids through message updates", () => {
const [message] = buildTranscript([
assistantChunk(90, "msg-identity", "One ", {
channelId: "33333333-3333-3333-3333-333333333333",
sessionId: "sess-identity",
turnId: "turn-identity",
}),
assistantChunk(91, "msg-identity", "Two", {
channelId: "33333333-3333-3333-3333-333333333333",
sessionId: null,
turnId: null,
}),
]).filter((item) => item.type === "message" && item.role === "assistant");
assert.equal(message.text, "One Two");
assert.equal(message.channelId, "33333333-3333-3333-3333-333333333333");
assert.equal(message.turnId, "turn-identity");
assert.equal(message.sessionId, "sess-identity");
});
test("buildTranscript promotes ACP plan updates to first-class plan items", () => {
const items = buildTranscript([
sessionUpdate(90, {
sessionUpdate: "plan",
content: { type: "text", text: "- [ ] Build registry" },
}),
]);
assert.equal(items.length, 1);
assert.equal(items[0].type, "plan");
assert.equal(items[0].renderClass, "plan");
assert.match(items[0].text, /Build registry/);
});
test("buildTranscript stores first-class render class descriptors for tool items", () => {
const [item] = toolItems([
acpToolUpdate(91, {
sessionUpdate: "tool_call",
toolCallId: "call-edit",
status: "completed",
title: "str_replace",
kind: "str_replace",
rawInput: { path: "src/app.ts" },
}),
]);
assert.equal(item.renderClass, "file-edit");
assert.equal(item.descriptor.label, "Edited file");
assert.equal(item.descriptor.preview, "src/app.ts");
});
test("buildTranscript surfaces session/request_permission as a permission lifecycle item", () => {
const transcript = buildTranscript([
{
seq: 1,
timestamp: "2026-06-30T09:00:00.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
method: "session/request_permission",
params: {
toolCallId: "tool-1",
title: "Confirm force-with-lease push to block/buzz.",
options: [
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
],
},
},
},
]);
assert.equal(transcript.length, 1);
assert.equal(transcript[0].type, "lifecycle");
assert.equal(transcript[0].renderClass, "permission");
assert.equal(transcript[0].title, "Permission requested");
assert.match(transcript[0].text, /Confirm force-with-lease push/);
});
test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => {
const transcript = buildTranscript([
{
seq: 1,
timestamp: "2026-06-30T09:00:00.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "session-1",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
toolName: "dev__shell",
status: "completed",
rawInput: { command: "echo hi" },
content: [{ type: "text", text: "hi" }],
},
},
},
},
]);
assert.equal(transcript[0].type, "tool");
assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z");
});
test("buildTranscript preserves permission, free-form status, and raw rail render classes", () => {
const transcript = buildTranscript([
{
...baseEvent,
seq: 1,
kind: "acp_read",
payload: {
method: "session/request_permission",
params: {
title: "Confirm force-with-lease push",
toolCallId: "tool-push",
options: [
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
],
},
},
},
{
...baseEvent,
seq: 2,
kind: "acp_read",
payload: {
type: "observer_connected",
title: "Observer connected",
text: "ACP stream attached",
},
},
{
...baseEvent,
seq: 3,
kind: "raw_json_rpc",
payload: {
method: "workspace/diagnostic",
params: { ok: true },
},
},
]);
const permissionItem = transcript.find(
(item) =>
item.id.startsWith("permission:") && item.renderClass === "permission",
);
assert.ok(
permissionItem,
"permission request should flow through the reducer",
);
assert.doesNotMatch(
permissionItem.text,
/^Permission requested\b/,
"permission detail should not duplicate the row title",
);
assert.ok(
transcript.some(
(item) =>
item.renderClass === "status" && item.title === "Observer connected",
),
"free-form status fixture should flow through the reducer",
);
assert.ok(
transcript.some(
(item) => item.type === "metadata" && item.renderClass === "raw-rail",
),
"raw_json_rpc fixture should flow through the reducer",
);
});
test("buildTranscript separates repeated lifecycle text", () => {
const events = [
{
seq: 1,
timestamp: "2026-06-30T09:00:00.000Z",
kind: "turn_error",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId: "turn-1",
payload: { outcome: "recovered", error: "first" },
},
{
seq: 2,
timestamp: "2026-06-30T09:00:01.000Z",
kind: "turn_error",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId: "turn-1",
payload: { outcome: "recovered", error: "second" },
},
];
const [item] = buildTranscript(events);
assert.equal(item.type, "lifecycle");
assert.equal(item.text, "recovered: first\nrecovered: second");
});

View File

@@ -1,4 +1,6 @@
import type { import type {
AgentActivityDescriptor,
AgentActivityRenderClass,
ObserverEvent, ObserverEvent,
PromptSection, PromptSection,
ToolStatus, ToolStatus,
@@ -9,13 +11,15 @@ import {
isGenericToolTitle, isGenericToolTitle,
normalizeToolStatus, normalizeToolStatus,
} from "./agentSessionToolCatalog"; } from "./agentSessionToolCatalog";
import { asRecord, asString } from "./agentSessionUtils"; import { classifyTool } from "./agentSessionToolClassifier";
import { asRecord, asString, titleCase } from "./agentSessionUtils";
import { import {
describeTurnStarted, describeTurnStarted,
describeSessionResolved, describeSessionResolved,
extractBlockText, extractBlockText,
extractContentText, extractContentText,
extractPromptText, extractPromptText,
extractTriggeringEventIds,
extractToolArgs, extractToolArgs,
extractToolIdentity, extractToolIdentity,
extractToolResult, extractToolResult,
@@ -30,6 +34,7 @@ export type TranscriptState = {
itemsById: Map<string, TranscriptItem>; itemsById: Map<string, TranscriptItem>;
activeMessageKey: Map<string, string>; activeMessageKey: Map<string, string>;
sealedKeys: Set<string>; sealedKeys: Set<string>;
triggeringEventIdsByTurn: Map<string, string[]>;
continuationSeq: number; continuationSeq: number;
latestSessionId: string | null; latestSessionId: string | null;
}; };
@@ -40,6 +45,7 @@ export function createEmptyTranscriptState(): TranscriptState {
itemsById: new Map(), itemsById: new Map(),
activeMessageKey: new Map(), activeMessageKey: new Map(),
sealedKeys: new Set(), sealedKeys: new Set(),
triggeringEventIdsByTurn: new Map(),
continuationSeq: 0, continuationSeq: 0,
latestSessionId: null, latestSessionId: null,
}; };
@@ -55,6 +61,7 @@ type TranscriptDraft = {
itemsById: Map<string, TranscriptItem>; itemsById: Map<string, TranscriptItem>;
activeMessageKey: Map<string, string>; activeMessageKey: Map<string, string>;
sealedKeys: Set<string>; sealedKeys: Set<string>;
triggeringEventIdsByTurn: Map<string, string[]>;
continuationSeq: number; continuationSeq: number;
latestSessionId: string | null; latestSessionId: string | null;
changed: boolean; changed: boolean;
@@ -66,6 +73,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft {
itemsById: state.itemsById, itemsById: state.itemsById,
activeMessageKey: state.activeMessageKey, activeMessageKey: state.activeMessageKey,
sealedKeys: state.sealedKeys, sealedKeys: state.sealedKeys,
triggeringEventIdsByTurn: state.triggeringEventIdsByTurn,
continuationSeq: state.continuationSeq, continuationSeq: state.continuationSeq,
latestSessionId: state.latestSessionId, latestSessionId: state.latestSessionId,
changed: false, changed: false,
@@ -109,6 +117,104 @@ function sealOpenMessages(d: TranscriptDraft) {
} }
} }
function turnMapKey(channelKey: string, turnKey: string | number | null) {
return `${channelKey}:${turnKey ?? "unknown"}`;
}
function rememberTriggeringEventIds(
d: TranscriptDraft,
channelKey: string,
turnKey: string | number | null,
ids: string[],
) {
if (ids.length === 0) return;
d.triggeringEventIdsByTurn = new Map(d.triggeringEventIdsByTurn);
d.triggeringEventIdsByTurn.set(turnMapKey(channelKey, turnKey), ids);
}
function getSingleTriggeringEventId(
d: TranscriptDraft,
channelKey: string,
turnKey: string | number | null,
) {
const ids = d.triggeringEventIdsByTurn.get(turnMapKey(channelKey, turnKey));
return ids?.length === 1 ? maybeNostrEventId(ids[0]) : null;
}
function maybeNostrEventId(id: string | null | undefined) {
return id && /^[0-9a-fA-F]{64}$/.test(id) ? id : null;
}
function stringifyPayload(value: unknown) {
try {
return JSON.stringify(value, null, 2) ?? String(value);
} catch {
return String(value);
}
}
function describePermissionRequest(payload: Record<string, unknown>) {
const params = asRecord(payload.params);
const title =
asString(params.title) ??
asString(params.message) ??
asString(params.reason) ??
"Permission requested";
const toolCallId =
asString(params.toolCallId) ?? asString(params.tool_call_id);
const options = Array.isArray(params.options)
? params.options
.map((option) => {
const record = asRecord(option);
return (
asString(record.name) ??
asString(record.kind) ??
asString(record.optionId)
);
})
.filter((option): option is string => Boolean(option))
: [];
const detail: string[] = [];
if (title !== "Permission requested") detail.push(title);
if (toolCallId) detail.push(`Tool call: ${toolCallId}`);
if (options.length > 0) detail.push(`Options: ${options.join(", ")}`);
return {
title,
text: detail.join("\n"),
descriptor: {
renderClass: "permission" as const,
label: "Permission requested",
preview: title,
action: { verb: "Requested", object: title },
tone: "admin" as const,
operation: "session/request_permission",
object: title,
source: "acp" as const,
groupKey: "permission:request",
},
};
}
function describeFreeformStatus(payload: Record<string, unknown>) {
const statusType = asString(payload.type) ?? asString(payload.status);
const title =
asString(payload.title) ?? (statusType ? titleCase(statusType) : null);
const text = asString(payload.text) ?? asString(payload.message);
if (!title || !text) return null;
return { statusType: statusType ?? title.toLowerCase(), title, text };
}
function rawPayloadTitle(payload: unknown) {
const record = asRecord(payload);
return asString(record.method) ?? asString(record.type) ?? "raw_json_rpc";
}
type TranscriptItemContext = {
channelId: string | null;
turnId: string | null;
sessionId: string | null;
};
function upsertMessage( function upsertMessage(
d: TranscriptDraft, d: TranscriptDraft,
id: string, id: string,
@@ -116,8 +222,10 @@ function upsertMessage(
title: string, title: string,
text: string, text: string,
timestamp: string, timestamp: string,
channelId: string | null, ctx: TranscriptItemContext,
authorPubkey: string | null = null, authorPubkey: string | null = null,
acpSource?: string,
messageId: string | null = null,
) { ) {
const currentKey = d.activeMessageKey.get(id); const currentKey = d.activeMessageKey.get(id);
@@ -127,8 +235,12 @@ function upsertMessage(
replaceItem(d, currentKey, { replaceItem(d, currentKey, {
...existing, ...existing,
text: existing.text + text, text: existing.text + text,
channelId, channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
authorPubkey: authorPubkey ?? existing.authorPubkey, authorPubkey: authorPubkey ?? existing.authorPubkey,
acpSource: acpSource ?? existing.acpSource,
messageId: messageId ?? existing.messageId,
}); });
return; return;
} }
@@ -139,12 +251,17 @@ function upsertMessage(
pushItem(d, { pushItem(d, {
id: newKey, id: newKey,
type: "message", type: "message",
renderClass: "message",
role, role,
title, title,
text, text,
timestamp, timestamp,
channelId, messageId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
authorPubkey, authorPubkey,
acpSource,
}); });
d.activeMessageKey = new Map(d.activeMessageKey); d.activeMessageKey = new Map(d.activeMessageKey);
d.activeMessageKey.set(id, newKey); d.activeMessageKey.set(id, newKey);
@@ -157,15 +274,172 @@ function upsertTextItem(
title: string, title: string,
text: string, text: string,
timestamp: string, timestamp: string,
channelId: string | null, ctx: TranscriptItemContext,
acpSource?: string,
) { ) {
const existing = d.itemsById.get(id); const existing = d.itemsById.get(id);
if (existing && existing.type === type) { if (existing && existing.type === type) {
replaceItem(d, id, { ...existing, text: existing.text + text, channelId }); replaceItem(d, id, {
...existing,
text:
type === "lifecycle"
? joinLifecycleText(existing.text, text)
: existing.text + text,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return; return;
} }
sealOpenMessages(d); sealOpenMessages(d);
pushItem(d, { id, type, title, text, timestamp, channelId }); if (type === "thought") {
pushItem(d, {
id,
type: "thought",
renderClass: "thought",
title,
text,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
return;
}
upsertLifecycleItem(
d,
id,
title.toLowerCase().includes("error") ? "error" : "status",
title,
text,
timestamp,
ctx,
acpSource,
);
}
function joinLifecycleText(existing: string, next: string) {
if (!existing) return next;
if (!next) return existing;
return `${existing}\n${next}`;
}
function upsertLifecycleItem(
d: TranscriptDraft,
id: string,
renderClass: Extract<
AgentActivityRenderClass,
"status" | "permission" | "error"
>,
title: string,
text: string,
timestamp: string,
ctx: TranscriptItemContext,
acpSource?: string,
descriptor?: AgentActivityDescriptor,
) {
const existing = d.itemsById.get(id);
if (existing?.type === "lifecycle") {
replaceItem(d, id, {
...existing,
renderClass,
title,
text: joinLifecycleText(existing.text, text),
descriptor: descriptor ?? existing.descriptor,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
sealOpenMessages(d);
pushItem(d, {
id,
type: "lifecycle",
renderClass,
title,
text,
timestamp,
descriptor,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function upsertPlan(
d: TranscriptDraft,
id: string,
title: string,
text: string,
timestamp: string,
ctx: TranscriptItemContext,
acpSource?: string,
updateMarkerId?: string,
) {
const existing = d.itemsById.get(id);
if (existing?.type === "plan") {
const changed = existing.text !== text;
replaceItem(d, id, {
...existing,
text,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
if (changed) {
pushItem(d, {
id: updateMarkerId ?? `${id}:update:${timestamp}`,
type: "plan",
renderClass: "plan",
title: "Plan updated",
text: summarizePlanUpdate(text),
timestamp,
isUpdate: true,
targetId: id,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
return;
}
sealOpenMessages(d);
pushItem(d, {
id,
type: "plan",
renderClass: "plan",
title,
text,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function summarizePlanUpdate(text: string) {
const taskMatches = [...text.matchAll(/\[[ xX]\]/g)];
if (taskMatches.length > 0) {
const completed = taskMatches.filter((match) =>
match[0].toLowerCase().includes("x"),
).length;
return `${completed}/${taskMatches.length} complete`;
}
const stepCount = text
.split(/\r?\n/)
.filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S/.test(line)).length;
return stepCount > 0 ? `${stepCount} step${stepCount === 1 ? "" : "s"}` : "";
} }
function upsertMetadata( function upsertMetadata(
@@ -174,15 +448,46 @@ function upsertMetadata(
title: string, title: string,
sections: PromptSection[], sections: PromptSection[],
timestamp: string, timestamp: string,
channelId: string | null, ctx: TranscriptItemContext,
acpSource?: string,
) { ) {
const existing = d.itemsById.get(id); const existing = d.itemsById.get(id);
if (existing?.type === "metadata") { if (existing?.type === "metadata") {
replaceItem(d, id, { ...existing, sections, channelId }); replaceItem(d, id, {
...existing,
sections,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return; return;
} }
sealOpenMessages(d); sealOpenMessages(d);
pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId }); pushItem(d, {
id,
type: "metadata",
renderClass: "raw-rail",
title,
sections,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function isTerminalToolStatus(status: ToolStatus) {
return status === "completed" || status === "failed";
}
function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus {
if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) {
return existing;
}
return next;
} }
function upsertTool( function upsertTool(
@@ -196,7 +501,8 @@ function upsertTool(
result: string, result: string,
isError: boolean, isError: boolean,
timestamp: string, timestamp: string,
channelId: string | null, ctx: TranscriptItemContext,
acpSource?: string,
) { ) {
const existing = d.itemsById.get(id); const existing = d.itemsById.get(id);
const canonicalBuzzToolName = const canonicalBuzzToolName =
@@ -211,30 +517,57 @@ function upsertTool(
} else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) { } else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) {
updatedToolName = toolName; updatedToolName = toolName;
} }
replaceItem(d, id, { const mergedStatus = mergeToolStatus(existing.status, status);
...existing, const updatedArgs = Object.keys(args).length > 0 ? args : existing.args;
const updatedResult = result || existing.result;
const updatedIsError = isError || existing.isError;
const descriptor = classifyTool({
title: updatedTitle, title: updatedTitle,
toolName: updatedToolName, toolName: updatedToolName,
buzzToolName: updatedBuzzToolName, buzzToolName: updatedBuzzToolName,
status, args: updatedArgs,
args: Object.keys(args).length > 0 ? args : existing.args, result: updatedResult,
result: result || existing.result, isError: updatedIsError || mergedStatus === "failed",
isError: isError || existing.isError, });
replaceItem(d, id, {
...existing,
renderClass: descriptor.renderClass,
descriptor,
title: updatedTitle,
toolName: updatedToolName,
buzzToolName: updatedBuzzToolName,
status: mergedStatus,
args: updatedArgs,
result: updatedResult,
isError: updatedIsError,
completedAt: completedAt:
(status === "completed" || status === "failed") && isTerminalToolStatus(mergedStatus) && existing.completedAt == null
existing.completedAt == null
? timestamp ? timestamp
: existing.completedAt, : existing.completedAt,
channelId, channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
}); });
return; return;
} }
const resolvedToolName = canonicalBuzzToolName ?? toolName;
const descriptor = classifyTool({
title,
toolName: resolvedToolName,
buzzToolName: canonicalBuzzToolName,
args,
result,
isError: isError || status === "failed",
});
sealOpenMessages(d); sealOpenMessages(d);
pushItem(d, { pushItem(d, {
id, id,
type: "tool", type: "tool",
renderClass: descriptor.renderClass,
descriptor,
title, title,
toolName: canonicalBuzzToolName ?? toolName, toolName: resolvedToolName,
buzzToolName: canonicalBuzzToolName, buzzToolName: canonicalBuzzToolName,
status, status,
args, args,
@@ -242,8 +575,11 @@ function upsertTool(
isError, isError,
timestamp, timestamp,
startedAt: timestamp, startedAt: timestamp,
completedAt: null, completedAt: isTerminalToolStatus(status) ? timestamp : null,
channelId, channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
}); });
} }
@@ -259,8 +595,34 @@ export function processTranscriptEvent(
const channelId = event.channelId ?? null; const channelId = event.channelId ?? null;
const ch = channelId ?? "global"; const ch = channelId ?? "global";
const ctx: TranscriptItemContext = {
channelId,
turnId: event.turnId,
sessionId: event.sessionId ?? d.latestSessionId,
};
if (event.kind === "turn_started") { if (event.kind === "raw_json_rpc") {
upsertMetadata(
d,
`raw-json-rpc:${ch}:${event.seq}`,
"Raw ACP payload",
[
{
title: rawPayloadTitle(event.payload),
body: stringifyPayload(event.payload),
},
],
event.timestamp,
ctx,
event.kind,
);
} else if (event.kind === "turn_started") {
rememberTriggeringEventIds(
d,
ch,
event.turnId ?? event.seq,
extractTriggeringEventIds(event.payload),
);
upsertTextItem( upsertTextItem(
d, d,
`turn:${ch}:${event.turnId ?? event.seq}`, `turn:${ch}:${event.turnId ?? event.seq}`,
@@ -268,7 +630,8 @@ export function processTranscriptEvent(
"Turn started", "Turn started",
describeTurnStarted(event.payload), describeTurnStarted(event.payload),
event.timestamp, event.timestamp,
channelId, ctx,
event.kind,
); );
} else if (event.kind === "session_resolved") { } else if (event.kind === "session_resolved") {
upsertTextItem( upsertTextItem(
@@ -278,7 +641,8 @@ export function processTranscriptEvent(
"Session ready", "Session ready",
describeSessionResolved(event.payload), describeSessionResolved(event.payload),
event.timestamp, event.timestamp,
channelId, ctx,
event.kind,
); );
} else if (event.kind === "acp_parse_error") { } else if (event.kind === "acp_parse_error") {
upsertTextItem( upsertTextItem(
@@ -288,7 +652,8 @@ export function processTranscriptEvent(
"Wire parse error", "Wire parse error",
extractBlockText(event.payload), extractBlockText(event.payload),
event.timestamp, event.timestamp,
channelId, ctx,
event.kind,
); );
} else if (event.kind === "turn_error" || event.kind === "agent_panic") { } else if (event.kind === "turn_error" || event.kind === "agent_panic") {
const payload = asRecord(event.payload); const payload = asRecord(event.payload);
@@ -303,13 +668,27 @@ export function processTranscriptEvent(
title, title,
`${outcome}: ${error}`, `${outcome}: ${error}`,
event.timestamp, event.timestamp,
channelId, ctx,
event.kind,
); );
} else if (event.kind === "acp_read" || event.kind === "acp_write") { } else if (event.kind === "acp_read" || event.kind === "acp_write") {
const payload = asRecord(event.payload); const payload = asRecord(event.payload);
const method = asString(payload.method); const method = asString(payload.method);
if (event.kind === "acp_write" && method === "session/prompt") { if (method === "session/request_permission") {
const request = describePermissionRequest(payload);
upsertLifecycleItem(
d,
`permission:${ch}:${event.turnId ?? event.seq}`,
"permission",
"Permission requested",
request.text,
event.timestamp,
ctx,
"permission_request",
request.descriptor,
);
} else if (event.kind === "acp_write" && method === "session/prompt") {
const promptText = extractPromptText(payload); const promptText = extractPromptText(payload);
if (promptText) { if (promptText) {
const parsedPrompt = parsePromptText(promptText); const parsedPrompt = parsePromptText(promptText);
@@ -321,8 +700,11 @@ export function processTranscriptEvent(
parsedPrompt.userTitle, parsedPrompt.userTitle,
parsedPrompt.userText, parsedPrompt.userText,
event.timestamp, event.timestamp,
channelId, ctx,
parsedPrompt.userPubkey, parsedPrompt.userPubkey,
"session/prompt:user",
parsedPrompt.userEventId ??
getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq),
); );
} }
if (parsedPrompt.sections.length > 0) { if (parsedPrompt.sections.length > 0) {
@@ -332,7 +714,8 @@ export function processTranscriptEvent(
"Prompt context", "Prompt context",
parsedPrompt.sections, parsedPrompt.sections,
event.timestamp, event.timestamp,
channelId, ctx,
"session/prompt:context",
); );
} }
} }
@@ -353,7 +736,7 @@ export function processTranscriptEvent(
"System prompt", "System prompt",
sections, sections,
event.timestamp, event.timestamp,
channelId, ctx,
); );
} }
} }
@@ -372,8 +755,10 @@ export function processTranscriptEvent(
parsedPrompt.userTitle, parsedPrompt.userTitle,
parsedPrompt.userText, parsedPrompt.userText,
event.timestamp, event.timestamp,
channelId, ctx,
parsedPrompt.userPubkey, parsedPrompt.userPubkey,
undefined,
parsedPrompt.userEventId,
); );
} }
if (parsedPrompt.sections.length > 0) { if (parsedPrompt.sections.length > 0) {
@@ -383,7 +768,7 @@ export function processTranscriptEvent(
"Prompt context", "Prompt context",
parsedPrompt.sections, parsedPrompt.sections,
event.timestamp, event.timestamp,
channelId, ctx,
); );
} }
} }
@@ -402,13 +787,17 @@ export function processTranscriptEvent(
"Assistant", "Assistant",
extractContentText(update.content), extractContentText(update.content),
event.timestamp, event.timestamp,
channelId, ctx,
null,
updateType,
); );
} else if (updateType === "user_message_chunk") { } else if (updateType === "user_message_chunk") {
// Suppress user_message_chunk echo when a steer already rendered // Suppress user_message_chunk echo when a steer already rendered
// the user message for this turn (Goose echoes steered content back). // the user message for this turn (Goose echoes steered content back).
const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`; const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`;
const authorPubkey = asString(update.authorPubkey);
if (!d.itemsById.has(steerKey)) { if (!d.itemsById.has(steerKey)) {
const channelMessageId = maybeNostrEventId(messageId);
upsertMessage( upsertMessage(
d, d,
`user:${ch}:${messageId ?? turnKey}`, `user:${ch}:${messageId ?? turnKey}`,
@@ -416,7 +805,10 @@ export function processTranscriptEvent(
"User", "User",
extractContentText(update.content), extractContentText(update.content),
event.timestamp, event.timestamp,
channelId, ctx,
authorPubkey,
updateType,
channelMessageId,
); );
} }
} else if (updateType === "agent_thought_chunk") { } else if (updateType === "agent_thought_chunk") {
@@ -427,7 +819,8 @@ export function processTranscriptEvent(
"Thinking", "Thinking",
extractContentText(update.content), extractContentText(update.content),
event.timestamp, event.timestamp,
channelId, ctx,
updateType,
); );
} else if (updateType === "tool_call") { } else if (updateType === "tool_call") {
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
@@ -443,7 +836,8 @@ export function processTranscriptEvent(
extractToolResult(update), extractToolResult(update),
false, false,
event.timestamp, event.timestamp,
channelId, ctx,
updateType,
); );
} else if (updateType === "tool_call_update") { } else if (updateType === "tool_call_update") {
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
@@ -462,17 +856,53 @@ export function processTranscriptEvent(
extractToolResult(update), extractToolResult(update),
status === "failed", status === "failed",
event.timestamp, event.timestamp,
channelId, ctx,
updateType,
); );
} else if (updateType === "plan") { } else if (updateType === "plan") {
upsertTextItem( upsertPlan(
d, d,
`plan:${ch}:${turnKey}`, `plan:${ch}:${turnKey}`,
"thought",
"Plan", "Plan",
extractContentText(update.content) || JSON.stringify(update, null, 2), extractContentText(update.content) || JSON.stringify(update, null, 2),
event.timestamp, event.timestamp,
channelId, ctx,
updateType,
`plan-update:${ch}:${turnKey}:${event.seq}`,
);
} else {
// Free-form observer status records are not part of the ACP session/update
// union. Surface only explicit title/text payloads; leave all other
// unknown frames out of the feed instead of guessing at semantics.
const status = describeFreeformStatus(payload);
if (status) {
upsertLifecycleItem(
d,
`status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`,
"status",
status.title,
status.text,
event.timestamp,
ctx,
status.statusType,
);
}
}
} else {
// Free-form observer status records are not part of the ACP JSON-RPC
// method set. Surface only explicit title/text payloads; leave all other
// unknown frames out of the feed instead of guessing at semantics.
const status = describeFreeformStatus(payload);
if (status) {
upsertLifecycleItem(
d,
`status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`,
"status",
status.title,
status.text,
event.timestamp,
ctx,
status.statusType,
); );
} }
} }
@@ -487,6 +917,7 @@ export function processTranscriptEvent(
itemsById: d.itemsById, itemsById: d.itemsById,
activeMessageKey: d.activeMessageKey, activeMessageKey: d.activeMessageKey,
sealedKeys: d.sealedKeys, sealedKeys: d.sealedKeys,
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
continuationSeq: d.continuationSeq, continuationSeq: d.continuationSeq,
latestSessionId: d.latestSessionId, latestSessionId: d.latestSessionId,
}; };

View File

@@ -0,0 +1,327 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildTranscriptDisplayBlocks,
flattenDisplayBlocks,
formatTurnSetupLabel,
} from "./agentSessionTranscriptGrouping.ts";
const baseTimestamp = "2026-06-14T22:20:23.000Z";
function lifecycle(id, title, acpSource, turnId, text = "") {
return {
id,
type: "lifecycle",
title,
text,
timestamp: baseTimestamp,
acpSource,
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function userPrompt(id, text, turnId) {
return {
id,
type: "message",
role: "user",
title: "Buzz event",
text,
timestamp: baseTimestamp,
acpSource: "session/prompt:user",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function promptContext(id, turnId) {
return {
id,
type: "metadata",
title: "Prompt context",
sections: [{ title: "Channel", body: "general" }],
timestamp: baseTimestamp,
acpSource: "session/prompt:context",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function assistantMessage(id, text, turnId) {
return {
id,
type: "message",
role: "assistant",
title: "Assistant",
text,
timestamp: "2026-06-14T22:20:47.000Z",
acpSource: "agent_message_chunk",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function toolCall(id, turnId) {
return {
id,
type: "tool",
title: "Shell",
toolName: "buzz-dev-mcp__shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: "2026-06-14T22:20:47.000Z",
startedAt: "2026-06-14T22:20:47.000Z",
completedAt: "2026-06-14T22:20:47.400Z",
acpSource: "tool_call_update",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => {
const rawItems = [
lifecycle(
"turn",
"Turn started",
"turn_started",
"turn-1",
"Triggered by 1 event.",
),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"),
promptContext("context", "turn-1"),
assistantMessage("assistant", "Thinking out loud.", "turn-1"),
toolCall("tool", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, [
"prompt",
"turn",
"session",
"context",
"assistant",
"tool",
]);
const turnBlock = blocks[0];
assert.equal(turnBlock?.kind, "turn");
assert.equal(turnBlock.segments[0]?.kind, "prompt");
const promptSegment = turnBlock.segments[0];
assert.equal(promptSegment.user.id, "prompt");
assert.equal(promptSegment.context?.id, "context");
assert.equal(promptSegment.setup.length, 2);
assert.equal(turnBlock.segments[1]?.kind, "item");
assert.equal(turnBlock.segments[2]?.kind, "item");
});
test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
userPrompt("prompt", "hello", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]?.kind, "turn");
const turnBlock = blocks[0];
assert.equal(turnBlock.segments.length, 1);
assert.equal(turnBlock.segments[0]?.kind, "prompt");
assert.equal(
formatTurnSetupLabel(turnBlock.segments[0].setup),
"Turn started · Session ready",
);
});
test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
promptContext("context", "turn-1"),
toolCall("tool", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, ["tool"]);
});
test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
promptContext("context", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
assert.deepEqual(blocks, []);
});
test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
userPrompt("prompt", "hello", "turn-1"),
lifecycle(
"error",
"Turn error",
"turn_error",
"turn-1",
"timeout: agent hung",
),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, ["prompt", "turn", "error"]);
assert.equal(blocks[0]?.segments[0]?.kind, "prompt");
assert.equal(blocks[0]?.segments[1]?.kind, "item");
assert.equal(blocks[0]?.segments[1]?.item.id, "error");
});
test("buildTranscriptDisplayBlocks passes through items without turnId", () => {
const orphan = {
id: "orphan",
type: "lifecycle",
title: "Wire parse error",
text: "bad json",
timestamp: baseTimestamp,
acpSource: "acp_parse_error",
channelId: "channel-1",
};
const blocks = buildTranscriptDisplayBlocks([orphan]);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]?.kind, "single");
assert.equal(blocks[0]?.item.id, "orphan");
});
test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", () => {
const items = [1, 2, 3].map((index) => ({
id: `tool:${index}`,
type: "tool",
renderClass: "generic",
descriptor: {
renderClass: "generic",
label: "Read file",
preview: `file-${index}.ts`,
groupKey: "read_file",
},
title: "read_file",
toolName: "read_file",
buzzToolName: null,
status: "completed",
args: { path: `file-${index}.ts` },
result: "",
isError: false,
timestamp: "2026-06-18T00:00:00Z",
startedAt: "2026-06-18T00:00:00Z",
completedAt: "2026-06-18T00:00:01Z",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
}));
const [block] = buildTranscriptDisplayBlocks(items);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.label, "Read 3 files");
});
test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () => {
const items = [1, 2].map((index) => ({
id: `edit:${index}`,
type: "tool",
renderClass: "file-edit",
descriptor: {
renderClass: "file-edit",
label: "Edited file",
preview: `src/file-${index}.ts`,
groupKey: "file-edit:str_replace",
},
title: "str_replace",
toolName: "str_replace",
buzzToolName: null,
status: "completed",
args: { path: `src/file-${index}.ts` },
result: "",
isError: false,
timestamp: "2026-06-18T00:00:00Z",
startedAt: "2026-06-18T00:00:00Z",
completedAt: "2026-06-18T00:00:01Z",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
}));
const [block] = buildTranscriptDisplayBlocks(items);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.label, "Edited 2 files");
assert.equal(block.segments[0].summary.renderClass, "file-edit");
assert.deepEqual(
block.segments[0].summary.items.map((item) => item.id),
["edit:1", "edit:2"],
);
});
test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => {
const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({
id,
type: "tool",
renderClass,
descriptor: {
renderClass,
label,
preview: id,
source: "harness",
groupKey,
},
title: label,
toolName: label,
buzzToolName: null,
status: "completed",
args: {},
result: "",
isError: false,
timestamp: "2026-06-18T00:00:00Z",
startedAt: "2026-06-18T00:00:00Z",
completedAt: "2026-06-18T00:00:01Z",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
});
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "generic", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-2", "Read file", "generic", "read_file"),
mkTool("read-3", "Read file", "generic", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "item", "item", "item"],
);
});

View File

@@ -0,0 +1,291 @@
import type { TranscriptItem } from "./agentSessionTypes";
import { classifyToolItem } from "./agentSessionToolClassifier";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { kind: "summary"; summary: TranscriptSameKindSummary }
| { kind: "setup"; items: Extract<TranscriptItem, { type: "lifecycle" }>[] }
| {
kind: "prompt";
user: Extract<TranscriptItem, { type: "message" }>;
context: Extract<TranscriptItem, { type: "metadata" }> | null;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
};
export type TranscriptDisplayBlock =
| { kind: "single"; item: TranscriptItem }
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
export type TranscriptSameKindSummary = {
id: string;
label: string;
count: number;
items: TranscriptItem[];
renderClass: TranscriptItem["renderClass"] | null;
timestamp: string;
};
function isUserPrompt(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "message" }> {
return (
item.type === "message" &&
item.role === "user" &&
item.acpSource === "session/prompt:user"
);
}
function isPromptContext(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
return (
item.type === "metadata" && item.acpSource === "session/prompt:context"
);
}
function isSetupLifecycle(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
return (
item.type === "lifecycle" &&
(item.acpSource === "turn_started" || item.acpSource === "session_resolved")
);
}
type TurnBucket = {
turnId: string;
items: TranscriptItem[];
};
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
const userPrompt = items.find(isUserPrompt) ?? null;
const setupLifecycle = items.filter(isSetupLifecycle);
const promptContext = items.find(isPromptContext) ?? null;
const consumed = new Set<TranscriptItem>();
if (userPrompt) consumed.add(userPrompt);
for (const item of setupLifecycle) consumed.add(item);
if (promptContext) consumed.add(promptContext);
const activity = items.filter((item) => !consumed.has(item));
if (!userPrompt) {
return groupSameKindSegments(
activity.map((item) => ({ kind: "item", item })),
);
}
const segments: TranscriptTurnSegment[] = [
{
kind: "prompt",
user: userPrompt,
context: promptContext,
setup: setupLifecycle,
},
];
for (const item of activity) {
segments.push({ kind: "item", item });
}
return groupSameKindSegments(segments);
}
function groupSameKindSegments(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
const grouped: TranscriptTurnSegment[] = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment.kind !== "item") {
grouped.push(segment);
continue;
}
const key = sameKindKey(segment.item);
if (!key) {
grouped.push(segment);
continue;
}
const run = [segment.item];
let j = i + 1;
while (j < segments.length) {
const next = segments[j];
if (next.kind !== "item" || sameKindKey(next.item) !== key) break;
run.push(next.item);
j += 1;
}
if (run.length >= minimumSummaryRunLength(run[0])) {
grouped.push({
kind: "summary",
summary: {
id: `summary:${key}:${run[0].id}`,
label: sameKindLabel(run[0], run.length),
count: run.length,
items: run,
renderClass: getRenderClass(run[0]),
timestamp: run[0].timestamp,
},
});
i = j - 1;
} else {
grouped.push(...run.map((item) => ({ kind: "item" as const, item })));
i = j - 1;
}
}
return grouped;
}
function sameKindKey(item: TranscriptItem): string | null {
if (item.type !== "tool") return null;
const renderClass = getRenderClass(item);
if (renderClass === "message") {
return null;
}
const descriptor = item.descriptor ?? classifyToolItem(item);
return descriptor.groupKey ?? renderClass;
}
function sameKindLabel(item: TranscriptItem, count: number): string {
if (item.type !== "tool") return `${count} items`;
const descriptor = item.descriptor ?? classifyToolItem(item);
const renderClass = getRenderClass(item);
const label = descriptor.label;
if (renderClass === "file-edit") {
return `Edited ${count} file${count === 1 ? "" : "s"}`;
}
if (label === "Read file") return `Read ${count} files`;
if (label === "Ran command") return `Ran ${count} commands`;
if (renderClass === "relay-op") return `Ran ${count} Buzz relay ops`;
return `${label} ×${count}`;
}
function minimumSummaryRunLength(item: TranscriptItem): number {
return getRenderClass(item) === "file-edit" ? 2 : 3;
}
function getRenderClass(item: TranscriptItem) {
if (item.type !== "tool") return item.renderClass;
const descriptor = item.descriptor ?? classifyToolItem(item);
return item.renderClass ?? descriptor.renderClass;
}
/**
* Build presentation-only display blocks from normalized transcript items.
* Raw observer order is preserved in the source items; this only reorders
* within a turn for user-facing narrative flow.
*/
export function buildTranscriptDisplayBlocks(
items: TranscriptItem[],
): TranscriptDisplayBlock[] {
const blocks: TranscriptDisplayBlock[] = [];
const turnBuckets = new Map<string, TurnBucket>();
// Callers pass a channel-scoped item stream; revisit this bare turnId bucket
// if grouping ever receives multi-channel transcript items.
const displayOrder: Array<
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
> = [];
for (const item of items) {
const turnId = item.turnId;
if (!turnId) {
displayOrder.push({ kind: "single", item });
continue;
}
let bucket = turnBuckets.get(turnId);
if (!bucket) {
bucket = { turnId, items: [] };
turnBuckets.set(turnId, bucket);
displayOrder.push({ kind: "turn", turnId });
}
bucket.items.push(item);
}
for (const entry of displayOrder) {
if (entry.kind === "single") {
blocks.push({ kind: "single", item: entry.item });
continue;
}
const bucket = turnBuckets.get(entry.turnId);
if (!bucket || bucket.items.length === 0) {
continue;
}
const segments = classifyTurnItems(bucket.items);
if (segments.length > 0) {
blocks.push({
kind: "turn",
turnId: entry.turnId,
segments,
});
}
}
return blocks;
}
/** Flatten display blocks back to items for testing display order. */
export function flattenDisplayBlocks(
blocks: TranscriptDisplayBlock[],
): TranscriptItem[] {
const result: TranscriptItem[] = [];
for (const block of blocks) {
if (block.kind === "single") {
result.push(block.item);
continue;
}
for (const segment of block.segments) {
if (segment.kind === "item") {
result.push(segment.item);
} else if (segment.kind === "prompt") {
result.push(segment.user);
result.push(...segment.setup);
if (segment.context) {
result.push(segment.context);
}
} else if (segment.kind === "summary") {
result.push(...segment.summary.items);
} else {
result.push(...segment.items);
}
}
}
return result;
}
/** Human-readable labels for a collapsed turn setup row. */
export function formatTurnSetupLabel(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string {
const labels = items.map((item) => item.title);
return labels.join(" · ");
}
/** Earliest timestamp among setup lifecycle items. */
export function turnSetupTimestamp(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string | null {
if (items.length === 0) return null;
return items.reduce(
(earliest, item) =>
Date.parse(item.timestamp) < Date.parse(earliest)
? item.timestamp
: earliest,
items[0].timestamp,
);
}
/** Optional detail text from setup lifecycle items (e.g. trigger count). */
export function turnSetupDetail(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string | null {
const details = items
.map((item) => item.text.trim())
.filter((text) => text.length > 0);
if (details.length === 0) return null;
return details.join(" ");
}

View File

@@ -20,6 +20,7 @@ test("parsePromptText returns the empty/Prompt fallback for whitespace-only inpu
userText: "", userText: "",
userTitle: "Prompt", userTitle: "Prompt",
userPubkey: null, userPubkey: null,
userEventId: null,
}); });
}); });
@@ -36,14 +37,16 @@ test("parsePromptText wraps header-less free text in a single Prompt section", (
assert.equal(result.userText, ""); assert.equal(result.userText, "");
assert.equal(result.userTitle, "Buzz event"); assert.equal(result.userTitle, "Buzz event");
assert.equal(result.userPubkey, null); assert.equal(result.userPubkey, null);
assert.equal(result.userEventId, null);
}); });
test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => { test("parsePromptText extracts event id, content, hex pubkey, and a title-cased kind", () => {
const text = [ const text = [
"[System]", "[System]",
"system preamble here", "system preamble here",
"", "",
"[Buzz event: @mention]", "[Buzz event: @mention]",
`Event ID: ${HEX_UPPER}`,
"Channel: demo", "Channel: demo",
`From: Wes (hex: ${HEX})`, `From: Wes (hex: ${HEX})`,
"Content: hello @Brain please look", "Content: hello @Brain please look",
@@ -53,6 +56,7 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", ()
assert.equal(result.userText, "hello @Brain please look"); assert.equal(result.userText, "hello @Brain please look");
assert.equal(result.userPubkey, HEX); assert.equal(result.userPubkey, HEX);
assert.equal(result.userEventId, HEX);
// titleCase capitalizes after word boundaries but leaves the leading "@" // titleCase capitalizes after word boundaries but leaves the leading "@"
// (a non-word char) in place: "@mention" -> "@Mention". // (a non-word char) in place: "@mention" -> "@Mention".
assert.equal(result.userTitle, "@Mention"); assert.equal(result.userTitle, "@Mention");
@@ -63,6 +67,38 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", ()
); );
}); });
test("parsePromptText preserves multiline event content in the user bubble text", () => {
const text = [
"[Buzz event: @mention]",
"Event ID: event-1",
"Channel: agents",
`From: tho (hex: ${HEX})`,
"Time: 2026-06-15T17:15:00Z",
"Content: @Ned",
"",
"- remove that stray cherry pick if it's not adding value here",
"- help me understand what that e2eBridge change does",
"- we'd want the e2e seed path as a separate pull request",
'Tags: [["h","agents"]]',
"Parsed: mentions=[Ned]",
].join("\n");
const result = parsePromptText(text);
assert.equal(
result.userText,
[
"@Ned",
"",
"- remove that stray cherry pick if it's not adding value here",
"- help me understand what that e2eBridge change does",
"- we'd want the e2e seed path as a separate pull request",
].join("\n"),
);
assert.equal(result.userPubkey, HEX);
assert.equal(result.userEventId, null);
});
test("parsePromptText lowercases the extracted hex pubkey", () => { test("parsePromptText lowercases the extracted hex pubkey", () => {
const text = [ const text = [
"[Buzz event: dm]", "[Buzz event: dm]",

View File

@@ -18,6 +18,7 @@ export function parsePromptText(text: string): {
userText: string; userText: string;
userTitle: string; userTitle: string;
userPubkey: string | null; userPubkey: string | null;
userEventId: string | null;
} { } {
const sections = parsePromptSections(text).filter( const sections = parsePromptSections(text).filter(
(s) => s.body.trim().length > 0, (s) => s.body.trim().length > 0,
@@ -28,12 +29,13 @@ export function parsePromptText(text: string): {
userText: text.trim(), userText: text.trim(),
userTitle: "Prompt", userTitle: "Prompt",
userPubkey: null, userPubkey: null,
userEventId: null,
}; };
} }
const eventSection = sections.find((section) => { const eventSection = sections.find((section) => {
const title = section.title.toLowerCase(); const title = section.title.toLowerCase();
return title.startsWith("buzz event") || title.startsWith("buzz event"); return title.startsWith("buzz event");
}); });
const eventContent = eventSection const eventContent = eventSection
? extractEventContent(eventSection.body) ? extractEventContent(eventSection.body)
@@ -41,6 +43,7 @@ export function parsePromptText(text: string): {
const eventAuthorPubkey = eventSection const eventAuthorPubkey = eventSection
? extractEventAuthorPubkey(eventSection.body) ? extractEventAuthorPubkey(eventSection.body)
: null; : null;
const eventId = eventSection ? extractEventId(eventSection.body) : null;
const eventKind = eventSection?.title.split(":").slice(1).join(":").trim(); const eventKind = eventSection?.title.split(":").slice(1).join(":").trim();
return { return {
@@ -48,6 +51,7 @@ export function parsePromptText(text: string): {
userText: eventContent, userText: eventContent,
userTitle: eventKind ? titleCase(eventKind) : "Buzz event", userTitle: eventKind ? titleCase(eventKind) : "Buzz event",
userPubkey: eventAuthorPubkey, userPubkey: eventAuthorPubkey,
userEventId: eventId,
}; };
} }
@@ -127,9 +131,39 @@ function parsePromptSections(text: string): PromptSection[] {
return sections; return sections;
} }
const EVENT_CONTENT_BOUNDARY_RE =
/^(?:Event ID|Channel|Kind|From|Time|Tags|Parsed):\s*/;
const EVENT_BLOCK_BOUNDARY_RE = /^--- Event \d+\b/;
function extractEventContent(body: string): string { function extractEventContent(body: string): string {
const contentMatch = body.match(/^Content:\s*(.*)$/m); const lines = body.split(/\r?\n/);
return contentMatch?.[1]?.trim() ?? ""; const chunks: string[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^Content:\s?(.*)$/);
if (!match) {
continue;
}
const contentLines = [match[1] ?? ""];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j];
if (
EVENT_CONTENT_BOUNDARY_RE.test(line) ||
EVENT_BLOCK_BOUNDARY_RE.test(line)
) {
break;
}
contentLines.push(line);
}
const content = contentLines.join("\n").trim();
if (content) {
chunks.push(content);
}
}
return chunks.join("\n\n");
} }
function extractEventAuthorPubkey(body: string): string | null { function extractEventAuthorPubkey(body: string): string | null {
@@ -137,6 +171,11 @@ function extractEventAuthorPubkey(body: string): string | null {
return fromMatch?.[1]?.toLowerCase() ?? null; return fromMatch?.[1]?.toLowerCase() ?? null;
} }
function extractEventId(body: string): string | null {
const eventIdMatch = body.match(/^Event ID:\s*([0-9a-fA-F]{64})\b/m);
return eventIdMatch?.[1]?.toLowerCase() ?? null;
}
export function extractContentText(value: unknown): string { export function extractContentText(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(extractBlockText).join("\n"); if (Array.isArray(value)) return value.map(extractBlockText).join("\n");
@@ -242,13 +281,17 @@ export function extractToolResult(update: Record<string, unknown>): string {
return extractBlockText(update.rawOutput); return extractBlockText(update.rawOutput);
} }
export function describeTurnStarted(payload: unknown): string { export function extractTriggeringEventIds(payload: unknown): string[] {
const record = asRecord(payload); const record = asRecord(payload);
const ids = Array.isArray(record.triggeringEventIds) return Array.isArray(record.triggeringEventIds)
? record.triggeringEventIds.filter( ? record.triggeringEventIds.filter(
(id): id is string => typeof id === "string", (id): id is string => typeof id === "string",
) )
: []; : [];
}
export function describeTurnStarted(payload: unknown): string {
const ids = extractTriggeringEventIds(payload);
return ids.length > 0 return ids.length > 0
? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.` ? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.`
: ""; : "";

View File

@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getActivityHeadline,
isMeaningfulItem,
} from "./agentSessionTranscriptPresentation.ts";
const baseTimestamp = "2026-06-14T19:00:00.000Z";
function makeTool(overrides = {}) {
return {
id: "tool:1",
type: "tool",
title: "Send Message",
toolName: "send_message",
buzzToolName: "send_message",
status: "executing",
args: { channel_id: "abc" },
result: "",
isError: false,
timestamp: baseTimestamp,
startedAt: baseTimestamp,
completedAt: null,
...overrides,
};
}
function makeMessage(overrides = {}) {
return {
id: "msg:1",
type: "message",
role: "assistant",
title: "Assistant",
text: "Looking into that now.",
timestamp: baseTimestamp,
...overrides,
};
}
test("getActivityHeadline formats tool titles and assistant text", () => {
assert.equal(getActivityHeadline(makeTool()), "Send Message · abc");
assert.equal(
getActivityHeadline(makeMessage({ text: "First line\nSecond line" })),
"First line",
);
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
});
test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
assert.equal(
isMeaningfulItem({
id: "life:1",
type: "lifecycle",
title: "Turn started",
text: "",
timestamp: baseTimestamp,
}),
false,
);
assert.equal(
isMeaningfulItem({
id: "meta:1",
type: "metadata",
title: "Prompt context",
sections: [],
timestamp: baseTimestamp,
}),
false,
);
assert.equal(
isMeaningfulItem({
id: "life:2",
type: "lifecycle",
title: "Turn error",
text: "boom",
timestamp: baseTimestamp,
}),
true,
);
});
test("getActivityHeadline uses semantic tool descriptors", () => {
assert.equal(
getActivityHeadline(
makeTool({
title: "Shell",
toolName: "dev__shell",
buzzToolName: null,
args: { command: "buzz messages send --content hi" },
descriptor: {
renderClass: "message",
label: "Send Message",
preview: "hi",
source: "shell",
groupKey: "buzz-cli:messages.send",
},
}),
),
"Send Message · hi",
);
});
test("isMeaningfulItem ignores suppressed tools", () => {
assert.equal(
isMeaningfulItem(
makeTool({
renderClass: "suppressed",
descriptor: {
renderClass: "suppressed",
label: "Checked todos",
preview: null,
source: "harness",
groupKey: "suppressed:stop-hook",
},
}),
),
false,
);
});

View File

@@ -0,0 +1,62 @@
import type { TranscriptItem } from "./agentSessionTypes";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
const LIFECYCLE_NOISE = new Set([
"turn started",
"session ready",
"wire parse error",
]);
/** Human-readable headline for a single transcript item. */
export function getActivityHeadline(item: TranscriptItem): string | null {
if (item.type === "tool") {
const summary = buildCompactToolSummary(item);
return [summary.label, summary.preview].filter(Boolean).join(" · ");
}
if (item.type === "message") {
if (item.role === "assistant") {
const trimmed = item.text.trim();
if (trimmed.length > 0) {
const firstLine = trimmed.split("\n")[0]?.trim() ?? "";
if (firstLine.length > 0) {
return firstLine.length > 72
? `${firstLine.slice(0, 69)}…`
: firstLine;
}
}
return "Responding";
}
return item.title || "User prompt";
}
if (item.type === "thought") {
return item.title === "Plan" ? "Planning" : item.title;
}
if (item.type === "metadata") {
return item.title;
}
return item.title;
}
function isLifecycleNoise(
item: Extract<TranscriptItem, { type: "lifecycle" }>,
) {
return LIFECYCLE_NOISE.has(item.title.toLowerCase());
}
/** Whether an item should contribute to the "Now" summary and headline scan. */
export function isMeaningfulItem(item: TranscriptItem): boolean {
if (item.type === "tool" && item.renderClass === "suppressed") {
return false;
}
if (item.type === "lifecycle") {
return !isLifecycleNoise(item);
}
if (item.type === "metadata") {
return false;
}
return true;
}

View File

@@ -20,44 +20,107 @@ export type ConnectionState =
export type ToolStatus = "executing" | "completed" | "failed" | "pending"; export type ToolStatus = "executing" | "completed" | "failed" | "pending";
export type AgentActivityRenderClass =
| "message"
| "relay-op"
| "file-edit"
| "shell"
| "status"
| "thought"
| "plan"
| "permission"
| "error"
| "generic"
| "raw-rail"
| "suppressed";
export type AgentActivityTone = "read" | "write" | "admin" | "neutral";
export type AgentActivityAction = {
verb: string;
object?: string | null;
};
export type AgentActivityDescriptor = {
renderClass: AgentActivityRenderClass;
label: string;
preview: string | null;
action?: AgentActivityAction;
tone?: AgentActivityTone;
operation?: string;
object?: string | null;
source?: "mcp" | "shell" | "acp" | "harness" | "fallback";
groupKey?: string;
reason?: string;
};
/** Observer/ACP wire label for dev-only transcript debugging. */
export type TranscriptAcpSource = string;
/** Shared optional identity fields attached during transcript construction. */
export type TranscriptItemIdentity = {
turnId?: string | null;
sessionId?: string | null;
channelId?: string | null;
};
export type TranscriptItem = export type TranscriptItem =
| { | ({
id: string; id: string;
type: "message"; type: "message";
renderClass: "message";
role: "assistant" | "user"; role: "assistant" | "user";
title: string; title: string;
text: string; text: string;
timestamp: string; timestamp: string;
messageId?: string | null;
acpSource?: TranscriptAcpSource;
authorPubkey?: string | null; authorPubkey?: string | null;
channelId?: string | null; } & TranscriptItemIdentity)
} | ({
| {
id: string; id: string;
type: "thought"; type: "thought";
renderClass: "thought";
title: string; title: string;
text: string; text: string;
timestamp: string; timestamp: string;
channelId?: string | null; acpSource?: TranscriptAcpSource;
} } & TranscriptItemIdentity)
| { | ({
id: string;
type: "plan";
renderClass: "plan";
title: string;
text: string;
timestamp: string;
isUpdate?: boolean;
targetId?: string;
acpSource?: TranscriptAcpSource;
} & TranscriptItemIdentity)
| ({
id: string; id: string;
type: "lifecycle"; type: "lifecycle";
renderClass: "status" | "permission" | "error";
title: string; title: string;
text: string; text: string;
timestamp: string; timestamp: string;
channelId?: string | null; descriptor?: AgentActivityDescriptor;
} acpSource?: TranscriptAcpSource;
| { } & TranscriptItemIdentity)
| ({
id: string; id: string;
type: "metadata"; type: "metadata";
renderClass: "raw-rail";
title: string; title: string;
sections: PromptSection[]; sections: PromptSection[];
timestamp: string; timestamp: string;
channelId?: string | null; acpSource?: TranscriptAcpSource;
} } & TranscriptItemIdentity)
| { | ({
id: string; id: string;
type: "tool"; type: "tool";
renderClass: AgentActivityRenderClass;
descriptor: AgentActivityDescriptor;
title: string; title: string;
toolName: string; toolName: string;
buzzToolName: string | null; buzzToolName: string | null;
@@ -68,8 +131,8 @@ export type TranscriptItem =
timestamp: string; timestamp: string;
startedAt: string; startedAt: string;
completedAt: string | null; completedAt: string | null;
channelId?: string | null; acpSource?: TranscriptAcpSource;
}; } & TranscriptItemIdentity);
export type PromptSection = { export type PromptSection = {
title: string; title: string;

View File

@@ -50,6 +50,44 @@ export function formatCodeValue(value: string): string {
} }
} }
export type ShellToolOutput = {
exitCode: number | null;
raw: string;
stderr: string;
stdout: string;
timedOut: boolean;
};
export function parseShellToolOutput(result: string): ShellToolOutput {
const parsed = parseToolResultValue(result);
const record = asRecord(parsed);
const hasShellShape =
"stdout" in record ||
"stderr" in record ||
"exit_code" in record ||
"exitCode" in record ||
"timed_out" in record ||
"timedOut" in record;
if (!hasShellShape) {
return {
exitCode: null,
raw: typeof parsed === "string" ? parsed : result,
stderr: "",
stdout: "",
timedOut: false,
};
}
return {
exitCode: getToolNumber(record, ["exit_code", "exitCode"]),
raw: "",
stderr: getOptionalString(record, ["stderr"]),
stdout: getOptionalString(record, ["stdout"]),
timedOut: getOptionalBoolean(record, ["timed_out", "timedOut"]),
};
}
export function titleCase(value: string): string { export function titleCase(value: string): string {
return value return value
.replace(/[_-]+/g, " ") .replace(/[_-]+/g, " ")
@@ -64,6 +102,117 @@ export function asRecord(value: unknown): Record<string, unknown> {
: {}; : {};
} }
/**
* True when a tool image source is an inline `data:image/` URI that should be
* rendered as-is. This is the dual-layer image-scheme guard: only the
* `data:image/` prefix is treated as a safe passthrough — every other scheme
* (including other `data:` subtypes) must be routed through the relay rewriter.
* Never widen this beyond `data:image/`.
*/
export function isInlineImageData(source: string): boolean {
return source.startsWith("data:image/");
}
function getToolNumber(
record: Record<string, unknown>,
keys: string[],
): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return null;
}
function getOptionalBoolean(
record: Record<string, unknown>,
keys: string[],
): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return false;
}
function getOptionalString(
record: Record<string, unknown>,
keys: string[],
): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
}
return "";
}
/** Format a millisecond duration; negative input yields null. */
export function formatDurationMs(ms: number): string | null {
if (ms < 0) return null;
const totalSeconds = ms / 1000;
if (totalSeconds < 60) {
return totalSeconds < 10
? `${totalSeconds.toFixed(1)}s`
: `${Math.round(totalSeconds)}s`;
}
let minutes = Math.floor(totalSeconds / 60);
let seconds = Math.round(totalSeconds % 60);
if (seconds === 60) {
minutes += 1;
seconds = 0;
}
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
/**
* Parse a tool result string into a value. Handles the double-encoding case
* where a JSON string itself contains JSON. Returns null on empty or invalid
* input.
*/
export function parseToolResultValue(result: string): unknown {
const trimmed = result.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "string") return parsed;
try {
return JSON.parse(parsed);
} catch {
return parsed;
}
} catch {
return null;
}
}
/**
* Resolve a tool's display duration. Prefers the start/complete timestamps,
* then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed
* result payload.
*/
export function getToolDurationDisplay(item: {
startedAt?: string | null;
completedAt?: string | null;
result: string;
}): string | null {
if (item.startedAt && item.completedAt) {
return formatDuration(item.startedAt, item.completedAt);
}
const resultRecord = asRecord(parseToolResultValue(item.result));
const durationMs =
getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ??
getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]);
return durationMs == null ? null : formatDurationMs(durationMs);
}
export function asString(value: unknown): string | null { export function asString(value: unknown): string | null {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -80,14 +229,16 @@ export function shortenMiddle(value: string, maxLength: number) {
return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`;
} }
const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, { const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric", hour: "numeric",
hour12: true,
minute: "2-digit", minute: "2-digit",
second: "2-digit",
}); });
const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, { const transcriptTitleTimeFormat = new Intl.DateTimeFormat(undefined, {
month: "short", weekday: "long",
year: "numeric",
month: "long",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
@@ -97,14 +248,15 @@ const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
export function formatTranscriptTime(isoTimestamp: string): string | null { export function formatTranscriptTime(isoTimestamp: string): string | null {
const date = new Date(isoTimestamp); const date = new Date(isoTimestamp);
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
const now = new Date(); return transcriptTimeFormat.format(date);
const sameDay = }
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() && export function formatTranscriptTimestampTitle(
date.getDate() === now.getDate(); isoTimestamp: string,
return sameDay ): string | undefined {
? sameDayTimeFormat.format(date) const date = new Date(isoTimestamp);
: crossDayTimeFormat.format(date); if (Number.isNaN(date.getTime())) return isoTimestamp || undefined;
return transcriptTitleTimeFormat.format(date);
} }
export function formatDuration( export function formatDuration(

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts";
function rawEvent(overrides = {}) {
return {
seq: 1,
kind: "acp",
sessionId: "sess-1",
channelId: "channel-1",
payload: {},
...overrides,
};
}
test("describeRawEvent surfaces the session/update sessionUpdate label", () => {
const event = rawEvent({
payload: {
method: "session/update",
params: { update: { sessionUpdate: "agent_message_chunk" } },
},
});
assert.equal(describeRawEvent(event), "agent_message_chunk");
});
test("describeRawEvent falls back to the method when session/update lacks an update label", () => {
const event = rawEvent({
payload: { method: "session/update", params: {} },
});
assert.equal(describeRawEvent(event), "session/update");
});
test("describeRawEvent uses the method for non-session/update payloads", () => {
const event = rawEvent({ payload: { method: "session/prompt" } });
assert.equal(describeRawEvent(event), "session/prompt");
});
test("describeRawEvent falls back to the event kind when no method is present", () => {
const event = rawEvent({ kind: "acp_parse_error", payload: {} });
assert.equal(describeRawEvent(event), "acp_parse_error");
});

View File

@@ -1,4 +1,5 @@
import { Octagon, Settings } from "lucide-react"; import * as React from "react";
import { Octagon, Settings, TerminalSquare } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -20,8 +21,10 @@ import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu"; } from "@/shared/ui/dropdown-menu";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
@@ -59,7 +62,19 @@ export function AgentSessionThreadPanel({
useEscapeKey(onClose, isOverlay || isSinglePanelView); useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>(); const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`;
const [rawFeedState, setRawFeedState] = React.useState(() => ({
scopeKey: rawFeedScopeKey,
show: false,
}));
const showRawFeed =
rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show;
const handleRawFeedChange = React.useCallback(
(checked: boolean) => {
setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked });
},
[rawFeedScopeKey],
);
async function handleInterruptTurn() { async function handleInterruptTurn() {
if (!channel) { if (!channel) {
return; return;
@@ -81,7 +96,7 @@ export function AgentSessionThreadPanel({
const agentHeaderActions = ( const agentHeaderActions = (
<AuxiliaryPanelHeaderActions> <AuxiliaryPanelHeaderActions>
{isLive && isWorking ? ( {isLive ? (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -108,6 +123,30 @@ export function AgentSessionThreadPanel({
className="min-w-56" className="min-w-56"
onCloseAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}
> >
<DropdownMenuCheckboxItem
checked={showRawFeed}
className="items-start gap-3"
data-testid="agent-session-toggle-raw-feed"
onCheckedChange={handleRawFeedChange}
title={
showRawFeed
? "Hide raw JSON-RPC payloads."
: channel
? "Show raw JSON-RPC payloads for this channel."
: "Show raw JSON-RPC payloads for this agent."
}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm font-medium">
<TerminalSquare className="h-4 w-4 text-muted-foreground" />
Raw
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
Show raw JSON-RPC activity.
</span>
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="items-start gap-3" className="items-start gap-3"
data-testid="agent-session-stop-turn" data-testid="agent-session-stop-turn"
@@ -118,7 +157,9 @@ export function AgentSessionThreadPanel({
title={ title={
canStopCurrentTurn canStopCurrentTurn
? "Interrupt the current ACP turn without stopping the agent process." ? "Interrupt the current ACP turn without stopping the agent process."
: "Only locally managed agents can be interrupted from this workspace." : isWorking
? "Only locally managed agents can be interrupted from this workspace."
: "Available while the agent is working."
} }
> >
<Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" />
@@ -128,7 +169,9 @@ export function AgentSessionThreadPanel({
</span> </span>
{!canStopCurrentTurn ? ( {!canStopCurrentTurn ? (
<span className="mt-0.5 block text-xs text-muted-foreground"> <span className="mt-0.5 block text-xs text-muted-foreground">
Only available for locally managed agents. {isWorking
? "Only available for locally managed agents."
: "Available while the agent is working."}
</span> </span>
) : null} ) : null}
</span> </span>
@@ -146,7 +189,9 @@ export function AgentSessionThreadPanel({
backButtonTestId="agent-session-back" backButtonTestId="agent-session-back"
onBack={onBackToProfile} onBack={onBackToProfile}
> >
<AuxiliaryPanelTitle>Activity</AuxiliaryPanelTitle> <AuxiliaryPanelTitle>
{showRawFeed ? "Raw ACP Activity" : "Activity"}
</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup> </AuxiliaryPanelHeaderGroup>
{agentHeaderActions} {agentHeaderActions}
</> </>
@@ -186,8 +231,9 @@ export function AgentSessionThreadPanel({
: `Mention ${agent.name} in any channel to see its work here.` : `Mention ${agent.name} in any channel to see its work here.`
} }
profiles={profiles} profiles={profiles}
rawLayout="exclusive"
showHeader={false} showHeader={false}
showRaw={false} showRaw={showRawFeed}
/> />
</AuxiliaryPanelBody> </AuxiliaryPanelBody>
</AuxiliaryPanel> </AuxiliaryPanel>

View File

@@ -2,8 +2,10 @@ import * as React from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; import {
import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog"; getActivityHeadline,
isMeaningfulItem,
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types"; import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
@@ -27,18 +29,6 @@ const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180; const HOVER_CLOSE_DELAY_MS = 180;
const HEADLINE_ROTATION_MS = 2200; const HEADLINE_ROTATION_MS = 2200;
function getActivityHeadline(item: TranscriptItem): string | null {
if (item.type === "tool") {
return formatToolTitle(item.buzzToolName ?? item.toolName, item.title);
}
if (item.type === "message") {
return item.role === "assistant" ? "Responding" : item.title;
}
return item.title;
}
export function BotActivityComposerAction({ export function BotActivityComposerAction({
agents, agents,
channelId = null, channelId = null,
@@ -78,7 +68,11 @@ export function BotActivityComposerAction({
: transcript; : transcript;
for (let i = scopedTranscript.length - 1; i >= 0; i--) { for (let i = scopedTranscript.length - 1; i >= 0; i--) {
const headline = getActivityHeadline(scopedTranscript[i]); const item = scopedTranscript[i];
if (!isMeaningfulItem(item)) {
continue;
}
const headline = getActivityHeadline(item);
if (!headline || seen.has(headline)) { if (!headline || seen.has(headline)) {
continue; continue;
} }
@@ -185,10 +179,11 @@ export function BotActivityComposerAction({
"border border-background", "border border-background",
isInline isInline
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs" ? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
: "!h-5 !w-5 text-3xs", : "shrink-0",
)} )}
displayName={agent.name} displayName={agent.name}
key={agent.pubkey} key={agent.pubkey}
size="xs"
/> />
))} ))}
</span> </span>
@@ -248,8 +243,9 @@ export function BotActivityComposerAction({
> >
<UserAvatar <UserAvatar
avatarUrl={agentAvatarUrl(agent)} avatarUrl={agentAvatarUrl(agent)}
className="!h-6 !w-6 shrink-0 text-2xs" className="shrink-0"
displayName={agent.name} displayName={agent.name}
size="sm"
/> />
<span className="min-w-0 flex-1 truncate">{agent.name}</span> <span className="min-w-0 flex-1 truncate">{agent.name}</span>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" /> <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />

View File

@@ -285,32 +285,13 @@ export function ChannelScreen({
: [], : [],
[activeChannel], [activeChannel],
); );
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
...messageAuthorPubkeys,
...messageMentionPubkeys,
...activeDmParticipantPubkeys,
...typingEntries.map((entry) => entry.pubkey),
]),
],
[
activeDmParticipantPubkeys,
messageAuthorPubkeys,
messageMentionPubkeys,
typingEntries,
],
);
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
const channelMembers = channelMembersQuery.data; const channelMembers = channelMembersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery();
const managedAgents = managedAgentsQuery.data ?? []; const managedAgents = managedAgentsQuery.data ?? [];
const relayAgentsQuery = useRelayAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? []; const relayAgents = relayAgentsQuery.data ?? [];
const agentPubkeys = React.useMemo(() => { const knownAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
for (const member of channelMembers ?? []) { for (const member of channelMembers ?? []) {
if (member.role === "bot" || member.isAgent) { if (member.role === "bot" || member.isAgent) {
@@ -323,6 +304,31 @@ export function ChannelScreen({
for (const agent of relayAgents) { for (const agent of relayAgents) {
pubkeys.add(normalizePubkey(agent.pubkey)); pubkeys.add(normalizePubkey(agent.pubkey));
} }
return pubkeys;
}, [channelMembers, managedAgents, relayAgents]);
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
...messageAuthorPubkeys,
...messageMentionPubkeys,
...activeDmParticipantPubkeys,
...knownAgentPubkeys,
...typingEntries.map((entry) => entry.pubkey),
]),
],
[
activeDmParticipantPubkeys,
knownAgentPubkeys,
messageAuthorPubkeys,
messageMentionPubkeys,
typingEntries,
],
);
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set(knownAgentPubkeys);
for (const [pubkey, profile] of Object.entries( for (const [pubkey, profile] of Object.entries(
messageProfilesQuery.data?.profiles ?? {}, messageProfilesQuery.data?.profiles ?? {},
)) { )) {
@@ -331,7 +337,7 @@ export function ChannelScreen({
} }
} }
return pubkeys; return pubkeys;
}, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]); }, [knownAgentPubkeys, messageProfilesQuery.data]);
const agentPubkeysPending = const agentPubkeysPending =
activeChannel?.channelType === "dm" && activeChannel?.channelType === "dm" &&
(channelMembersQuery.isPending || (channelMembersQuery.isPending ||

View File

@@ -99,7 +99,7 @@ export function mergeAgentNamesIntoProfiles(
relayAgents: RelayAgent[], relayAgents: RelayAgent[],
): UserProfileLookup { ): UserProfileLookup {
const merged = { ...profiles }; const merged = { ...profiles };
for (const agent of [...relayAgents, ...managedAgents]) { for (const agent of relayAgents) {
const key = normalizePubkey(agent.pubkey); const key = normalizePubkey(agent.pubkey);
merged[key] = { merged[key] = {
...merged[key], ...merged[key],
@@ -109,5 +109,15 @@ export function mergeAgentNamesIntoProfiles(
isAgent: true, isAgent: true,
}; };
} }
for (const agent of managedAgents) {
const key = normalizePubkey(agent.pubkey);
merged[key] = {
...merged[key],
displayName: merged[key]?.displayName || agent.name,
avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl,
nip05Handle: merged[key]?.nip05Handle ?? null,
isAgent: true,
};
}
return merged; return merged;
} }

View File

@@ -45,10 +45,7 @@ import type {
RuntimeConfigSurface, RuntimeConfigSurface,
} from "@/shared/api/types"; } from "@/shared/api/types";
type RawIdentity = { type RawIdentity = { pubkey: string; display_name: string };
pubkey: string;
display_name: string;
};
type RawProfile = { type RawProfile = {
pubkey: string; pubkey: string;
@@ -206,6 +203,7 @@ export type RawManagedAgent = {
max_turn_duration_seconds: number | null; max_turn_duration_seconds: number | null;
parallelism: number; parallelism: number;
system_prompt: string | null; system_prompt: string | null;
avatar_url?: string | null;
model: string | null; model: string | null;
provider: string | null; provider: string | null;
persona_out_of_date: boolean; persona_out_of_date: boolean;
@@ -869,6 +867,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
maxTurnDurationSeconds: agent.max_turn_duration_seconds, maxTurnDurationSeconds: agent.max_turn_duration_seconds,
parallelism: agent.parallelism, parallelism: agent.parallelism,
systemPrompt: agent.system_prompt, systemPrompt: agent.system_prompt,
avatarUrl: agent.avatar_url ?? null,
model: agent.model, model: agent.model,
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them. // Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
provider: agent.provider ?? null, provider: agent.provider ?? null,

View File

@@ -293,6 +293,7 @@ export type ManagedAgent = {
maxTurnDurationSeconds: number | null; maxTurnDurationSeconds: number | null;
parallelism: number; parallelism: number;
systemPrompt: string | null; systemPrompt: string | null;
avatarUrl: string | null;
model: string | null; model: string | null;
/** LLM inference provider, from the agent's pinned record snapshot. */ /** LLM inference provider, from the agent's pinned record snapshot. */
provider: string | null; provider: string | null;

View File

@@ -65,12 +65,14 @@ import {
} from "./markdown/CodeBlock"; } from "./markdown/CodeBlock";
import { FileCard } from "./markdown/FileCard"; import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { resolveFileCard } from "./markdownFileCard"; import { resolveFileCard } from "./markdownFileCard";
import type { import type {
ImetaEntry, ImetaEntry,
MarkdownProps, MarkdownProps,
MarkdownRuntime, MarkdownRuntime,
MarkdownVariant,
} from "./markdown/types"; } from "./markdown/types";
import { SpoilerInline } from "./markdown/SpoilerInline"; import { SpoilerInline } from "./markdown/SpoilerInline";
import { import {
@@ -1501,12 +1503,19 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
} }
function createMarkdownComponents( function createMarkdownComponents(
variant: MarkdownVariant,
runtimeRef: React.RefObject<MarkdownRuntime>, runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true, interactive = true,
mediaInset = false,
): Components { ): Components {
const paragraphClassName = "leading-[inherit]"; const paragraphClassName =
const listItemClassName = "my-1 [&_p]:inline"; variant === "tight"
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground"; ? "leading-5"
: variant === "compact"
? "leading-6"
: "leading-[inherit]";
const listItemClassName = "[&_p]:inline";
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80";
return { return {
spoiler: ({ spoiler: ({
@@ -1699,7 +1708,12 @@ function createMarkdownComponents(
if (resolvedSrc?.endsWith(".mp4")) { if (resolvedSrc?.endsWith(".mp4")) {
const entry = src ? imetaByUrl?.get(src) : undefined; const entry = src ? imetaByUrl?.get(src) : undefined;
return ( return (
<span data-block-media=""> <span
className={cn(
mediaInset && "mx-1.5 block max-w-[calc(100%-0.75rem)]",
)}
data-block-media=""
>
<MarkdownVideoPlayer <MarkdownVideoPlayer
key={src ?? resolvedSrc} key={src ?? resolvedSrc}
alt={alt} alt={alt}
@@ -1722,6 +1736,7 @@ function createMarkdownComponents(
</span> </span>
); );
}, },
input: MarkdownInput,
li: ({ children }) => <li className={listItemClassName}>{children}</li>, li: ({ children }) => <li className={listItemClassName}>{children}</li>,
ol: ({ children }) => ( ol: ({ children }) => (
<ol className={cn("list-decimal", listClassName)}>{children}</ol> <ol className={cn("list-decimal", listClassName)}>{children}</ol>
@@ -1910,14 +1925,17 @@ function createMarkdownComponents(
function MarkdownInner({ function MarkdownInner({
channelNames, channelNames,
className, className,
compact = false,
content, content,
customEmoji, customEmoji,
imetaByUrl, imetaByUrl,
interactive = true, interactive = true,
agentMentionPubkeysByName, agentMentionPubkeysByName,
mediaInset = false,
mentionNames, mentionNames,
mentionPubkeysByName, mentionPubkeysByName,
searchQuery, searchQuery,
tight = false,
videoReviewContext, videoReviewContext,
}: MarkdownProps) { }: MarkdownProps) {
const { channels: rawChannels } = useChannelNavigation(); const { channels: rawChannels } = useChannelNavigation();
@@ -1958,9 +1976,16 @@ function MarkdownInner({
onOpenMessageLink, onOpenMessageLink,
}); });
const variant: MarkdownVariant = tight
? "tight"
: compact
? "compact"
: "default";
const components = React.useMemo( const components = React.useMemo(
() => createMarkdownComponents(runtimeRef, interactive), () =>
[runtimeRef, interactive], createMarkdownComponents(variant, runtimeRef, interactive, mediaInset),
[variant, runtimeRef, interactive, mediaInset],
); );
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
@@ -2028,6 +2053,13 @@ function MarkdownInner({
"[&>*+hr]:mt-4 [&>hr+*]:mt-4", "[&>*+hr]:mt-4 [&>hr+*]:mt-4",
"[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5", "[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5",
].join(" "), ].join(" "),
// Variant overrides: density tweaks for agent-session transcript surfaces.
// Layered after the base owl-spacing set so tailwind-merge lets the
// narrower leading + tighter inter-block gaps win for compact/tight.
variant === "compact" &&
"leading-6 [&>*+*]:mt-2 [&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
variant === "tight" &&
"leading-5 [&>*+*]:mt-2 [&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
className, className,
)} )}
> >
@@ -2053,8 +2085,11 @@ export const Markdown = React.memo(
(prev, next) => (prev, next) =>
prev.content === next.content && prev.content === next.content &&
prev.className === next.className && prev.className === next.className &&
prev.compact === next.compact &&
prev.customEmoji === next.customEmoji && prev.customEmoji === next.customEmoji &&
prev.interactive === next.interactive && prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.tight === next.tight &&
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
prev.mentionPubkeysByName === next.mentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) && shallowArrayEqual(prev.mentionNames, next.mentionNames) &&

View File

@@ -0,0 +1,33 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Checkbox } from "@/shared/ui/checkbox";
type MarkdownInputProps = React.ComponentProps<"input"> & {
node?: unknown;
};
export function MarkdownInput({
checked,
className,
node: _node,
type,
...props
}: MarkdownInputProps) {
if (type === "checkbox") {
return (
<Checkbox
aria-label={checked ? "Completed task" : "Incomplete task"}
checked={Boolean(checked)}
className={cn(
"pointer-events-none mr-1.5 inline-flex align-[-0.125rem] disabled:opacity-45",
className,
)}
disabled
tabIndex={-1}
/>
);
}
return <input {...props} className={className} type={type} />;
}

View File

@@ -35,6 +35,7 @@ export type MarkdownRuntime = {
export type MarkdownProps = { export type MarkdownProps = {
channelNames?: string[]; channelNames?: string[];
className?: string; className?: string;
compact?: boolean;
content: string; content: string;
customEmoji?: CustomEmoji[]; customEmoji?: CustomEmoji[];
imetaByUrl?: ImetaLookup; imetaByUrl?: ImetaLookup;
@@ -42,6 +43,10 @@ export type MarkdownProps = {
agentMentionPubkeysByName?: Record<string, string>; agentMentionPubkeysByName?: Record<string, string>;
mentionNames?: string[]; mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>; mentionPubkeysByName?: Record<string, string>;
mediaInset?: boolean;
searchQuery?: string; searchQuery?: string;
tight?: boolean;
videoReviewContext?: VideoReviewContext; videoReviewContext?: VideoReviewContext;
}; };
export type MarkdownVariant = "default" | "compact" | "tight";

View File

@@ -10,10 +10,14 @@ const toggleVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: "border border-input/40 bg-background hover:bg-muted/70", ghost:
"bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
outline:
"border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground",
}, },
size: { size: {
default: "h-9 px-3 min-w-9", default: "h-9 px-3 min-w-9",
xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5",
sm: "h-8 px-2 min-w-8", sm: "h-8 px-2 min-w-8",
lg: "h-10 px-3 min-w-10", lg: "h-10 px-3 min-w-10",
}, },

View File

@@ -39,6 +39,7 @@ type MockCommandAvailability = {
type MockManagedAgentSeed = { type MockManagedAgentSeed = {
pubkey: string; pubkey: string;
name: string; name: string;
avatarUrl?: string | null;
personaId?: string | null; personaId?: string | null;
status?: RawManagedAgent["status"]; status?: RawManagedAgent["status"];
channelNames?: string[]; channelNames?: string[];
@@ -389,6 +390,7 @@ type RawManagedAgent = {
max_turn_duration_seconds: number | null; max_turn_duration_seconds: number | null;
parallelism: number; parallelism: number;
system_prompt: string | null; system_prompt: string | null;
avatar_url: string | null;
model: string | null; model: string | null;
env_vars?: Record<string, string>; env_vars?: Record<string, string>;
status: "running" | "stopped" | "deployed" | "not_deployed"; status: "running" | "stopped" | "deployed" | "not_deployed";
@@ -937,6 +939,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null, max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null,
parallelism: agent.parallelism, parallelism: agent.parallelism,
system_prompt: agent.system_prompt, system_prompt: agent.system_prompt,
avatar_url: agent.avatar_url ?? null,
model: agent.model, model: agent.model,
env_vars: { ...(agent.env_vars ?? {}) }, env_vars: { ...(agent.env_vars ?? {}) },
status: agent.status, status: agent.status,
@@ -1450,6 +1453,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
max_turn_duration_seconds: null, max_turn_duration_seconds: null,
parallelism: 1, parallelism: 1,
system_prompt: null, system_prompt: null,
avatar_url: seed.avatarUrl ?? null,
model: null, model: null,
env_vars: {}, env_vars: {},
status, status,
@@ -5670,6 +5674,7 @@ async function handleCreateManagedAgent(
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null, max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
parallelism: args.input.parallelism ?? 1, parallelism: args.input.parallelism ?? 1,
system_prompt: args.input.systemPrompt?.trim() || null, system_prompt: args.input.systemPrompt?.trim() || null,
avatar_url: avatarUrl,
model: args.input.model?.trim() || null, model: args.input.model?.trim() || null,
env_vars: { ...(args.input.envVars ?? {}) }, env_vars: { ...(args.input.envVars ?? {}) },
status: args.input.spawnAfterCreate ? "running" : "stopped", status: args.input.spawnAfterCreate ? "running" : "stopped",

View File

@@ -961,6 +961,14 @@ test("shows and clears activity indicators for active channel agents", async ({
await expect(page.getByTestId("agent-session-thread-panel")).toContainText( await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
"alice", "alice",
); );
await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0);
await page.getByTestId("agent-session-settings-menu-trigger").click();
await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible();
await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
"No ACP activity yet",
);
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
await page.evaluate((pubkey) => { await page.evaluate((pubkey) => {

View File

@@ -26,6 +26,8 @@ const CASEY_PROFILE_PUBKEY =
"1111111111111111111111111111111111111111111111111111111111111111"; "1111111111111111111111111111111111111111111111111111111111111111";
const PROFILE_ONLY_AGENT_PUBKEY = const PROFILE_ONLY_AGENT_PUBKEY =
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
const UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY =
"7777777777777777777777777777777777777777777777777777777777777777";
const SYSTEM_MESSAGE_KIND = 40099; const SYSTEM_MESSAGE_KIND = 40099;
/** Locator scoped to the mention autocomplete dropdown inside the composer. */ /** Locator scoped to the mention autocomplete dropdown inside the composer. */
@@ -1463,7 +1465,17 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
test("wave attachment huddle waits for placeholder profile-only bot data", async ({ test("wave attachment huddle waits for placeholder profile-only bot data", async ({
page, page,
}) => { }) => {
await installMockBridge(page, { usersBatchDelayMs: 2_000 }); await installMockBridge(page, {
searchProfiles: [
{
pubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY,
displayName: "nova",
ownerPubkey: TEST_IDENTITIES.tyler.pubkey,
isAgent: true,
},
],
usersBatchDelayMs: 2_000,
});
await page.goto("/"); await page.goto("/");
await page.getByTestId("channel-general").click(); await page.getByTestId("channel-general").click();
@@ -1484,7 +1496,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
}, },
{ {
kind: SYSTEM_MESSAGE_KIND, kind: SYSTEM_MESSAGE_KIND,
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY, targetPubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY,
}, },
); );
await waitForTimelineSettled(page); await waitForTimelineSettled(page);
@@ -1492,12 +1504,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
const joinedRow = page const joinedRow = page
.getByTestId("system-message-row") .getByTestId("system-message-row")
.filter({ hasText: "joined the channel" }); .filter({ hasText: "joined the channel" });
const agentChip = joinedRow.locator( const agentChip = joinedRow.locator("[data-mention]", {
"[data-mention].agent-mention-highlight", hasText: "77777777…7777",
{ });
hasText: "mira",
},
);
await expect(agentChip).toBeVisible({ timeout: 5_000 }); await expect(agentChip).toBeVisible({ timeout: 5_000 });
await agentChip.hover(); await agentChip.hover();
@@ -1506,7 +1515,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
); );
await expect(profilePopover).toBeVisible(); await expect(profilePopover).toBeVisible();
await profilePopover await profilePopover
.getByTestId(`user-profile-popover-wave-${PROFILE_ONLY_AGENT_PUBKEY}`) .getByTestId(
`user-profile-popover-wave-${UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY}`,
)
.click(); .click();
const startHuddleButton = page const startHuddleButton = page
@@ -1518,7 +1529,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
await expect await expect
.poll(() => readStartHuddleMemberPubkeys(page)) .poll(() => readStartHuddleMemberPubkeys(page))
.toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY])); .toEqual(expect.arrayContaining([UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY]));
}); });
test("wave attachment huddle waits for delayed bot DM pubkey", async ({ test("wave attachment huddle waits for delayed bot DM pubkey", async ({