mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
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:
committed by
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
68a0cc8506
commit
67700e60c8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
48
desktop/src/features/agents/ui/agentSessionPanelLayout.ts
Normal file
48
desktop/src/features/agents/ui/agentSessionPanelLayout.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
157
desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs
Normal file
157
desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
550
desktop/src/features/agents/ui/agentSessionToolSummary.ts
Normal file
550
desktop/src/features/agents/ui/agentSessionToolSummary.ts
Normal 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"}`;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
208
desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
Normal file
208
desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
Normal 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(" ");
|
||||
}
|
||||
@@ -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]",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
42
desktop/src/features/agents/ui/rawEventRail.test.mjs
Normal file
42
desktop/src/features/agents/ui/rawEventRail.test.mjs
Normal 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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user