feat(desktop): polish agent-session transcript UI atop declared-ownership model

Replay of PR #1061 (tho/activity-ui-polish) onto fresh main: lands the
20 UI-polish commits as one net diff, reconciled with #1229's merged
declared-ownership model (viewerIsOwner = isCurrentUserOwner || isOwner)
and #1089's content-visibility-auto virtualization.

Notable reconciles:
- markdown.tsx: ported the compact/tight variant system into main's
  newer lightbox/spoiler markdown component (rather than overwriting it),
  layering variant density/leading overrides after the base owl-spacing
  so tailwind-merge wins. Dropped the branch's hardcoded text-[15px] in
  favor of main's rem-token text-sm base (post-#1052 zoom-safe scale).
- agentSessionTranscript.ts: pass TranscriptItemContext (not channelId).
- managed_agents: thread avatar_url through ManagedAgentSummary so the
  transcript renders the assistant-bubble avatar from the pinned record
  snapshot; bumped runtime.rs size override 2001 -> 2002 for the +1 line.

Observer-seed screenshots intentionally excluded (separate follow-up).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-24 22:59:45 -07:00
committed by npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent 68a0cc8506
commit 67700e60c8
32 changed files with 3141 additions and 496 deletions

View File

@@ -46,8 +46,10 @@ const overrides = new Map([
["src-tauri/src/managed_agents/nest.rs", 1450],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup.
["src-tauri/src/managed_agents/runtime.rs", 2001],
// unify refactor followup. +1 line from the activity-ui-polish replay:
// build_managed_agent_summary now threads avatar_url so the transcript UI can
// render the assistant-bubble avatar from the pinned record snapshot.
["src-tauri/src/managed_agents/runtime.rs", 2002],
["src-tauri/src/managed_agents/personas.rs", 1080],
// Phase-2 inbound reconcile + review-fix cycle: reconcile_inbound_persona_event
// dispatches 30175/30176/30177 inbound plus kind:5 tombstone consume

View File

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

View File

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

View File

@@ -1,29 +1,16 @@
import * as React from "react";
import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ChevronDown, Send } 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 { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { TranscriptItem } from "./agentSessionTypes";
import { getBuzzToolInfo } from "./agentSessionToolCatalog";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import {
formatToolTitle,
getBuzzToolInfo,
getToolStatusDisplay,
} from "./agentSessionToolCatalog";
import {
asRecord,
formatCodeValue,
formatDuration,
formatTranscriptTime,
getResultArray,
getToolString,
getToolStringList,
shortenMiddle,
getToolDurationDisplay,
isInlineImageData,
} from "./agentSessionUtils";
export function ToolItem({
@@ -32,14 +19,12 @@ export function ToolItem({
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 compactSummary = buildCompactToolSummary(item);
const duration = getToolDurationDisplay(item);
const handleToggle = React.useCallback(
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
setIsExpanded(event.currentTarget.open);
@@ -48,40 +33,38 @@ export function ToolItem({
);
return (
<div className="not-prose w-full px-1">
<div
className="not-prose w-full px-0 py-0.5"
data-testid="transcript-tool-item"
>
<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",
)}
<summary
className={cn(
"max-w-full cursor-pointer list-none py-px",
compactSummary.presentation === "inline" &&
"inline-flex items-center gap-1.5",
compactSummaryTone(),
)}
>
{compactSummary.presentation === "message" ? (
<CompactMessageSummary
duration={duration}
isError={item.isError || item.status === "failed"}
label={compactSummary.label}
preview={compactSummary.preview}
/>
) : 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" />
) : (
<CompactToolSummaryRow
duration={duration}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
/>
)}
</summary>
<ToolDetailBlocks
@@ -89,6 +72,14 @@ export function ToolItem({
description={buzzTool?.label}
hasArgs={hasArgs}
hasResult={hasResult}
imagePreview={
compactSummary.kind === "view_image" && isExpanded
? {
src: compactSummary.thumbnailSrc,
title: compactSummary.preview,
}
: null
}
isError={item.isError}
result={item.result}
/>
@@ -97,11 +88,219 @@ export function ToolItem({
);
}
function compactSummaryTone() {
return "text-muted-foreground/60 group-open:text-muted-foreground";
}
function resolveImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}
function CompactToolSummaryRow({
duration,
label,
preview,
thumbnailSrc,
}: {
duration: string | 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]);
return (
<>
<span className={cn("shrink-0 text-sm font-semibold", mutedTone)}>
{label}
</span>
{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}
/>
) : 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 CompactMessageSummary({
duration,
isError,
label,
preview,
}: {
duration: string | null;
isError: boolean;
label: string;
preview: string | null;
}) {
const mutedTone = compactSummaryTone();
return (
<div className="flex max-w-[85%] flex-col items-start gap-1.5">
<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">
<Send className={cn("h-3.5 w-3.5 shrink-0", mutedTone)} />
<span className={cn("truncate text-xs font-medium", mutedTone)}>
{label}
</span>
{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,
)}
/>
</div>
</div>
);
}
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 ToolDetailBlocks({
args,
description,
hasArgs,
hasResult,
imagePreview,
isError,
result,
}: {
@@ -109,6 +308,7 @@ function ToolDetailBlocks({
description?: string;
hasArgs: boolean;
hasResult: boolean;
imagePreview: { src: string | null; title: string | null } | null;
isError: boolean;
result: string;
}) {
@@ -119,6 +319,12 @@ function ToolDetailBlocks({
{description}
</p>
) : null}
{imagePreview?.src ? (
<ViewImageToolPreview
src={imagePreview.src}
title={imagePreview.title}
/>
) : null}
{hasArgs ? (
<ToolCodeBlock
label="Parameters"
@@ -158,7 +364,7 @@ function ToolCodeBlock({
</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",
"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",
@@ -169,260 +375,3 @@ function ToolCodeBlock({
</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

@@ -1,32 +1,90 @@
import * as React from "react";
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
import {
AlertCircle,
Brain,
CheckCheck,
ChevronDown,
CircleDot,
Radio,
TerminalSquare,
} from "lucide-react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { Toggle } from "@/shared/ui/toggle";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { TranscriptItem } from "./agentSessionTypes";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { ToolItem } from "./AgentSessionToolItem";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
turnSetupDetail,
turnSetupTimestamp,
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import { formatTranscriptTime } from "./agentSessionUtils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
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;
}
}
type AgentTranscriptIdentityProps = {
agentAvatarUrl: string | null;
agentName: string;
agentPubkey: string;
};
export function AgentSessionTranscriptList({
agentAvatarUrl,
agentName,
agentPubkey,
emptyDescription,
items,
profiles,
}: {
agentName: string;
}: AgentTranscriptIdentityProps & {
emptyDescription: string;
isWorking?: boolean;
items: TranscriptItem[];
profiles?: UserProfileLookup;
}) {
const displayBlocks = React.useMemo(
() => buildTranscriptDisplayBlocks(items),
[items],
);
if (items.length === 0) {
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" />
<p className="mt-3 text-sm font-medium">No ACP activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
@@ -35,37 +93,427 @@ export function AgentSessionTranscriptList({
}
return (
<div
aria-label="Live ACP transcript"
aria-live="polite"
className="w-full py-1"
role="log"
<div className="w-full">
<div
aria-label="Live ACP transcript"
aria-live="polite"
className="w-full py-0.5"
role="log"
>
{displayBlocks.map((block) => (
<div
className="content-visibility-auto"
key={getDisplayBlockKey(block)}
>
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</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}`}
>
{items.map((item) => (
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}>
<TranscriptItemView
agentName={agentName}
item={item}
profiles={profiles}
/>
</div>
{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="first:mt-0 mt-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>
);
}
const TranscriptItemView = React.memo(function TranscriptItemView({
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
}
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
}
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} />;
}
return (
<TranscriptItemRow
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={segment.item}
profiles={profiles}
/>
);
}
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 = [],
}: {
agentName: string;
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const [contextOpen, setContextOpen] = React.useState(false);
const 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"
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="group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1">
<div className="w-full min-w-0 rounded-2xl bg-muted p-2.5 text-sm leading-relaxed text-foreground">
<Markdown content={text || " "} mediaInset tight />
{contextOpen && context ? (
<PromptContextSections sections={context.sections} setup={setup} />
) : null}
</div>
<TurnSetupFooter
context={context}
contextOpen={contextOpen}
items={setup}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
</div>
</div>
);
}
function PromptContextSections({
sections,
setup,
}: {
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
return (
<div
className="mt-2 space-y-2 border-t border-border/40 pt-2"
data-testid="transcript-prompt-context-sections"
>
<PromptSetupSummary items={setup} />
{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-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open/section:rotate-180" />
</summary>
<pre className="mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-background/40 px-2 py-1.5 font-mono text-xs leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))}
</div>
);
}
function PromptSetupSummary({
items,
}: {
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const setupText = [label, detail].filter(Boolean).join(" · ");
if (!setupText) {
return null;
}
return (
<p
className="text-xs leading-5 text-muted-foreground"
data-testid="transcript-prompt-setup-summary"
>
{setupText}
</p>
);
}
function TurnSetupFooter({
context = null,
contextOpen = false,
items,
onContextOpenChange,
timestamp,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
contextOpen?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
onContextOpenChange?: (open: boolean) => void;
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 <TranscriptTimestamp timestamp={timestamp} />;
}
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"
>
{showSetup ? <CheckCheck aria-hidden="true" /> : null}
Context
</Toggle>
) : null;
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext && showSetup ? (
<Tooltip>
<TooltipTrigger asChild>{contextToggle}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
) : null}
{!showContext && showSetup ? (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
) : null}
{showContext && !showSetup ? contextToggle : null}
<TranscriptTimestamp timestamp={timestamp} />
</div>
);
}
function TranscriptItemRow({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
return (
<div
className={cn("first:mt-0", getTranscriptItemRowSpacing(item))}
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="mt-1.5 rounded-md px-2 py-1.5">
<TurnSetupFooter items={items} timestamp={timestamp} />
</div>
);
}
function getTranscriptItemRowSpacing(item: TranscriptItem): string {
if (item.type === "message") {
return "my-2.5";
}
if (item.type === "tool") {
return "my-1";
}
return "my-2";
}
const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
if (item.type === "message") {
return (
<MessageItem agentName={agentName} item={item} profiles={profiles} />
<MessageItem
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
profiles={profiles}
/>
);
}
if (item.type === "tool") {
@@ -81,11 +529,12 @@ const TranscriptItemView = React.memo(function TranscriptItemView({
});
function MessageItem({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: {
agentName: string;
}: AgentTranscriptIdentityProps & {
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
@@ -101,34 +550,54 @@ function MessageItem({
profiles,
})
: item.title || "User";
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
const assistantLabel = resolveUserLabel({
pubkey: agentPubkey,
fallbackName: agentName,
profiles,
preferResolvedSelfLabel: true,
});
const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
return (
<div
className={cn(
"flex flex-row px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
"flex animate-in fade-in duration-200 motion-reduce:animate-none",
isAssistant
? "flex-row px-0 py-1.5"
: "flex-row items-start justify-end px-0 py-0.5",
)}
data-role={isAssistant ? "assistant-message" : "user-message"}
data-testid={
isAssistant ? "transcript-assistant-message" : "transcript-user-message"
}
>
{!isAssistant ? (
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="mr-2 mt-1 h-5 w-5 shrink-0 text-3xs"
className="order-last ml-2 mt-1 shrink-0"
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%]",
"group relative flex min-w-0 flex-col gap-1",
isAssistant ? "w-full items-start" : "max-w-[85%] items-end",
)}
>
{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" />
<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-xs font-semibold text-foreground">
{assistantLabel}
</span>
<span className="font-normal text-foreground">{agentName}</span>
<TranscriptTimestamp timestamp={item.timestamp} />
</div>
) : null}
@@ -139,10 +608,10 @@ function MessageItem({
)}
>
{isAssistant ? (
<Markdown content={text || " "} />
<Markdown compact content={text || " "} />
) : (
<>
<p className="whitespace-pre-wrap break-words">{text}</p>
<Markdown content={text || " "} mediaInset tight />
<TranscriptTimestamp timestamp={item.timestamp} />
</>
)}
@@ -158,7 +627,10 @@ function ThoughtItem({
item: Extract<TranscriptItem, { type: "thought" }>;
}) {
return (
<details className="group not-prose w-full px-1">
<details
className="group not-prose w-full rounded-md border border-transparent px-0"
data-testid="transcript-thought-item"
>
<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>
@@ -166,7 +638,7 @@ function ThoughtItem({
<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() || " "} />
<Markdown compact content={item.text.trim() || " "} />
</div>
</details>
);
@@ -178,11 +650,14 @@ function MetadataItem({
item: Extract<TranscriptItem, { type: "metadata" }>;
}) {
return (
<details className="group not-prose w-full px-1">
<details
className="group not-prose w-full rounded-md border border-border/50 bg-muted/20 px-2 py-1"
data-testid="transcript-metadata-item"
>
<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">
<TerminalSquare className="h-3.5 w-3.5 shrink-0 opacity-70" />
<span className="truncate text-xs font-medium">{item.title}</span>
<span className="shrink-0 text-xs text-muted-foreground/70">
{item.sections.length} section{item.sections.length === 1 ? "" : "s"}
</span>
<TranscriptTimestamp timestamp={item.timestamp} />
@@ -198,7 +673,7 @@ function MetadataItem({
<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">
<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>
@@ -217,12 +692,20 @@ function LifecycleItem({
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",
"flex items-center justify-start gap-1.5 rounded-md px-2 py-1.5 text-left text-xs",
isError
? "border border-destructive/20 bg-destructive/5 text-destructive"
: "text-muted-foreground/80",
)}
data-testid="transcript-lifecycle-item"
>
{isError ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<CircleDot className="h-3 w-3 shrink-0 opacity-50" />
)}
<span className="font-medium">{item.title}</span>
{item.text ? <span> - {item.text}</span> : null}
{item.text ? <span className="opacity-80">· {item.text}</span> : null}
<TranscriptTimestamp timestamp={item.timestamp} />
</div>
);
@@ -248,7 +731,7 @@ function TranscriptTimestamp({ timestamp }: { timestamp: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 cursor-default text-2xs text-muted-foreground/60">
<span className="shrink-0 cursor-default text-xs text-muted-foreground/60">
{formatted}
</span>
</TooltipTrigger>

View File

@@ -21,14 +21,23 @@ import type {
ObserverEvent,
TranscriptItem,
} from "./agentSessionTypes";
import {
deriveLatestSessionId,
resolveRawRailLayout,
scopeByChannel,
} from "./agentSessionPanelLayout";
import { shorten } from "./agentSessionUtils";
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
type ManagedAgentSessionPanelProps = {
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">;
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
avatarUrl?: string | null;
};
channelId?: string | null;
className?: string;
emptyDescription?: string;
isWorking?: boolean;
rawLayout?: "responsive" | "exclusive";
showHeader?: boolean;
showRaw?: boolean;
profiles?: UserProfileLookup;
@@ -39,6 +48,8 @@ export function ManagedAgentSessionPanel({
channelId = null,
className,
emptyDescription = "Mention this agent in a channel to watch the next turn.",
isWorking = false,
rawLayout = "responsive",
showHeader = true,
showRaw = true,
profiles,
@@ -52,29 +63,21 @@ export function ManagedAgentSessionPanel({
// Filter transcript items by channelId (lightweight — items now carry channelId)
const scopedTranscript = React.useMemo(
() =>
channelId
? transcript.filter((item) => item.channelId === channelId)
: transcript,
() => scopeByChannel(transcript, channelId),
[channelId, transcript],
);
// Filter raw events by channelId for the RawEventRail
const scopedEvents = React.useMemo(
() =>
channelId
? events.filter((event) => event.channelId === channelId)
: events,
() => scopeByChannel(events, channelId),
[channelId, events],
);
// Derive latestSessionId from channel-scoped events
const latestSessionId = React.useMemo(() => {
for (let i = scopedEvents.length - 1; i >= 0; i--) {
if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId;
}
return null;
}, [scopedEvents]);
const latestSessionId = React.useMemo(
() => deriveLatestSessionId(scopedEvents),
[scopedEvents],
);
return (
<section
@@ -93,13 +96,17 @@ export function ManagedAgentSessionPanel({
) : null}
<SessionBody
agentAvatarUrl={agent.avatarUrl ?? null}
agentName={agent.name}
agentPubkey={agent.pubkey}
connectionState={connectionState}
emptyDescription={emptyDescription}
errorMessage={errorMessage}
events={scopedEvents}
hasObserver={hasObserver}
isWorking={isWorking}
profiles={profiles}
rawLayout={rawLayout}
showRaw={showRaw}
transcript={scopedTranscript}
/>
@@ -143,26 +150,51 @@ function SessionHeader({
}
function SessionBody({
agentAvatarUrl,
agentName,
agentPubkey,
connectionState,
emptyDescription,
errorMessage,
events,
hasObserver,
isWorking,
profiles,
rawLayout,
showRaw,
transcript,
}: {
agentAvatarUrl: string | null;
agentName: string;
agentPubkey: string;
connectionState: ConnectionState;
emptyDescription: string;
errorMessage: string | null;
events: ObserverEvent[];
hasObserver: boolean;
isWorking: boolean;
profiles?: UserProfileLookup;
rawLayout: "responsive" | "exclusive";
showRaw: boolean;
transcript: TranscriptItem[];
}) {
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
if (rawRail.mode === "exclusive") {
return (
<>
<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 ? (
@@ -172,18 +204,21 @@ function SessionBody({
) : (
<div
className={cn(
showRaw
rawRail.mode === "side"
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
: "mt-0",
)}
>
<AgentSessionTranscriptList
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
emptyDescription={emptyDescription}
isWorking={isWorking}
items={transcript}
profiles={profiles}
/>
{showRaw ? <RawEventRail events={events} /> : null}
{rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
</div>
)}

View File

@@ -1,46 +1,28 @@
import * as React from "react";
import { Button } from "@/shared/ui/button";
import type { ObserverEvent } from "./agentSessionTypes";
import { describeRawEvent } from "./agentSessionTranscript";
export function RawEventRail({ events }: { events: ObserverEvent[] }) {
const [expanded, setExpanded] = React.useState(false);
const visible = expanded ? events : events.slice(-18);
return (
<aside className="rounded-lg border border-border/70 bg-[#17171d] text-zinc-100">
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<div>
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-zinc-400">
Raw ACP
<section className="flex min-h-0 w-full flex-col text-foreground">
<div className="min-h-0 flex-1">
{events.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No raw events yet.
</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">
{visible.map((event) => (
{events.map((event) => (
<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}
>
<summary className="cursor-pointer select-none text-xs text-zinc-300">
<span className="font-mono text-zinc-500">#{event.seq}</span>{" "}
<summary className="cursor-pointer select-none text-xs text-muted-foreground transition-colors group-open:text-foreground">
<span className="font-mono text-muted-foreground/70">
#{event.seq}
</span>{" "}
{describeRawEvent(event)}
</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)}
</pre>
</details>
@@ -48,6 +30,6 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
</div>
)}
</div>
</aside>
</section>
);
}

View File

@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
deriveLatestSessionId,
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);
});
// ---- 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,48 @@
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 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,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatDurationMs,
getToolDurationDisplay,
isInlineImageData,
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");
});
// ---- 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);
});
// ---- 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,157 @@
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, "buzz");
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, "shell");
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.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");
});
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",
);
});

View File

@@ -0,0 +1,550 @@
import type { ToolStatus, TranscriptItem } from "./agentSessionTypes";
import {
formatToolTitle,
getBuzzToolInfo,
isGenericToolTitle,
normalizeToolNameText,
} from "./agentSessionToolCatalog";
import {
asRecord,
getToolString,
getToolStringList,
} from "./agentSessionUtils";
export type CompactToolKind =
| "shell"
| "read_file"
| "view_image"
| "str_replace"
| "todo"
| "stop_hook"
| "post_compact_hook"
| "dev_mcp"
| "buzz"
| "generic";
export type CompactToolSummary = {
kind: CompactToolKind;
label: string;
preview: string | null;
/** When set, the compact row renders a tiny image instead of text preview. */
thumbnailSrc: string | null;
presentation: "inline" | "message";
};
const DEVELOPER_TOOL_BASES = new Set([
"shell",
"read_file",
"view_image",
"str_replace",
"todo",
"stop",
"postcompact",
]);
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
/** Build the muted compact summary label and preview for any tool row. */
export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
const kind = resolveCompactToolKind(item);
const messageSendPreview = extractMessageSendPreview(item, kind);
if (messageSendPreview !== undefined) {
return {
kind,
label: compactMessageSendLabel(item.status, item.isError),
preview: messageSendPreview,
thumbnailSrc: null,
presentation: "message",
};
}
const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind);
return {
kind,
label: compactToolLabel(kind, item, item.status, item.isError),
preview,
thumbnailSrc,
presentation: "inline",
};
}
function resolveCompactToolKind(item: ToolItem): CompactToolKind {
const developerKind = resolveDeveloperToolKind(item);
if (developerKind) {
return developerKind;
}
for (const value of [item.buzzToolName, item.toolName, item.title]) {
if (value && getBuzzToolInfo(value)) {
return "buzz";
}
}
return "generic";
}
function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null {
for (const value of [item.toolName, item.title, item.buzzToolName]) {
const kind = classifyDeveloperToolName(value);
if (kind) return kind;
}
return null;
}
function classifyDeveloperToolName(
value: string | null | undefined,
): CompactToolKind | null {
if (!value) return null;
const normalized = normalizeToolNameText(value);
const base = stripMcpServerPrefix(normalized);
if (base === "shell" || normalized.endsWith("_shell")) {
return "shell";
}
if (base === "read_file") return "read_file";
if (base === "view_image") return "view_image";
if (base === "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)) {
return "dev_mcp";
}
if (normalized.includes("buzz_dev_mcp")) {
return "dev_mcp";
}
return null;
}
function stripMcpServerPrefix(normalized: string): string {
return normalized.replace(/^buzz_dev_mcp_/, "");
}
function compactToolLabel(
kind: CompactToolKind,
item: ToolItem,
status: ToolStatus,
isError: boolean,
): string {
const failed = isError || status === "failed";
const running = status === "executing" || status === "pending";
if (kind === "buzz") {
const title = formatToolTitle(
item.buzzToolName ?? item.toolName,
item.title,
);
if (failed) return `${title} failed`;
if (running) return title;
return title;
}
const labels: Record<
Exclude<CompactToolKind, "buzz">,
{ completed: string; running: string; failed: string }
> = {
generic: {
completed: "Ran tool",
running: "Running tool",
failed: "Tool failed",
},
...developerToolLabels(),
};
const set = labels[kind];
if (failed) return set.failed;
if (running) return set.running;
return set.completed;
}
function developerToolLabels(): Record<
Exclude<CompactToolKind, "buzz" | "generic">,
{ completed: string; running: string; failed: string }
> {
return {
shell: {
completed: "Ran command",
running: "Running command",
failed: "Command failed",
},
read_file: {
completed: "Read file",
running: "Reading file",
failed: "Read failed",
},
view_image: {
completed: "Viewed image",
running: "Viewing image",
failed: "View failed",
},
str_replace: {
completed: "Edited file",
running: "Editing file",
failed: "Edit failed",
},
todo: {
completed: "Updated todos",
running: "Updating todos",
failed: "Todo update failed",
},
stop_hook: {
completed: "Checked todos",
running: "Checking todos",
failed: "Todo check failed",
},
post_compact_hook: {
completed: "Synced todos",
running: "Syncing todos",
failed: "Todo sync failed",
},
dev_mcp: {
completed: "Ran tool",
running: "Running tool",
failed: "Tool failed",
},
};
}
function compactMessageSendLabel(status: ToolStatus, isError: boolean) {
if (isError || status === "failed") {
return "Send Message failed";
}
return "Send Message";
}
type CompactToolPreview = {
preview: string | null;
thumbnailSrc: string | null;
};
function extractCompactToolPreview(
item: ToolItem,
kind: CompactToolKind,
): CompactToolPreview {
const args = item.args;
switch (kind) {
case "shell":
return textPreview(getToolString(args, ["command"]));
case "read_file":
case "str_replace":
return textPreview(getToolString(args, ["path"]));
case "view_image":
return getViewImagePreview(getToolString(args, ["source"]));
case "todo":
return textPreview(getTodoPreview(args));
case "stop_hook":
case "post_compact_hook":
return emptyPreview();
case "dev_mcp":
case "generic":
return textPreview(
getToolString(args, [
"command",
"path",
"source",
"query",
"name",
"content",
"message",
]) ??
(item.title && !isGenericToolTitle(item.title) ? item.title : null),
);
case "buzz":
return textPreview(extractBuzzToolPreview(args));
}
}
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 extractMessageSendPreview(
item: ToolItem,
kind: CompactToolKind,
): string | null | undefined {
if (isBuzzSendMessageTool(item)) {
return extractBuzzToolMessageContent(item.args);
}
if (kind !== "shell") {
return undefined;
}
const command = getToolString(item.args, ["command"]);
return command ? extractBuzzCliSendMessageContent(command) : undefined;
}
function isBuzzSendMessageTool(item: ToolItem) {
return [item.buzzToolName, item.toolName, item.title].some((value) => {
if (!value) return false;
return normalizeToolNameText(value) === "send_message";
});
}
function extractBuzzToolMessageContent(
args: Record<string, unknown>,
): string | null {
return getToolString(args, ["content", "message", "text", "body"]);
}
function extractBuzzCliSendMessageContent(
command: string,
): string | null | undefined {
const tokens = tokenizeShellCommand(command);
const commandRange = findBuzzMessagesSendCommand(tokens);
if (!commandRange) {
return undefined;
}
const content = getFlagValue(tokens, commandRange.sendIndex + 1, "--content");
if (!content) {
return null;
}
if (content !== "-") {
return content;
}
const pipedContent = extractSimpleEchoPipeContent(
tokens,
commandRange.buzzIndex,
);
return pipedContent ?? null;
}
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 findBuzzMessagesSendCommand(
tokens: string[],
): { buzzIndex: number; sendIndex: number } | null {
for (let i = 0; i < tokens.length; i++) {
if (!isBuzzExecutable(tokens[i])) {
continue;
}
const messagesIndex = tokens.indexOf("messages", i + 1);
if (messagesIndex === -1) {
continue;
}
if (
messagesIndex > i &&
hasCommandSeparator(tokens, i + 1, messagesIndex)
) {
continue;
}
if (tokens[messagesIndex + 1] === "send") {
return { buzzIndex: i, sendIndex: messagesIndex + 1 };
}
}
return null;
}
function isBuzzExecutable(token: string) {
return token === "buzz" || token.split(/[\\/]/).pop() === "buzz";
}
function hasCommandSeparator(tokens: string[], start: number, end: number) {
for (let i = start; i < end; i++) {
if (isCommandSeparator(tokens[i])) {
return true;
}
}
return false;
}
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;
}
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 textPreview(preview: string | null): CompactToolPreview {
return { preview, thumbnailSrc: null };
}
function emptyPreview(): CompactToolPreview {
return { preview: null, thumbnailSrc: null };
}
function getViewImagePreview(source: string | null): CompactToolPreview {
if (!source) {
return emptyPreview();
}
const trimmed = source.trim();
if (
trimmed.startsWith("data:image/") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("https://")
) {
return {
preview: trimmed,
thumbnailSrc: trimmed,
};
}
const basename = trimmed.split(/[/\\]/).pop() ?? trimmed;
return {
preview: basename,
thumbnailSrc: null,
};
}
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

@@ -109,6 +109,12 @@ function sealOpenMessages(d: TranscriptDraft) {
}
}
type TranscriptItemContext = {
channelId: string | null;
turnId: string | null;
sessionId: string | null;
};
function upsertMessage(
d: TranscriptDraft,
id: string,
@@ -116,8 +122,9 @@ function upsertMessage(
title: string,
text: string,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
authorPubkey: string | null = null,
acpSource?: string,
) {
const currentKey = d.activeMessageKey.get(id);
@@ -127,8 +134,11 @@ function upsertMessage(
replaceItem(d, currentKey, {
...existing,
text: existing.text + text,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
authorPubkey: authorPubkey ?? existing.authorPubkey,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
@@ -143,8 +153,11 @@ function upsertMessage(
title,
text,
timestamp,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
authorPubkey,
acpSource,
});
d.activeMessageKey = new Map(d.activeMessageKey);
d.activeMessageKey.set(id, newKey);
@@ -157,15 +170,33 @@ function upsertTextItem(
title: string,
text: string,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
if (existing && existing.type === type) {
replaceItem(d, id, { ...existing, text: existing.text + text, channelId });
replaceItem(d, id, {
...existing,
text: existing.text + text,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
sealOpenMessages(d);
pushItem(d, { id, type, title, text, timestamp, channelId });
pushItem(d, {
id,
type,
title,
text,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function upsertMetadata(
@@ -174,15 +205,33 @@ function upsertMetadata(
title: string,
sections: PromptSection[],
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
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;
}
sealOpenMessages(d);
pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId });
pushItem(d, {
id,
type: "metadata",
title,
sections,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function upsertTool(
@@ -196,7 +245,8 @@ function upsertTool(
result: string,
isError: boolean,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
const canonicalBuzzToolName =
@@ -225,7 +275,10 @@ function upsertTool(
existing.completedAt == null
? timestamp
: existing.completedAt,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
@@ -243,7 +296,10 @@ function upsertTool(
timestamp,
startedAt: timestamp,
completedAt: null,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
@@ -259,6 +315,11 @@ export function processTranscriptEvent(
const channelId = event.channelId ?? null;
const ch = channelId ?? "global";
const ctx: TranscriptItemContext = {
channelId,
turnId: event.turnId,
sessionId: event.sessionId ?? d.latestSessionId,
};
if (event.kind === "turn_started") {
upsertTextItem(
@@ -268,7 +329,8 @@ export function processTranscriptEvent(
"Turn started",
describeTurnStarted(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "session_resolved") {
upsertTextItem(
@@ -278,7 +340,8 @@ export function processTranscriptEvent(
"Session ready",
describeSessionResolved(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "acp_parse_error") {
upsertTextItem(
@@ -288,7 +351,8 @@ export function processTranscriptEvent(
"Wire parse error",
extractBlockText(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
const payload = asRecord(event.payload);
@@ -303,7 +367,8 @@ export function processTranscriptEvent(
title,
`${outcome}: ${error}`,
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
const payload = asRecord(event.payload);
@@ -321,8 +386,9 @@ export function processTranscriptEvent(
parsedPrompt.userTitle,
parsedPrompt.userText,
event.timestamp,
channelId,
ctx,
parsedPrompt.userPubkey,
"session/prompt:user",
);
}
if (parsedPrompt.sections.length > 0) {
@@ -332,7 +398,8 @@ export function processTranscriptEvent(
"Prompt context",
parsedPrompt.sections,
event.timestamp,
channelId,
ctx,
"session/prompt:context",
);
}
}
@@ -353,7 +420,7 @@ export function processTranscriptEvent(
"System prompt",
sections,
event.timestamp,
channelId,
ctx,
);
}
}
@@ -372,7 +439,9 @@ export function processTranscriptEvent(
"Assistant",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
null,
updateType,
);
} else if (updateType === "user_message_chunk") {
upsertMessage(
@@ -382,7 +451,9 @@ export function processTranscriptEvent(
"User",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
null,
updateType,
);
} else if (updateType === "agent_thought_chunk") {
upsertTextItem(
@@ -392,7 +463,8 @@ export function processTranscriptEvent(
"Thinking",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "tool_call") {
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
@@ -408,7 +480,8 @@ export function processTranscriptEvent(
extractToolResult(update),
false,
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "tool_call_update") {
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
@@ -427,7 +500,8 @@ export function processTranscriptEvent(
extractToolResult(update),
status === "failed",
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "plan") {
upsertTextItem(
@@ -437,7 +511,8 @@ export function processTranscriptEvent(
"Plan",
extractContentText(update.content) || JSON.stringify(update, null, 2),
event.timestamp,
channelId,
ctx,
updateType,
);
}
}

View File

@@ -0,0 +1,213 @@
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");
});

View File

@@ -0,0 +1,208 @@
import type { TranscriptItem } from "./agentSessionTypes";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { 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[] };
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")
);
}
function isErrorLifecycle(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
return (
item.type === "lifecycle" && item.title.toLowerCase().includes("error")
);
}
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 activity.map((item) => ({ kind: "item", item }));
}
const segments: TranscriptTurnSegment[] = [
{
kind: "prompt",
user: userPrompt,
context: promptContext,
setup: setupLifecycle,
},
];
for (const item of activity) {
if (isErrorLifecycle(item)) {
segments.push({ kind: "item", item });
continue;
}
segments.push({ kind: "item", item });
}
return segments;
}
/**
* 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>();
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 {
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

@@ -67,6 +67,37 @@ 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);
});
test("parsePromptText lowercases the extracted hex pubkey", () => {
const text = [
"[Buzz event: dm]",

View File

@@ -125,9 +125,39 @@ function parsePromptSections(text: string): PromptSection[] {
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 {
const contentMatch = body.match(/^Content:\s*(.*)$/m);
return contentMatch?.[1]?.trim() ?? "";
const lines = body.split(/\r?\n/);
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 {

View File

@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildTranscriptPresentation,
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");
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("buildTranscriptPresentation marks running tools as active while working", () => {
const items = [
makeMessage({ id: "msg:user", role: "user", text: "Please help" }),
makeTool({ id: "tool:running", status: "executing" }),
];
const presentation = buildTranscriptPresentation(items, true);
assert.equal(presentation.state, "tool_running");
assert.equal(presentation.headline, "Send Message");
assert.equal(presentation.counts.tools, 1);
assert.equal(presentation.counts.messages, 1);
assert.ok(presentation.activeItemIds.has("tool:running"));
});
test("buildTranscriptPresentation highlights assistant streaming while working", () => {
const items = [
makeMessage({ id: "msg:assistant", role: "assistant", text: "Drafting" }),
];
const presentation = buildTranscriptPresentation(items, true);
assert.equal(presentation.state, "responding");
assert.equal(presentation.headline, "Drafting");
assert.ok(presentation.activeItemIds.has("msg:assistant"));
});
test("buildTranscriptPresentation surfaces lifecycle errors", () => {
const items = [
makeTool({
id: "tool:done",
status: "completed",
completedAt: "2026-06-14T19:00:05.000Z",
}),
{
id: "life:error",
type: "lifecycle",
title: "Turn error",
text: "timeout",
timestamp: "2026-06-14T19:00:06.000Z",
},
];
const presentation = buildTranscriptPresentation(items, false);
assert.equal(presentation.state, "error");
assert.equal(presentation.hasError, true);
assert.equal(presentation.headline, "Turn error");
});
test("buildTranscriptPresentation returns idle state when not working", () => {
const items = [
makeTool({
id: "tool:done",
status: "completed",
completedAt: "2026-06-14T19:00:05.000Z",
}),
];
const presentation = buildTranscriptPresentation(items, false);
assert.equal(presentation.state, "idle");
assert.equal(presentation.activeItemIds.size, 0);
assert.equal(presentation.headline, "Send Message");
});

View File

@@ -0,0 +1,280 @@
import { formatToolTitle } from "./agentSessionToolCatalog";
import type { TranscriptItem } from "./agentSessionTypes";
export type TranscriptActivityCounts = {
tools: number;
toolErrors: number;
thoughts: number;
messages: number;
lifecycle: number;
metadata: number;
};
export type TranscriptActivityState =
| "idle"
| "responding"
| "thinking"
| "tool_running"
| "error";
export type TranscriptPresentation = {
headline: string;
state: TranscriptActivityState;
counts: TranscriptActivityCounts;
latestMeaningfulItem: TranscriptItem | null;
latestMeaningfulItemId: string | null;
activeItemIds: ReadonlySet<string>;
lastUpdatedAt: string | null;
hasError: boolean;
};
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") {
return formatToolTitle(item.buzzToolName ?? item.toolName, item.title);
}
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 === "lifecycle") {
return !isLifecycleNoise(item);
}
if (item.type === "metadata") {
return false;
}
return true;
}
function isToolRunning(item: Extract<TranscriptItem, { type: "tool" }>) {
return item.status === "executing" || item.status === "pending";
}
function isLifecycleError(
item: Extract<TranscriptItem, { type: "lifecycle" }>,
) {
return item.title.toLowerCase().includes("error");
}
function countItems(items: TranscriptItem[]): TranscriptActivityCounts {
const counts: TranscriptActivityCounts = {
tools: 0,
toolErrors: 0,
thoughts: 0,
messages: 0,
lifecycle: 0,
metadata: 0,
};
for (const item of items) {
switch (item.type) {
case "tool":
counts.tools += 1;
if (item.isError || item.status === "failed") {
counts.toolErrors += 1;
}
break;
case "thought":
counts.thoughts += 1;
break;
case "message":
counts.messages += 1;
break;
case "lifecycle":
counts.lifecycle += 1;
break;
case "metadata":
counts.metadata += 1;
break;
}
}
return counts;
}
function findLatestMeaningfulItem(
items: TranscriptItem[],
): TranscriptItem | null {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (isMeaningfulItem(item)) {
return item;
}
}
return null;
}
function resolveActivityState(
latest: TranscriptItem | null,
hasError: boolean,
isWorking: boolean,
): TranscriptActivityState {
if (!isWorking) {
return hasError ? "error" : "idle";
}
if (hasError && latest?.type === "lifecycle" && isLifecycleError(latest)) {
return "error";
}
if (latest?.type === "tool" && isToolRunning(latest)) {
return "tool_running";
}
if (latest?.type === "thought") {
return "thinking";
}
if (latest?.type === "message" && latest.role === "assistant") {
return "responding";
}
if (latest?.type === "tool") {
return "tool_running";
}
return "idle";
}
function resolveHeadline(
latest: TranscriptItem | null,
state: TranscriptActivityState,
isWorking: boolean,
): string {
if (latest) {
const headline = getActivityHeadline(latest);
if (headline) {
return headline;
}
}
if (isWorking) {
switch (state) {
case "tool_running":
return "Running a tool";
case "thinking":
return "Thinking";
case "responding":
return "Responding";
case "error":
return "Encountered an error";
default:
return "Working";
}
}
if (state === "error") {
return "Last turn ended with an error";
}
return "Waiting for activity";
}
function collectActiveItemIds(
items: TranscriptItem[],
isWorking: boolean,
): ReadonlySet<string> {
if (!isWorking || items.length === 0) {
return new Set();
}
const active = new Set<string>();
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (item.type === "tool" && isToolRunning(item)) {
active.add(item.id);
break;
}
if (item.type === "thought") {
active.add(item.id);
break;
}
if (item.type === "message" && item.role === "assistant") {
active.add(item.id);
break;
}
}
return active;
}
function detectError(items: TranscriptItem[]): boolean {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (!isMeaningfulItem(item)) {
continue;
}
if (item.type === "lifecycle" && isLifecycleError(item)) {
return true;
}
if (item.type === "tool" && (item.isError || item.status === "failed")) {
return true;
}
break;
}
return false;
}
/** Derive presentation metadata for a transcript list. */
export function buildTranscriptPresentation(
items: TranscriptItem[],
isWorking = false,
): TranscriptPresentation {
const latestMeaningfulItem = findLatestMeaningfulItem(items);
const hasError = detectError(items);
const state = resolveActivityState(latestMeaningfulItem, hasError, isWorking);
return {
headline: resolveHeadline(latestMeaningfulItem, state, isWorking),
state,
counts: countItems(items),
latestMeaningfulItem,
latestMeaningfulItemId: latestMeaningfulItem?.id ?? null,
activeItemIds: collectActiveItemIds(items, isWorking),
lastUpdatedAt:
items.length > 0 ? (items[items.length - 1]?.timestamp ?? null) : null,
hasError,
};
}

View File

@@ -20,42 +20,52 @@ export type ConnectionState =
export type ToolStatus = "executing" | "completed" | "failed" | "pending";
/** 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 =
| {
| ({
id: string;
type: "message";
role: "assistant" | "user";
title: string;
text: string;
timestamp: string;
acpSource?: TranscriptAcpSource;
authorPubkey?: string | null;
channelId?: string | null;
}
| {
} & TranscriptItemIdentity)
| ({
id: string;
type: "thought";
title: string;
text: string;
timestamp: string;
channelId?: string | null;
}
| {
acpSource?: TranscriptAcpSource;
} & TranscriptItemIdentity)
| ({
id: string;
type: "lifecycle";
title: string;
text: string;
timestamp: string;
channelId?: string | null;
}
| {
acpSource?: TranscriptAcpSource;
} & TranscriptItemIdentity)
| ({
id: string;
type: "metadata";
title: string;
sections: PromptSection[];
timestamp: string;
channelId?: string | null;
}
| {
acpSource?: TranscriptAcpSource;
} & TranscriptItemIdentity)
| ({
id: string;
type: "tool";
title: string;
@@ -68,8 +78,8 @@ export type TranscriptItem =
timestamp: string;
startedAt: string;
completedAt: string | null;
channelId?: string | null;
};
acpSource?: TranscriptAcpSource;
} & TranscriptItemIdentity);
export type PromptSection = {
title: string;

View File

@@ -64,6 +64,91 @@ 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;
}
/** 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 {
return typeof value === "string" ? value : null;
}

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 { ArrowLeft, CircleDot, Octagon, X } from "lucide-react";
import * as React from "react";
import { ArrowLeft, CircleDot, Octagon, TerminalSquare, X } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -25,6 +26,7 @@ import {
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { Switch } from "@/shared/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
@@ -60,6 +62,19 @@ export function AgentSessionThreadPanel({
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
const rawFeedScopeKey = `${agent.pubkey}:${channel.id}`;
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() {
try {
@@ -84,6 +99,32 @@ export function AgentSessionThreadPanel({
Live
</Badge>
) : null}
{isLive ? (
<div
className="flex min-w-19 shrink-0 items-center justify-end gap-2"
title={
showRawFeed
? "Hide raw JSON-RPC payloads."
: "Show raw JSON-RPC payloads for this channel."
}
>
<label
className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-muted-foreground"
htmlFor="agent-session-raw-feed-switch"
>
<TerminalSquare className="h-3 w-3" />
Raw
</label>
<Switch
aria-label={showRawFeed ? "Hide raw feed" : "Show raw feed"}
checked={showRawFeed}
className="shrink-0 data-[state=unchecked]:bg-muted-foreground/45 [&>span]:bg-white"
data-testid="agent-session-toggle-raw-feed"
id="agent-session-raw-feed-switch"
onCheckedChange={handleRawFeedChange}
/>
</div>
) : null}
{isLive && isWorking ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -137,7 +178,9 @@ export function AgentSessionThreadPanel({
>
<ArrowLeft />
</Button>
<AuxiliaryPanelTitle>Activity</AuxiliaryPanelTitle>
<AuxiliaryPanelTitle>
{showRawFeed ? "Raw ACP Activity" : "Activity"}
</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
{agentHeaderActions}
</>
@@ -158,9 +201,11 @@ export function AgentSessionThreadPanel({
channelId={channel.id}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
isWorking={isWorking}
profiles={profiles}
rawLayout="exclusive"
showHeader={false}
showRaw={false}
showRaw={showRawFeed}
/>
</div>
);

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { Loader2 } from "lucide-react";
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog";
import { getActivityHeadline } from "@/features/agents/ui/agentSessionTranscriptPresentation";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -27,18 +26,6 @@ const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 180;
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({
agents,
channelId = null,
@@ -185,10 +172,11 @@ export function BotActivityComposerAction({
"border border-background",
isInline
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
: "!h-5 !w-5 text-3xs",
: "shrink-0",
)}
displayName={agent.name}
key={agent.pubkey}
size="xs"
/>
))}
</span>
@@ -240,8 +228,9 @@ export function BotActivityComposerAction({
>
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className="!h-6 !w-6 shrink-0 text-2xs"
className="shrink-0"
displayName={agent.name}
size="sm"
/>
<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" />

View File

@@ -271,25 +271,6 @@ export function ChannelScreen({
: [],
[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 channelMembers = channelMembersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
@@ -311,6 +292,27 @@ export function ChannelScreen({
}
return pubkeys;
}, [channelMembers, managedAgents, relayAgents]);
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
...messageAuthorPubkeys,
...messageMentionPubkeys,
...activeDmParticipantPubkeys,
...agentPubkeys,
...typingEntries.map((entry) => entry.pubkey),
]),
],
[
activeDmParticipantPubkeys,
agentPubkeys,
messageAuthorPubkeys,
messageMentionPubkeys,
typingEntries,
],
);
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
});
const {
botTypingEntries,
channelAgentSessionAgents: activeChannelAgentSessionAgents,

View File

@@ -98,7 +98,7 @@ export function mergeAgentNamesIntoProfiles(
relayAgents: RelayAgent[],
): UserProfileLookup {
const merged = { ...profiles };
for (const agent of [...relayAgents, ...managedAgents]) {
for (const agent of relayAgents) {
const key = normalizePubkey(agent.pubkey);
merged[key] = {
...merged[key],
@@ -108,5 +108,15 @@ export function mergeAgentNamesIntoProfiles(
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;
}

View File

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

View File

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

View File

@@ -269,6 +269,7 @@ function messageLinkUrlTransform(value: string, key: string): string {
type MarkdownProps = {
channelNames?: string[];
className?: string;
compact?: boolean;
content: string;
customEmoji?: CustomEmoji[];
imetaByUrl?: ImetaLookup;
@@ -276,10 +277,14 @@ type MarkdownProps = {
agentMentionPubkeysByName?: Record<string, string>;
mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>;
mediaInset?: boolean;
searchQuery?: string;
tight?: boolean;
videoReviewContext?: VideoReviewContext;
};
type MarkdownVariant = "default" | "compact" | "tight";
type ImageLightboxBox = {
height: number;
left: number;
@@ -1782,12 +1787,23 @@ function SpoilerInline({
}
function createMarkdownComponents(
variant: MarkdownVariant,
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
mediaInset = false,
): Components {
const paragraphClassName = "leading-[inherit]";
const listItemClassName = "my-1 [&_p]:inline";
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground";
const paragraphClassName =
variant === "tight"
? "leading-5"
: variant === "compact"
? "leading-6"
: "leading-[inherit]";
const listItemClassName =
variant === "tight" ? "my-0.5 [&_p]:inline" : "my-1 [&_p]:inline";
const listClassName =
variant === "tight"
? "space-y-0.5 pl-6 marker:text-muted-foreground"
: "space-y-1 pl-6 marker:text-muted-foreground";
return {
spoiler: ({
@@ -1969,7 +1985,12 @@ function createMarkdownComponents(
if (resolvedSrc?.endsWith(".mp4")) {
const entry = src ? imetaByUrl?.get(src) : undefined;
return (
<span data-block-media="">
<span
className={cn(
mediaInset && "mx-1.5 block max-w-[calc(100%-0.75rem)]",
)}
data-block-media=""
>
<MarkdownVideoPlayer
key={src ?? resolvedSrc}
alt={alt}
@@ -2175,14 +2196,17 @@ function createMarkdownComponents(
function MarkdownInner({
channelNames,
className,
compact = false,
content,
customEmoji,
imetaByUrl,
interactive = true,
agentMentionPubkeysByName,
mediaInset = false,
mentionNames,
mentionPubkeysByName,
searchQuery,
tight = false,
videoReviewContext,
}: MarkdownProps) {
const { channels: rawChannels } = useChannelNavigation();
@@ -2219,9 +2243,16 @@ function MarkdownInner({
onOpenMessageLink,
});
const variant: MarkdownVariant = tight
? "tight"
: compact
? "compact"
: "default";
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
@@ -2287,6 +2318,13 @@ function MarkdownInner({
"[&>*+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",
].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,
)}
>
@@ -2302,8 +2340,11 @@ export const Markdown = React.memo(
(prev, next) =>
prev.content === next.content &&
prev.className === next.className &&
prev.compact === next.compact &&
prev.customEmoji === next.customEmoji &&
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.tight === next.tight &&
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&

View File

@@ -10,10 +10,14 @@ const toggleVariants = cva(
variants: {
variant: {
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: {
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",
lg: "h-10 px-3 min-w-10",
},

View File

@@ -39,6 +39,7 @@ type MockCommandAvailability = {
type MockManagedAgentSeed = {
pubkey: string;
name: string;
avatarUrl?: string | null;
personaId?: string | null;
status?: RawManagedAgent["status"];
channelNames?: string[];
@@ -385,6 +386,7 @@ type RawManagedAgent = {
max_turn_duration_seconds: number | null;
parallelism: number;
system_prompt: string | null;
avatar_url: string | null;
model: string | null;
env_vars?: Record<string, string>;
status: "running" | "stopped" | "deployed" | "not_deployed";
@@ -929,6 +931,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null,
parallelism: agent.parallelism,
system_prompt: agent.system_prompt,
avatar_url: agent.avatar_url ?? null,
model: agent.model,
env_vars: { ...(agent.env_vars ?? {}) },
status: agent.status,
@@ -1018,6 +1021,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
max_turn_duration_seconds: null,
parallelism: 1,
system_prompt: null,
avatar_url: seed.avatarUrl ?? null,
model: null,
env_vars: {},
status,
@@ -5128,6 +5132,7 @@ async function handleCreateManagedAgent(
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
parallelism: args.input.parallelism ?? 1,
system_prompt: args.input.systemPrompt?.trim() || null,
avatar_url: avatarUrl,
model: args.input.model?.trim() || null,
env_vars: { ...(args.input.envVars ?? {}) },
status: args.input.spawnAfterCreate ? "running" : "stopped",

View File

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