Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 67 additions & 53 deletions desktop/src/features/channels/ui/BotActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -57,43 +61,54 @@ export function BotActivityComposerAction({
Boolean(singleWorkingAgent),
singleWorkingAgent?.pubkey,
);
const activityHeadlines = React.useMemo(() => {
if (!singleWorkingAgent) {
return [];
}

const seen = new Set<string>();
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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 (
Expand Down
206 changes: 206 additions & 0 deletions desktop/src/features/channels/ui/botActivityStatus.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading