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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -1291,10 +1291,13 @@ tasks unknown.

### Activity updates in the conversation

The live conversation indicator remains visible while agents work, even when
recorded activity is collapsed or expanded. It keeps dots beside current tool
labels; reduced motion leaves the dots static. Activity text may shimmer too,
but does not replace that persistent working cue.
When no live activity bubble on the triggering message shows an agent, the live
conversation indicator remains visible while it works, even when recorded
activity is collapsed or expanded. While that bubble shows, it is the agent's
working indicator and the conversation adds no second one; the conversation cue
returns once recorded history owns the work. The indicator keeps dots beside
current tool labels; reduced motion leaves the dots static. Activity text may
shimmer too, but does not replace that persistent working cue.
When local request evidence is available, old activity does not inherit the
working/thinking state of a different request in the same thread.

Expand Down
14 changes: 8 additions & 6 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,12 +719,14 @@ available rather than silently discarding it.
not a guarantee that an agent is available or that a message was delivered.
Expand grouped work updates to read each message; the collapsed excerpt names
its speaker. Thread work appears inside its thread; the channel’s reply summary
still shows when an agent is working there. Dots remain at the end of the
conversation while agents work, alongside current step details when available.
With Reduce Motion enabled, the dots stay still. Open the live indicator or
expand recorded work to view the agent’s activity or **Stop** the running
request. The live indicator keeps its controls while recorded updates are
visible. Stop availability depends on the agent type.
still shows when an agent is working there. While the activity under your
message shows an agent working, it is that agent's only working indicator.
Otherwise dots remain at the end of the conversation while agents work,
alongside current step details when available. With Reduce Motion enabled,
the dots stay still. Open the live indicator or expand recorded work to view
the agent’s activity or **Stop** the running request. The live indicator
keeps its controls while recorded updates are visible. Stop availability
depends on the agent type.
- Agent-to-agent messages stay in the conversation as collapsed history after
work finishes or you reopen a thread. Expand them to read dispatches, handoffs,
and results. The working indicator shows current activity separately.
Expand Down
64 changes: 62 additions & 2 deletions src/bundled/agent-activity/ActivityAccessory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1688,16 +1688,20 @@ it.each([
name: `View agent activity: Blossom, ${label}`,
}),
).toBeVisible();
// The message bubble is the agent's one working indicator here.
expect(
screen.getByRole("button", { name: "Activity: Blossom working" }),
).toBeVisible();
screen.queryByRole("button", { name: /Activity: .*working/ }),
).toBeNull();
expect(screen.getAllByText(label)).toHaveLength(1);
view.rerender(ui([A]));
expect(
screen.getByRole("button", {
name: `View agent activity: Blossom, ${label}`,
}),
).toBeVisible();
expect(
screen.queryByRole("button", { name: /Activity: .*working/ }),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Reading notes.txt" }),
).toBeNull();
Expand Down Expand Up @@ -1774,6 +1778,58 @@ it.each([false, true])(
},
);

it("lets a thread root's live bubble replace that thread's indicator", () => {
const local = createLocalAgentActivity();
const f = fixture(local);
const typing = [{ pubkey: A, channelId: "c", threadRootId: root }];
const session = {
...f.props.session,
typing: { snapshot: () => typing, subscribe: () => () => {} },
} as unknown as RelaySession;
// MessageRow passes the row's own thread root: none for a thread's root row.
const props = { ...f.props, session, threadRootId: undefined };
local.begin({
scope: "test",
agent: A,
channelId: "c",
messageId: root,
threadRootId: root,
thread: true,
});
const ui = (layout: "thread" | "timeline") => (
<ActivityIndicatorPresentation>
<ActivityAccessory {...props} messageLayout={layout} />
<TypingIndicator
session={session}
channelId="c"
threadRootId={root}
inFlow
/>
</ActivityIndicatorPresentation>
);
const view = render(ui("thread"));
try {
expect(
screen.getByLabelText("Agent activity on this message"),
).toBeVisible();
expect(
screen.queryByRole("button", { name: /Activity: .*working/ }),
).toBeNull();
// In the channel timeline the root shows no thread bubble, so the thread
// keeps its own indicator.
view.rerender(ui("timeline"));
expect(
screen.queryByLabelText("Agent activity on this message"),
).toBeNull();
expect(
screen.getByRole("button", { name: /Activity: .*working/ }),
).toBeVisible();
} finally {
view.unmount();
f.dispose();
}
});

