diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 78dd98304..959a767d7 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -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; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -48,7 +53,15 @@ export function ToolItem({ ); return ( -
+
) : null} {toolTitle} + {isActive ? ( + + + Live + + ) : null} {buzzTool ? ( ) : null} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 2f1f47e1f..08bc730a3 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -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 ( -
+

No ACP activity yet

{emptyDescription}

@@ -35,57 +64,295 @@ export function AgentSessionTranscriptList({ } return ( -
- {items.map((item) => ( -
- -
- ))} +
+ +
+ {items.map((item) => ( +
+ +
+ ))} +
); } +function TranscriptNowSummary({ + agentName, + compact, + isWorking, + presentation, + showInterventionHint, +}: { + agentName: string; + compact: boolean; + isWorking: boolean; + presentation: ReturnType; + 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 ( +
+
+ + + +
+
+

+ Now +

+ · +

{agentName}

+ {lastUpdated ? ( + <> + · +

+ {lastUpdated} +

+ + ) : null} +
+

+ {isWorking && state !== "idle" && state !== "error" ? ( + {headline} + ) : ( + headline + )} +

+
+ + {statusLabel} + + {counts.tools > 0 ? ( + 0 ? "error" : "default"} + /> + ) : null} + {counts.thoughts > 0 ? ( + + ) : null} + {counts.messages > 0 ? ( + + ) : null} +
+ {showInterventionHint && isWorking ? ( +

+ Use Stop{" "} + above to interrupt this turn without stopping the agent process. +

+ ) : null} +
+
+
+ ); +} + +function ActivityCountBadge({ + count, + label, + tone = "default", +}: { + count: number; + label: string; + tone?: "default" | "error"; +}) { + return ( + + {count} {label} + {count === 1 ? "" : "s"} + + ); +} + +function itemsHaveActivity( + counts: ReturnType["counts"], +) { + return ( + counts.tools > 0 || + counts.thoughts > 0 || + counts.messages > 0 || + counts.lifecycle > 0 + ); +} + +function getStateIcon( + state: ReturnType["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["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 ( - + ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { - return ; + return ; } return ; }); function MessageItem({ agentName, + compact, + isActive, item, profiles, }: { agentName: string; + compact: boolean; + isActive: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -105,9 +372,16 @@ function MessageItem({ return (
{!isAssistant ? ( {agentName} + {isActive ? ( + + + Live + + ) : null}
) : null} @@ -153,15 +436,35 @@ function MessageItem({ } function ThoughtItem({ + compact, + isActive, item, }: { + compact: boolean; + isActive: boolean; item: Extract; }) { return ( -
+
- + {item.title} + {isActive ? ( + + + Live + + ) : null} @@ -173,16 +476,24 @@ function ThoughtItem({ } function MetadataItem({ + compact, item, }: { + compact: boolean; item: Extract; }) { return ( -
+
- - {item.title} - + + {item.title} + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -217,12 +528,20 @@ function LifecycleItem({ return (
+ {isError ? ( + + ) : ( + + )} {item.title} - {item.text ? - {item.text} : null} + {item.text ? · {item.text} : null}
); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 3defb7d2b..efea6d336 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -28,8 +28,11 @@ type ManagedAgentSessionPanelProps = { agent: Pick; 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({ @@ -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({ > {showRaw ? : null}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs new file mode 100644 index 000000000..a13736189 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts new file mode 100644 index 000000000..bb6fd4c9f --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -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; + 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, +) { + 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) { + return item.status === "executing" || item.status === "pending"; +} + +function isLifecycleError( + item: Extract, +) { + 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 { + if (!isWorking || items.length === 0) { + return new Set(); + } + + const active = new Set(); + + 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, + }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index ae633bbf3..e366fe1fe 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -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} />
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index a289200ad..5e56422e1 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 6f4f53327..cb77d8a42 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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) => {