diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index d78d1d21af..58f2b19aec 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -12,6 +12,8 @@ import { restoreActiveAgentTurnsForCommunity, clearSavedCommunitySnapshot, clearActiveTurnsForAgent, + getWorkingAgentPubkeysForThread, + getActiveTurnCountForChannel, } from "./activeAgentTurnsStore.ts"; import { injectObserverEventsForE2E, @@ -2018,3 +2020,110 @@ describe("clearActiveTurnsForAgent", () => { mock.timers.reset(); }); }); + +describe("getWorkingAgentPubkeysForThread", () => { + beforeEach(() => { + resetActiveAgentTurnsStore(); + }); + + it("matches a turn whose sessionId prefixes the thread root id", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + sessionId: "aaaa000011112222", + turnId: "aaaa000011112222-1700000000000", + }), + ]); + + assert.deepEqual( + [ + ...getWorkingAgentPubkeysForThread( + "chan-1", + "aaaa000011112222deadbeefdeadbeef", + ), + ], + [AGENT], + ); + assert.equal( + getWorkingAgentPubkeysForThread( + "chan-1", + "bbbb000011112222deadbeefdeadbeef", + ).length, + 0, + ); + assert.equal( + getWorkingAgentPubkeysForThread( + "chan-2", + "aaaa000011112222deadbeefdeadbeef", + ).length, + 0, + ); + }); + + it("ignores turns without a sessionId", () => { + syncAgentTurnsFromEvents(AGENT, [makeEvent({ sessionId: null })]); + + assert.equal( + getWorkingAgentPubkeysForThread("chan-1", "any-root-id").length, + 0, + ); + }); + + it("drops the agent once its turn completes", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ sessionId: "aaaa000011112222" }), + ]); + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 2, + kind: "turn_completed", + timestamp: "2024-01-01T00:01:00Z", + }), + ]); + + assert.equal( + getWorkingAgentPubkeysForThread( + "chan-1", + "aaaa000011112222deadbeefdeadbeef", + ).length, + 0, + ); + }); +}); + +describe("getActiveTurnCountForChannel", () => { + beforeEach(() => { + resetActiveAgentTurnsStore(); + }); + + it("counts only this agent's live turns in this channel", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t-a", sessionId: "aaaa" }), + makeEvent({ seq: 2, turnId: "t-b", sessionId: "bbbb" }), + makeEvent({ seq: 3, turnId: "t-c", channelId: "chan-2" }), + ]); + syncAgentTurnsFromEvents(AGENT_2, [ + makeEvent({ seq: 1, turnId: "t-d", sessionId: "dddd" }), + ]); + + assert.equal(getActiveTurnCountForChannel(AGENT, "chan-1"), 2); + assert.equal(getActiveTurnCountForChannel(AGENT, "chan-2"), 1); + assert.equal(getActiveTurnCountForChannel(AGENT_2, "chan-1"), 1); + }); + + it("returns 0 for missing inputs and completed turns", () => { + assert.equal(getActiveTurnCountForChannel(null, "chan-1"), 0); + assert.equal(getActiveTurnCountForChannel(AGENT, null), 0); + + syncAgentTurnsFromEvents(AGENT, [makeEvent({ seq: 1, turnId: "t-a" })]); + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 2, + turnId: "t-a", + kind: "turn_completed", + timestamp: "2024-01-01T00:01:00Z", + }), + ]); + + assert.equal(getActiveTurnCountForChannel(AGENT, "chan-1"), 0); + }); +}); diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 3af41ae078..c86d3de0d1 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -40,6 +40,10 @@ const PRUNE_INTERVAL_MS = 5_000; type ActiveTurn = { turnId: string; channelId: string; + /** Thread-root prefix the emitting harness scopes this turn to (NIP-AO + * envelope `sessionId`, the root event id shortened). Null when the frame + * carried none — such turns are channel-scoped only. */ + sessionId: string | null; startedAt: number; lastActivityAt: number; }; @@ -84,6 +88,9 @@ const clockOffsetByAgent = new Map(); // Only regenerated when the underlying turn map for an agent actually changes. const cachedTurnSummaries = new Map(); let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null; +// `${channelId}|${threadRootId}` → working pubkeys, reference-stable per notify. +const cachedThreadWorkingPubkeys = new Map(); +const EMPTY_THREAD_PUBKEYS: readonly string[] = Object.freeze([]); // Composite watermark per agent: the newest observer event processed, by // (timestamp, seq) ordering. An event is processed only if it is strictly @@ -104,6 +111,8 @@ let pruneInterval: ReturnType | null = null; function invalidateCache(agentKey: string) { cachedTurnSummaries.delete(agentKey); cachedChannelTurnSummaries = null; + // Thread-scoped snapshots span agents, so any agent change clears them all. + cachedThreadWorkingPubkeys.clear(); } function notifyListeners() { @@ -139,6 +148,7 @@ function startTurn( channelId: string, turnId: string, timestamp: string, + sessionId: string | null = null, ) { const key = normalizePubkey(agentPubkey); let agentTurns = activeTurnsByAgent.get(key); @@ -166,6 +176,7 @@ function startTurn( agentTurns.set(turnId, { turnId, channelId, + sessionId, startedAt, lastActivityAt: Date.now(), }); @@ -214,7 +225,13 @@ function resurrectTurn(agentPubkey: string, event: ObserverEvent): boolean { frameAt !== null && startedAtMs !== null && startedAtMs <= frameAt ? startedAt : event.timestamp; - startTurn(agentPubkey, event.channelId, event.turnId, safeStartedAt); + startTurn( + agentPubkey, + event.channelId, + event.turnId, + safeStartedAt, + event.sessionId ?? null, + ); return true; } @@ -353,6 +370,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { event.channelId, event.turnId ?? `seq-${event.seq}`, event.timestamp, + event.sessionId ?? null, ); notifyListeners(); return; @@ -546,6 +564,72 @@ export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] { ); } +/** + * Live observer turns one agent is running in one channel. The channel + * composer bar uses this to switch from a detailed single-turn status line to + * an aggregate ("2 threads") when parallel conversations would otherwise + * interleave in one line. Returns a primitive, so useSyncExternalStore + * reference stability is free. + */ +export function getActiveTurnCountForChannel( + agentPubkey: string | null | undefined, + channelId: string | null, +): number { + if (!agentPubkey || !channelId) return 0; + const turns = activeTurnsByAgent.get(normalizePubkey(agentPubkey)); + if (!turns) return 0; + let count = 0; + for (const turn of turns.values()) { + if (turn.channelId === channelId) count += 1; + } + return count; +} + +/** + * Agents with a live observer turn scoped to one thread. A turn qualifies + * when its channel matches AND the thread root id begins with the turn's + * `sessionId` (harnesses emit the root event id shortened, so prefix match is + * the identity test). Turns without a sessionId never qualify — the channel + * bar covers those. + */ +export function getWorkingAgentPubkeysForThread( + channelId: string | null, + threadRootId: string | null, +): readonly string[] { + if (!channelId || !threadRootId) return EMPTY_THREAD_PUBKEYS; + const cacheKey = `${channelId}|${threadRootId}`; + const cached = cachedThreadWorkingPubkeys.get(cacheKey); + if (cached) return cached; + + const pubkeys: string[] = []; + for (const [agentKey, turns] of activeTurnsByAgent) { + for (const turn of turns.values()) { + if (turn.channelId !== channelId) continue; + if (!turn.sessionId || !threadRootId.startsWith(turn.sessionId)) continue; + pubkeys.push(agentKey); + break; + } + } + cachedThreadWorkingPubkeys.set(cacheKey, pubkeys); + return pubkeys; +} + +/** + * Hook: agents working in one specific thread, observer-derived. + * Reference-stable between store notifications. + */ +export function useThreadWorkingAgentPubkeys( + channelId: string | null, + threadRootId: string | null, +): readonly string[] { + const getSnapshot = React.useCallback( + () => getWorkingAgentPubkeysForThread(channelId, threadRootId), + [channelId, threadRootId], + ); + + return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot); +} + /** * Sync every running/deployed agent's observer events into the active-turns * store. Extracted from the bridge hook so a regression can drive the exact diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a96103..9d9c232716 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -3,10 +3,18 @@ import { Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { - getActivityHeadline, - isMeaningfulItem, - isSpineItem, -} from "@/features/agents/ui/agentSessionTranscriptPresentation"; + getActiveTurnCountForChannel, + subscribeActiveAgentTurns, +} from "@/features/agents/activeAgentTurnsStore"; +import { + 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"; @@ -19,6 +27,9 @@ export type BotActivityAgent = Pick; type BotActivityBarProps = { agents: BotActivityAgent[]; channelId?: string | null; + /** Thread root id when this bar lives in a thread composer — locks the + * status detail onto that thread's turn instead of the channel's newest. */ + threadRootId?: string | null; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; @@ -28,11 +39,12 @@ 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, channelId = null, + threadRootId = null, onOpenAgentSession, openAgentSessionPubkey, profiles, @@ -57,43 +69,62 @@ 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, threadRootId) + : null, + [channelId, singleWorkingAgent, threadRootId, 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; - } + const channelTurnCount = React.useSyncExternalStore( + subscribeActiveAgentTurns, + React.useCallback( + () => getActiveTurnCountForChannel(singleWorkingPubkey, channelId), + [channelId, singleWorkingPubkey], + ), + ); + + // 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 +155,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 +167,31 @@ 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", + // Parallel turns in one channel would interleave in a single + // detailed line, so past one turn the channel bar aggregates; + // each thread's own bar still carries that thread's detail. + channelTurnCount > 1 && !threadRootId + ? [ + `${channelTurnCount} threads`, + ...(elapsed ? [elapsed] : []), + ].join(" · ") + : 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/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 54c0dcd5d9..b36486d85a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -37,6 +37,7 @@ import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewMod import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; +import { useThreadComposerWorkingPubkeys } from "@/features/channels/ui/useThreadComposerWorkingPubkeys"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; import { @@ -412,20 +413,13 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; - const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) return []; - return botTypingEntries - .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + const threadComposerWorkingBotPubkeys = useThreadComposerWorkingPubkeys( + activeChannel?.id ?? null, + openThreadHeadId ?? null, + botTypingEntries, + ); const hasThreadComposerBotActivity = - threadComposerBotTypingPubkeys.length > 0; + threadComposerWorkingBotPubkeys.length > 0; const directMessageIntro = React.useMemo( () => buildDirectMessageIntro({ @@ -900,10 +894,11 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null 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..4312548944 --- /dev/null +++ b/desktop/src/features/channels/ui/botActivityStatus.test.mjs @@ -0,0 +1,231 @@ +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"); + }); +}); + +describe("buildStableActivityStatus thread scoping", () => { + it("locks onto the thread's turn when threadRootId is given", () => { + const status = buildStableActivityStatus( + [ + tool({ + sessionId: "aaaa000011112222", + turnId: "t-a", + status: "executing", + descriptor: { preview: "thread-a-work" }, + }), + tool({ + sessionId: "bbbb000011112222", + turnId: "t-b", + status: "executing", + descriptor: { preview: "thread-b-work" }, + }), + ], + CHANNEL, + "aaaa000011112222deadbeefdeadbeef", + ); + assert.equal(status.activity, "Bash: thread-a-work"); + assert.equal(status.toolCount, 1); + }); +}); diff --git a/desktop/src/features/channels/ui/botActivityStatus.ts b/desktop/src/features/channels/ui/botActivityStatus.ts new file mode 100644 index 0000000000..7c1ec3417c --- /dev/null +++ b/desktop/src/features/channels/ui/botActivityStatus.ts @@ -0,0 +1,215 @@ +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, + threadRootId: string | null = null, +): StableActivityStatus { + const channelScoped = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + // Thread bars lock onto their own turn: the emitting harness stamps items + // with the thread root shortened as sessionId, so prefix match selects them. + const scoped = threadRootId + ? channelScoped.filter( + (item) => + typeof item.sessionId === "string" && + item.sessionId.length > 0 && + threadRootId.startsWith(item.sessionId), + ) + : channelScoped; + + 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(" · "); +} diff --git a/desktop/src/features/channels/ui/useThreadComposerWorkingPubkeys.ts b/desktop/src/features/channels/ui/useThreadComposerWorkingPubkeys.ts new file mode 100644 index 0000000000..b81017e032 --- /dev/null +++ b/desktop/src/features/channels/ui/useThreadComposerWorkingPubkeys.ts @@ -0,0 +1,40 @@ +import * as React from "react"; + +import { useThreadWorkingAgentPubkeys } from "@/features/agents/activeAgentTurnsStore"; + +type BotTypingEntry = { pubkey: string; threadHeadId?: string | null }; + +/** + * Working agents for the THREAD composer bar: observer-derived turns scoped to + * the open thread (frame `sessionId` is the thread root shortened, so prefix + * match is the identity test), folded with thread-scoped typing entries — the + * same observer-primary/typing-fallback rule the channel composer bar uses. + */ +export function useThreadComposerWorkingPubkeys( + channelId: string | null, + openThreadHeadId: string | null, + botTypingEntries: readonly BotTypingEntry[], +): string[] { + const observerPubkeys = useThreadWorkingAgentPubkeys( + channelId, + openThreadHeadId, + ); + + return React.useMemo(() => { + const merged = [...observerPubkeys]; + if (openThreadHeadId) { + for (const entry of botTypingEntries) { + if (entry.threadHeadId !== openThreadHeadId) continue; + if ( + !merged.some( + (candidate) => + candidate.toLowerCase() === entry.pubkey.toLowerCase(), + ) + ) { + merged.push(entry.pubkey); + } + } + } + return merged; + }, [botTypingEntries, observerPubkeys, openThreadHeadId]); +}