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:
Taylor Ho
2026-06-15 15:59:26 -07:00
parent 0851322d9f
commit cb684ad315
8 changed files with 834 additions and 48 deletions
@@ -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,
+9
View File
@@ -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) => {