diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a96103..065dacd9ed 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -3,10 +3,14 @@ import { Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { - getActivityHeadline, - isMeaningfulItem, - isSpineItem, -} from "@/features/agents/ui/agentSessionTranscriptPresentation"; + getAgentWorkingState, + subscribeAgentWorkingSignal, +} from "@/features/agents/agentWorkingSignal"; +import { + buildStableActivityStatus, + formatElapsed, + formatStatusSegments, +} from "@/features/channels/ui/botActivityStatus"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -28,7 +32,7 @@ type BotActivityBarProps = { const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; -const HEADLINE_ROTATION_MS = 2200; +const ELAPSED_TICK_MS = 1000; export function BotActivityComposerAction({ agents, @@ -57,43 +61,54 @@ export function BotActivityComposerAction({ Boolean(singleWorkingAgent), singleWorkingAgent?.pubkey, ); - const activityHeadlines = React.useMemo(() => { - if (!singleWorkingAgent) { - return []; - } - - const seen = new Set(); - const headlines: string[] = []; - const scopedTranscript = channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript; - - // Two-tier scan: spine items first (reads recede when real work is present). - // If no spine headlines are found (session start / idle), fall back to all - // meaningful items so the bar isn't left empty. - const passFilter: (item: (typeof scopedTranscript)[number]) => boolean = - scopedTranscript.some(isSpineItem) ? isSpineItem : isMeaningfulItem; + const activityStatus = React.useMemo( + () => + singleWorkingAgent + ? buildStableActivityStatus(transcript, channelId) + : null, + [channelId, singleWorkingAgent, transcript], + ); - for (let i = scopedTranscript.length - 1; i >= 0; i--) { - const item = scopedTranscript[i]; - if (!passFilter(item)) { - continue; - } - const headline = getActivityHeadline(item); - if (!headline || seen.has(headline)) { - continue; + // Turn-start anchor for the elapsed segment, observer-primary with a + // typing fallback — the same signal every other working affordance reads. + const singleWorkingPubkey = singleWorkingAgent?.pubkey ?? null; + const workingState = React.useSyncExternalStore( + subscribeAgentWorkingSignal, + React.useCallback( + () => getAgentWorkingState(singleWorkingPubkey, channelId), + [channelId, singleWorkingPubkey], + ), + ); + const anchorAt = React.useMemo(() => { + if (!singleWorkingAgent || workingState.channels.length === 0) { + return null; + } + if (channelId) { + const scoped = workingState.channels.find( + (channel) => channel.channelId === channelId, + ); + if (scoped) { + return scoped.anchorAt; } + } + return workingState.channels.reduce( + (earliest, channel) => Math.min(earliest, channel.anchorAt), + Number.POSITIVE_INFINITY, + ); + }, [channelId, singleWorkingAgent, workingState]); - seen.add(headline); - headlines.unshift(headline); - if (headlines.length >= 5) { - break; - } + // Re-render once a second while a turn is running so the elapsed segment + // ticks in place — the only part of the line that changes on its own. + const [, tick] = React.useReducer((count: number) => count + 1, 0); + React.useEffect(() => { + if (anchorAt === null) { + return; } - return headlines; - }, [channelId, singleWorkingAgent, transcript]); - const [headlineIndex, setHeadlineIndex] = React.useState(0); + const interval = window.setInterval(tick, ELAPSED_TICK_MS); + + return () => window.clearInterval(interval); + }, [anchorAt]); const clearHoverTimer = React.useCallback(() => { if (hoverTimerRef.current !== null) { @@ -124,18 +139,6 @@ export function BotActivityComposerAction({ return () => clearHoverTimer(); }, [clearHoverTimer]); - React.useEffect(() => { - if (activityHeadlines.length <= 1) { - return; - } - - const interval = window.setInterval(() => { - setHeadlineIndex((current) => (current + 1) % activityHeadlines.length); - }, HEADLINE_ROTATION_MS); - - return () => window.clearInterval(interval); - }, [activityHeadlines.length]); - if (workingAgents.length === 0) { return null; } @@ -148,12 +151,23 @@ export function BotActivityComposerAction({ ? `${workingAgents[0]?.name ?? "Agent"} is working` : `${workingAgents.length} agents working`; const isInline = variant === "inline"; + const elapsed = + anchorAt !== null && Number.isFinite(anchorAt) + ? formatElapsed(Date.now() - anchorAt) + : null; const visibleStatusLabel = workingAgents.length === 1 - ? `${workingAgents[0]?.name ?? "Agent"}: ${ - activityHeadlines[headlineIndex % activityHeadlines.length] ?? - "Working" - }` + ? [ + workingAgents[0]?.name ?? "Agent", + formatStatusSegments( + activityStatus ?? { + activity: "Working", + toolCount: 0, + context: null, + }, + elapsed, + ), + ].join(" · ") : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( diff --git a/desktop/src/features/channels/ui/botActivityStatus.test.mjs b/desktop/src/features/channels/ui/botActivityStatus.test.mjs new file mode 100644 index 0000000000..2cffcbc494 --- /dev/null +++ b/desktop/src/features/channels/ui/botActivityStatus.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildStableActivityStatus, + formatElapsed, + formatStatusSegments, + formatTokens, +} from "./botActivityStatus.ts"; + +const CHANNEL = "channel-1"; +const TURN = "session-100"; +const OLD_TURN = "session-50"; + +function tool(overrides = {}) { + return { + id: `tool:${CHANNEL}:${Math.random()}`, + type: "tool", + renderClass: "shell", + descriptor: { preview: null }, + title: "Bash", + toolName: "shell", + buzzToolName: "shell", + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: "2026-08-03T12:00:00Z", + startedAt: "2026-08-03T12:00:00Z", + completedAt: null, + turnId: TURN, + channelId: CHANNEL, + ...overrides, + }; +} + +function thought(overrides = {}) { + return { + id: `thought:${Math.random()}`, + type: "thought", + renderClass: "thought", + title: "Thinking", + text: "…", + timestamp: "2026-08-03T12:00:00Z", + turnId: TURN, + channelId: CHANNEL, + ...overrides, + }; +} + +function usage(text, overrides = {}) { + return { + id: `usage:${CHANNEL}:${TURN}`, + type: "lifecycle", + renderClass: "status", + title: "Usage", + text, + timestamp: "2026-08-03T12:00:00Z", + turnId: TURN, + channelId: CHANNEL, + ...overrides, + }; +} + +describe("formatTokens", () => { + it("keeps small counts, rounds K, trims whole M", () => { + assert.equal(formatTokens(950), "950"); + assert.equal(formatTokens(87_500), "88K"); + assert.equal(formatTokens(118_000), "118K"); + assert.equal(formatTokens(1_000_000), "1M"); + assert.equal(formatTokens(1_500_000), "1.5M"); + }); +}); + +describe("formatElapsed", () => { + it("scales seconds → minutes → hours and clamps negatives", () => { + assert.equal(formatElapsed(42_000), "42s"); + assert.equal(formatElapsed(130_000), "2m 10s"); + assert.equal(formatElapsed(3_840_000), "1h 4m"); + assert.equal(formatElapsed(-5_000), "0s"); + }); +}); + +describe("buildStableActivityStatus", () => { + it("prefers the newest running tool and counts turn tools", () => { + const status = buildStableActivityStatus( + [ + tool({ title: "Read", status: "completed" }), + tool({ + title: "Bash", + status: "executing", + descriptor: { preview: "npm test" }, + }), + ], + CHANNEL, + ); + assert.equal(status.activity, "Bash: npm test"); + assert.equal(status.toolCount, 2); + }); + + it("falls back to the newest phase item when no tool is running", () => { + const status = buildStableActivityStatus( + [tool({ status: "completed" }), thought()], + CHANNEL, + ); + assert.equal(status.activity, "Thinking"); + }); + + it("scopes counters to the current turn and channel", () => { + const status = buildStableActivityStatus( + [ + tool({ turnId: OLD_TURN }), + tool({ channelId: "channel-2", turnId: "other-session" }), + tool({ status: "executing" }), + ], + CHANNEL, + ); + assert.equal(status.toolCount, 1); + }); + + it("excludes suppressed rows from count and activity", () => { + const status = buildStableActivityStatus( + [tool(), tool({ renderClass: "suppressed", status: "executing" })], + CHANNEL, + ); + assert.equal(status.toolCount, 1); + assert.equal(status.activity, "Bash"); + }); + + it("parses the newest usage reading into a compact ctx string", () => { + const status = buildStableActivityStatus( + [usage("Tokens: 118000/1000000"), tool({ status: "executing" })], + CHANNEL, + ); + assert.equal(status.context, "118K/1M"); + }); + + it("shortens absolute-path previews to their basename", () => { + const status = buildStableActivityStatus( + [ + tool({ + title: "Read", + status: "executing", + renderClass: "file-read", + descriptor: { preview: "/Users/tolga/projects/bot/src/index.ts" }, + }), + ], + CHANNEL, + ); + assert.equal(status.activity, "Read: index.ts"); + }); + + it("returns the idle shape for an empty transcript", () => { + const status = buildStableActivityStatus([], CHANNEL); + assert.deepEqual(status, { + activity: "Working", + toolCount: 0, + context: null, + }); + }); +}); + +describe("formatStatusSegments", () => { + it("joins segments and omits empty counters", () => { + assert.equal( + formatStatusSegments( + { activity: "Bash: npm test", toolCount: 12, context: "118K/1M" }, + "2m 10s", + ), + "Bash: npm test · 2m 10s · 12 tools · ctx 118K/1M", + ); + assert.equal( + formatStatusSegments( + { activity: "Working", toolCount: 0, context: null }, + null, + ), + "Working", + ); + assert.equal( + formatStatusSegments( + { activity: "Thinking", toolCount: 1, context: null }, + "5s", + ), + "Thinking · 5s · 1 tool", + ); + }); +}); + +describe("buildStableActivityStatus previews", () => { + it("prefers a human description over the raw command preview", () => { + const status = buildStableActivityStatus( + [ + tool({ + status: "executing", + args: { + command: "sed -n '420,432p' file.ts", + description: "Read the store cache", + }, + descriptor: { preview: "sed -n '420,432p' file.ts" }, + }), + ], + CHANNEL, + ); + assert.equal(status.activity, "Bash: Read the store cache"); + }); +}); diff --git a/desktop/src/features/channels/ui/botActivityStatus.ts b/desktop/src/features/channels/ui/botActivityStatus.ts new file mode 100644 index 0000000000..a14e5d8752 --- /dev/null +++ b/desktop/src/features/channels/ui/botActivityStatus.ts @@ -0,0 +1,204 @@ +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; + +/** + * Stable, Slack-style status line for the composer activity bar. + * + * The bar used to rotate through the last few distinct transcript headlines, + * which read as "3-4 things flickering in a loop" during a long agent turn. + * Instead we derive ONE line that updates in place: + * + * Assistant · Bash: npm test · 2m 10s · 12 tools · ctx 118K/1M + * + * Everything here is a pure projection of the transcript; the elapsed-time + * segment is rendered by the component (it needs a ticking clock). + */ + +export type StableActivityStatus = { + /** What the agent is doing right now ("Bash: npm test", "Thinking", …). */ + activity: string; + /** Tool calls in the current turn (suppressed rows excluded). */ + toolCount: number; + /** Compact context reading ("118K/1M"), when a usage frame has arrived. */ + context: string | null; +}; + +/** Preview clamp keeps the trailing counters visible in a truncating row. */ +const MAX_PREVIEW_CHARS = 40; + +type ToolItem = Extract; + +function isRunning(item: ToolItem): boolean { + return item.status === "executing" || item.status === "pending"; +} + +function isCountableTool(item: TranscriptItem): item is ToolItem { + return item.type === "tool" && item.renderClass !== "suppressed"; +} + +/** Absolute paths shorten to their basename; anything else passes through. */ +function shortPreview(preview: string): string { + let out = preview; + if (out.startsWith("/") || out.startsWith("~")) { + const base = out.split("/").filter(Boolean).at(-1); + if (base) { + out = base; + } + } + if (out.length > MAX_PREVIEW_CHARS) { + out = `${out.slice(0, MAX_PREVIEW_CHARS - 1)}…`; + } + return out; +} + +/** + * "Bash: Run the test suite" — the emitted tool title plus a clamped preview. + * A human-authored `description` argument (Claude Code sends one with every + * shell call) beats the raw command line: the bar is a status line, not a + * terminal, and `sed -n '420,432p' …` reads as noise there. The session panel + * still shows the full command. + */ +function toolActivity(item: ToolItem): string { + const description = item.args?.description; + if (typeof description === "string" && description.trim().length > 0) { + return `${item.title}: ${shortPreview(description.trim())}`; + } + const preview = item.descriptor?.preview; + if (typeof preview === "string" && preview.trim().length > 0) { + return `${item.title}: ${shortPreview(preview.trim())}`; + } + return item.title; +} + +function phaseActivity(item: TranscriptItem): string | null { + if (isCountableTool(item)) { + return toolActivity(item); + } + if (item.type === "thought") { + return "Thinking"; + } + if (item.type === "plan") { + return "Planning"; + } + if (item.type === "message" && item.role === "assistant") { + return "Responding"; + } + return null; +} + +/** 87_500 → "88K", 1_000_000 → "1M", 1_500_000 → "1.5M", 950 → "950". */ +export function formatTokens(count: number): string { + if (count >= 1_000_000) { + const millions = count / 1_000_000; + const rounded = Math.round(millions * 10) / 10; + return Number.isInteger(rounded) ? `${rounded}M` : `${rounded.toFixed(1)}M`; + } + if (count >= 1_000) { + return `${Math.round(count / 1_000)}K`; + } + return `${count}`; +} + +/** 42_000ms → "42s", 130_000 → "2m 10s", 3_840_000 → "1h 4m". */ +export function formatElapsed(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) { + return `${totalMinutes}m ${totalSeconds % 60}s`; + } + return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`; +} + +const USAGE_TEXT = /Tokens:\s*(\d+)\/(\d+)/; + +/** + * Project the (channel-scoped) transcript into one stable status reading. + * The current turn is whichever turnId the newest attributed item carries; + * counters are scoped to it so a fresh turn starts back at zero. + */ +export function buildStableActivityStatus( + transcript: TranscriptItem[], + channelId: string | null, +): StableActivityStatus { + const scoped = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + + let currentTurnId: string | null = null; + for (let i = scoped.length - 1; i >= 0; i--) { + const turnId = scoped[i]?.turnId; + if (typeof turnId === "string" && turnId.length > 0) { + currentTurnId = turnId; + break; + } + } + + const turnItems = currentTurnId + ? scoped.filter((item) => item.turnId === currentTurnId) + : []; + + let toolCount = 0; + let runningTool: ToolItem | null = null; + for (const item of turnItems) { + if (!isCountableTool(item)) { + continue; + } + toolCount += 1; + if (isRunning(item)) { + runningTool = item; // newest running tool wins + } + } + + let activity = runningTool ? toolActivity(runningTool) : null; + if (!activity) { + for (let i = turnItems.length - 1; i >= 0 && !activity; i--) { + const item = turnItems[i]; + if (item) { + activity = phaseActivity(item); + } + } + } + + // Context survives across turns (it is a session-level reading), so the + // newest usage item in the whole scope wins, not just the current turn. + let context: string | null = null; + for (let i = scoped.length - 1; i >= 0; i--) { + const item = scoped[i]; + if (item?.type !== "lifecycle" || !item.id.startsWith("usage:")) { + continue; + } + const match = USAGE_TEXT.exec(item.text); + if (match?.[1] && match[2]) { + context = `${formatTokens(Number(match[1]))}/${formatTokens(Number(match[2]))}`; + } + break; + } + + return { + activity: activity ?? "Working", + toolCount, + context, + }; +} + +/** Join the per-turn segments the bar renders after the agent's name. */ +export function formatStatusSegments( + status: StableActivityStatus, + elapsed: string | null, +): string { + const segments = [status.activity]; + if (elapsed) { + segments.push(elapsed); + } + if (status.toolCount > 0) { + segments.push( + `${status.toolCount} ${status.toolCount === 1 ? "tool" : "tools"}`, + ); + } + if (status.context) { + segments.push(`ctx ${status.context}`); + } + return segments.join(" · "); +}