diff --git a/docs/buzz-help.md b/docs/buzz-help.md index d276859c0a..406c613972 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1158,8 +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. 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..4762b77e14 100644 --- a/src/bundled/agent-activity/ActivityAccessory.test.tsx +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -2701,3 +2701,174 @@ 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(); + } +}); + +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 8ca0fe678e..193005b301 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"; @@ -72,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) { @@ -126,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 @@ -177,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) => @@ -187,7 +209,26 @@ 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 && + !channelThreadRoots.has(work.threadRootId), + ), + }, + channelId, + ).get(entry.agent); + if (current?.messageId !== messageId) return false; + } return local.some( (work) => !work.thread && @@ -197,6 +238,7 @@ function MessageActivity( work.threadRootId === message.id ), ); + } const turns = activityTranscript(entry.selected.records, { agent: entry.agent, channelId, diff --git a/src/features/agents/current-activity.test.ts b/src/features/agents/current-activity.test.ts index 25982543db..94ecaa4f79 100644 --- a/src/features/agents/current-activity.test.ts +++ b/src/features/agents/current-activity.test.ts @@ -273,43 +273,67 @@ 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); +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( - currentActivity( - live, - [], - new Set(), - "channel", - root, - false, - new Set([root]), - ).has(agent), - ).toBe(true); + project({ + ...base, + local: [ + { + scope: "test", + agent, + channelId: "channel", + threadRootId: root, + messageId: root, + state: "working", + startedAt: now, + soloChat: true, + }, + ], + }).get(agent)?.roots, + ).toEqual([null]); }); it("lists each working request here by its exact triggers, local work superseding remote turns", () => { diff --git a/src/features/agents/current-activity.ts b/src/features/agents/current-activity.ts index 3d4c2165f8..9217339a9b 100644 --- a/src/features/agents/current-activity.ts +++ b/src/features/agents/current-activity.ts @@ -168,7 +168,11 @@ export function currentActivity( (!threadRootId || row.threadRootId === threadRootId) && (row.state === "working" || row.state === "waiting"), ) - .map((row) => localWorkRoot(row) ?? null), + .map((row) => + row.soloChat && !row.thread && !threadRootId + ? null + : (localWorkRoot(row) ?? null), + ), ), ]; agents.set(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/agents/work-stop.ts b/src/features/agents/work-stop.ts index 2193a1fe46..9bee9f4e31 100644 --- a/src/features/agents/work-stop.ts +++ b/src/features/agents/work-stop.ts @@ -1,8 +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 { localWorkRoot } from "./local-activity"; +import { localActivity, localWorkRoot, type LocalWork } from "./local-activity"; import { activityTranscript } from "./activity-transcript"; export type WorkStopAgents = Pick; @@ -63,19 +62,38 @@ export function conversationWorkStops( threadRootId: string | undefined, activity: Activity | undefined, agents: WorkStopAgents | undefined, + channelThreadRoots?: ReadonlySet, ): { control: WorkStop; work?: LocalWork; - threadRootId?: string | undefined; + threadRootId?: string | null | undefined; }[] { if (!snapshot) return []; const local = (snapshot.local ?? []).filter( (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( + { + local: local.filter( + (work) => + work.soloChat && + !work.thread && + work.stoppable && + (work.state === "working" || work.state === "waiting"), + ), + }, + channelId, + ).get(agent); const result = local.flatMap((work) => { + const continuous = work.soloChat && !work.thread && !threadRootId; + if (continuous && work !== solo) return []; const control = workStop( { ...work, lifecycle: true, working: work.state === "working" }, work, @@ -83,7 +101,15 @@ export function conversationWorkStops( agents, ); return control - ? [{ control, work, threadRootId: localWorkRoot(work) }] + ? [ + { + control: continuous + ? { ...control, key: JSON.stringify([agent, channelId, null]) } + : control, + work, + threadRootId: continuous ? null : localWorkRoot(work), + }, + ] : []; }); // Local lifecycle evidence owns this agent's state, including settled work. diff --git a/src/features/messages/ActivityGroupPreview.test.tsx b/src/features/messages/ActivityGroupPreview.test.tsx index c66530775d..bbcda4ab2d 100644 --- a/src/features/messages/ActivityGroupPreview.test.tsx +++ b/src/features/messages/ActivityGroupPreview.test.tsx @@ -549,6 +549,93 @@ 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 active = () => + screen.getByText(unlinked.content).closest("[data-active]"); + expect(active()).toBeNull(); + 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(active()).not.toBeNull(); + act(() => current.finish({ ok: true })); + expect(active()).toBeNull(); + } 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/TypingIndicator.stop.test.tsx b/src/features/messages/TypingIndicator.stop.test.tsx index 3f8f2dc93f..957efe6a95 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, @@ -235,3 +239,356 @@ it("identifies each hosted thread and stops only the selected work", 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); + 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, + step: { tool: "Read", target: "follow-up.txt" }, + }, + ]; + f.update([], { local }); + render(); + await user.click( + screen.getByRole("button", { name: "Reading follow-up.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", + "follow-up", + ); +}); + +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", + step: { tool: "Read", target: "follow-up.txt" }, + startedAt: 2, + state: "working", + soloChat: true, + stoppable: true, + }, + ], + }); + render( + , + ); + await user.click( + screen.getByRole("button", { name: "Reading follow-up.txt" }), + ); + await user.click(screen.getByRole("button", { name: "Stop Agent" })); + expect(interrupt).toHaveBeenCalledExactlyOnceWith( + agent, + "channel", + "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(() => true); + 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( + true : undefined} + />, + ); + 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(); + } + 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( + profileActivity + ? profileActivityViewTarget(agent) + : "buzz://channel/channel", + undefined, + ); + if (profileActivity) + expect( + claimProfileTranscript(profileActivityViewTarget(agent) ?? "") !== + undefined, + ).toBe(stoppable); + }, +); + +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, + ); + }, +); + +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); +}); + +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"); +}); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index 9fbff6ce68..70a5bcc173 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -265,6 +265,7 @@ export function useConversationAgents( .sort(); return { activity, + channelThreadRoots: watchChannel ? channelThreadRoots : undefined, details, entries, replaced, @@ -304,6 +305,7 @@ export function TypingIndicator({ }) { const { activity, + channelThreadRoots, details, entries, replaced, @@ -344,6 +346,7 @@ export function TypingIndicator({ threadRootId, session.agentActivity, hostedAgents, + channelThreadRoots, ), ]), ); @@ -385,9 +388,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"; @@ -422,9 +426,9 @@ 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, + workRoot?: string | null, ) => { const detail = workRoot ? currentActivity( @@ -438,14 +442,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 ? localWorkRoot(work) : undefined) ?? - threadRootId ?? - (knownRoots.length === 1 ? knownRoots[0] : undefined); + (work?.soloChat && !work.thread + ? (threadRootId ?? null) + : ((work ? localWorkRoot(work) : undefined) ?? + threadRootId ?? + (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)}`; @@ -477,7 +480,11 @@ export function TypingIndicator({ onOpen={() => navigate( target, - workRoot ? { rootId: workRoot } : activityOrigin(pubkey), + workRoot !== undefined + ? workRoot + ? { rootId: workRoot } + : null + : activityOrigin(pubkey), ) } /> @@ -590,8 +597,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); }) @@ -652,6 +663,7 @@ export function TypingIndicator({ ); } +// A null origin selects known channel work; undefined leaves scope inferred. function AgentChooser({ label, content, @@ -662,11 +674,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); @@ -695,7 +707,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. diff --git a/src/features/messages/use-activity-indicator.ts b/src/features/messages/use-activity-indicator.ts index 4ef5b423b3..033e4eb883 100644 --- a/src/features/messages/use-activity-indicator.ts +++ b/src/features/messages/use-activity-indicator.ts @@ -69,7 +69,6 @@ export function useActivityIndicator( work.channelId === row.channelId && (!row.threadRootId || work.threadRootId === row.threadRootId), ); - if (!requestId) return true; if (local.length) return local.some( (work) => diff --git a/tests/browser/conversation.spec.mjs b/tests/browser/conversation.spec.mjs index 20459e60ca..06df3eda21 100644 --- a/tests/browser/conversation.spec.mjs +++ b/tests/browser/conversation.spec.mjs @@ -134,6 +134,7 @@ test("independent packed author consumer and native-installed contribution survi // The fixture loads every bundled plugin; compile it outside the // navigation budget, which parallel CI builds can otherwise exhaust. await warmUp(server, "/tests/fixtures/conversation.tsx"); + await server.transformRequest("/src/shared/styles/globals.css"); const errors = watchPageErrors(page); await page.route("**/proof-media/**", (route) => route.fulfill({ diff --git a/tests/browser/solo-activity.spec.mjs b/tests/browser/solo-activity.spec.mjs new file mode 100644 index 0000000000..f5bfcbb93e --- /dev/null +++ b/tests/browser/solo-activity.spec.mjs @@ -0,0 +1,129 @@ +import { test, expect } from "./fixture.mjs"; +import { + closeSettings, + openPage, + openSettings, + selectSettingsSection, + sendOnlyTo, +} from "./navigation.mjs"; +import { setPluginEnabled } from "./plugin-settings.mjs"; +import { settle } from "./timeline.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); + 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:/ }), + ).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"), + }); + await expect(activity).toHaveCount(0); + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); + await page + .getByRole("button", { name: "Manage plugins", exact: true }) + .click(); + await setPluginEnabled(page, "Agent Activity", false); + await closeSettings(page); + await activity + .getByRole("button", { name: "Reading follow-up.txt", 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(); + 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`), + }); + } + } + await stop.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => window.localAgentFixture.stopped())) + .toEqual([second]); + await expect(stop).toHaveCount(1); +});