mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(agents): improve activity transcript observability
- Add transcript presentation model with Now summary, activity state, and live item highlighting - Support compact channel activity panel mode with intervention hint wiring - Refine tool, thought, metadata, and lifecycle row hierarchy for at-a-glance scanning - Share activity headline derivation with composer bot activity chip - Extend channel activity E2E coverage for Now summary and stop affordance
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react";
|
||||
import { ArrowUpRight, ChevronDown, CircleDot, Wrench } from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
@@ -7,6 +7,7 @@ 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 { Badge } from "@/shared/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
@@ -27,8 +28,12 @@ import {
|
||||
} from "./agentSessionUtils";
|
||||
|
||||
export function ToolItem({
|
||||
compact = false,
|
||||
isActive = false,
|
||||
item,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
isActive?: boolean;
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
@@ -48,7 +53,15 @@ export function ToolItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full px-1">
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose w-full",
|
||||
compact ? "px-0" : "px-1",
|
||||
isActive &&
|
||||
"rounded-lg border border-primary/15 bg-primary/[0.03] px-2 py-1",
|
||||
)}
|
||||
data-testid="transcript-tool-item"
|
||||
>
|
||||
<details
|
||||
className="group w-full"
|
||||
onToggle={handleToggle}
|
||||
@@ -59,13 +72,22 @@ export function ToolItem({
|
||||
<ToolIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
buzzTool ? "text-primary" : "text-muted-foreground",
|
||||
buzzTool || isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{toolTitle}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-[9px] font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
{buzzTool ? (
|
||||
<BuzzToolInlineAction args={item.args} result={item.result} />
|
||||
) : null}
|
||||
|
||||
@@ -1,32 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Loader2,
|
||||
Radio,
|
||||
TerminalSquare,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Shimmer } from "@/shared/ui/Shimmer";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { ToolItem } from "./AgentSessionToolItem";
|
||||
import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation";
|
||||
import { formatTranscriptTime } from "./agentSessionUtils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export function AgentSessionTranscriptList({
|
||||
agentName,
|
||||
compact = false,
|
||||
emptyDescription,
|
||||
isWorking = false,
|
||||
items,
|
||||
profiles,
|
||||
showInterventionHint = false,
|
||||
}: {
|
||||
agentName: string;
|
||||
compact?: boolean;
|
||||
emptyDescription: string;
|
||||
isWorking?: boolean;
|
||||
items: TranscriptItem[];
|
||||
profiles?: UserProfileLookup;
|
||||
showInterventionHint?: boolean;
|
||||
}) {
|
||||
const presentation = React.useMemo(
|
||||
() => buildTranscriptPresentation(items, isWorking),
|
||||
[items, isWorking],
|
||||
);
|
||||
|
||||
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={cn(
|
||||
"flex flex-col items-center justify-center px-6 py-10 text-center",
|
||||
compact ? "min-h-40" : "min-h-56",
|
||||
)}
|
||||
>
|
||||
<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,57 +64,295 @@ export function AgentSessionTranscriptList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Live ACP transcript"
|
||||
aria-live="polite"
|
||||
className="w-full py-1"
|
||||
role="log"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div className="mt-4 first:mt-0" key={item.id}>
|
||||
<TranscriptItemView
|
||||
agentName={agentName}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="w-full">
|
||||
<TranscriptNowSummary
|
||||
agentName={agentName}
|
||||
compact={compact}
|
||||
isWorking={isWorking}
|
||||
presentation={presentation}
|
||||
showInterventionHint={showInterventionHint}
|
||||
/>
|
||||
<div
|
||||
aria-label="Live ACP transcript"
|
||||
aria-live="polite"
|
||||
className={cn("w-full", compact ? "py-0.5" : "py-1")}
|
||||
role="log"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
className={cn(
|
||||
"first:mt-0",
|
||||
compact ? "mt-2.5" : "mt-4",
|
||||
getItemSpacingClass(item),
|
||||
)}
|
||||
key={item.id}
|
||||
>
|
||||
<TranscriptItemView
|
||||
agentName={agentName}
|
||||
compact={compact}
|
||||
isActive={presentation.activeItemIds.has(item.id)}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptNowSummary({
|
||||
agentName,
|
||||
compact,
|
||||
isWorking,
|
||||
presentation,
|
||||
showInterventionHint,
|
||||
}: {
|
||||
agentName: string;
|
||||
compact: boolean;
|
||||
isWorking: boolean;
|
||||
presentation: ReturnType<typeof buildTranscriptPresentation>;
|
||||
showInterventionHint: boolean;
|
||||
}) {
|
||||
const { counts, hasError, headline, lastUpdatedAt, state } = presentation;
|
||||
const showSummary = isWorking || hasError || itemsHaveActivity(counts);
|
||||
|
||||
if (!showSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const StateIcon = getStateIcon(state, isWorking);
|
||||
const statusLabel = getStateLabel(state, isWorking);
|
||||
const lastUpdated = lastUpdatedAt
|
||||
? formatTranscriptTime(lastUpdatedAt)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-10 mb-3 rounded-lg border bg-background/95 backdrop-blur-sm",
|
||||
hasError
|
||||
? "border-destructive/30 bg-destructive/[0.04]"
|
||||
: "border-border/70",
|
||||
compact ? "px-2.5 py-2" : "px-3 py-2.5",
|
||||
)}
|
||||
data-testid="agent-transcript-now-summary"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
|
||||
hasError
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: isWorking
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<StateIcon
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
isWorking &&
|
||||
state !== "error" &&
|
||||
state !== "idle" &&
|
||||
"animate-pulse",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Now
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground/70">·</span>
|
||||
<p className="text-[11px] text-muted-foreground">{agentName}</p>
|
||||
{lastUpdated ? (
|
||||
<>
|
||||
<span className="text-[11px] text-muted-foreground/70">·</span>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
{lastUpdated}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 text-sm font-medium leading-snug",
|
||||
hasError ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{isWorking && state !== "idle" && state !== "error" ? (
|
||||
<Shimmer>{headline}</Shimmer>
|
||||
) : (
|
||||
headline
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
className="h-5 px-1.5 text-[10px] font-normal"
|
||||
variant={
|
||||
hasError ? "destructive" : isWorking ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{counts.tools > 0 ? (
|
||||
<ActivityCountBadge
|
||||
count={counts.tools}
|
||||
label="tool"
|
||||
tone={counts.toolErrors > 0 ? "error" : "default"}
|
||||
/>
|
||||
) : null}
|
||||
{counts.thoughts > 0 ? (
|
||||
<ActivityCountBadge count={counts.thoughts} label="thought" />
|
||||
) : null}
|
||||
{counts.messages > 0 ? (
|
||||
<ActivityCountBadge count={counts.messages} label="message" />
|
||||
) : null}
|
||||
</div>
|
||||
{showInterventionHint && isWorking ? (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
Use <span className="font-medium text-foreground">Stop</span>{" "}
|
||||
above to interrupt this turn without stopping the agent process.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityCountBadge({
|
||||
count,
|
||||
label,
|
||||
tone = "default",
|
||||
}: {
|
||||
count: number;
|
||||
label: string;
|
||||
tone?: "default" | "error";
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
className="h-5 px-1.5 text-[10px] font-normal"
|
||||
variant={tone === "error" ? "destructive" : "outline"}
|
||||
>
|
||||
{count} {label}
|
||||
{count === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function itemsHaveActivity(
|
||||
counts: ReturnType<typeof buildTranscriptPresentation>["counts"],
|
||||
) {
|
||||
return (
|
||||
counts.tools > 0 ||
|
||||
counts.thoughts > 0 ||
|
||||
counts.messages > 0 ||
|
||||
counts.lifecycle > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getStateIcon(
|
||||
state: ReturnType<typeof buildTranscriptPresentation>["state"],
|
||||
isWorking: boolean,
|
||||
) {
|
||||
if (state === "error") {
|
||||
return AlertCircle;
|
||||
}
|
||||
if (!isWorking) {
|
||||
return CircleDot;
|
||||
}
|
||||
switch (state) {
|
||||
case "tool_running":
|
||||
return Wrench;
|
||||
case "thinking":
|
||||
return Brain;
|
||||
case "responding":
|
||||
return Bot;
|
||||
default:
|
||||
return Loader2;
|
||||
}
|
||||
}
|
||||
|
||||
function getStateLabel(
|
||||
state: ReturnType<typeof buildTranscriptPresentation>["state"],
|
||||
isWorking: boolean,
|
||||
) {
|
||||
if (state === "error") {
|
||||
return "Error";
|
||||
}
|
||||
if (!isWorking) {
|
||||
return "Idle";
|
||||
}
|
||||
switch (state) {
|
||||
case "tool_running":
|
||||
return "Running tool";
|
||||
case "thinking":
|
||||
return "Thinking";
|
||||
case "responding":
|
||||
return "Responding";
|
||||
default:
|
||||
return "Working";
|
||||
}
|
||||
}
|
||||
|
||||
function getItemSpacingClass(item: TranscriptItem) {
|
||||
if (item.type === "lifecycle") {
|
||||
return "mt-2 first:mt-0";
|
||||
}
|
||||
if (item.type === "metadata" || item.type === "thought") {
|
||||
return "mt-2 first:mt-0";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const TranscriptItemView = React.memo(function TranscriptItemView({
|
||||
agentName,
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
agentName: string;
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem agentName={agentName} item={item} profiles={profiles} />
|
||||
<MessageItem
|
||||
agentName={agentName}
|
||||
compact={compact}
|
||||
isActive={isActive}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === "tool") {
|
||||
return <ToolItem item={item} />;
|
||||
return <ToolItem compact={compact} isActive={isActive} item={item} />;
|
||||
}
|
||||
if (item.type === "thought") {
|
||||
return <ThoughtItem item={item} />;
|
||||
return <ThoughtItem compact={compact} isActive={isActive} item={item} />;
|
||||
}
|
||||
if (item.type === "metadata") {
|
||||
return <MetadataItem item={item} />;
|
||||
return <MetadataItem compact={compact} item={item} />;
|
||||
}
|
||||
return <LifecycleItem item={item} />;
|
||||
});
|
||||
|
||||
function MessageItem({
|
||||
agentName,
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
agentName: string;
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
@@ -105,9 +372,16 @@ function MessageItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
|
||||
"flex flex-row animate-in fade-in duration-200 motion-reduce:animate-none",
|
||||
compact ? "px-0 py-0.5" : "px-1 py-1",
|
||||
isAssistant &&
|
||||
isActive &&
|
||||
"rounded-lg border border-primary/15 bg-primary/[0.03] px-2 py-1.5",
|
||||
)}
|
||||
data-role={isAssistant ? "assistant-message" : "user-message"}
|
||||
data-testid={
|
||||
isAssistant ? "transcript-assistant-message" : "transcript-user-message"
|
||||
}
|
||||
>
|
||||
{!isAssistant ? (
|
||||
<UserAvatar
|
||||
@@ -129,6 +403,15 @@ function MessageItem({
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="font-normal text-foreground">{agentName}</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-[9px] font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -153,15 +436,35 @@ function MessageItem({
|
||||
}
|
||||
|
||||
function ThoughtItem({
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
}: {
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: Extract<TranscriptItem, { type: "thought" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<details
|
||||
className={cn(
|
||||
"group not-prose w-full rounded-md border border-transparent",
|
||||
compact ? "px-0" : "px-1",
|
||||
isActive && "border-primary/15 bg-primary/[0.03] px-2 py-1",
|
||||
)}
|
||||
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" />
|
||||
<Brain className={cn("h-4 w-4", isActive && "text-primary")} />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-[9px] font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
@@ -173,16 +476,24 @@ function ThoughtItem({
|
||||
}
|
||||
|
||||
function MetadataItem({
|
||||
compact,
|
||||
item,
|
||||
}: {
|
||||
compact: boolean;
|
||||
item: Extract<TranscriptItem, { type: "metadata" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<details
|
||||
className={cn(
|
||||
"group not-prose w-full rounded-md border border-border/50 bg-muted/20",
|
||||
compact ? "px-2 py-1" : "px-2 py-1.5",
|
||||
)}
|
||||
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-[10px] text-muted-foreground/70">
|
||||
{item.sections.length} section{item.sections.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
@@ -217,12 +528,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>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,11 @@ type ManagedAgentSessionPanelProps = {
|
||||
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
||||
channelId?: string | null;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
emptyDescription?: string;
|
||||
isWorking?: boolean;
|
||||
showHeader?: boolean;
|
||||
showInterventionHint?: boolean;
|
||||
showRaw?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
@@ -38,8 +41,11 @@ export function ManagedAgentSessionPanel({
|
||||
agent,
|
||||
channelId = null,
|
||||
className,
|
||||
compact = false,
|
||||
emptyDescription = "Mention this agent in a channel to watch the next turn.",
|
||||
isWorking = false,
|
||||
showHeader = true,
|
||||
showInterventionHint = false,
|
||||
showRaw = true,
|
||||
profiles,
|
||||
}: ManagedAgentSessionPanelProps) {
|
||||
@@ -94,12 +100,15 @@ export function ManagedAgentSessionPanel({
|
||||
|
||||
<SessionBody
|
||||
agentName={agent.name}
|
||||
compact={compact}
|
||||
connectionState={connectionState}
|
||||
emptyDescription={emptyDescription}
|
||||
errorMessage={errorMessage}
|
||||
events={scopedEvents}
|
||||
hasObserver={hasObserver}
|
||||
isWorking={isWorking}
|
||||
profiles={profiles}
|
||||
showInterventionHint={showInterventionHint}
|
||||
showRaw={showRaw}
|
||||
transcript={scopedTranscript}
|
||||
/>
|
||||
@@ -144,22 +153,28 @@ function SessionHeader({
|
||||
|
||||
function SessionBody({
|
||||
agentName,
|
||||
compact,
|
||||
connectionState,
|
||||
emptyDescription,
|
||||
errorMessage,
|
||||
events,
|
||||
hasObserver,
|
||||
isWorking,
|
||||
profiles,
|
||||
showInterventionHint,
|
||||
showRaw,
|
||||
transcript,
|
||||
}: {
|
||||
agentName: string;
|
||||
compact: boolean;
|
||||
connectionState: ConnectionState;
|
||||
emptyDescription: string;
|
||||
errorMessage: string | null;
|
||||
events: ObserverEvent[];
|
||||
hasObserver: boolean;
|
||||
isWorking: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
showInterventionHint: boolean;
|
||||
showRaw: boolean;
|
||||
transcript: TranscriptItem[];
|
||||
}) {
|
||||
@@ -179,9 +194,12 @@ function SessionBody({
|
||||
>
|
||||
<AgentSessionTranscriptList
|
||||
agentName={agentName}
|
||||
compact={compact}
|
||||
emptyDescription={emptyDescription}
|
||||
isWorking={isWorking}
|
||||
items={transcript}
|
||||
profiles={profiles}
|
||||
showInterventionHint={showInterventionHint}
|
||||
/>
|
||||
{showRaw ? <RawEventRail events={events} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -160,9 +160,12 @@ export function AgentSessionThreadPanel({
|
||||
agent={agent}
|
||||
channelId={channel.id}
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
compact
|
||||
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
|
||||
isWorking={isWorking}
|
||||
profiles={profiles}
|
||||
showHeader={false}
|
||||
showInterventionHint={canInterruptTurn}
|
||||
showRaw={false}
|
||||
/>
|
||||
</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,
|
||||
|
||||
@@ -773,6 +773,15 @@ 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")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-transcript-now-summary")).toContainText(
|
||||
"Working",
|
||||
);
|
||||
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