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