it("shows thinking once across the live bubble, recorded history and conversation indicator", () => {
const local = createLocalAgentActivity();
const f = fixture(local);
Expand Down Expand Up @@ -1867,6 +1923,10 @@ it("shows thinking once across the live bubble, recorded history and conversatio
const view = render(ui(false));
try {
expect(screen.getAllByText("Thinking")).toHaveLength(1);
// Until history owns the work, the message bubble is its one indicator.
expect(
screen.queryByRole("button", { name: /Activity: .*working/ }),
).toBeNull();
view.rerender(ui(true));
expect(
screen.queryByLabelText("Agent activity on this message"),
Expand Down
21 changes: 19 additions & 2 deletions src/bundled/agent-activity/ActivityAccessory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -876,11 +876,28 @@ function TypingReplacement({
session,
channelId,
threadRootId,
message,
messageLayout,
agent,
shared = false,
}: ComposerAccessoryProps & { agent: string; shared?: boolean }) {
}: ComposerAccessoryProps & {
agent: string;
/** Only the live bubble shares its registration. It shows the agent working,
* so the conversation shows no second cue (presence). */
shared?: boolean;
}) {
// A thread's root row has no thread root of its own, but its bubble shows in
// that thread's conversation, so it owns that thread's indicator.
const shownIn =
threadRootId ?? (messageLayout === "thread" ? message?.id : undefined);
useActivityTypingReplacement(
{ session, channelId, threadRootId, pubkey: agent },
{
session,
channelId,
threadRootId: shownIn,
pubkey: agent,
presence: shared,
},
shared,
);
useTypingReplacement(
Expand Down
32 changes: 31 additions & 1 deletion src/features/conversation/typing-presentation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { RelaySession } from "../relay/session";
import {
ActivityIndicatorPresentation,
useActivityTypingReplacement,
usePresentedAgents,
useReplacedTypers,
} from "./typing-presentation";

Expand All @@ -15,12 +16,14 @@ const session = {} as RelaySession;
function Replacement({
channelId,
participants,
presence,
}: {
channelId: string;
participants: readonly string[];
presence?: boolean;
}) {
useActivityTypingReplacement(
{ session, channelId, threadRootId: undefined, pubkey: "" },
{ session, channelId, threadRootId: undefined, pubkey: "", presence },
true,
participants,
);
Expand Down Expand Up @@ -50,3 +53,30 @@ it("re-renders a replaced-typers reader only when its conversation's keys change
rerender(view(["one", "four"], ["two", "three"]));
expect([...replaced]).toEqual(["one", "four"]);
});

it("separates rows that show an agent working from rows that own only its details", () => {
let replaced: ReadonlySet<string> = new Set();
let presented: ReadonlySet<string> = new Set();
function Reader() {
replaced = useReplacedTypers(session, "alpha", undefined);
presented = usePresentedAgents(session, "alpha", undefined);
return null;
}
const view = (presence: boolean) => (
<ActivityIndicatorPresentation>
<Reader />
<Replacement channelId="alpha" participants={["history"]} />
<Replacement
channelId="alpha"
participants={["bubble"]}
presence={presence}
/>
<Replacement channelId="beta" participants={["elsewhere"]} presence />
</ActivityIndicatorPresentation>
);
const { rerender } = render(view(true));
expect([...replaced].sort()).toEqual(["bubble", "history"]);
expect([...presented]).toEqual(["bubble"]);
rerender(view(false));
expect([...presented]).toEqual([]);
});
26 changes: 25 additions & 1 deletion src/features/conversation/typing-presentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type Replacement = Readonly<{
threadRootId: string | undefined;
pubkey: string;
requestId?: string | undefined;
/** This mounted row is the agent's live working indicator, not only its
* step details: the conversation must not show a second one. */
presence?: boolean | undefined;
}>;
function createPresentation() {
const entries = new Map<symbol, Replacement>();
Expand Down Expand Up @@ -117,7 +120,8 @@ function useReplacement(
) {
const store = context?.store;
const active = context?.active;
const { session, channelId, threadRootId, pubkey, requestId } = value ?? {};
const { session, channelId, threadRootId, pubkey, requestId, presence } =
value ?? {};
const participantKeys = (participants ?? (pubkey ? [pubkey] : [])).join(":");
useLayoutEffect(() => {
if (!active || !enabled || !session || !channelId || !participantKeys)
Expand All @@ -129,6 +133,7 @@ function useReplacement(
threadRootId,
pubkey,
requestId,
presence,
})),
);
}, [
Expand All @@ -140,12 +145,30 @@ function useReplacement(
threadRootId,
participantKeys,
requestId,
presence,
]);
}
/** Agents whose step details a mounted row shows in this conversation. */
export function useReplacedTypers(
session: object | undefined,
channelId: string,
threadRootId: string | undefined,
) {
return useScopedReplacements(session, channelId, threadRootId, false);
}
/** Agents whose live working indicator a mounted row shows in this conversation. */
export function usePresentedAgents(
session: object | undefined,
channelId: string,
threadRootId: string | undefined,
) {
return useScopedReplacements(session, channelId, threadRootId, true);
}
function useScopedReplacements(
session: object | undefined,
channelId: string,
threadRootId: string | undefined,
presenceOnly: boolean,
) {
const context = useContext(Presentation);
const activityStore = useContext(ActivityPresentation);
Expand Down Expand Up @@ -173,6 +196,7 @@ export function useReplacedTypers(
],
(entry) =>
!entry.requestId &&
(!presenceOnly || !!entry.presence) &&
entry.session === session &&
entry.channelId === channelId &&
entry.threadRootId === threadRootId,
Expand Down
58 changes: 46 additions & 12 deletions src/features/messages/ChannelTimeline.viewport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { foldMessages } from "../relay/fold";
import { keypair, message } from "../relay/testing";
import type { ChannelMessage, ChannelWindow } from "../relay/contracts";
import {
ActivityIndicatorPresentation,
TypingPresentation,
useActivityTypingReplacement,
useTypingReplacement,
} from "../conversation/typing-presentation";
import type { RelaySession } from "../relay/session";
Expand All @@ -32,6 +34,20 @@ function RecordedTyping({ session }: { session: RelaySession }) {
);
return null;
}
/** A message's live bubble registers presence in the conversation's store. */
function LiveBubble({ session }: { session: RelaySession }) {
useActivityTypingReplacement(
{
session,
channelId: "alpha",
threadRootId: undefined,
pubkey: "agent",
presence: true,
},
true,
);
return null;
}

const viewport = vi.hoisted(() => ({
size: 0,
Expand Down Expand Up @@ -334,9 +350,13 @@ it.each([1, 4])(
},
);

it.each([false, true])(
"keeps ongoing typing in the reply group after completed history (replaced=%s)",
(replaced) => {
it.each([
[false, false],
[true, false],
[true, true],
])(
"keeps ongoing typing in the reply group after completed history (replaced=%s, presented=%s)",
(replaced, presented) => {
const { owner, updates, content } = activityFixture();
try {
const history = updates(2);
Expand Down Expand Up @@ -366,17 +386,31 @@ it.each([false, true])(
},
};
const view = render(
<TypingPresentation active>
{replaced && <RecordedTyping session={queries} />}
{content(
[...history, { ...reply, id: "reply", content: "Main reply" }],
undefined,
true,
queries,
)}
</TypingPresentation>,
<ActivityIndicatorPresentation>
<TypingPresentation active>
{replaced && !presented && <RecordedTyping session={queries} />}
{presented && <LiveBubble session={queries} />}
{content(
[...history, { ...reply, id: "reply", content: "Main reply" }],
undefined,
true,
queries,
)}
</TypingPresentation>
</ActivityIndicatorPresentation>,
);
expect(screen.queryByText("2 completed steps")).toBeNull();
if (presented) {
// A live bubble elsewhere shows the agent: no conversation cue, and
// the reply does not join one.
expect(
screen.queryByRole("button", { name: /Activity: .*working/ }),
).toBeNull();
expect(
screen.getByText("Main reply").closest("li"),
).not.toHaveAttribute("data-stack-next", "true");
return;
}
expect(
screen.getByRole("button", { name: /Activity: .*working/ }),
).toBeVisible();
Expand Down
Loading
Loading