From e0ef8981ac96724353f754f0a720f1a20500a1d6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 15:14:18 -0500 Subject: [PATCH 01/15] fix(activity): group Solo follow-ups under one agent row Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- docs/buzz-help.md | 2 +- src/features/agents/current-activity.test.ts | 20 +++++ src/features/agents/current-activity.ts | 4 +- src/features/agents/work-stop.ts | 19 +++- .../messages/TypingIndicator.stop.test.tsx | 50 +++++++++++ src/features/messages/TypingIndicator.tsx | 2 +- tests/browser/solo-activity.spec.mjs | 87 +++++++++++++++++++ 7 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 tests/browser/solo-activity.spec.mjs diff --git a/docs/buzz-help.md b/docs/buzz-help.md index ad45f58bb1..319d78d202 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -723,7 +723,7 @@ available rather than silently discarding it. 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 + request. In a Solo chat, follow-up messages share one live row per agent. Separate thread work keeps its own row. 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, diff --git a/src/features/agents/current-activity.test.ts b/src/features/agents/current-activity.test.ts index c0ad401f6d..e80ec18318 100644 --- a/src/features/agents/current-activity.test.ts +++ b/src/features/agents/current-activity.test.ts @@ -311,3 +311,23 @@ it("moves channel-started local work into its observed reply thread", () => { ).has(agent), ).toBe(true); }); + +it("keeps top-level Solo requests in the channel conversation", () => { + expect( + project({ + ...base, + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: root, + state: "working", + startedAt: now, + soloChat: true, + }, + ], + }).get(agent)?.roots, + ).toEqual([null]); +}); diff --git a/src/features/agents/current-activity.ts b/src/features/agents/current-activity.ts index 0071a4ddff..a4d7527b0f 100644 --- a/src/features/agents/current-activity.ts +++ b/src/features/agents/current-activity.ts @@ -168,7 +168,9 @@ export function currentActivity( (!threadRootId || row.threadRootId === threadRootId) && (row.state === "working" || row.state === "waiting"), ) - .map((row) => row.threadRootId), + .map((row) => + row.soloChat && !row.thread ? null : row.threadRootId, + ), ), ]; agents.set(agent, { diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index d52e5dae71..c2086b60f0 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -1,7 +1,7 @@ import type { RelaySession } from "../relay/session"; import type { Agents } from "./service"; import type { RequestWork } from "./request-work"; -import type { LocalWork } from "./local-activity"; +import { localActivity, type LocalWork } from "./local-activity"; import { activityTranscript } from "./activity-transcript"; export type WorkStopAgents = Pick; @@ -74,14 +74,29 @@ export function conversationWorkStops( work.channelId === channelId && (!threadRootId || work.threadRootId === threadRootId), ); + // Top-level Solo messages steer one conversation, so they share one control. + const solo = localActivity( + { local: local.filter((work) => work.soloChat && !work.thread) }, + channelId, + ).get(agent); const result = local.flatMap((work) => { + const continuous = work.soloChat && !work.thread; + if (continuous && work !== solo) return []; const control = workStop( { ...work, lifecycle: true, working: work.state === "working" }, work, activity, agents, ); - return control ? [{ control, work, threadRootId: work.threadRootId }] : []; + return control + ? [ + { + control, + work, + threadRootId: continuous ? undefined : work.threadRootId, + }, + ] + : []; }); // Local lifecycle evidence owns this agent's state, including settled work. if (local.length || snapshot.status !== "listening") return result; diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 3f8f2dc93f..0ffebcee65 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -235,3 +235,53 @@ it("identifies each hosted thread and stops only the selected work", async () => }); } }); + +it("shows one Solo conversation row while a follow-up waits in the same agent session", async () => { + const f = fixture(); + const user = userEvent.setup(); + const interrupt = vi.fn(async () => undefined); + const session = { + ...f.session, + agentActivity: { ...f.session.agentActivity, interrupt }, + }; + const local = [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: "first", + startedAt: 1, + state: "working" as const, + soloChat: true as const, + stoppable: true as const, + step: { tool: "Read", target: "first.txt" }, + }, + { + scope: "test", + agent, + channelId: "channel", + threadRootId: secondRoot, + messageId: "follow-up", + startedAt: 2, + state: "working" as const, + soloChat: true as const, + stoppable: true as const, + }, + ]; + f.update([], { local }); + render(); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + expect(screen.getAllByRole("button", { name: /^Stop Agent/ })).toHaveLength( + 1, + ); + expect(screen.queryByText(/Thread ·/)).toBeNull(); + // Actual thread work remains separate even in a Solo channel. + f.update([], { local: local.map((work) => ({ ...work, thread: true })) }); + expect(screen.getAllByRole("button", { name: /^Stop Agent/ })).toHaveLength( + 2, + ); + f.update([], { local }); + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + expect(interrupt).toHaveBeenCalledExactlyOnceWith(agent, "channel", "first"); +}); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index dd4ecd6ccb..a02d69a297 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -378,7 +378,7 @@ export function TypingIndicator({ ); const conversationRoot = workRoot ?? - work?.threadRootId ?? + (work?.soloChat && !work.thread ? undefined : work?.threadRootId) ?? threadRootId ?? (knownRoots.length === 1 ? knownRoots[0] : undefined); const conversation = conversationRoot diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs new file mode 100644 index 0000000000..e9969b3f9a --- /dev/null +++ b/tests/browser/solo-activity.spec.mjs @@ -0,0 +1,87 @@ +import { test, expect } from "./fixture.mjs"; +import { openPage, sendOnlyTo } from "./navigation.mjs"; + +const chat = "33333333-3333-4333-8333-333333333333"; +const agent = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +test.use({ + productionBroker: true, + localAgentActivity: "resumable", + channelIds: [chat], + channelNames: { [chat]: "Project planning" }, + soloChannels: [chat], + ownSoloChannels: [chat], + sessionMembers: { [chat]: [agent] }, + historyCounts: { [chat]: 1 }, +}); + +// App composition: real Solo admission, live work projection and popover controls. +// The fixture holds external work at an explicit handover boundary. +test("Solo follow-ups share one agent Activity row", async ({ + page, + app, +}, testInfo) => { + await page.goto(app.origin); + await openPage(page, "Messages"); + await page + .getByRole("navigation", { name: "Solo conversations" }) + .locator(`[data-solo-channel-id="${chat}"]`) + .click(); + await expect + .poll(() => page.evaluate(() => !!window.localAgentFixture)) + .toBe(true); + await sendOnlyTo( + page, + page.getByRole("button", { name: /Change agents$|^Choose agent$/ }), + { name: "Local tester", pubkey: agent }, + ); + const editor = page.getByRole("textbox", { + name: "Message your agents", + exact: true, + }); + await editor.fill("Review the project plan"); + await editor.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.localAgentFixture.pending().length)) + .toBe(1); + const first = await page.evaluate( + () => window.localAgentFixture.pending()[0], + ); + await page.evaluate((id) => window.localAgentFixture.handover(id), first); + await editor.fill("Also check the schedule"); + await editor.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.localAgentFixture.pending().length)) + .toBe(2); + await page + .getByRole("button", { + name: "Activity: Local tester working", + exact: true, + }) + .click(); + const stop = page.getByRole("button", { + name: "Stop Local tester", + exact: true, + }); + await expect(stop).toHaveCount(1); + await expect(page.getByText(/^Thread ·/)).toHaveCount(0); + for (const colorScheme of ["light", "dark"]) { + await page.emulateMedia({ colorScheme }); + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 720 }); + await expect(stop).toBeVisible(); + const box = await stop.boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(width); + await page.screenshot({ + path: testInfo.outputPath(`solo-activity-${colorScheme}-${width}.png`), + }); + } + } + await stop.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.localAgentFixture.stopped())) + .toEqual([first]); + await expect(stop).toHaveCount(1); +}); From ba5e4114035e22382317e7b8f3ebd4aa7118e416 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 15:46:33 -0500 Subject: [PATCH 02/15] fix(activity): preserve grouped Stop capability and Solo destination Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/agents/work-stop.ts | 10 +- .../messages/TypingIndicator.stop.test.tsx | 97 +++++++++++++++++++ src/features/messages/TypingIndicator.tsx | 8 +- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index c2086b60f0..b31b254709 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -76,7 +76,15 @@ export function conversationWorkStops( ); // Top-level Solo messages steer one conversation, so they share one control. const solo = localActivity( - { local: local.filter((work) => work.soloChat && !work.thread) }, + { + local: local.filter( + (work) => + work.soloChat && + !work.thread && + work.stoppable && + (work.state === "working" || work.state === "waiting"), + ), + }, channelId, ).get(agent); const result = local.flatMap((work) => { diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 0ffebcee65..0478889a7b 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -285,3 +285,100 @@ it("shows one Solo conversation row while a follow-up waits in the same agent se await user.click(screen.getByRole("button", { name: "Stop Agent" })); expect(interrupt).toHaveBeenCalledExactlyOnceWith(agent, "channel", "first"); }); + +it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", async () => { + const f = fixture(); + const user = userEvent.setup(); + const interrupt = vi.fn(async () => undefined); + f.update([], { + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: "older", + startedAt: 1, + state: "working", + soloChat: true, + step: { tool: "Read", target: "first.txt" }, + }, + { + scope: "test", + agent, + channelId: "channel", + threadRootId: secondRoot, + messageId: "stoppable", + startedAt: 2, + state: "working", + soloChat: true, + stoppable: true, + }, + ], + }); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + expect(interrupt).toHaveBeenCalledExactlyOnceWith( + agent, + "channel", + "stoppable", + ); +}); + +it("opens the Solo conversation when the same agent also works in a thread", async () => { + const f = fixture(); + const user = userEvent.setup(); + const openActivity = vi.fn(); + f.update([], { + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: "solo", + startedAt: 1, + state: "working", + soloChat: true, + stoppable: true, + step: { tool: "Read", target: "first.txt" }, + }, + { + scope: "test", + agent, + channelId: "channel", + threadRootId: secondRoot, + messageId: "thread", + startedAt: 2, + state: "working", + soloChat: true, + thread: true, + stoppable: true, + }, + ], + }); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + await user.click( + screen.getByRole("button", { name: "Open conversation for Agent" }), + ); + expect(openActivity).toHaveBeenCalledWith( + "buzz://channel/channel", + undefined, + ); +}); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index a02d69a297..49591d86a1 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -378,9 +378,11 @@ export function TypingIndicator({ ); const conversationRoot = workRoot ?? - (work?.soloChat && !work.thread ? undefined : work?.threadRootId) ?? - threadRootId ?? - (knownRoots.length === 1 ? knownRoots[0] : undefined); + (work?.soloChat && !work.thread + ? null + : (work?.threadRootId ?? + threadRootId ?? + (knownRoots.length === 1 ? knownRoots[0] : undefined))); const conversation = conversationRoot ? `buzz://message?${new URLSearchParams({ channel: channelId, id: conversationRoot, thread: conversationRoot })}` : `buzz://channel/${encodeURIComponent(channelId)}`; From b5a10ce14e81e4e848700ce296c9704e039d34f1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 15:49:15 -0500 Subject: [PATCH 03/15] test(browser): prepare fixture styles before navigation Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- tests/browser/composer-attachments.spec.mjs | 2 ++ tests/browser/conversation.spec.mjs | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/browser/composer-attachments.spec.mjs b/tests/browser/composer-attachments.spec.mjs index 3f0d7fd84c..06c9b01c2a 100644 --- a/tests/browser/composer-attachments.spec.mjs +++ b/tests/browser/composer-attachments.spec.mjs @@ -482,6 +482,7 @@ test("preparation preserves snapshot/animation and uses lossless WebP for pixel }); await server.listen(); try { + await server.transformRequest("/src/shared/styles/globals.css"); await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`); for (const [name, type] of [ ["still.png", "image/png"], @@ -1213,6 +1214,7 @@ test("PNG and JPEG preparation uploads metadata-free browser-encoded bytes", asy }); await server.listen(); try { + await server.transformRequest("/src/shared/styles/globals.css"); await page.goto(`http://127.0.0.1:${server.httpServer.address().port}`); const results = await page.evaluate(async () => { const { prepareAttachment } = await import( diff --git a/tests/browser/conversation.spec.mjs b/tests/browser/conversation.spec.mjs index eb49fc13e4..83163e4720 100644 --- a/tests/browser/conversation.spec.mjs +++ b/tests/browser/conversation.spec.mjs @@ -127,6 +127,7 @@ test("independent packed author consumer and native-installed contribution survi server: { host: "127.0.0.1", port: 0, strictPort: false }, }); await server.listen(); + await server.transformRequest("/src/shared/styles/globals.css"); const errors = watchPageErrors(page); await page.route("**/proof-media/**", (route) => route.fulfill({ From 0a611166590c59c4e3874e7b8f4647061f60a45c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 16:48:09 -0500 Subject: [PATCH 04/15] fix(activity): keep Solo scope separate from thread labels Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/agents/work-stop.ts | 4 ++-- src/features/messages/TypingIndicator.stop.test.tsx | 2 ++ src/features/messages/TypingIndicator.tsx | 13 +++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index b31b254709..b93f1a3cd2 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -65,7 +65,7 @@ export function conversationWorkStops( ): { control: WorkStop; work?: LocalWork; - threadRootId?: string | undefined; + threadRootId?: string | null | undefined; }[] { if (!snapshot) return []; const local = (snapshot.local ?? []).filter( @@ -101,7 +101,7 @@ export function conversationWorkStops( { control, work, - threadRootId: continuous ? undefined : work.threadRootId, + threadRootId: continuous ? null : work.threadRootId, }, ] : []; diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 0478889a7b..31e638534f 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -374,6 +374,8 @@ it("opens the Solo conversation when the same agent also works in a thread", asy />, ); await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + expect(screen.queryByText(/Channel conversation \/ Thread/)).toBeNull(); + expect(screen.getByText("Channel conversation")).toBeVisible(); await user.click( screen.getByRole("button", { name: "Open conversation for Agent" }), ); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 49591d86a1..7ac1525793 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -320,9 +320,10 @@ export function TypingIndicator({ ); const name = (pubkey: string) => resolveName(pubkey, profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)); - const scopeLabel = (pubkey: string, workRoot?: string) => { + const scopeLabel = (pubkey: string, workRoot?: string | null) => { if (threadRootId) return undefined; - const roots = workRoot ? [workRoot] : (details.get(pubkey)?.roots ?? []); + const roots = + workRoot !== undefined ? [workRoot] : (details.get(pubkey)?.roots ?? []); const labels = roots.slice(0, 2).map((root) => { if (root === null) return "Channel conversation"; if (!root) return "Work with unconfirmed thread"; @@ -359,7 +360,7 @@ export function TypingIndicator({ pubkey: string, navigate: (target: string, origin?: MessageLinkOrigin) => void, work?: LocalWork, - workRoot?: string, + workRoot?: string | null, ) => { const detail = workRoot ? currentActivity( @@ -414,7 +415,11 @@ export function TypingIndicator({ onOpen={() => navigate( target, - workRoot ? { rootId: workRoot } : activityOrigin(pubkey), + workRoot !== undefined + ? workRoot + ? { rootId: workRoot } + : undefined + : activityOrigin(pubkey), ) } /> From ea200436659848f4e6771c766a6da4af40e8a269 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 17:45:01 -0500 Subject: [PATCH 05/15] Test Solo Activity after live bubble suppression Verify the live bubble owns the working cue, then exercise grouped Solo Stop controls through the host fallback with Agent Activity disabled. Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- tests/browser/solo-activity.spec.mjs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs index e9969b3f9a..f5931f43de 100644 --- a/tests/browser/solo-activity.spec.mjs +++ b/tests/browser/solo-activity.spec.mjs @@ -1,5 +1,12 @@ import { test, expect } from "./fixture.mjs"; -import { openPage, sendOnlyTo } from "./navigation.mjs"; +import { + closeSettings, + openPage, + openSettings, + selectSettingsSection, + sendOnlyTo, +} from "./navigation.mjs"; +import { setPluginEnabled } from "./plugin-settings.mjs"; const chat = "33333333-3333-4333-8333-333333333333"; const agent = @@ -53,11 +60,22 @@ test("Solo follow-ups share one agent Activity row", async ({ await expect .poll(() => page.evaluate(() => window.localAgentFixture.pending().length)) .toBe(2); + const activity = page.locator("[data-conversation-activity]"); + // Live message bubbles own the working cue. Without that plugin, the host + // fallback still groups continuous Solo work into one actionable row. + await expect( + page.getByRole("button", { name: /^View agent activity:/ }).first(), + ).toBeVisible(); + await expect(activity).toHaveCount(0); + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); await page - .getByRole("button", { - name: "Activity: Local tester working", - exact: true, - }) + .getByRole("button", { name: "Manage plugins", exact: true }) + .click(); + await setPluginEnabled(page, "Agent Activity", false); + await closeSettings(page); + await activity + .getByRole("button", { name: "Reading notes.txt", exact: true }) .click(); const stop = page.getByRole("button", { name: "Stop Local tester", From 9f8b74f4a54f5ea5beb47c814a5c34d9fd05801d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 6 Nov 2026 18:07:10 -0500 Subject: [PATCH 06/15] Preserve Solo Activity navigation across thread scopes Keep observed reply-thread destinations and transcript origins. Treat mixed channel and thread roots as aggregate channel work when no Stop control is available. Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/agents/current-activity.test.ts | 80 ++++----- src/features/agents/current-activity.ts | 4 +- src/features/agents/work-stop.ts | 2 +- .../messages/TypingIndicator.stop.test.tsx | 152 ++++++++++++------ src/features/messages/TypingIndicator.tsx | 7 +- 5 files changed, 150 insertions(+), 95 deletions(-) diff --git a/src/features/agents/current-activity.test.ts b/src/features/agents/current-activity.test.ts index e80ec18318..3fe9a6469c 100644 --- a/src/features/agents/current-activity.test.ts +++ b/src/features/agents/current-activity.test.ts @@ -273,44 +273,48 @@ it("keeps known thread work out of the channel flow without hiding channel work" ).toBe(true); }); -it("moves channel-started local work into its observed reply thread", () => { - const live = { - ...base, - local: [ - { - scope: "test", - agent, - channelId: "channel", - messageId: root, - threadRootId: root, - state: "working" as const, - startedAt: 1, - }, - ], - }; - expect( - currentActivity( - live, - [], - new Set(), - "channel", - undefined, - false, - new Set([root]), - ).size, - ).toBe(0); - expect( - currentActivity( - live, - [], - new Set(), - "channel", - root, - false, - new Set([root]), - ).has(agent), - ).toBe(true); -}); +it.each([false, true])( + "moves channel-started local work into its observed reply thread (Solo: %s)", + (soloChat) => { + const live = { + ...base, + local: [ + { + scope: "test", + agent, + channelId: "channel", + messageId: root, + threadRootId: root, + state: "working" as const, + startedAt: 1, + ...(soloChat ? { soloChat: true as const } : {}), + }, + ], + }; + expect( + currentActivity( + live, + [], + new Set(), + "channel", + undefined, + false, + new Set([root]), + ).size, + ).toBe(0); + expect( + currentActivity( + live, + [], + new Set(), + "channel", + root, + false, + new Set([root]), + ).get(agent)?.roots, + ).toEqual([root]); + }, +); it("keeps top-level Solo requests in the channel conversation", () => { expect( diff --git a/src/features/agents/current-activity.ts b/src/features/agents/current-activity.ts index a4d7527b0f..34cffc38de 100644 --- a/src/features/agents/current-activity.ts +++ b/src/features/agents/current-activity.ts @@ -169,7 +169,9 @@ export function currentActivity( (row.state === "working" || row.state === "waiting"), ) .map((row) => - row.soloChat && !row.thread ? null : row.threadRootId, + row.soloChat && !row.thread && !threadRootId + ? null + : row.threadRootId, ), ), ]; diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index b93f1a3cd2..c0959ad8d5 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -88,7 +88,7 @@ export function conversationWorkStops( channelId, ).get(agent); const result = local.flatMap((work) => { - const continuous = work.soloChat && !work.thread; + const continuous = work.soloChat && !work.thread && !threadRootId; if (continuous && work !== solo) return []; const control = workStop( { ...work, lifecycle: true, working: work.state === "working" }, diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 31e638534f..f137ddd1da 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -334,53 +334,105 @@ it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", asyn ); }); -it("opens the Solo conversation when the same agent also works in a thread", async () => { - const f = fixture(); - const user = userEvent.setup(); - const openActivity = vi.fn(); - f.update([], { - local: [ - { - scope: "test", - agent, - channelId: "channel", - threadRootId: root, - messageId: "solo", - startedAt: 1, - state: "working", - soloChat: true, - stoppable: true, - step: { tool: "Read", target: "first.txt" }, - }, - { - scope: "test", - agent, - channelId: "channel", - threadRootId: secondRoot, - messageId: "thread", - startedAt: 2, - state: "working", - soloChat: true, - thread: true, - stoppable: true, - }, - ], - }); - render( - , - ); - await user.click(screen.getByRole("button", { name: "Reading first.txt" })); - expect(screen.queryByText(/Channel conversation \/ Thread/)).toBeNull(); - expect(screen.getByText("Channel conversation")).toBeVisible(); - await user.click( - screen.getByRole("button", { name: "Open conversation for Agent" }), - ); - expect(openActivity).toHaveBeenCalledWith( - "buzz://channel/channel", - undefined, - ); -}); +it.each([true, false])( + "opens the Solo conversation when the same agent also works in a thread (stoppable: %s)", + async (stoppable) => { + const f = fixture(); + const user = userEvent.setup(); + const openActivity = vi.fn(); + f.update([], { + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: "solo", + startedAt: 1, + state: "working", + soloChat: true, + ...(stoppable ? { stoppable: true as const } : {}), + step: { tool: "Read", target: "first.txt" }, + }, + { + scope: "test", + agent, + channelId: "channel", + threadRootId: secondRoot, + messageId: "thread", + startedAt: 2, + state: "working", + soloChat: true, + thread: true, + ...(stoppable ? { stoppable: true as const } : {}), + }, + ], + }); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + if (stoppable) { + expect(screen.queryByText(/Channel conversation \/ Thread/)).toBeNull(); + expect(screen.getByText("Channel conversation")).toBeVisible(); + } + await user.click( + screen.getByRole("button", { name: "Open conversation for Agent" }), + ); + expect(openActivity).toHaveBeenCalledWith( + "buzz://channel/channel", + undefined, + ); + }, +); + +it.each([false, true])( + "keeps a Solo request in its observed reply thread (profile activity: %s)", + async (profileActivity) => { + const f = fixture(); + const user = userEvent.setup(); + const openActivity = vi.fn(); + f.update([], { + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: root, + startedAt: 1, + state: "working", + soloChat: true, + stoppable: true, + }, + ], + }); + render( + true : undefined} + />, + ); + await user.click( + screen.getByRole("button", { name: "Activity: Agent working" }), + ); + await user.click( + screen.getByRole("button", { + name: profileActivity ? "View Agent activity" : "Open thread for Agent", + }), + ); + expect(openActivity).toHaveBeenCalledWith( + profileActivity + ? expect.any(String) + : `buzz://message?channel=channel&id=${root}&thread=${root}`, + profileActivity ? { rootId: root } : undefined, + ); + }, +); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 6f31e953fd..952b237b69 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -385,16 +385,13 @@ export function TypingIndicator({ if (!detail) return null; const target = profileActivityViewTarget(pubkey); const canOpen = !!target && !!openActivity && !!canOpenActivity?.(target); - const knownRoots = detail.roots.filter( - (value) => typeof value === "string", - ); const conversationRoot = workRoot ?? (work?.soloChat && !work.thread - ? null + ? (threadRootId ?? null) : (work?.threadRootId ?? threadRootId ?? - (knownRoots.length === 1 ? knownRoots[0] : undefined))); + (detail.roots.length === 1 ? detail.roots[0] : undefined))); const conversation = conversationRoot ? `buzz://message?${new URLSearchParams({ channel: channelId, id: conversationRoot, thread: conversationRoot })}` : `buzz://channel/${encodeURIComponent(channelId)}`; From 8ecece6e48e248b7ebe39351b902fbabf3746a94 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 7 Nov 2026 07:32:08 -0500 Subject: [PATCH 07/15] Preserve selected Solo scope when opening its transcript Keep explicit channel scope through the Activity chooser while leaving aggregate mixed-work navigation inferred. Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- .../messages/TypingIndicator.stop.test.tsx | 37 +++++++++++++++---- src/features/messages/TypingIndicator.tsx | 19 ++++++---- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index f137ddd1da..723f2491e2 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -7,6 +7,10 @@ import { AgentsContext } from "../agents/react"; import type { Agents } from "../agents/service"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; import { TypingIndicator } from "./TypingIndicator"; +import { + claimProfileTranscript, + profileActivityViewTarget, +} from "../profiles/target"; import { agent, other, @@ -334,12 +338,17 @@ it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", asyn ); }); -it.each([true, false])( - "opens the Solo conversation when the same agent also works in a thread (stoppable: %s)", - async (stoppable) => { +it.each([ + { stoppable: true, profileActivity: false }, + { stoppable: false, profileActivity: false }, + { stoppable: true, profileActivity: true }, + { stoppable: false, profileActivity: true }, +])( + "opens the Solo conversation when the same agent also works in a thread (stoppable: $stoppable, profile activity: $profileActivity)", + async ({ stoppable, profileActivity }) => { const f = fixture(); const user = userEvent.setup(); - const openActivity = vi.fn(); + const openActivity = vi.fn(() => true); f.update([], { local: [ { @@ -373,6 +382,7 @@ it.each([true, false])( session={f.session} channelId="channel" openActivity={openActivity} + canOpenActivity={profileActivity ? () => true : undefined} />, ); await user.click(screen.getByRole("button", { name: "Reading first.txt" })); @@ -380,13 +390,24 @@ it.each([true, false])( expect(screen.queryByText(/Channel conversation \/ Thread/)).toBeNull(); expect(screen.getByText("Channel conversation")).toBeVisible(); } - await user.click( - screen.getByRole("button", { name: "Open conversation for Agent" }), - ); + const [row] = screen.getAllByRole("button", { + name: profileActivity + ? "View Agent activity" + : "Open conversation for Agent", + }); + if (!row) throw new Error("Missing Activity row"); + await user.click(row); expect(openActivity).toHaveBeenCalledWith( - "buzz://channel/channel", + profileActivity + ? profileActivityViewTarget(agent) + : "buzz://channel/channel", undefined, ); + if (profileActivity) + expect( + claimProfileTranscript(profileActivityViewTarget(agent) ?? "") !== + undefined, + ).toBe(stoppable); }, ); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 952b237b69..0434eddb27 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -369,7 +369,7 @@ export function TypingIndicator({ : undefined; const agentStatus = ( pubkey: string, - navigate: (target: string, origin?: MessageLinkOrigin) => void, + navigate: (target: string, origin?: MessageLinkOrigin | null) => void, work?: LocalWork, workRoot?: string | null, ) => { @@ -426,7 +426,7 @@ export function TypingIndicator({ workRoot !== undefined ? workRoot ? { rootId: workRoot } - : undefined + : null : activityOrigin(pubkey), ) } @@ -540,8 +540,12 @@ export function TypingIndicator({ openActivity && ((target, origin) => { const agent = profileActivityViewKey(target); - const show = (next: string) => openActivity(next, origin); - return agent && (origin?.rootId || knownConversation(agent)) + const show = (next: string) => + openActivity(next, origin ?? undefined); + return agent && + (origin === null || + origin?.rootId || + knownConversation(agent)) ? openProfileTranscript(target, show) : show(target); }) @@ -602,6 +606,7 @@ export function TypingIndicator({ ); } +// A null origin selects known channel work; undefined leaves scope inferred. function AgentChooser({ label, content, @@ -612,11 +617,11 @@ function AgentChooser({ label: string; content: ReactNode; openTarget?: - | ((target: string, origin?: MessageLinkOrigin) => boolean) + | ((target: string, origin?: MessageLinkOrigin | null) => boolean) | undefined; directTarget?: string | undefined; children( - navigate: (target: string, origin?: MessageLinkOrigin) => void, + navigate: (target: string, origin?: MessageLinkOrigin | null) => void, ): ReactNode; }) { const [open, setOpen] = useState(false); @@ -645,7 +650,7 @@ function AgentChooser({ document.addEventListener("keydown", dismiss, true); return () => document.removeEventListener("keydown", dismiss, true); }, [open]); - const navigate = (target: string, origin?: MessageLinkOrigin) => { + const navigate = (target: string, origin?: MessageLinkOrigin | null) => { const previous = document.activeElement; // The popup action unmounts after navigation. Existing panel navigation // captures activeElement, so give it the retained avatar control instead. From 158a51b23866a1f49877a0273f1df8f97af31ce6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 7 Nov 2026 16:18:01 -0500 Subject: [PATCH 08/15] fix(activity): align in-flow controls with observed thread scope Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/agents/work-stop.ts | 6 +- .../messages/TypingIndicator.stop.test.tsx | 63 +++++++++++++++++++ src/features/messages/TypingIndicator.tsx | 3 + 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index c0959ad8d5..0435d500f9 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -62,6 +62,7 @@ export function conversationWorkStops( threadRootId: string | undefined, activity: Activity | undefined, agents: WorkStopAgents | undefined, + channelThreadRoots?: ReadonlySet, ): { control: WorkStop; work?: LocalWork; @@ -72,7 +73,10 @@ export function conversationWorkStops( (work) => work.agent === agent && work.channelId === channelId && - (!threadRootId || work.threadRootId === threadRootId), + (!threadRootId || work.threadRootId === threadRootId) && + (threadRootId || + !channelThreadRoots || + (!work.thread && !channelThreadRoots.has(work.threadRootId))), ); // Top-level Solo messages steer one conversation, so they share one control. const solo = localActivity( diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 723f2491e2..8d134082a3 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -457,3 +457,66 @@ it.each([false, true])( ); }, ); + +it("keeps in-flow Solo status and controls in the same observed-thread scope", async () => { + const f = fixture(); + const user = userEvent.setup(); + const interrupt = vi.fn(async () => undefined); + const window = f.session.channels.window("channel"); + const rows = window.rows.map((row) => ({ + ...row, + replyCount: row.id === root ? 1 : 0, + })); + const session = { + ...f.session, + channels: { ...f.session.channels, window: () => ({ ...window, rows }) }, + agentActivity: { ...f.session.agentActivity, interrupt }, + } as typeof f.session; + const running = { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: root, + startedAt: 1, + state: "working" as const, + soloChat: true as const, + stoppable: true as const, + }; + f.update([], { + local: [ + { ...running, step: { tool: "Read", target: "first.txt" } }, + { + ...running, + threadRootId: secondRoot, + messageId: secondRoot, + startedAt: 2, + state: "waiting", + }, + ], + }); + const view = render( + , + ); + const trigger = screen.getByRole("button", { + name: "Activity: Agent working", + }); + expect(trigger.querySelector('[data-state="waiting"]')).not.toBeNull(); + await user.click(trigger); + expect(screen.getByText("Received · waiting to start")).toBeVisible(); + expect(screen.queryByText("Reading first.txt")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + expect(interrupt).toHaveBeenLastCalledWith(agent, "channel", secondRoot); + view.unmount(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + expect(interrupt).toHaveBeenLastCalledWith(agent, "channel", root); +}); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 0434eddb27..a7b024281c 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -211,6 +211,7 @@ export function useConversationAgents( .sort(); return { activity: (session.agentActivity?.snapshot ?? noActivity)(), + channelThreadRoots: watchChannel ? channelThreadRoots : undefined, details, entries, replaced, @@ -250,6 +251,7 @@ export function TypingIndicator({ }) { const { activity, + channelThreadRoots, details, entries, replaced, @@ -290,6 +292,7 @@ export function TypingIndicator({ threadRootId, session.agentActivity, hostedAgents, + channelThreadRoots, ), ]), ); From 23c3056ed8cf4e40c15a5fd37b73aae18eab8947 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 10 Nov 2026 06:49:04 -0800 Subject: [PATCH 09/15] fix(activity): retain ownership checks for unlinked history Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- .../messages/ActivityGroupPreview.test.tsx | 86 +++++++++++++++++++ .../messages/use-activity-indicator.ts | 1 - 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/features/messages/ActivityGroupPreview.test.tsx b/src/features/messages/ActivityGroupPreview.test.tsx index c66530775d..795c14f251 100644 --- a/src/features/messages/ActivityGroupPreview.test.tsx +++ b/src/features/messages/ActivityGroupPreview.test.tsx @@ -549,6 +549,92 @@ it("keeps a dispatch readable after its sender finishes, while the helper works f.dispose(); } }); +it.each(["standalone", "grouped"] as const)( + "keeps unlinked %s Solo history idle during another request", + (surface) => { + const f = fixture(); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: keypair().pubkey, + media: () => undefined, + query: async () => [], + }); + const { threadRootId: _root, replyParentId: _parent, ...unlinked } = step; + const rows = + surface === "standalone" + ? [unlinked] + : [ + { ...unlinked, id: "earlier-step", content: "Earlier update" }, + unlinked, + ]; + function Preview() { + const presentation = useActivityPresentation( + rows, + f.activity, + new Set(["agent"]), + ); + const group = presentation.groups[0]; + if (!group) throw new Error("Missing activity group"); + expect(group.requestId).toBeUndefined(); + const session = { ...owner.session, agentActivity: f.activity }; + return surface === "standalone" ? ( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + /> + ) : ( + undefined} + expanded={false} + onToggle={() => {}} + /> + ); + } + try { + const unrelated = f.local.begin({ + scope: "test", + agent: "agent", + channelId: "channel", + messageId: "other-request", + threadRootId: "other-request", + soloChat: true, + }); + unrelated.started(); + render(); + const text = () => screen.getByText(unlinked.content); + expect(text()).not.toHaveAttribute("data-active"); + act(() => unrelated.finish({ ok: true })); + let current: ReturnType; + act(() => { + current = f.local.begin({ + scope: "test", + agent: "agent", + channelId: "channel", + messageId: unlinked.id, + threadRootId: unlinked.id, + soloChat: true, + }); + current.started(); + }); + expect(text()).toHaveAttribute("data-active", "true"); + act(() => current.finish({ ok: true })); + expect(text()).not.toHaveAttribute("data-active"); + } finally { + owner.dispose(); + f.dispose(); + } + }, +); + it("shimmers a standalone working step and stops when that request finishes", () => { const f = fixture(); const owner = createRelaySession({ diff --git a/src/features/messages/use-activity-indicator.ts b/src/features/messages/use-activity-indicator.ts index 9967fc6ebf..2a50c670a3 100644 --- a/src/features/messages/use-activity-indicator.ts +++ b/src/features/messages/use-activity-indicator.ts @@ -54,7 +54,6 @@ export function useActivityIndicator( (!row.threadRootId || work.threadRootId === row.threadRootId), ); return ( - !requestId || !local.length || local.some( (work) => From f7f38e7e46b34d8dfc615931fd56c3c7672ab5d7 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 16:56:45 -0500 Subject: [PATCH 10/15] fix(activity): keep one live Solo bubble across follow-ups Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- docs/buzz-help.md | 2 +- .../agent-activity/ActivityAccessory.test.tsx | 105 ++++++++++++++++++ .../agent-activity/ActivityAccessory.tsx | 20 +++- tests/browser/solo-activity.spec.mjs | 9 +- 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 5e73f86bdb..406c613972 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1158,7 +1158,7 @@ available rather than silently discarding it. its activity; hover or focus the bubble to reveal **Stop** (always visible on touch screens). For several agents, open or hover the bubble to choose an agent’s activity or stop an individual request. Expand recorded work for its - activity controls. In a Solo chat, follow-up messages share one live row per agent. Separate thread work keeps its own row. Stop availability depends on the agent type. + activity controls. In a Solo chat, follow-ups share one live bubble per agent: the newest working request carries it, or the newest waiting request when none is working. The fallback Activity chooser also groups these requests into one row per agent. Separate thread work and request failures keep their own indicators. Stop availability depends on the agent type. For native Claude, Codex and Goose, Stop also cancels pending plugin tool calls that use the host signal. It cannot undo a write already sent. - Agent-to-agent messages stay in the conversation as collapsed history after diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx index 79c39df74b..8800fa24e9 100644 --- a/src/bundled/agent-activity/ActivityAccessory.test.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -2701,3 +2701,108 @@ it.each([ } }, ); + +it("keeps one Solo bubble on the newest working request, preserving other agents and outcomes", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + const begin = (agent: string, messageId: string) => + local.begin({ + scope: "test", + channelId: "c", + threadRootId: messageId, + messageId, + agent, + soloChat: true, + }); + const first = begin(A, root); + first.started(); + const other = begin(B, root); + other.started(); + const second = begin(A, next); + const view = render( + <> +
+ +
+
+ +
+ , + ); + try { + // A queued follow-up does not duplicate the current worker. + expect(screen.getByTestId("second")).toBeEmptyDOMElement(); + act(() => second.started()); + expect( + within(screen.getByTestId("first")).getByRole("button", { + name: /^View agent activity: Bubbles/, + }), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("second")).getByRole("button", { + name: /^View agent activity: Blossom/, + }), + ).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: /^View agent activity: Blossom/ }), + ).toHaveLength(1); + // The earlier request still has an independent outcome, not a fake success. + act(() => + first.finish({ + ok: false, + error: "Could not complete the earlier request", + }), + ); + expect(screen.getByTestId("first")).toHaveTextContent("Couldn’t finish"); + act(() => second.finish({ ok: true })); + expect(screen.getByTestId("second")).toBeEmptyDOMElement(); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("preserves separate working bubbles for actual Solo threads", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + for (const messageId of [root, next]) + local + .begin({ + scope: "test", + channelId: "c", + threadRootId: messageId, + messageId, + agent: A, + soloChat: true, + thread: true, + }) + .started(); + const view = render( + <> + + + , + ); + try { + expect( + screen.getAllByRole("button", { name: /^View agent activity: Blossom/ }), + ).toHaveLength(2); + } finally { + view.unmount(); + f.dispose(); + } +}); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index 8ca0fe678e..042f837986 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -54,6 +54,7 @@ import { type RequestWork, } from "../../features/agents/request-work"; import { + localActivity, localWorkLabel, type WorkStep, } from "../../features/agents/local-activity"; @@ -187,7 +188,23 @@ function MessageActivity( work.channelId === channelId && work.messageId === messageId, ); - if (local.length) + if (local.length) { + // Solo follow-ups steer one conversation. Its newest working request + // owns the bubble; waiting follow-ups and older requests add no copy. + if ( + (entry.state === "working" || entry.state === "waiting") && + local.some((work) => work.soloChat && !work.thread) + ) { + const current = localActivity( + { + local: (snapshot.local ?? []) + .filter((work) => work.soloChat && !work.thread) + .reverse(), + }, + channelId, + ).get(entry.agent); + if (current?.messageId !== messageId) return false; + } return local.some( (work) => !work.thread && @@ -197,6 +214,7 @@ function MessageActivity( work.threadRootId === message.id ), ); + } const turns = activityTranscript(entry.selected.records, { agent: entry.agent, channelId, diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs index f5931f43de..95c2c99380 100644 --- a/tests/browser/solo-activity.spec.mjs +++ b/tests/browser/solo-activity.spec.mjs @@ -7,6 +7,7 @@ import { sendOnlyTo, } from "./navigation.mjs"; import { setPluginEnabled } from "./plugin-settings.mjs"; +import { settle } from "./timeline.mjs"; const chat = "33333333-3333-4333-8333-333333333333"; const agent = @@ -64,8 +65,12 @@ test("Solo follow-ups share one agent Activity row", async ({ // Live message bubbles own the working cue. Without that plugin, the host // fallback still groups continuous Solo work into one actionable row. await expect( - page.getByRole("button", { name: /^View agent activity:/ }).first(), - ).toBeVisible(); + page.getByRole("button", { name: /^View agent activity:/ }), + ).toHaveCount(1); + await settle(page); + await page.screenshot({ + path: testInfo.outputPath("solo-followups-message-bubble.png"), + }); await expect(activity).toHaveCount(0); await openSettings(page); await selectSettingsSection(page, "Manage plugins"); From 325c3de1aec2c44efd2a0d0c38dfcd611c3a51f3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 17:14:46 -0500 Subject: [PATCH 11/15] test(activity): check the active excerpt across renderers Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/messages/ActivityGroupPreview.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/features/messages/ActivityGroupPreview.test.tsx b/src/features/messages/ActivityGroupPreview.test.tsx index 795c14f251..bbcda4ab2d 100644 --- a/src/features/messages/ActivityGroupPreview.test.tsx +++ b/src/features/messages/ActivityGroupPreview.test.tsx @@ -610,8 +610,9 @@ it.each(["standalone", "grouped"] as const)( }); unrelated.started(); render(); - const text = () => screen.getByText(unlinked.content); - expect(text()).not.toHaveAttribute("data-active"); + const active = () => + screen.getByText(unlinked.content).closest("[data-active]"); + expect(active()).toBeNull(); act(() => unrelated.finish({ ok: true })); let current: ReturnType; act(() => { @@ -625,9 +626,9 @@ it.each(["standalone", "grouped"] as const)( }); current.started(); }); - expect(text()).toHaveAttribute("data-active", "true"); + expect(active()).not.toBeNull(); act(() => current.finish({ ok: true })); - expect(text()).not.toHaveAttribute("data-active"); + expect(active()).toBeNull(); } finally { owner.dispose(); f.dispose(); From 067b7a7f670ec2bd254f69d1302ea241649a3107 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 18:16:31 -0500 Subject: [PATCH 12/15] fix(activity): align Solo bubble and Stop request ownership Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- .../agent-activity/ActivityAccessory.tsx | 6 +++--- src/features/agents/local-activity.test.ts | 20 +++++++++++++++++++ src/features/agents/local-activity.ts | 12 ++++++++++- .../messages/TypingIndicator.stop.test.tsx | 18 +++++++++++++---- tests/browser/solo-activity.spec.mjs | 17 ++++++++++++++-- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index 042f837986..041d22139f 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -197,9 +197,9 @@ function MessageActivity( ) { const current = localActivity( { - local: (snapshot.local ?? []) - .filter((work) => work.soloChat && !work.thread) - .reverse(), + local: (snapshot.local ?? []).filter( + (work) => work.soloChat && !work.thread, + ), }, channelId, ).get(entry.agent); diff --git a/src/features/agents/local-activity.test.ts b/src/features/agents/local-activity.test.ts index df1605d7b4..b50256fea3 100644 --- a/src/features/agents/local-activity.test.ts +++ b/src/features/agents/local-activity.test.ts @@ -549,3 +549,23 @@ it("retries through the session only for this scope and accessible channels", () "This request can't be retried here", ); }); + +it.each(["waiting", "working"] as const)( + "uses the newest %s Solo request across live projections", + (state) => { + const activity = createLocalAgentActivity(); + const first = activity.begin({ ...request("first"), soloChat: true }); + const second = activity.begin({ ...request("second"), soloChat: true }); + if (state === "working") { + first.started(); + second.started(); + } + const snapshot = { local: activity.reader.work() }; + expect(localActivity(snapshot, "c").get("a")?.messageId).toBe("second"); + // A concrete thread retains its request-specific selection. + expect( + localActivity(snapshot, "c", snapshot.local[0]?.threadRootId).get("a") + ?.messageId, + ).toBe("first"); + }, +); diff --git a/src/features/agents/local-activity.ts b/src/features/agents/local-activity.ts index 937f04b99a..cf1d9e1196 100644 --- a/src/features/agents/local-activity.ts +++ b/src/features/agents/local-activity.ts @@ -464,7 +464,17 @@ export function localActivity( }; for (const row of rows) { const previous = agents.get(row.agent); - if (!previous || rank[row.state] > rank[previous.state]) + if ( + !previous || + rank[row.state] > rank[previous.state] || + (row.state === previous.state && + (row.state === "working" || row.state === "waiting") && + !threadRootId && + row.soloChat && + previous.soloChat && + !row.thread && + !previous.thread) + ) agents.set(row.agent, row); } return agents; diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 8d134082a3..6c2cda0fa8 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -240,7 +240,7 @@ it("identifies each hosted thread and stops only the selected work", async () => } }); -it("shows one Solo conversation row while a follow-up waits in the same agent session", async () => { +it("uses the newest working Solo request for its one row and Stop target", async () => { const f = fixture(); const user = userEvent.setup(); const interrupt = vi.fn(async () => undefined); @@ -271,11 +271,14 @@ it("shows one Solo conversation row while a follow-up waits in the same agent se state: "working" as const, soloChat: true as const, stoppable: true as const, + step: { tool: "Read", target: "follow-up.txt" }, }, ]; f.update([], { local }); render(); - await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + await user.click( + screen.getByRole("button", { name: "Reading follow-up.txt" }), + ); expect(screen.getAllByRole("button", { name: /^Stop Agent/ })).toHaveLength( 1, ); @@ -287,7 +290,11 @@ it("shows one Solo conversation row while a follow-up waits in the same agent se ); f.update([], { local }); await user.click(screen.getByRole("button", { name: "Stop Agent" })); - expect(interrupt).toHaveBeenCalledExactlyOnceWith(agent, "channel", "first"); + expect(interrupt).toHaveBeenCalledExactlyOnceWith( + agent, + "channel", + "follow-up", + ); }); it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", async () => { @@ -313,6 +320,7 @@ it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", asyn channelId: "channel", threadRootId: secondRoot, messageId: "stoppable", + step: { tool: "Read", target: "follow-up.txt" }, startedAt: 2, state: "working", soloChat: true, @@ -329,7 +337,9 @@ it("keeps Stop for a stoppable Solo follow-up when older work cannot stop", asyn channelId="channel" />, ); - await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + await user.click( + screen.getByRole("button", { name: "Reading follow-up.txt" }), + ); await user.click(screen.getByRole("button", { name: "Stop Agent" })); expect(interrupt).toHaveBeenCalledExactlyOnceWith( agent, diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs index 95c2c99380..a1a63597ea 100644 --- a/tests/browser/solo-activity.spec.mjs +++ b/tests/browser/solo-activity.spec.mjs @@ -67,6 +67,19 @@ test("Solo follow-ups share one agent Activity row", async ({ await expect( page.getByRole("button", { name: /^View agent activity:/ }), ).toHaveCount(1); + const second = await page.evaluate( + () => window.localAgentFixture.pending()[1], + ); + await page.evaluate((id) => { + window.localAgentFixture.handover(id); + window.localAgentFixture.step(id, "Read", "follow-up.txt"); + }, second); + await expect( + page.getByRole("button", { + name: "View agent activity: Local tester, Reading follow-up.txt", + exact: true, + }), + ).toHaveCount(1); await settle(page); await page.screenshot({ path: testInfo.outputPath("solo-followups-message-bubble.png"), @@ -80,7 +93,7 @@ test("Solo follow-ups share one agent Activity row", async ({ await setPluginEnabled(page, "Agent Activity", false); await closeSettings(page); await activity - .getByRole("button", { name: "Reading notes.txt", exact: true }) + .getByRole("button", { name: "Reading follow-up.txt", exact: true }) .click(); const stop = page.getByRole("button", { name: "Stop Local tester", @@ -105,6 +118,6 @@ test("Solo follow-ups share one agent Activity row", async ({ await page.keyboard.press("Enter"); await expect .poll(() => page.evaluate(() => window.localAgentFixture.stopped())) - .toEqual([first]); + .toEqual([second]); await expect(stop).toHaveCount(1); }); From 0f2d3a25a0081d95009736b02ef1068a91573a60 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 18:26:58 -0500 Subject: [PATCH 13/15] fix(activity): exclude observed threads from Solo bubble ownership Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- .../agent-activity/ActivityAccessory.test.tsx | 66 +++++++++++++++++++ .../agent-activity/ActivityAccessory.tsx | 32 +++++++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx index 8800fa24e9..4762b77e14 100644 --- a/src/bundled/agent-activity/ActivityAccessory.test.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -2806,3 +2806,69 @@ it("preserves separate working bubbles for actual Solo threads", () => { f.dispose(); } }); + +it("keeps the Solo follow-up bubble when older work belongs to an observed thread", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + const first = { ...f.props.message, replyCount: 1 }; + const second = { ...f.props.message, id: next, createdAt: 2 }; + const rows = [first, second]; + const session = { + ...f.props.session, + channels: { + window: () => ({ rows }), + subscribeWindow: () => () => {}, + }, + } as unknown as RelaySession; + local + .begin({ + scope: "test", + channelId: "c", + threadRootId: root, + messageId: root, + agent: A, + soloChat: true, + }) + .started(); + local.begin({ + scope: "test", + channelId: "c", + threadRootId: next, + messageId: next, + agent: A, + soloChat: true, + }); + const view = render( + <> +
+ +
+
+ +
+ , + ); + try { + expect(screen.getByTestId("thread-root")).toBeEmptyDOMElement(); + expect( + within(screen.getByTestId("follow-up")).getByRole("button", { + name: /^View agent activity: Blossom/, + }), + ).toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx index 041d22139f..193005b301 100644 --- a/src/bundled/agent-activity/ActivityAccessory.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -73,6 +73,7 @@ const noSubscribe = () => () => {}; export type ActivityAgents = WorkStopAgents; type ActivityProps = ComposerAccessoryProps & { agents?: ActivityAgents }; const noWork: RequestWork[] = []; +const noRows: readonly { id: string; replyCount: number }[] = []; /** Activity belongs to the exact triggering message, not the composer. Once an * agent has publicly replied, its outcome follows that reply instead. */ export function ActivityAccessory(props: ActivityProps) { @@ -127,6 +128,29 @@ function MessageActivity( hasWork ? session.agentActivity.subscribe : noSubscribe, session.agentActivity.snapshot, ); + const threadScoped = + props.threadScoped ?? + (props.messageLayout === "thread" || !!message?.threadRootId); + const subscribeChannel = useCallback( + (listener: () => void) => + hasWork && !threadScoped + ? (session.channels?.subscribeWindow?.(channelId, listener) ?? + noSubscribe()) + : noSubscribe(), + [session, channelId, hasWork, threadScoped], + ); + const channelRows = useSyncExternalStore(subscribeChannel, () => + hasWork && !threadScoped + ? (session.channels?.window?.(channelId).rows ?? noRows) + : noRows, + ); + const channelThreadRoots = useMemo( + () => + new Set( + channelRows.filter((row) => row.replyCount > 0).map((row) => row.id), + ), + [channelRows], + ); const work = useMemo( () => messageId && hasWork @@ -178,9 +202,6 @@ function MessageActivity( const visible = (entry: RequestWork) => { if (!canOpen(profileActivityViewTarget(entry.agent) ?? "")) return false; if (recorded.has(entry.agent)) return false; - const threadScoped = - props.threadScoped ?? - (props.messageLayout === "thread" || !!message?.threadRootId); if (!threadScoped) { const local = (snapshot.local ?? []).filter( (work) => @@ -198,7 +219,10 @@ function MessageActivity( const current = localActivity( { local: (snapshot.local ?? []).filter( - (work) => work.soloChat && !work.thread, + (work) => + work.soloChat && + !work.thread && + !channelThreadRoots.has(work.threadRootId), ), }, channelId, From 1307fdd9aabf76d4bec87cc46938e2806c437344 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 18:33:49 -0500 Subject: [PATCH 14/15] fix(activity): retain pending Solo Stop across owner changes Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/features/agents/work-stop.ts | 4 +- .../messages/TypingIndicator.stop.test.tsx | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/features/agents/work-stop.ts b/src/features/agents/work-stop.ts index 2cf3b589a2..9bee9f4e31 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -103,7 +103,9 @@ export function conversationWorkStops( return control ? [ { - control, + control: continuous + ? { ...control, key: JSON.stringify([agent, channelId, null]) } + : control, work, threadRootId: continuous ? null : localWorkRoot(work), }, diff --git a/src/features/messages/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 6c2cda0fa8..957efe6a95 100644 --- a/src/features/messages/TypingIndicator.stop.test.tsx +++ b/src/features/messages/TypingIndicator.stop.test.tsx @@ -530,3 +530,65 @@ it("keeps in-flow Solo status and controls in the same observed-thread scope", a await user.click(screen.getByRole("button", { name: "Stop Agent" })); expect(interrupt).toHaveBeenLastCalledWith(agent, "channel", root); }); + +it("keeps a grouped Solo Stop pending when its request owner changes", async () => { + const f = fixture(); + const user = userEvent.setup(); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const interrupt = vi.fn(() => pending); + const session = { + ...f.session, + agentActivity: { ...f.session.agentActivity, interrupt }, + }; + const local = [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: "first", + startedAt: 1, + state: "working" as const, + soloChat: true as const, + stoppable: true as const, + step: { tool: "Read", target: "first.txt" }, + }, + { + scope: "test", + agent, + channelId: "channel", + threadRootId: secondRoot, + messageId: "second", + startedAt: 2, + state: "waiting" as const, + soloChat: true as const, + stoppable: true as const, + }, + ]; + f.update([], { local }); + render(); + await user.click(screen.getByRole("button", { name: "Reading first.txt" })); + try { + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + f.update([], { + local: local.map((work) => ({ ...work, state: "working" as const })), + }); + const stop = screen.getByRole("button", { name: "Stop Agent" }); + expect(stop).toHaveAttribute("aria-disabled", "true"); + expect(stop).toHaveTextContent("Stopping"); + await user.click(stop); + expect(interrupt).toHaveBeenCalledExactlyOnceWith( + agent, + "channel", + "first", + ); + } finally { + await act(async () => release()); + } + expect( + screen.getByRole("button", { name: "Stop Agent" }), + ).not.toHaveAttribute("aria-disabled", "true"); +}); From 942e2465d9337756aeeebc90c01585afecd022a1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 12 Nov 2026 18:37:26 -0500 Subject: [PATCH 15/15] test(activity): wait for popup layout after viewport changes Co-authored-by: Leona <223be3431f004b70add8b6504a977b2da75f6fa73fb3ef2fa0ddae86f8643409@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- tests/browser/solo-activity.spec.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs index a1a63597ea..f5bfcbb93e 100644 --- a/tests/browser/solo-activity.spec.mjs +++ b/tests/browser/solo-activity.spec.mjs @@ -106,9 +106,15 @@ test("Solo follow-ups share one agent Activity row", async ({ for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 720 }); await expect(stop).toBeVisible(); - const box = await stop.boundingBox(); - expect(box.x).toBeGreaterThanOrEqual(0); - expect(box.x + box.width).toBeLessThanOrEqual(width); + await expect + .poll(async () => (await stop.boundingBox())?.x ?? -1) + .toBeGreaterThanOrEqual(0); + await expect + .poll(async () => { + const box = await stop.boundingBox(); + return box ? box.x + box.width : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(width); await page.screenshot({ path: testInfo.outputPath(`solo-activity-${colorScheme}-${width}.png`), });