diff --git a/docs/agents.md b/docs/agents.md index 0270a1aa3..f379dca5d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -358,22 +358,16 @@ HTTP broker/verified transport, including corrupted signature rejection. ## Agent Activity plugin -**Agent Activity** is an independently toggleable bundled plugin that owns capture -and the transcript panel. Conversation presentation uses a compact, floating -avatar control above the composer, without a reserved strip or permanent avatar -container. Hover, click, tap or keyboard opens **Working now**, reusing the sidebar's -working-agent row. The row opens its known thread (or channel when no unique thread -is known); a separate **View activity** action opens the agent's transcript. - -The channel control includes work in threads. A thread control includes only -exact-root evidence, never unconfirmed work. Current owner-visible turns provide -brief tool status; unknown scopes fall back to channel activity. Multiple concurrent -scopes never choose an arbitrary transcript thread. Public typing for the viewer's -own `agentChoices` identities remains visible without telemetry; someone else's -self-declared agent remains ordinary typing, without this owner-activity doorway. -All projections consume existing stores and loaded profiles/channel rows, not new -capture leases, relay reads or timers. A persistent, visually hidden status region -announces working/typing transitions even when visual controls are absent. +**Agent Activity** owns live capture demand and the full Transcript/Raw panel; +host-owned saved history remains independent of plugin enablement. This branch +adds an avatar-and-dots bubble beneath the exact message that triggered current +work. Multiple turns group by agent; completing one turn does not hide another. +Hover, click, tap or keyboard opens a compact avatar-and-latest-tool popup. Its +row opens that exact agent's profile Activity tab, using the mainline transcript, +conversation filtering and saved-history controls. Escape or outside click closes +the popup. Human typing remains in the ordinary conversation indicator. + +### Local plugin-agent lifecycle The popup resets on conversation changes and when current work ends. Idle and historical activity remain accessible from the activity panel rather diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index b6463f0f9..270cec7a8 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -291,7 +291,20 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .find((panel) => panel.pluginId === "buzz.agent-activity"); assert.equal(activity.title, "Agent Activity"); assert.equal(activity.launcher, undefined); - assert.equal(services.conversation.accessories.snapshot().length, 0); + assert.equal(services.conversation.accessories.snapshot().length, 2); + assert.deepEqual( + services.conversation.accessories + .snapshot() + .map(({ id, placement }) => [id, placement]), + [ + ["activity", "message"], + ["typing-presentation", undefined], + ], + ); + assert.equal( + services.conversation.accessories.snapshot()[0].pluginId, + "buzz.agent-activity", + ); assert.match( renderToStaticMarkup(createElement(activity.component)), /Connect to a community/, diff --git a/src/app/services.ts b/src/app/services.ts index 53e73e626..a6da7a1d9 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,3 +1,4 @@ +import { createLocalAgentActivity } from "../features/agents/local-activity"; // FOUNDATION: Compose the bundled distribution, plugin runtime, and services here. import { createIdentity, @@ -45,6 +46,7 @@ export function createServices() { bundled: bundledPlugins, }); const localAgents = createLocalAgents(); + const localActivity = createLocalAgentActivity(); const navigationHost = provideNavigation(ctx); const navigation = navigationHost.navigation; const browser = new BrowserService(ctx); @@ -64,13 +66,21 @@ export function createServices() { localAgents.agents, identity?.ready, identity ? connectNativeTransport : undefined, + localActivity.reader, ); ctx.provide("communityReader", { snapshot: communities.snapshot, subscribe: communities.subscribe, }); const relay = communities.relay; - const agents = new AgentsService(ctx, communities); + const agents = new AgentsService( + ctx, + communities, + undefined, + undefined, + undefined, + localActivity, + ); ctx.effect(() => localAgents.bind({ snapshot: agents.local, subscribe: agents.subscribe }), ); diff --git a/src/bundled/agent-activity/ActivityAccessory.module.css b/src/bundled/agent-activity/ActivityAccessory.module.css new file mode 100644 index 000000000..a3324da3d --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.module.css @@ -0,0 +1,43 @@ +.root { + margin-block-start: var(--space-2); + min-width: 0; + flex-shrink: 0; +} +.previews { + display: flex; + flex-direction: column; +} +/* Error dismissal is a sibling action, never a button nested in NavigationItem. */ +.row { + display: flex; + align-items: center; + gap: var(--space-1); +} +.row > :first-child { + min-width: 0; + flex: 1; +} + +/* One glanceable line each; NavigationItem's label truncation applies per line. */ +.preview { + min-width: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-1); +} +.preview > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* Errors explain what to do; keep their reason readable instead of clipped. */ +.preview[data-error] > * { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; +} +.detail { + color: var(--text-subtle); +} diff --git a/src/bundled/agent-activity/ActivityAccessory.test.tsx b/src/bundled/agent-activity/ActivityAccessory.test.tsx new file mode 100644 index 000000000..7526316fa --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.test.tsx @@ -0,0 +1,519 @@ +// @vitest-environment jsdom +import { createLocalAgentActivity } from "../../features/agents/local-activity"; +import "@testing-library/jest-dom/vitest"; +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; + +import { afterEach, expect, it, vi } from "vitest"; +import { + ActivityAccessory, + ActivityTypingReplacement, +} from "./ActivityAccessory"; +import { TypingPresentation } from "../../features/conversation/typing-presentation"; +import { TypingIndicator } from "../../features/messages/TypingIndicator"; +import { createAgentActivity } from "../../features/agents/activity"; +import { createAgentLibrary } from "../../features/agents/library"; +import type { RelaySession } from "../../features/relay/session"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +const A = "b".repeat(64), + B = "c".repeat(64), + viewer = "a".repeat(64), + root = "1".repeat(64), + next = "2".repeat(64); +function fixture(local?: ReturnType) { + vi.useFakeTimers(); + vi.setSystemTime(1000000); + let generation = 0, + serial = 0; + const activity = createAgentActivity( + true, + (g) => { + generation = g ?? 0; + }, + () => true, + undefined, + undefined, + undefined, + local ? { source: local.reader, scope: "test" } : undefined, + ); + const release = activity.queries.activate(); + const live = { + status: "connected" as const, + routes: [ + { id: "observer", status: "live" as const, replay: "unknown" as const }, + ], + }; + activity.state(live); + const profiles = new Map([ + [A, { name: "Blossom", isAgent: true }], + [B, { name: "Bubbles", isAgent: true }], + ]); + const session = { + viewer, + presence: { + subscribe: () => () => {}, + status: () => "online", + limited: () => false, + }, + agentActivity: activity.queries, + agentChoices: createAgentLibrary(undefined).queries, + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: vi.fn(async () => {}), + }, + live: { retry: () => activity.state(live) }, + media: () => undefined, + } as unknown as RelaySession; + const send = ( + agent: string, + turnId: string, + seq: number, + kind: string, + payload: unknown = {}, + ) => + act(() => { + activity.receive( + { + id: (++serial).toString(16).padStart(64, "0"), + agent, + createdAt: Math.floor(Date.now() / 1000), + plaintext: JSON.stringify({ + kind, + turnId, + channelId: "c", + sessionId: "S", + seq, + timestamp: new Date().toISOString(), + payload, + }), + }, + generation, + ); + }); + const open = vi.fn(() => true); + const props = { + session, + scope: "test", + channelId: "c", + threadRootId: root, + message: { + id: root, + channelId: "c", + authorId: viewer, + createdAt: 1, + content: "Request", + mentions: [], + attachments: [], + reactions: [], + participants: [], + replyCount: 0, + }, + canOpen: () => true, + open, + }; + return { + activity, + release, + send, + props, + live, + profiles, + dispose: () => { + release(); + activity.dispose(); + }, + }; +} + +it("shows one bubble with one avatar per agent across multiple turns, removing only the settled agent", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "a1", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(A, "a2", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(B, "b1", 1, "turn_started", { triggeringEventIds: [root] }); + expect( + screen.getAllByRole("button", { name: /View agent activity/ }), + ).toHaveLength(1); + expect(screen.getAllByTitle("Blossom")).toHaveLength(1); + expect(screen.getAllByTitle("Bubbles")).toHaveLength(1); + expect(view.container.querySelector(".buzz-avatar-status-dot")).toBeNull(); + f.send(A, "a1", 2, "turn_completed"); + expect( + screen.getByRole("button", { name: /View agent activity: Blossom/ }), + ).toBeInTheDocument(); + f.send(A, "a2", 2, "turn_completed"); + expect( + screen.queryByRole("button", { name: /Blossom/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /View agent activity: Bubbles/ }), + ).toBeInTheDocument(); + f.send(B, "b1", 2, "turn_completed"); + expect( + screen.queryByLabelText("Agent activity on this message"), + ).not.toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("does not show sibling thread work and reports lost freshness without pretending completion", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "other", 1, "turn_started", { triggeringEventIds: [next] }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + f.send(B, "here", 1, "turn_started", { triggeringEventIds: [root] }); + act(() => vi.advanceTimersByTime(31000)); + expect( + screen.getByRole("button", { name: /View agent activity: Bubbles/ }), + ).toHaveTextContent("Status unknown"); + f.send(B, "here", 2, "turn_completed"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("keeps namesakes as separate exact identities", () => { + const f = fixture(); + f.profiles.set(B, { name: "Blossom", isAgent: true }); + const view = render(); + try { + f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(B, "b", 1, "turn_started", { triggeringEventIds: [root] }); + const avatars = screen.getAllByTitle(/Blossom/); + expect(avatars).toHaveLength(2); + expect(avatars[0]?.getAttribute("title")).not.toBe( + avatars[1]?.getAttribute("title"), + ); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("previews only live scoped actions and retains profile navigation", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "old", 1, "turn_started", { triggeringEventIds: [root] }); + const thought = (text: string) => ({ + method: "session/update", + params: { + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }, + }, + }); + f.send(A, "old", 2, "acp_read", thought("Old thought")); + f.send(A, "old", 3, "turn_completed"); + f.send(A, "new", 1, "turn_started", { triggeringEventIds: [root] }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + expect( + screen.getByText("Waiting for activity details"), + ).toBeInTheDocument(); + expect(screen.queryByText("Old thought")).not.toBeInTheDocument(); + f.send(A, "new", 2, "acp_read", thought("Current thought")); + expect(screen.getByText("Current thought")).toBeInTheDocument(); + f.send(B, "sibling", 1, "turn_started", { triggeringEventIds: [next] }); + f.send(B, "sibling", 2, "acp_read", thought("Private sibling thought")); + expect( + screen.queryByText("Private sibling thought"), + ).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(31000)); + expect( + screen.getByText("Activity interrupted or out of date"), + ).toBeInTheDocument(); + expect(screen.queryByText("Current thought")).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "View activity for Blossom" }), + ); + expect(f.props.open).toHaveBeenCalledExactlyOnceWith( + `buzz:profile?agent=${A}&tab=activity`, + ); + } finally { + view.unmount(); + f.dispose(); + } +}); +it("removes the whole popup when access is revoked", () => { + const f = fixture(); + const view = render(); + try { + f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + view.rerender( false} />); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("makes the compact avatar and tool preview one action, retaining the popup when opening fails", () => { + const f = fixture(); + f.props.open.mockReturnValue(false); + const view = render(); + try { + f.send(A, "a", 1, "turn_started", { triggeringEventIds: [root] }); + f.send(A, "a", 2, "acp_read", { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId: "read", + title: "buzz-dev-mcp__read_file", + status: "in_progress", + rawInput: { path: "/project/file.ts" }, + }, + }, + }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + const popup = within( + screen.getByRole("dialog", { name: "Agent activity" }), + ); + expect(popup.getAllByRole("button")).toHaveLength(1); + for (const text of [ + "Agent activity", + "Latest reported activity for this message.", + "Blossom", + "View activity", + "Close", + ]) + expect(popup.queryByText(text, { exact: true })).not.toBeInTheDocument(); + const row = popup.getByRole("button", { + name: "View activity for Blossom", + }); + expect(row).toHaveTextContent("Read file · Running"); + expect(row.querySelector(".buzz-avatar")).not.toBeNull(); + fireEvent.click(popup.getByText("file.ts")); + expect(f.props.open).toHaveBeenCalledExactlyOnceWith( + `buzz:profile?agent=${A}&tab=activity`, + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + view.rerender( + !target.startsWith("buzz:profile?")} + />, + ); + expect(row).toBeDisabled(); + fireEvent.click(row); + expect(f.props.open).toHaveBeenCalledOnce(); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("keeps main's unassociated work visible, replacing it only for a linked message in this thread", () => { + const f = fixture(); + const typing = [{ pubkey: A, channelId: "c", threadRootId: root }]; + const session = { + ...f.props.session, + typing: { snapshot: () => typing, subscribe: () => () => {} }, + } as unknown as RelaySession; + const props = { ...f.props, session }; + const ui = (replacement: boolean) => ( + + {/* MessageRow gives each row its own presentation scope. */} + + + + {replacement && } + + + ); + const view = render(ui(true)); + try { + f.send(A, "unlinked", 1, "turn_liveness"); + expect( + screen.getByRole("button", { name: "Activity: Blossom working" }), + ).toBeInTheDocument(); + f.send(A, "sibling", 1, "turn_started", { triggeringEventIds: [next] }); + expect( + screen.getByRole("button", { name: "Activity: Blossom working" }), + ).toBeInTheDocument(); + f.send(A, "here", 1, "turn_started", { triggeringEventIds: [root] }); + expect( + screen.getByRole("button", { + name: "View agent activity: Blossom, working", + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Activity: Blossom working" }), + ).not.toBeInTheDocument(); + view.rerender(ui(false)); + expect( + screen.getByRole("button", { name: "Activity: Blossom working" }), + ).toBeInTheDocument(); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("shows local receipt and failure without requiring an ACP transcript, and opens the agent profile", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + const view = render(); + try { + let work!: ReturnType; + act(() => { + work = local.begin({ + scope: "test", + channelId: "c", + threadRootId: root, + messageId: root, + agent: A, + }); + }); + const bubble = screen.getByRole("button", { + name: "View agent activity: Blossom, Received · waiting to start", + }); + expect(bubble.querySelector('[data-state="waiting"]')).not.toBeNull(); + act(() => work.started()); + expect(bubble).toHaveAccessibleName( + "View agent activity: Blossom, Starting", + ); + expect(bubble.querySelector('[data-state="waiting"]')).not.toBeNull(); + act(() => work.step("Read", "notes.txt")); + expect(bubble).toHaveAccessibleName( + "View agent activity: Blossom, Reading notes.txt", + ); + expect(bubble.querySelector('[data-state="working"]')).not.toBeNull(); + act(() => work.finish({ ok: false, error: "Provider unavailable" })); + expect(bubble).toHaveTextContent("Could not finish"); + expect(bubble.querySelector("[data-state]")).toBeNull(); + fireEvent.click(bubble); + expect(screen.getByText("Provider unavailable")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "View activity for Blossom" }), + ); + expect(f.props.open).toHaveBeenCalledExactlyOnceWith( + `buzz:profile?agent=${A}&tab=activity`, + ); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("dismisses only that agent's error on this message, without changing other work", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + const onFocusedRemoval = vi.fn(); + const view = render( + , + ); + const begin = (agent: string, messageId: string) => + local.begin({ + scope: "test", + channelId: "c", + threadRootId: root, + messageId, + agent, + }); + try { + act(() => { + begin(A, root).finish({ + ok: false, + error: "Provider unavailable after a long and detailed reason", + }); + begin(A, next).finish({ ok: false, error: "Elsewhere" }); + begin(B, root).step("Read", "notes.txt"); + }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + expect( + screen.getByText("Provider unavailable after a long and detailed reason") + .parentElement, + ).toHaveAttribute("data-error"); + expect( + screen.queryByRole("button", { name: "Dismiss error from Bubbles" }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss error from Blossom" }), + ); + expect(f.props.open).not.toHaveBeenCalled(); + expect(onFocusedRemoval).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "View activity for Bubbles" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "View activity for Blossom" }), + ).toBeNull(); + expect( + local.reader + .work() + .filter((row) => row.agent === A) + .map((row) => [row.messageId, row.state, row.dismissed]), + ).toEqual([ + // The outcome remains as settled evidence; only its display is hidden. + [root, "error", true], + [next, "error", undefined], + ]); + } finally { + view.unmount(); + f.dispose(); + } +}); + +it("moves focus out when dismissing the last error removes the bubble", () => { + const local = createLocalAgentActivity(); + const f = fixture(local); + const onFocusedRemoval = vi.fn(); + const view = render( + , + ); + try { + act(() => { + local + .begin({ + scope: "test", + channelId: "c", + threadRootId: root, + messageId: root, + agent: A, + }) + .finish({ ok: false, error: "Provider unavailable" }); + }); + fireEvent.click( + screen.getByRole("button", { name: /View agent activity/ }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss error from Blossom" }), + ); + expect(onFocusedRemoval).toHaveBeenCalledOnce(); + expect(screen.queryByText("Provider unavailable")).toBeNull(); + } finally { + view.unmount(); + f.dispose(); + } +}); diff --git a/src/bundled/agent-activity/ActivityAccessory.tsx b/src/bundled/agent-activity/ActivityAccessory.tsx new file mode 100644 index 000000000..245353bc5 --- /dev/null +++ b/src/bundled/agent-activity/ActivityAccessory.tsx @@ -0,0 +1,447 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { AvatarStack } from "../../shared/design-system/ui/AvatarStack"; +import type { ComposerAccessoryProps } from "../../features/conversation/contracts"; +import { useChannelIdentityNames } from "../../features/identity-names/react"; +import type { activityRecords } from "../../features/agents/activity-records"; +import { activityTarget } from "../../features/agents/activity-target"; +import { profileActivityViewTarget } from "../../features/profiles/target"; +import { publicKeyLabels } from "../../shared/identity/public-key"; +import { Button } from "../../shared/design-system/ui/Button"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { XIcon } from "../../shared/design-system/icons"; +import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { ShimmerText } from "../../shared/design-system/ui/ShimmerText"; +import { TypingDots } from "../../shared/design-system/ui/TypingDots"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; +import { currentActivity } from "./current-activity"; +import { messageActivity } from "./message-activity"; +import type { ActivityTurn } from "../../features/agents/activity"; +import { + requestWork, + type RequestWork, +} from "../../features/agents/request-work"; +import { + localWorkLabel, + type WorkStep, +} from "../../features/agents/local-activity"; +import { useTypingReplacement } from "../../features/conversation/typing-presentation"; +import styles from "./ActivityAccessory.module.css"; + +const noSubscribe = () => () => {}; +/** Activity belongs to the exact triggering message, not the composer. */ +export function ActivityAccessory(props: ComposerAccessoryProps) { + const { session, channelId, message, canOpen } = props; + const messageId = message?.id; + // Every row mounts this; only rows with work subscribe to each activity + // step, profile and choice change. Others read once and wait for work. + const hasWork = useSyncExternalStore( + session.agentActivity.subscribe, + () => + !!messageId && + messageActivity(session.agentActivity.snapshot(), channelId, messageId) + .length > 0, + ); + const snapshot = useSyncExternalStore( + hasWork ? session.agentActivity.subscribe : noSubscribe, + session.agentActivity.snapshot, + ); + const entries = useMemo( + () => + messageId && hasWork + ? messageActivity(snapshot, channelId, messageId) + : [], + [snapshot, channelId, messageId, hasWork], + ); + const profiles = useSyncExternalStore( + hasWork ? session.profiles.subscribe : noSubscribe, + session.profiles.snapshot, + ); + const choices = useSyncExternalStore( + hasWork ? session.agentChoices.subscribe : noSubscribe, + session.agentChoices.snapshot, + ); + const resolveName = useChannelIdentityNames(session, channelId); + const keys = entries.map((entry) => entry.agent); + const identityKeys = keys.join(":"); + useEffect(() => { + if (identityKeys) + void session.profiles + .ensure(identityKeys.split(":"), "background") + .catch(() => {}); + }, [session.profiles, identityKeys]); + const suffixes = publicKeyLabels(keys); + const names = keys.map((key) => + resolveName( + key, + profiles.get(key)?.name ?? + choices.identities.find((row) => row.pubkey === key)?.name ?? + suffixes.get(key) ?? + "Agent", + ), + ); + const visible = entries.filter((entry) => + entry.lifecycle + ? canOpen(profileActivityViewTarget(entry.agent) ?? "") + : canOpen(activityTarget(entry.agent, channelId)), + ); + const shown: Entry[] = visible.map((entry) => { + const name = names[keys.indexOf(entry.agent)] ?? "Agent"; + return { + agent: entry.agent, + name: + names.filter((value) => value === name).length > 1 + ? `${name} · ${suffixes.get(entry.agent)}` + : name, + picture: profiles.get(entry.agent)?.picture, + working: entry.working, + state: entry.state, + reason: entry.reason, + ...(entry.step ? { step: entry.step } : {}), + lifecycle: entry.lifecycle, + records: entry.selected.records, + turns: entry.turns, + }; + }); + // When work ends (not when access is revoked), keep the last bubble long + // enough for the shared leave recipe, then remove it. + const section = useRef(null); + const retained = useRef([]); + const [leaving, setLeaving] = useState(null); + const ended = !entries.length; + useLayoutEffect(() => { + // Child closes its portal and restores focus before this subtree is inert. + if (section.current) section.current.inert = !shown.length; + if (shown.length) { + retained.current = shown; + if (leaving) setLeaving(null); + return; + } + if (ended && retained.current.length) setLeaving(retained.current); + retained.current = []; + }); + useLayoutEffect(() => { + if (!leaving) return; + const animations = section.current?.getAnimations?.() ?? []; + if (!animations.length) { + setLeaving(null); + return; + } + let live = true; + const done = () => { + if (live) setLeaving(null); + }; + void Promise.all(animations.map((animation) => animation.finished)).then( + done, + done, + ); + return () => { + live = false; + }; + }, [leaving]); + // The render where work ends keeps the same element (from the last shown + // entries) so the list never loses and regains its height mid-handoff. + const bubble = shown.length + ? shown + : (leaving ?? (ended ? retained.current : [])); + if (!bubble.length) return null; + return ( +
+
+ {shown.map((entry) => ( + + ))} + 0} + /> +
+
+ ); +} + +type Entry = { + agent: string; + name: string; + picture: string | undefined; + working: boolean; + state: RequestWork["state"]; + reason: string | undefined; + step?: WorkStep; + lifecycle: boolean; + records: ReturnType; + turns: readonly ActivityTurn[]; +}; + +function ActivityBubble({ + entries, + interactive, + ...props +}: ComposerAccessoryProps & { entries: Entry[]; interactive: boolean }) { + const [expanded, setExpanded] = useState(false); + const trigger = useRef(null); + const popup = useRef(null); + useLayoutEffect(() => { + if (interactive) return; + const active = trigger.current?.ownerDocument.activeElement; + if ( + active && + (trigger.current?.contains(active) || popup.current?.contains(active)) + ) + props.onFocusedRemoval?.(); + setExpanded(false); + }, [interactive, props.onFocusedRemoval]); + const working = entries.some((entry) => entry.working); + // Local work breathes until its first step ("Starting"), like the sidebar and thread. + const starting = (entry: Entry) => + entry.lifecycle && + (entry.state === "waiting" || (entry.state === "working" && !entry.step)); + const waiting = entries.some(starting); + const stepping = entries.some((entry) => entry.working && !starting(entry)); + + const unknown = entries.some((entry) => !entry.lifecycle && !entry.working); + const failed = entries.some( + (entry) => entry.lifecycle && entry.state === "error", + ); + const label = (entry: Entry) => + entry.lifecycle + ? localWorkLabel({ + state: + entry.state === "ended" + ? "done" + : entry.state === "unknown" + ? "error" + : entry.state, + ...(entry.reason ? { error: entry.reason } : {}), + ...(entry.step ? { step: entry.step } : {}), + }) + : entry.working + ? "working" + : "status unknown"; + return ( + setExpanded(interactive && open)} + > + { + if (!interactive) { + event.preventBaseUIHandler(); + return; + } + if (props.openThread?.()) { + event.preventBaseUIHandler(); + setExpanded(false); + } + }} + render={ + + } + /> + + {expanded && ( +
+ {entries.map((entry) => { + // Both open the agent's profile on its activity: a plugin + // agent's own session tab, or the harness Activity tab. + const target = profileActivityViewTarget(entry.agent); + const preview = entry.working + ? currentActivity(entry.records, entry.turns) + : undefined; + return ( +
+ { + if (target && props.canOpen(target) && props.open(target)) + setExpanded(false); + }} + icon={ + + } + label={ + + + {entry.lifecycle + ? label(entry) + : !entry.working + ? "Activity interrupted or out of date" + : (preview?.title ?? + "Waiting for activity details")} + + {preview?.detail && ( + + {preview.detail} + + )} + + } + /> + {entry.lifecycle && entry.state === "error" ? ( + } + onClick={(event) => { + event.stopPropagation(); + if ( + props.message && + props.session.agentActivity.dismissError?.( + entry.agent, + props.channelId, + props.message.id, + ) + ) { + if (entries.length === 1) props.onFocusedRemoval?.(); + setExpanded(entries.length > 1); + } + }} + /> + ) : undefined} +
+ ); + })} +
+ )} +
+
+ ); +} + +/** Message-linked work replaces the composer control; unassociated work keeps + * main's existing presentation rather than disappearing without a bubble. */ +export function ActivityTypingReplacement(props: ComposerAccessoryProps) { + const activity = useSyncExternalStore( + props.session.agentActivity.subscribe, + props.session.agentActivity.snapshot, + ); + const ids = [ + ...(props.threadRootId ? [props.threadRootId] : []), + ...(activity.local ?? []) + .filter( + (request) => + request.channelId === props.channelId && + (!props.threadRootId || + (request.threadRootId ?? request.messageId) === props.threadRootId), + ) + .map((request) => request.messageId), + ...activity.turns.flatMap((turn) => + turn.channelId === props.channelId + ? (turn.requests ?? []) + .filter( + (request) => + !props.threadRootId || + request.threadRootId === props.threadRootId, + ) + .map((request) => request.messageId) + : [], + ), + ]; + const agents = requestWork(activity, props.channelId, ids); + return ( + <> + {agents.map(({ agent }) => ( + + ))} + + ); +} +function TypingReplacement({ + session, + channelId, + threadRootId, + agent, +}: ComposerAccessoryProps & { agent: string }) { + useTypingReplacement( + { session, channelId, threadRootId, pubkey: agent }, + true, + ); + return null; +} + +function ActivityAvatar({ + session, + entry, + size = "compact", +}: Pick & { + entry: Entry; + size?: "compact" | "small"; +}) { + return ( + + ); +} diff --git a/src/bundled/agent-activity/activity-presentation.test.ts b/src/bundled/agent-activity/activity-presentation.test.ts new file mode 100644 index 000000000..4e72e97d1 --- /dev/null +++ b/src/bundled/agent-activity/activity-presentation.test.ts @@ -0,0 +1,129 @@ +import { expect, it } from "vitest"; +import { activityPresentation } from "./activity-presentation"; +import type { ToolItem } from "../../features/agents/activity-transcript"; +const entry = ( + toolName: string, + input: unknown = {}, + output = "", +): ToolItem => ({ + id: "tool", + type: "tool", + kind: "other", + toolCallId: "tool", + at: 0, + diffs: [], + paths: [], + truncated: false, + title: toolName, + input: JSON.stringify(input), + output, + status: "completed", +}); +it("maps only exact tools and structured paths; commands remain indivisible", () => { + expect( + activityPresentation( + entry("buzz-dev-mcp__read_file", { path: "/work/src/Composer.tsx" }), + ), + ).toMatchObject({ title: "Read file", target: "Composer.tsx" }); + expect( + activityPresentation( + entry("buzz-dev-mcp__str_replace", { path: "C:\\work\\Composer.tsx" }), + ), + ).toMatchObject({ title: "Edit file", target: "Composer.tsx" }); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { + workdir: "/work/project", + command: "read file; send message", + }), + ), + ).toMatchObject({ title: "Run command", target: "read file; send message" }); + expect( + activityPresentation(entry("unknown_shell", { path: "/work/secret" })), + ).toMatchObject({ title: "unknown_shell", target: undefined }); +}); +it("never invents a file target from malformed or directory paths", () => { + for (const path of [ + "", + "/", + "/work/", + "C:\\", + "..", + 12, + { path: "file" }, + "a\nb", + ]) { + expect( + activityPresentation(entry("buzz-dev-mcp__read_file", { path })), + ).toMatchObject({ title: "buzz-dev-mcp__read_file", target: undefined }); + } +}); +it("preserves stderr, empty output and wrapper warnings without interpreting markup", () => { + const result = { + stdout: "", + stderr: "warning", + exit_code: 2, + timed_out: false, + stdout_truncated: true, + stderr_truncated: false, + }; + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", {}, JSON.stringify(result)), + ).shellOutput, + ).toEqual({ + stdout: "", + stderr: "warning", + failed: true, + note: "Exit code 2 · Output was truncated by the tool", + }); + expect( + activityPresentation( + entry( + "buzz-dev-mcp__shell", + {}, + JSON.stringify({ ...result, stdout: "", stderr: "" }), + ), + ).shellOutput?.stdout, + ).toBe(""); + expect( + activityPresentation(entry("other", {}, JSON.stringify(result))) + .shellOutput, + ).toBeUndefined(); + for (const output of [ + "broken", + JSON.stringify({ ...result, stderr: null }), + JSON.stringify({ stdout: "hi" }), + ]) + expect( + activityPresentation(entry("buzz-dev-mcp__shell", {}, output)) + .shellOutput, + ).toBeUndefined(); +}); +it("previews distinct command text rather than repeated working directories", () => { + const first = activityPresentation( + entry("buzz-dev-mcp__shell", { + command: "\ncat > REPORT.md <<'EOF'\nprivate text", + workdir: ".buzz", + }), + ); + const second = activityPresentation( + entry("buzz-dev-mcp__shell", { + command: "wc -w REPORT.md", + workdir: ".buzz", + }), + ); + expect(first.target).toBe("cat > REPORT.md <<'EOF'"); + expect(second.target).toBe("wc -w REPORT.md"); + expect(first.target).not.toContain("private text"); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { command: "x".repeat(120) }), + ).target, + ).toBe(`${"x".repeat(90)}…`); + expect( + activityPresentation( + entry("buzz-dev-mcp__shell", { command: "\t\u001bwc -w file" }), + ).target, + ).toBe("wc -w file"); +}); diff --git a/src/bundled/agent-activity/activity-presentation.ts b/src/bundled/agent-activity/activity-presentation.ts new file mode 100644 index 000000000..894c9f281 --- /dev/null +++ b/src/bundled/agent-activity/activity-presentation.ts @@ -0,0 +1,90 @@ +import type { ToolItem } from "../../features/agents/activity-transcript"; +const object = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +function parsed(value: string) { + try { + return object(JSON.parse(value)); + } catch { + return; + } +} +function basename(value: unknown): string | undefined { + if ( + typeof value !== "string" || + !value.trim() || + /[/\\]$/.test(value) || + /[\r\n]/.test(value) + ) + return; + const name = value.split(/[/\\]/).at(-1); + return name && name !== "." && name !== ".." ? name : undefined; +} + +/** Display only. Never feeds send association, status, permissions or execution. */ +export function activityPresentation(entry: ToolItem) { + const input = parsed(entry.input); + const name = entry.title; + let title = entry.title, + target: string | undefined; + if (name === "buzz-dev-mcp__shell") { + title = "Run command"; + const line = + typeof input?.command === "string" + ? input.command + .split(/\r?\n/) + .find((part) => part.trim()) + ?.replace(/\p{Cc}/gu, " ") + .trim() + : undefined; + target = line + ? `${line.slice(0, 90)}${line.length > 90 ? "…" : ""}` + : undefined; + } else if ( + name === "buzz-dev-mcp__read_file" || + name === "buzz-dev-mcp__str_replace" || + name === "buzz-dev-mcp__view_image" + ) { + target = basename(input?.path); + title = target + ? name.endsWith("read_file") + ? "Read file" + : name.endsWith("str_replace") + ? "Edit file" + : "View image" + : entry.title; + } + // Only unwrap the observed shell wrapper. Missing/malformed fields retain the + // exact output text; raw source is accessible for every presentation below. + const result = + name === "buzz-dev-mcp__shell" ? parsed(entry.output) : undefined; + const shellOutput = + result && + typeof result.stdout === "string" && + typeof result.stderr === "string" && + Number.isInteger(result.exit_code) && + typeof result.timed_out === "boolean" && + typeof result.stdout_truncated === "boolean" && + typeof result.stderr_truncated === "boolean" + ? { + stdout: result.stdout, + stderr: result.stderr, + failed: result.exit_code !== 0 || result.timed_out, + note: [ + result.exit_code !== 0 ? `Exit code ${result.exit_code}` : "", + result.timed_out ? "Command timed out" : "", + result.stdout_truncated || result.stderr_truncated + ? "Output was truncated by the tool" + : "", + ] + .filter(Boolean) + .join(" · "), + } + : undefined; + const command = + name === "buzz-dev-mcp__shell" && typeof input?.command === "string" + ? input.command + : undefined; + return { title, target, shellOutput, command }; +} diff --git a/src/bundled/agent-activity/current-activity.test.ts b/src/bundled/agent-activity/current-activity.test.ts new file mode 100644 index 000000000..2ba8e0804 --- /dev/null +++ b/src/bundled/agent-activity/current-activity.test.ts @@ -0,0 +1,82 @@ +import { expect, it } from "vitest"; +import { currentActivity } from "./current-activity"; +import type { ActivityRecord } from "../../features/agents/activity-records"; +const frame = (id: string, turnId: string, update: object): ActivityRecord => ({ + id, + envelopeId: id, + agent: "a", + kind: "acp_read", + receivedAt: 1, + plaintext: JSON.stringify({ + kind: "acp_read", + channelId: "c", + turnId, + payload: { method: "session/update", params: { update } }, + }), +}); +const tool = { + sessionUpdate: "tool_call", + toolCallId: "t", + title: "buzz-dev-mcp__read_file", + status: "in_progress", + rawInput: { path: "/project/file.ts" }, +}; +it("does not borrow historical actions when typing has no live turn", () => { + const records = [frame("1", "old", tool)]; + expect(currentActivity(records, [])).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "old", state: "ended" }]), + ).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "old", state: "unknown" }]), + ).toBeUndefined(); + expect( + currentActivity(records, [{ turnId: "new", state: "working" }]), + ).toBeUndefined(); +}); +it("prefers the latest tool over later prose, including folded completions and failures", () => { + const turns = [{ turnId: "t", state: "working" }]; + const records = [ + frame("1", "t", tool), + frame("2", "t", { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Thinking text" }, + }), + ]; + expect(currentActivity(records, turns)).toEqual({ + title: "Read file · Running", + detail: "file.ts", + }); + records.push( + frame("3", "t", { + sessionUpdate: "tool_call_update", + toolCallId: "t", + status: "completed", + }), + ); + expect(currentActivity(records, turns)).toEqual({ + title: "Read file · Completed", + detail: "file.ts", + }); + records.push( + frame("4", "t", { + sessionUpdate: "tool_call_update", + toolCallId: "t", + status: "failed", + }), + ); + expect(currentActivity(records, turns)?.title).toBe("Read file · Failed"); +}); +it("bounds long preview text without rendering it as markup", () => { + const result = currentActivity( + [ + frame("1", "t", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "