Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e0ef898
fix(activity): group Solo follow-ups under one agent row
johnmatthewtennant Nov 6, 2026
ba5e411
fix(activity): preserve grouped Stop capability and Solo destination
johnmatthewtennant Nov 6, 2026
b5a10ce
test(browser): prepare fixture styles before navigation
johnmatthewtennant Nov 6, 2026
0a61116
fix(activity): keep Solo scope separate from thread labels
johnmatthewtennant Nov 6, 2026
8e9641a
Merge main into Solo Activity fix
johnmatthewtennant Nov 6, 2026
ea20043
Test Solo Activity after live bubble suppression
johnmatthewtennant Nov 6, 2026
9f8b74f
Preserve Solo Activity navigation across thread scopes
johnmatthewtennant Nov 6, 2026
8ecece6
Preserve selected Solo scope when opening its transcript
johnmatthewtennant Nov 7, 2026
158a51b
fix(activity): align in-flow controls with observed thread scope
johnmatthewtennant Nov 7, 2026
6e9219c
Merge main into Solo Activity fix
johnmatthewtennant Nov 10, 2026
23c3056
fix(activity): retain ownership checks for unlinked history
johnmatthewtennant Nov 10, 2026
729f4d0
Merge current main into Solo Activity fix
johnmatthewtennant Nov 10, 2026
8b00f55
Merge plugin-wake activity mapping from main
johnmatthewtennant Nov 10, 2026
b67f5bb
Merge main Stop guidance into Solo Activity
johnmatthewtennant Nov 10, 2026
6946377
Merge current main into Solo Activity
johnmatthewtennant Nov 12, 2026
f7f38e7
fix(activity): keep one live Solo bubble across follow-ups
johnmatthewtennant Nov 12, 2026
325c3de
test(activity): check the active excerpt across renderers
johnmatthewtennant Nov 12, 2026
067b7a7
fix(activity): align Solo bubble and Stop request ownership
johnmatthewtennant Nov 12, 2026
0f2d3a2
fix(activity): exclude observed threads from Solo bubble ownership
johnmatthewtennant Nov 12, 2026
1307fdd
fix(activity): retain pending Solo Stop across owner changes
johnmatthewtennant Nov 12, 2026
942e246
test(activity): wait for popup layout after viewport changes
johnmatthewtennant Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions src/bundled/agent-activity/ActivityAccessory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<div data-testid="first">
<ActivityAccessory
{...f.props}
threadRootId={undefined}
messageLayout="timeline"
/>
</div>
<div data-testid="second">
<ActivityAccessory
{...f.props}
threadRootId={undefined}
messageLayout="timeline"
message={{ ...f.props.message, id: next, createdAt: 2 }}
/>
</div>
</>,
);
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(
<>
<ActivityAccessory {...f.props} messageLayout="thread" />
<ActivityAccessory
{...f.props}
threadRootId={next}
messageLayout="thread"
message={{ ...f.props.message, id: next }}
/>
</>,
);
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(
<>
<div data-testid="thread-root">
<ActivityAccessory
{...f.props}
session={session}
threadRootId={undefined}
messageLayout="timeline"
message={first}
/>
</div>
<div data-testid="follow-up">
<ActivityAccessory
{...f.props}
session={session}
threadRootId={undefined}
messageLayout="timeline"
message={second}
/>
</div>
</>,
);
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();
}
});
50 changes: 46 additions & 4 deletions src/bundled/agent-activity/ActivityAccessory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
type RequestWork,
} from "../../features/agents/request-work";
import {
localActivity,
localWorkLabel,
type WorkStep,
} from "../../features/agents/local-activity";
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -177,17 +202,33 @@ 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) =>
work.agent === entry.agent &&
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 &&
Expand All @@ -197,6 +238,7 @@ function MessageActivity(
work.threadRootId === message.id
),
);
}
const turns = activityTranscript(entry.selected.records, {
agent: entry.agent,
channelId,
Expand Down
96 changes: 60 additions & 36 deletions src/features/agents/current-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 5 additions & 1 deletion src/features/agents/current-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
johnmatthewtennant marked this conversation as resolved.
Resolved
? null
: (localWorkRoot(row) ?? null),
),
),
];
agents.set(agent, {
Expand Down
Loading
Loading