diff --git a/docs/agents.md b/docs/agents.md
index 6c2171f31b..803c70cc9b 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -1291,10 +1291,13 @@ tasks unknown.
### Activity updates in the conversation
-The live conversation indicator remains visible while agents work, even when
-recorded activity is collapsed or expanded. It keeps dots beside current tool
-labels; reduced motion leaves the dots static. Activity text may shimmer too,
-but does not replace that persistent working cue.
+When no live activity bubble on the triggering message shows an agent, the live
+conversation indicator remains visible while it works, even when recorded
+activity is collapsed or expanded. While that bubble shows, it is the agent's
+working indicator and the conversation adds no second one; the conversation cue
+returns once recorded history owns the work. The indicator keeps dots beside
+current tool labels; reduced motion leaves the dots static. Activity text may
+shimmer too, but does not replace that persistent working cue.
When local request evidence is available, old activity does not inherit the
working/thinking state of a different request in the same thread.
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index ad45f58bb1..1ef720fc47 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -719,12 +719,14 @@ available rather than silently discarding it.
not a guarantee that an agent is available or that a message was delivered.
Expand grouped work updates to read each message; the collapsed excerpt names
its speaker. Thread work appears inside its thread; the channel’s reply summary
- still shows when an agent is working there. Dots remain at the end of the
- conversation while agents work, alongside current step details when available.
- With Reduce Motion enabled, the dots stay still. Open the live indicator or
- expand recorded work to view the agent’s activity or **Stop** the running
- request. The live indicator keeps its controls while recorded updates are
- visible. Stop availability depends on the agent type.
+ still shows when an agent is working there. While the activity under your
+ message shows an agent working, it is that agent's only working indicator.
+ Otherwise dots remain at the end of the conversation while agents work,
+ alongside current step details when available. With Reduce Motion enabled,
+ the dots stay still. Open the live indicator or expand recorded work to view
+ the agent’s activity or **Stop** the running request. The live indicator
+ keeps its controls while recorded updates are visible. Stop availability
+ depends on the agent type.
- Agent-to-agent messages stay in the conversation as collapsed history after
work finishes or you reopen a thread. Expand them to read dispatches, handoffs,
and results. The working indicator shows current activity separately.
diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx
index 16d292fcf4..c029e69202 100644
--- a/src/bundled/agent-activity/ActivityAccessory.test.tsx
+++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx
@@ -1688,9 +1688,10 @@ it.each([
name: `View agent activity: Blossom, ${label}`,
}),
).toBeVisible();
+ // The message bubble is the agent's one working indicator here.
expect(
- screen.getByRole("button", { name: "Activity: Blossom working" }),
- ).toBeVisible();
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
expect(screen.getAllByText(label)).toHaveLength(1);
view.rerender(ui([A]));
expect(
@@ -1698,6 +1699,9 @@ it.each([
name: `View agent activity: Blossom, ${label}`,
}),
).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
expect(
screen.queryByRole("button", { name: "Reading notes.txt" }),
).toBeNull();
@@ -1774,6 +1778,58 @@ it.each([false, true])(
},
);
+it("lets a thread root's live bubble replace that thread's indicator", () => {
+ const local = createLocalAgentActivity();
+ const f = fixture(local);
+ const typing = [{ pubkey: A, channelId: "c", threadRootId: root }];
+ const session = {
+ ...f.props.session,
+ typing: { snapshot: () => typing, subscribe: () => () => {} },
+ } as unknown as RelaySession;
+ // MessageRow passes the row's own thread root: none for a thread's root row.
+ const props = { ...f.props, session, threadRootId: undefined };
+ local.begin({
+ scope: "test",
+ agent: A,
+ channelId: "c",
+ messageId: root,
+ threadRootId: root,
+ thread: true,
+ });
+ const ui = (layout: "thread" | "timeline") => (
+
+
+
+
+ );
+ const view = render(ui("thread"));
+ try {
+ expect(
+ screen.getByLabelText("Agent activity on this message"),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
+ // In the channel timeline the root shows no thread bubble, so the thread
+ // keeps its own indicator.
+ view.rerender(ui("timeline"));
+ expect(
+ screen.queryByLabelText("Agent activity on this message"),
+ ).toBeNull();
+ expect(
+ screen.getByRole("button", { name: /Activity: .*working/ }),
+ ).toBeVisible();
+ } finally {
+ view.unmount();
+ f.dispose();
+ }
+});
+
it("shows thinking once across the live bubble, recorded history and conversation indicator", () => {
const local = createLocalAgentActivity();
const f = fixture(local);
@@ -1867,6 +1923,10 @@ it("shows thinking once across the live bubble, recorded history and conversatio
const view = render(ui(false));
try {
expect(screen.getAllByText("Thinking")).toHaveLength(1);
+ // Until history owns the work, the message bubble is its one indicator.
+ expect(
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
view.rerender(ui(true));
expect(
screen.queryByLabelText("Agent activity on this message"),
diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx
index 1a06e241a5..712a51c377 100644
--- a/src/bundled/agent-activity/ActivityAccessory.tsx
+++ b/src/bundled/agent-activity/ActivityAccessory.tsx
@@ -876,11 +876,28 @@ function TypingReplacement({
session,
channelId,
threadRootId,
+ message,
+ messageLayout,
agent,
shared = false,
-}: ComposerAccessoryProps & { agent: string; shared?: boolean }) {
+}: ComposerAccessoryProps & {
+ agent: string;
+ /** Only the live bubble shares its registration. It shows the agent working,
+ * so the conversation shows no second cue (presence). */
+ shared?: boolean;
+}) {
+ // A thread's root row has no thread root of its own, but its bubble shows in
+ // that thread's conversation, so it owns that thread's indicator.
+ const shownIn =
+ threadRootId ?? (messageLayout === "thread" ? message?.id : undefined);
useActivityTypingReplacement(
- { session, channelId, threadRootId, pubkey: agent },
+ {
+ session,
+ channelId,
+ threadRootId: shownIn,
+ pubkey: agent,
+ presence: shared,
+ },
shared,
);
useTypingReplacement(
diff --git a/src/features/conversation/typing-presentation.test.tsx b/src/features/conversation/typing-presentation.test.tsx
index c2210d3b92..f0898de89a 100644
--- a/src/features/conversation/typing-presentation.test.tsx
+++ b/src/features/conversation/typing-presentation.test.tsx
@@ -5,6 +5,7 @@ import type { RelaySession } from "../relay/session";
import {
ActivityIndicatorPresentation,
useActivityTypingReplacement,
+ usePresentedAgents,
useReplacedTypers,
} from "./typing-presentation";
@@ -15,12 +16,14 @@ const session = {} as RelaySession;
function Replacement({
channelId,
participants,
+ presence,
}: {
channelId: string;
participants: readonly string[];
+ presence?: boolean;
}) {
useActivityTypingReplacement(
- { session, channelId, threadRootId: undefined, pubkey: "" },
+ { session, channelId, threadRootId: undefined, pubkey: "", presence },
true,
participants,
);
@@ -50,3 +53,30 @@ it("re-renders a replaced-typers reader only when its conversation's keys change
rerender(view(["one", "four"], ["two", "three"]));
expect([...replaced]).toEqual(["one", "four"]);
});
+
+it("separates rows that show an agent working from rows that own only its details", () => {
+ let replaced: ReadonlySet = new Set();
+ let presented: ReadonlySet = new Set();
+ function Reader() {
+ replaced = useReplacedTypers(session, "alpha", undefined);
+ presented = usePresentedAgents(session, "alpha", undefined);
+ return null;
+ }
+ const view = (presence: boolean) => (
+
+
+
+
+
+
+ );
+ const { rerender } = render(view(true));
+ expect([...replaced].sort()).toEqual(["bubble", "history"]);
+ expect([...presented]).toEqual(["bubble"]);
+ rerender(view(false));
+ expect([...presented]).toEqual([]);
+});
diff --git a/src/features/conversation/typing-presentation.tsx b/src/features/conversation/typing-presentation.tsx
index afd5a96c59..9378ec97e3 100644
--- a/src/features/conversation/typing-presentation.tsx
+++ b/src/features/conversation/typing-presentation.tsx
@@ -16,6 +16,9 @@ type Replacement = Readonly<{
threadRootId: string | undefined;
pubkey: string;
requestId?: string | undefined;
+ /** This mounted row is the agent's live working indicator, not only its
+ * step details: the conversation must not show a second one. */
+ presence?: boolean | undefined;
}>;
function createPresentation() {
const entries = new Map();
@@ -117,7 +120,8 @@ function useReplacement(
) {
const store = context?.store;
const active = context?.active;
- const { session, channelId, threadRootId, pubkey, requestId } = value ?? {};
+ const { session, channelId, threadRootId, pubkey, requestId, presence } =
+ value ?? {};
const participantKeys = (participants ?? (pubkey ? [pubkey] : [])).join(":");
useLayoutEffect(() => {
if (!active || !enabled || !session || !channelId || !participantKeys)
@@ -129,6 +133,7 @@ function useReplacement(
threadRootId,
pubkey,
requestId,
+ presence,
})),
);
}, [
@@ -140,12 +145,30 @@ function useReplacement(
threadRootId,
participantKeys,
requestId,
+ presence,
]);
}
+/** Agents whose step details a mounted row shows in this conversation. */
export function useReplacedTypers(
session: object | undefined,
channelId: string,
threadRootId: string | undefined,
+) {
+ return useScopedReplacements(session, channelId, threadRootId, false);
+}
+/** Agents whose live working indicator a mounted row shows in this conversation. */
+export function usePresentedAgents(
+ session: object | undefined,
+ channelId: string,
+ threadRootId: string | undefined,
+) {
+ return useScopedReplacements(session, channelId, threadRootId, true);
+}
+function useScopedReplacements(
+ session: object | undefined,
+ channelId: string,
+ threadRootId: string | undefined,
+ presenceOnly: boolean,
) {
const context = useContext(Presentation);
const activityStore = useContext(ActivityPresentation);
@@ -173,6 +196,7 @@ export function useReplacedTypers(
],
(entry) =>
!entry.requestId &&
+ (!presenceOnly || !!entry.presence) &&
entry.session === session &&
entry.channelId === channelId &&
entry.threadRootId === threadRootId,
diff --git a/src/features/messages/ChannelTimeline.viewport.test.tsx b/src/features/messages/ChannelTimeline.viewport.test.tsx
index 6d3260f17a..9f4a6a146a 100644
--- a/src/features/messages/ChannelTimeline.viewport.test.tsx
+++ b/src/features/messages/ChannelTimeline.viewport.test.tsx
@@ -20,7 +20,9 @@ import { foldMessages } from "../relay/fold";
import { keypair, message } from "../relay/testing";
import type { ChannelMessage, ChannelWindow } from "../relay/contracts";
import {
+ ActivityIndicatorPresentation,
TypingPresentation,
+ useActivityTypingReplacement,
useTypingReplacement,
} from "../conversation/typing-presentation";
import type { RelaySession } from "../relay/session";
@@ -32,6 +34,20 @@ function RecordedTyping({ session }: { session: RelaySession }) {
);
return null;
}
+/** A message's live bubble registers presence in the conversation's store. */
+function LiveBubble({ session }: { session: RelaySession }) {
+ useActivityTypingReplacement(
+ {
+ session,
+ channelId: "alpha",
+ threadRootId: undefined,
+ pubkey: "agent",
+ presence: true,
+ },
+ true,
+ );
+ return null;
+}
const viewport = vi.hoisted(() => ({
size: 0,
@@ -334,9 +350,13 @@ it.each([1, 4])(
},
);
-it.each([false, true])(
- "keeps ongoing typing in the reply group after completed history (replaced=%s)",
- (replaced) => {
+it.each([
+ [false, false],
+ [true, false],
+ [true, true],
+])(
+ "keeps ongoing typing in the reply group after completed history (replaced=%s, presented=%s)",
+ (replaced, presented) => {
const { owner, updates, content } = activityFixture();
try {
const history = updates(2);
@@ -366,17 +386,31 @@ it.each([false, true])(
},
};
const view = render(
-
- {replaced && }
- {content(
- [...history, { ...reply, id: "reply", content: "Main reply" }],
- undefined,
- true,
- queries,
- )}
- ,
+
+
+ {replaced && !presented && }
+ {presented && }
+ {content(
+ [...history, { ...reply, id: "reply", content: "Main reply" }],
+ undefined,
+ true,
+ queries,
+ )}
+
+ ,
);
expect(screen.queryByText("2 completed steps")).toBeNull();
+ if (presented) {
+ // A live bubble elsewhere shows the agent: no conversation cue, and
+ // the reply does not join one.
+ expect(
+ screen.queryByRole("button", { name: /Activity: .*working/ }),
+ ).toBeNull();
+ expect(
+ screen.getByText("Main reply").closest("li"),
+ ).not.toHaveAttribute("data-stack-next", "true");
+ return;
+ }
expect(
screen.getByRole("button", { name: /Activity: .*working/ }),
).toBeVisible();
diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx
index dd4ecd6ccb..40bf3e33cd 100644
--- a/src/features/messages/TypingIndicator.tsx
+++ b/src/features/messages/TypingIndicator.tsx
@@ -1,6 +1,9 @@
import { useActivityFocusHandoff } from "./use-activity-focus-handoff";
import messageStyles from "./Messages.module.css";
-import { useReplacedTypers } from "../conversation/typing-presentation";
+import {
+ usePresentedAgents,
+ useReplacedTypers,
+} from "../conversation/typing-presentation";
import { useChannelIdentityNames } from "../identity-names/react";
import {
useCallback,
@@ -97,6 +100,7 @@ export function useLiveConversationAgents(
threadRootId: string | undefined,
includeThreads = true,
): readonly string[] {
+ const presented = usePresentedAgents(session, channelId, threadRootId);
const watchChannel = !threadRootId && !includeThreads;
const subscribe = useCallback(
(listener: () => void) => {
@@ -130,7 +134,10 @@ export function useLiveConversationAgents(
includeThreads,
threadRootsOf(channelRowsOf(session, channelId, watchChannel)),
);
- return [...details.keys()].sort().join(",");
+ return [...details.keys()]
+ .filter((key) => !presented.has(key))
+ .sort()
+ .join(",");
};
const key = useSyncExternalStore(subscribe, getKey, getKey);
return useMemo(() => (key ? key.split(",") : []), [key]);
@@ -144,6 +151,7 @@ export function useConversationAgents(
includeThreads = true,
) {
const replaced = useReplacedTypers(session, channelId, threadRootId);
+ const presented = usePresentedAgents(session, channelId, threadRootId);
const entries = useSyncExternalStore(
session.typing.subscribe,
session.typing.snapshot,
@@ -196,8 +204,11 @@ export function useConversationAgents(
getDetails,
getDetails,
);
- // A message may own the step details, but the conversation keeps its busy cue.
- const agents = [...details.keys()].sort();
+ // A message's live bubble is the agent's working indicator. Recorded history
+ // may own only the step details; then the conversation keeps its busy cue.
+ const agents = [...details.keys()]
+ .filter((key) => !presented.has(key))
+ .sort();
return {
activity: (session.agentActivity?.snapshot ?? noActivity)(),
details,
diff --git a/tests/browser/local-agent-activity.spec.mjs b/tests/browser/local-agent-activity.spec.mjs
index e4abcd7a28..c58a701440 100644
--- a/tests/browser/local-agent-activity.spec.mjs
+++ b/tests/browser/local-agent-activity.spec.mjs
@@ -92,14 +92,15 @@ test("local agent receipt, work and failure agree across sidebar, thread and exa
).toHaveCount(0);
await page.evaluate((id) => window.localAgentFixture.handover(id), second.id);
await expect(waiting).toContainText("Reading notes.txt");
- // The real thread tail keeps dots when a message owns the tool details,
- // including without shimmer. jsdom cannot prove CSS visibility.
+ // The message's live bubble is the only working indicator: the thread tail
+ // adds no second one. Its dots stay visible without motion; jsdom cannot
+ // prove CSS visibility.
const liveStep = thread.getByRole("button", {
name: "Activity: Local tester working",
exact: true,
});
- const dots = liveStep.locator('[data-state="working"] > span');
- await expect(liveStep).toBeVisible();
+ await expect(liveStep).toHaveCount(0);
+ const dots = waiting.locator('[data-state="working"] > span');
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(dots).toHaveCount(3);
await expect(dots.first()).toBeVisible();