diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cdcb27a3a6..9662d06499 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -302,6 +302,8 @@ pub fn bundled_manifests() -> Vec { .expect("projects manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) .expect("agents manifest"), + serde_json::from_str(include_str!("../../../src/bundled/watching/manifest.json")) + .expect("watching manifest"), serde_json::from_str(include_str!( "../../../src/bundled/claude-code/manifest.json" )) @@ -352,6 +354,7 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.voice-notes" | "buzz.huddles" | "buzz.github" + | "buzz.watching" | "buzz.pairing" | "buzz.inbox" | "buzz.reminders" diff --git a/docs/VISION_ATTENTION_SUBSCRIPTIONS.md b/docs/VISION_ATTENTION_SUBSCRIPTIONS.md index 0a2807780b..b5ab5a9c3a 100644 --- a/docs/VISION_ATTENTION_SUBSCRIPTIONS.md +++ b/docs/VISION_ATTENTION_SUBSCRIPTIONS.md @@ -1,7 +1,7 @@ # Attention subscriptions -Proposed direction, October 23, 2026. UI ownership revised October 27, 2026. +Proposed direction, October 23, 2026. UI ownership revised October 28, 2026. An Interest holds task instructions. Its subscriptions specify when to wake the agent and an optional delivery address. Instructions belong to the Interest, not individual subscriptions. Agents create Interests and their watches through tools. -Event watches, timers and plugin sources remain distinct trigger types with shared scheduling and delivery. Preserve existing saved objects, timer budgets and producer completion. The bundled Agents plugin owns the Agent Attention overview for inspecting, pausing and deleting existing watches. +Event watches, timers and plugin sources remain distinct trigger types with shared scheduling and delivery. Preserve existing saved objects, timer budgets and producer completion. The bundled Agent Attention UI plugin (`buzz.watching`) owns inspecting, pausing and deleting existing watches, in a Watching tab on each agent on this computer. diff --git a/docs/agents.md b/docs/agents.md index 6d8704279d..edd66b8bf1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -57,13 +57,14 @@ servers do not support these operations. ## Agent Attention -The Agent Attention tab groups the selected community's local agents' subscriptions by Interest. It shows instructions, listening and delivery destinations, enabled state and available wake history. Ask an agent to create watches through its Attention tools; pausing or removing one preserves the other saved objects. Empty Interests remain visible and can be deleted after their watches are removed. +Attention itself (the runtime, tools, storage and sources) is core and runs whether or not anything displays it. Each agent's Settings tab holds the device switch. The bundled Agent Attention UI plugin (`buzz.watching`, on by default) adds a Watching tab to the settings of every agent running on this device, whatever its type. The tab explains Attention, then groups that agent's subscriptions by Interest. It shows instructions, listening and delivery destinations, enabled state and available wake history, and suggests asks while the agent has none. Ask an agent to create watches through its Attention tools; pausing or removing one preserves the other saved objects. Empty Interests remain visible and can be deleted after their watches are removed. Turning the plugin off hides the tab only. The former standalone Agent Attention page is gone, so an old link to `buzz.agents/attention` opens as an unavailable destination. ## Agent types and providers Harness plugins register agent types with the Agents service. An agent type owns the defaults, migration policy, per-agent settings tabs, model discovery and run -behavior for its agents. The host appends the shared Attention tab. +behavior for its agents. The host adds the device Attention switch to Settings, +and plugins can add a tab to every local agent with `ctx.agents.registerTab`. Attention is shared by every harness, and a harness has no attention code. `src/buzz-mcp/session-tools.ts` exports `BUZZ_SESSION`: the Buzz tools and the @@ -1048,7 +1049,7 @@ Use `watch_add` with `type: "source"`, a `provider` in `plugin/source` form and Source output accepts the same optional classifier questions as event watches. Classification preserves output ordering and completion until the resulting delivery settles. `agents.attentionProviders()` lists active registered provider keys with their optional `description` and `configExample`, including providers from unrelated plugins. Disabled and unloaded registrations disappear from discovery. -The bundled Agents plugin owns the command producer and discovery tool. The GitHub plugin owns the GitHub PR producer. Saved provider IDs remain `attention/command` and `attention/github-pr`, and discovery marks them `builtIn: true`. The bundled Agents plugin also owns the Agent Attention overview and controls for existing watches. The GitHub source uses the same PR parser and feedback policy as `github_watch_pr`; it requires the signed-in `gh` CLI. The tool configures Attention subscriptions rather than owning a separate polling loop. Other plugins can register their own source producers through the same lifecycle. Legacy Monitor command registrations and pending wakes are not imported; recreate needed command watches in Attention. +The bundled Agents plugin owns the command producer and discovery tool. The GitHub plugin owns the GitHub PR producer. Saved provider IDs remain `attention/command` and `attention/github-pr`, and discovery marks them `builtIn: true`. The bundled Agent Attention UI plugin owns the per-agent overview and controls for existing watches. The GitHub source uses the same PR parser and feedback policy as `github_watch_pr`; it requires the signed-in `gh` CLI. The tool configures Attention subscriptions rather than owning a separate polling loop. Other plugins can register their own source producers through the same lifecycle. Legacy Monitor command registrations and pending wakes are not imported; recreate needed command watches in Attention. ## Attention delivery destinations diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 57cf83bc34..fd984fe59e 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -280,7 +280,7 @@ falling back to the sidebar without failing activation. Older hosts ignore place and continue to show primary pages in their sidebar. Harness plugins register an agent type with `ctx.agents.register`. Per-agent -configuration belongs in its `tabs`; the Agents host adds Attention. An optional +configuration belongs in its `tabs`; the Agents host adds the device Attention switch. An optional `provider` contribution exposes a stable readiness snapshot, subscription, read-only `check()` and `DialogBody`. The Agents page supplies the Providers health submenu plus provider title, registered icon, modal framing, dismissal, @@ -291,6 +291,15 @@ active registered types appear there; plugin activation remains owned by Settings → Plugins → Manage plugins. Provider defaults are versioned device preferences, not agent-record migrations; blank per-agent values inherit them. +A plugin, bundled or installed, can add a tab to the settings of every agent +running on this device, whatever its type, with +`ctx.agents.registerTab({ id, title, component })`. The component receives +`{ agent }`; it reads and saves through the plugin's own `ctx.agents`. The tab +follows Activity and precedes History and Settings, in the agent's Build view and +its profile panel, and is not offered for hosted or elsewhere agents. It is +removed when the plugin is turned off or its scope ends. The bundled Agent +Attention UI plugin uses it. + A plugin, bundled or installed, can add a tool to every agent session in the app with `ctx.agents.addTool({ name, description, inputSchema, call })`. `call` gets the agent's Buzz client and where it is working, so the tool acts as the diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index f445a82d66..64d8c6cd47 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -37,6 +37,15 @@ vi.mock("../bundled", async () => ({ manifest: { id: "buzz.agents", name: "Agents", apiVersion: 1 }, module: await import("../bundled/agents"), }, + { + manifest: { + id: "buzz.watching", + name: "Agent Attention UI", + apiVersion: 1, + }, + module: await import("../bundled/watching"), + enabledByDefault: true, + }, { manifest: { id: "buzz.inbox", name: "Inbox", apiVersion: 1 }, module: await import("../bundled/inbox"), @@ -350,18 +359,29 @@ it("lists active primary pages in their sidebar and header placements", async () ).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Agents" })).toBeInTheDocument(); - const attentionTab = screen.getByRole("tab", { name: "Agent Attention" }); - await userEvent.click(attentionTab); - expect( - await screen.findByRole("region", { name: "Agent Attention" }), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "New watch" })).toBeNull(); - // The fixture page is active and searchable but never a sidebar row. expect(current.pages.snapshot().map((page) => page.key)).toContain( "fixture.notes/notes", ); + // The Watching tab is on by default, and attention keeps running without it. + const providers = () => + current.agents.attentionProviders().map(({ provider }) => provider); + await waitFor(() => + expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([ + "buzz.watching/watching", + ]), + ); + await act(() => current.plugins.change("disable", "buzz.watching")); + await waitFor(() => expect(current.agents.addedTabs()).toEqual([])); + expect(providers()).toContain("attention/command"); + await act(() => current.plugins.change("enable", "buzz.watching")); + await waitFor(() => + expect(current.agents.addedTabs().map((tab) => tab.key)).toEqual([ + "buzz.watching/watching", + ]), + ); + await act(() => current.plugins.change("disable", "buzz.agents")); await waitFor(() => expect( @@ -372,9 +392,6 @@ it("lists active primary pages in their sidebar and header placements", async () ); expect(screen.queryByRole("tab", { name: "Agents" })).not.toBeInTheDocument(); - expect( - screen.queryByRole("tab", { name: "Agent Attention" }), - ).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Search Buzz" })); const search = await screen.findByRole("dialog", { name: "Search Buzz" }); expect( diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 0a7aefa715..c75af3c0ac 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -54,7 +54,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ); assert.notEqual(services.accountActions.snapshot()[0], firstFeedback); await settle(); - assert.equal(services.pages.snapshot().length, 7); + assert.equal(services.pages.snapshot().length, 6); const { bundledPlugins } = await vite.ssrLoadModule( "/src/bundled/index.ts", ); @@ -101,16 +101,9 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .find((page) => page.key === "buzz.channels/channels"); assert.equal(messages.primary, true); assert.equal(messages.placement, "topbar"); - const attention = services.pages - .snapshot() - .find((page) => page.key === "buzz.agents/attention"); - assert.equal(attention.title, "Agent Attention"); - assert.equal(attention.layout, "workspace"); - assert.equal(attention.placement, "topbar"); - assert.equal(attention.primary, true); - assert.match( - renderToStaticMarkup(createElement(attention.component)), - /Agent Attention/, + assert.equal( + services.pages.snapshot().some((p) => p.key === "buzz.agents/attention"), + false, ); const inbox = services.pages .snapshot() @@ -341,7 +334,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services.pages.snapshot().some((page) => page.pluginId === "buzz.bestie"), false, ); - assert.equal(services.pages.snapshot().length, 7); + assert.equal(services.pages.snapshot().length, 6); await services.plugins.change("enable", "buzz.bestie"); // Management completion is not activation completion; Cordis still owns import/disposal barriers. await vi.waitFor(() => @@ -420,7 +413,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", const projects = services.pages .snapshot() .find((page) => page.pluginId === "buzz.repos"); - assert.equal(services.pages.snapshot().length, 7); + assert.equal(services.pages.snapshot().length, 6); assert.equal(projects.pluginId, "buzz.repos"); assert.equal(projects.id, "repos"); assert.equal(projects.title, "Repository"); @@ -457,7 +450,6 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.channels", "buzz.reminders", "buzz.agents", - "buzz.agents", "buzz.inbox", "buzz.bestie", ], diff --git a/src/app/shell/presentation.test.ts b/src/app/shell/presentation.test.ts index 022ee972f0..d6dfabdd5e 100644 --- a/src/app/shell/presentation.test.ts +++ b/src/app/shell/presentation.test.ts @@ -6,7 +6,6 @@ import { BellIcon, BrowserIcon, ChatsCircleIcon, - EyeIcon, RobotIcon, } from "../../shared/design-system/icons/index"; import { @@ -35,18 +34,11 @@ const messages = page("buzz.channels/channels", "Channels"); const inbox = page("buzz.inbox/inbox", "Inbox"); const bestie = page("buzz.bestie/bestie", "Bestie"); const projects = page("fixture.projects/projects", "Projects"); -const attention = page("buzz.agents/attention", "Agent Attention"); test("bundled page order ignores activation order without mutating the registry", () => { - const input = Object.freeze([attention, projects, bestie, messages, inbox]); - expect(orderPages(input)).toEqual([ - messages, - inbox, - bestie, - projects, - attention, - ]); - expect(input).toEqual([attention, projects, bestie, messages, inbox]); + const input = Object.freeze([projects, bestie, messages, inbox]); + expect(orderPages(input)).toEqual([messages, inbox, bestie, projects]); + expect(input).toEqual([projects, bestie, messages, inbox]); expect(orderPages([messages, projects])).toEqual([messages, projects]); expect(orderPages([projects])).toEqual([projects]); expect(orderPages([])).toEqual([]); @@ -87,7 +79,6 @@ test("other pages sort by label then full key and cannot claim bundled slots", ( test("bundled pages get their own icons and Bestie's row shows its artwork", () => { expect(pagePresentation(inbox).icon).toBe(BellIcon); - expect(pagePresentation(attention).icon).toBe(EyeIcon); expect(pagePresentation(page("buzz.agents/agents", "Agents")).icon).toBe( RobotIcon, ); diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index 37cc4506de..a39f6fd8f0 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -5,7 +5,6 @@ import { BrowserIcon, ChartBarIcon, ChatsCircleIcon, - EyeIcon, NoteIcon, GearIcon, LightningIcon, @@ -30,7 +29,6 @@ const bundledOrder = [ ]; export function orderPages(pages: readonly RegisteredPage[]) { const rank = (page: RegisteredPage) => { - if (page.key === "buzz.agents/attention") return bundledOrder.length + 1; const slot = bundledOrder.indexOf(page.key); return slot === -1 ? bundledOrder.length : slot; }; @@ -50,7 +48,6 @@ const bundledIcons = new Map([ ["buzz.reminders/reminders", AlarmIcon], ["buzz.bestie/bestie", BestieIcon], ["buzz.agents/agents", RobotIcon], - ["buzz.agents/attention", EyeIcon], ["buzz.workflows/workflows", LightningIcon], ["buzz.relay-notes/notes", NoteIcon], ["buzz.usage/usage", ChartBarIcon], diff --git a/src/bundled/agents/AgentDetails.test.tsx b/src/bundled/agents/AgentDetails.test.tsx index 46df984a51..65486cd08f 100644 --- a/src/bundled/agents/AgentDetails.test.tsx +++ b/src/bundled/agents/AgentDetails.test.tsx @@ -97,6 +97,8 @@ it("shows every affected community with unsaved shared settings and submits the }, snapshot: () => ({ agents: [agent] }), configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, memberships: () => [ { key: agent.key, origin: "https://current.example", connected: true }, { key: "other", origin: "wss://other.example", connected: false }, diff --git a/src/bundled/agents/AgentTabs.test.tsx b/src/bundled/agents/AgentTabs.test.tsx index 3c16a96fea..2fef16ac16 100644 --- a/src/bundled/agents/AgentTabs.test.tsx +++ b/src/bundled/agents/AgentTabs.test.tsx @@ -142,6 +142,8 @@ function editingFixture(split: boolean, save = vi.fn(async () => {})) { classifier: () => "unavailable", memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents, agent, type, @@ -385,6 +387,8 @@ it.each(["here", "hosted"] as const)( subscribe: () => () => {}, memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents } agent={agent} @@ -478,6 +482,8 @@ it("shows History for an agent whose config syncs", () => { { save: vi.fn(), configHistory: () => history, + addedTabs: () => [], + registerTab: () => {}, snapshot: () => ({ agents: [agent] }), subscribe: () => () => {}, } as unknown as Agents @@ -493,3 +499,48 @@ it("shows History for an agent whose config syncs", () => { ); expect(screen.getByRole("tab", { name: "History" })).toBeInTheDocument(); }); + +it("places plugin tabs after Activity and before Settings", () => { + const { props } = editingFixture(true); + const Page = () =>

; + render( + [ + { + id: "watching", + key: "buzz.watching/watching", + pluginId: "buzz.watching", + revision: "1", + title: "Watching", + component: Page, + }, + ], + } as unknown as Agents + } + />, + ); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Identity", + "Memory", + "Activity", + "Watching", + "Settings", + ]); +}); diff --git a/src/bundled/agents/AgentTabs.tsx b/src/bundled/agents/AgentTabs.tsx index 07b2429c6f..a2f062e894 100644 --- a/src/bundled/agents/AgentTabs.tsx +++ b/src/bundled/agents/AgentTabs.tsx @@ -59,6 +59,7 @@ export function AgentTabs(props: { ...supplied.filter((section) => section.value.startsWith("agent:type:")), ...supplied.filter((section) => section.value === "agent:memory"), ...supplied.filter((section) => section.value === "agent:activity"), + ...supplied.filter((section) => section.value.startsWith("agent:added:")), ...supplied.filter((section) => section.value === "agent:history"), { value: "agent:management", diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 2085960de9..64f8dd7edd 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -386,6 +386,8 @@ function setup( registerHook: vi.fn(), dispatchHook: vi.fn(async () => undefined), configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } satisfies Agents; const disconnected = { status: "disconnected", generation: 0 }; const relay = { diff --git a/src/bundled/agents/index.tsx b/src/bundled/agents/index.tsx index 3ecd195767..af3bc9430c 100644 --- a/src/bundled/agents/index.tsx +++ b/src/bundled/agents/index.tsx @@ -1,4 +1,3 @@ -import { createPage } from "./attention/Page"; import type { PluginModule } from "../../plugins/api"; import { registerAttention } from "../../features/agents/attention-command"; import { registerDiscovery } from "../../features/agents/attention-discovery"; @@ -35,18 +34,6 @@ export const apply: PluginModule["apply"] = (ctx) => { /> ), }); - ctx.pages.register({ - id: "attention", - title: "Agent Attention", - placement: "topbar", - layout: "workspace", - primary: true, - component: createPage({ - agents, - relay, - navigation: navigator, - }), - }); ctx.pages.register({ id: "agents", title: "Agents", diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index a654ddd2a2..12761c5a61 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -1172,6 +1172,8 @@ it.each(["Identity", "Settings"])( const agents = { save, configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, memberships: () => [], subscribe: () => () => {}, } as unknown as Agents; diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 04b2e3cf00..81f7051a6b 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -1517,6 +1517,8 @@ it.each(["Identity", "Settings"])( const agents = { save, configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, memberships: () => [], subscribe: () => () => {}, } as unknown as Agents; diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 7a73757b11..43b2432c15 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -24,6 +24,8 @@ import profilesManifest from "./profiles/manifest.json"; import * as profiles from "./profiles"; import agentsManifest from "./agents/manifest.json"; import * as agents from "./agents"; +import watchingManifest from "./watching/manifest.json"; +import * as watching from "./watching"; import claudeCodeManifest from "./claude-code/manifest.json"; import * as claudeCode from "./claude-code"; import codexManifest from "./codex/manifest.json"; @@ -174,6 +176,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: agents, enabledByDefault: true, }, + { + manifest: { ...watchingManifest, apiVersion: 1 }, + module: watching, + enabledByDefault: true, + }, { manifest: { ...claudeCodeManifest, apiVersion: 1 }, module: claudeCode, diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx index 9b72434ba9..175b3e070f 100644 --- a/src/bundled/pi/tabs.test.tsx +++ b/src/bundled/pi/tabs.test.tsx @@ -419,6 +419,8 @@ it("guards an invalid Pi provider draft through an agent tab switch", async () = subscribe: () => () => {}, memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents } agent={agent} diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index b5c0482d20..8b1c70388d 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -421,6 +421,8 @@ function renderAgentActivity( find: (pubkey: string) => (pubkey === key ? agent : undefined), memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents; const target = profileActivityViewTarget(key, { transcript: !!transcript }) ?? ""; diff --git a/src/bundled/agents/attention/AttentionPage.css b/src/bundled/watching/AttentionPage.css similarity index 96% rename from src/bundled/agents/attention/AttentionPage.css rename to src/bundled/watching/AttentionPage.css index b24174820b..925b90814c 100644 --- a/src/bundled/agents/attention/AttentionPage.css +++ b/src/bundled/watching/AttentionPage.css @@ -1,17 +1,9 @@ .attention-page { - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; -} -.attention-scroll { - min-height: 0; - flex: 1; - overflow: auto; - padding: var(--space-4) var(--space-panel-inset) var(--space-6); + min-width: 0; } .attention-content, -.attention-agent { +.attention-agent, +.attention-suggestions { display: grid; gap: var(--space-4); min-width: 0; @@ -66,7 +58,8 @@ align-items: center; gap: var(--space-2); } -.attention-agent { +.attention-agent, +.attention-suggestions { container-type: inline-size; padding: var(--space-4); border: 1px solid var(--border-standard); diff --git a/src/bundled/agents/attention/InstructionsMarkdown.test.tsx b/src/bundled/watching/InstructionsMarkdown.test.tsx similarity index 100% rename from src/bundled/agents/attention/InstructionsMarkdown.test.tsx rename to src/bundled/watching/InstructionsMarkdown.test.tsx diff --git a/src/bundled/agents/attention/InstructionsMarkdown.tsx b/src/bundled/watching/InstructionsMarkdown.tsx similarity index 100% rename from src/bundled/agents/attention/InstructionsMarkdown.tsx rename to src/bundled/watching/InstructionsMarkdown.tsx diff --git a/src/bundled/watching/README.md b/src/bundled/watching/README.md new file mode 100644 index 0000000000..2a393855c3 --- /dev/null +++ b/src/bundled/watching/README.md @@ -0,0 +1,10 @@ +# Agent Attention UI + +See what each agent is watching, and pause or remove it, from the agent's own settings. + +## Getting started + +1. Open **Agents**, choose an agent that runs on this computer, and open its **Watching** tab. +2. Pause or delete any Interest or watch there, or use the switch to pause watching on this device. To start watching something, ask the agent: for example, "Watch my PR and tell me when CI finishes or someone reviews it". + +Agent Attention UI is on by default. Turn it off in **Settings** → **Plugins** to hide the tab; attention keeps running. diff --git a/src/bundled/agents/attention/Page.test.tsx b/src/bundled/watching/Tab.test.tsx similarity index 84% rename from src/bundled/agents/attention/Page.test.tsx rename to src/bundled/watching/Tab.test.tsx index 5b5490f186..4721c05153 100644 --- a/src/bundled/agents/attention/Page.test.tsx +++ b/src/bundled/watching/Tab.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom import * as React from "react"; -import userEvent from "@testing-library/user-event"; import { act, cleanup, @@ -11,8 +10,9 @@ import { within, } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import type { Context } from "../../../plugins/author"; -import { createPage, describe } from "./Page"; +import { AgentSaveState } from "../../features/agents/AgentSaveButton"; +import type { Context } from "../../plugins/author"; +import { createTab, describe } from "./Tab"; afterEach(cleanup); const viewer = "f".repeat(64); @@ -53,7 +53,7 @@ it("keeps listening channels distinct from destinations and respects rearmed tim ).spent, ).toBe(true); }); -it("hides agents without subscriptions, reacts to changes and surfaces save errors", async () => { +it("shows one agent's watches, reacts to changes and surfaces save errors", async () => { let changed = () => {}; const enabled = true; const record = () => ({ @@ -121,10 +121,10 @@ it("hides agents without subscriptions, reacts to changes and surfaces save erro }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - const Page = createPage(ctx); - render(React.createElement(Page)); - expect(screen.queryByRole("region", { name: "Bo" })).toBeNull(); - const ada = screen.getByRole("region", { name: "Ada" }); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); + const ada = screen.getByRole("region", { name: "Watching" }); expect(within(ada).getByRole("heading", { name: "task" })).toBeTruthy(); expect(within(ada).getByText("1 interest · 1 watch")).toBeTruthy(); fireEvent.click( @@ -156,7 +156,7 @@ it("hides agents without subscriptions, reacts to changes and surfaces save erro expect(within(ada).getByText("Paused")).toBeTruthy(); expect( within(ada).getByRole("switch", { - name: "Attention for Ada on this device", + name: "Run Ada’s watches on this device", }), ).toBeTruthy(); fireEvent.click(within(ada).getByRole("switch", { name: "Watch task" })); @@ -178,10 +178,10 @@ it("hides agents without subscriptions, reacts to changes and surfaces save erro }; changed(); }); - expect(within(ada).getByText("Attention paused on this device")).toBeTruthy(); const toggle = within(ada).getByRole("switch", { - name: "Attention for Ada on this device", + name: "Run Ada’s watches on this device", }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); fireEvent.click(toggle); await waitFor(() => expect(save).toHaveBeenCalledWith("membership-a", { @@ -280,7 +280,9 @@ it.each(["source", "event", "timer"] as const)( }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); expect( screen .getByRole("button", { name: "Delete Interest release" }) @@ -349,7 +351,9 @@ it.each(["source", "event", "timer"] as const)( rejectInterest = false; fireEvent.click(removeInterest); await waitFor(() => - expect(screen.queryByRole("region", { name: "Ada" })).toBeNull(), + expect( + screen.getByText("Nothing is being watched yet. Try asking Ada:"), + ).toBeTruthy(), ); expect(save).toHaveBeenLastCalledWith("membership-a", { attention: { "interest/release": null }, @@ -357,7 +361,7 @@ it.each(["source", "event", "timer"] as const)( }, ); -it("keeps subscription controls disabled while saving and preserves the row on failure", async () => { +it("keeps subscription controls disabled and the tab open while saving, and preserves the row on failure", async () => { const record = { key: "membership-a", pubkey: "a", @@ -398,7 +402,14 @@ it("keeps subscription controls disabled while saving and preserves the row on f agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); + const report = vi.fn(); + render( + React.createElement( + AgentSaveState, + { value: report }, + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ), + ); const toggle = screen.getByRole("switch", { name: "Watch Build", }) as HTMLButtonElement; @@ -410,83 +421,27 @@ it("keeps subscription controls disabled while saving and preserves the row on f expect(toggle.getAttribute("aria-disabled")).toBe("true"), ); expect(remove.getAttribute("aria-disabled")).toBe("true"); + expect(report).toHaveBeenLastCalledWith( + { dirty: false, saving: true }, + expect.any(String), + ); fireEvent.click(remove); expect(save).toHaveBeenCalledTimes(1); await act(async () => reject(new Error("Could not save"))); await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Could not save"), ); + expect(report).toHaveBeenLastCalledWith( + { dirty: false, saving: false }, + expect.any(String), + ); expect(toggle.getAttribute("aria-disabled")).not.toBe("true"); expect(remove.getAttribute("aria-disabled")).not.toBe("true"); expect(toggle.getAttribute("aria-checked")).toBe("true"); expect(save).toHaveBeenCalledTimes(1); }); -it("resolves agent avatars through relay media, updates pictures and falls back on failure", async () => { - let changed = () => {}; - let agent = { - key: "membership-a", - pubkey: "a", - owner: viewer, - relay: "wss://example.com", - name: "Ada", - picture: "https://example.com/avatar.png", - attentionEnabled: true, - timers: {}, - attention: { - "watch/check": { - slug: "watch/check", - modifiedAt: 1, - value: { - type: "source", - interest_id: "task", - enabled: true, - provider: "attention/command", - config: { command: ["check"] }, - }, - }, - }, - }; - let snapshot = { status: "ready", running: [agent] }; - const media = vi.fn( - (picture: string) => `https://media.example/${encodeURIComponent(picture)}`, - ); - const connection = { status: "ready", ...community, session: { media } }; - const ctx = { - react: React, - agents: { - snapshot: () => snapshot, - subscribe: (listener: () => void) => { - changed = listener; - return () => {}; - }, - }, - relay: { snapshot: () => connection, subscribe: () => () => {} }, - } as unknown as Context; - const { container } = render(React.createElement(createPage(ctx))); - expect(media).toHaveBeenCalledWith("https://example.com/avatar.png", "small"); - const image = container.querySelector("img") as HTMLImageElement; - expect(image.src).toBe( - "https://media.example/https%3A%2F%2Fexample.com%2Favatar.png", - ); - fireEvent.load(image); - expect(image.getAttribute("data-loaded")).toBe("true"); - expect(container.querySelector(".buzz-avatar")?.textContent).toBe(""); - const picture = "data:image/png;base64,AA=="; - await act(async () => { - agent = { ...agent, picture }; - snapshot = { ...snapshot, running: [agent] }; - changed(); - }); - const replacement = container.querySelector("img") as HTMLImageElement; - expect(replacement.src).toBe(picture); - expect(replacement.getAttribute("data-loaded")).toBeNull(); - fireEvent.error(replacement); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector(".buzz-avatar")?.textContent).toBe("A"); -}); - -it("enables a chosen empty agent without creating subscriptions and reports failures", async () => { +it("explains Watching to a new agent and switches it on this device", async () => { const snapshot = { status: "ready", running: [ @@ -512,36 +467,86 @@ it("enables a chosen empty agent without creating subscriptions and reports fail agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); - expect(screen.queryByRole("region", { name: "Bo" })).toBeNull(); - fireEvent.click( - screen.getByRole("button", { name: "Enable Attention on this device" }), + render( + React.createElement(createTab(ctx), { agent: { key: "membership-b" } }), ); expect( - ( - screen.getByRole("button", { - name: "Enable Attention", - }) as HTMLButtonElement - ).disabled, - ).toBe(true); - const user = userEvent.setup(); - await user.click(screen.getByRole("combobox", { name: "Agent" })); - await user.click(await screen.findByRole("option", { name: "Bo" })); - fireEvent.click(screen.getByRole("button", { name: "Enable Attention" })); + screen.getByText(/Agents can watch for events and automatically wake/), + ).toBeTruthy(); + expect( + screen.getByText("Watches only run while Buzz is open on this device."), + ).toBeTruthy(); + expect( + screen.getByText("Nothing is being watched yet. Try asking Bo:"), + ).toBeTruthy(); + expect( + screen.getAllByRole("listitem").map((item) => item.textContent), + ).toEqual([ + "\u201cWatch this thread for new messages\u201d", + "\u201cWatch my PR and tell me when CI finishes or someone reviews it\u201d", + "\u201cCheck this channel every morning at 9\u201d", + ]); + expect(screen.queryByRole("button")).toBeNull(); + const toggle = screen.getByRole("switch", { + name: "Run Bo’s watches on this device", + }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(toggle); await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Enable failed"), ); - fireEvent.click(screen.getByRole("button", { name: "Enable Attention" })); - await waitFor(() => - expect(screen.getByRole("status").textContent).toContain( - "Attention enabled for Bo", - ), - ); + fireEvent.click(toggle); + await waitFor(() => expect(save).toHaveBeenCalledTimes(2)); expect(save).toHaveBeenLastCalledWith("membership-b", { attentionEnabled: true, }); - expect(screen.queryByRole("form")).toBeNull(); - expect(screen.queryByRole("region", { name: "Bo" })).toBeNull(); +}); + +it("says why an agent's watches are not shown", async () => { + let snapshot = { + status: "loading", + running: [] as Record[], + }; + let connection: Record = { + status: "disconnected", + ...community, + }; + let changed = () => {}; + const ctx = { + agents: { + snapshot: () => snapshot, + subscribe: () => () => {}, + save: vi.fn(), + }, + relay: { + snapshot: () => connection, + subscribe: (listener: () => void) => { + changed = listener; + return () => {}; + }, + }, + } as unknown as Context; + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); + expect(screen.getByRole("status").textContent).toBe("Loading watches…"); + snapshot = { status: "ready", running: [] }; + await act(async () => { + connection = { ...connection }; + changed(); + }); + expect( + await screen.findByText("This agent isn't running on this device."), + ).toBeTruthy(); + await act(async () => { + connection = { status: "disconnected", origin, viewer, scope: "" }; + changed(); + }); + expect( + await screen.findByText( + "Choose a community to manage this agent's watches.", + ), + ).toBeTruthy(); }); it.each([undefined, "missing"])( @@ -583,8 +588,10 @@ it.each([undefined, "missing"])( agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); - const repair = screen.getByRole("region", { name: "Without an interest" }); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); + const repair = screen.getByRole("region", { name: "Stray watches" }); expect(within(repair).getByText("watch/check")).toBeTruthy(); expect( screen.queryByRole("switch", { name: "Watch Release check" }), @@ -629,7 +636,9 @@ it("keeps unreadable legacy watches visible and removes only the selected object agents: { snapshot: () => snapshot, subscribe: () => () => {}, save }, relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); expect(screen.getByText("Legacy filter needs repair")).toBeTruthy(); fireEvent.click( screen.getByRole("button", { name: "Remove unreadable watch/old" }), @@ -736,7 +745,9 @@ it.each([false, true])( }, relay: { snapshot: () => connection, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); const icon = (name: string) => screen .getByRole("button", { name: `Open destination ${name}` }) @@ -745,7 +756,7 @@ it.each([false, true])( expect( screen.getByRole("button", { name: "Open destination Berd-call" }) .textContent, - ).toBe("→Berd-call"); + ).toBe("Replies inBerd-call"); expect(icon("Berd-call")).toBeTruthy(); expect(icon("buzz-github-prs")).toBeTruthy(); expect(icon("Berd-call")).not.toBe(icon("buzz-github-prs")); @@ -777,12 +788,12 @@ it.each([false, true])( "https://github.com/block/buzz-app/pull/1578", ); expect(screen.queryByText("Show raw")).toBeNull(); - expect(screen.getByText(/Message classifiers cannot run/)).toBeTruthy(); + expect(screen.getByText(/can't run message filters/)).toBeTruthy(); act(() => { snapshot = { ...snapshot, classifier: "available" }; changed(); }); - expect(screen.queryByText(/Message classifiers cannot run/)).toBeNull(); + expect(screen.queryByText(/can't run message filters/)).toBeNull(); }, ); @@ -860,10 +871,10 @@ it("keeps overview and destination navigation in the selected community", async }, navigation: { open }, } as unknown as Context; - render(React.createElement(createPage(ctx))); - expect(screen.getByRole("region", { name: "Ada" })).toBeTruthy(); - expect(screen.queryByRole("region", { name: "Bo" })).toBeNull(); - expect(screen.queryByRole("region", { name: "Cy" })).toBeNull(); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); + expect(screen.getByRole("region", { name: "Watching" })).toBeTruthy(); fireEvent.click( screen.getByRole("button", { name: "Open destination Release planning" }), ); @@ -881,10 +892,16 @@ it("keeps overview and destination navigation in the selected community", async }; changed(); }); - expect(screen.queryByRole("region", { name: "Ada" })).toBeNull(); - expect(screen.getByRole("region", { name: "Bo" })).toBeTruthy(); + expect( + screen.getByText( + "This agent belongs to another community. Switch to it to manage its watches.", + ), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Open destination Release planning" }), + ).toBeNull(); expect(save).not.toHaveBeenCalled(); - expect(screen.queryByRole("switch", { name: "Attention for Bo" })).toBeNull(); + expect(screen.queryByRole("switch")).toBeNull(); }); it("keeps technical data out of message summaries and avoids repeating the destination", () => { @@ -939,7 +956,9 @@ it("keeps technical data out of message summaries and avoids repeating the desti agents: { snapshot: () => snapshot, subscribe: () => () => {} }, relay: { snapshot: () => connection, subscribe: () => () => {} }, } as unknown as Context; - render(React.createElement(createPage(ctx))); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); expect(screen.getByRole("heading", { name: "release" })).toBeTruthy(); expect( screen.getByRole("button", { name: "Open destination Releases" }), diff --git a/src/bundled/agents/attention/Page.tsx b/src/bundled/watching/Tab.tsx similarity index 80% rename from src/bundled/agents/attention/Page.tsx rename to src/bundled/watching/Tab.tsx index 3ed5813d4e..b55ef57e5b 100644 --- a/src/bundled/agents/attention/Page.tsx +++ b/src/bundled/watching/Tab.tsx @@ -1,12 +1,8 @@ -import { relativeTimestamp } from "../../../shared/relative-timestamp"; -import { - attentionOf, - sourceCompleted, -} from "../../../features/agents/attention"; -import { relayOrigin } from "../../../features/communities/destination"; -import { relayPartition } from "../../../features/relay/partition"; -import { avatarMedia } from "../../../shared/avatar-source"; -import { Avatar } from "../../../shared/design-system/ui/Avatar"; +import { relativeTimestamp } from "../../shared/relative-timestamp"; +import { useAgentSaveState } from "../../features/agents/AgentSaveButton"; +import { attentionOf, sourceCompleted } from "../../features/agents/attention"; +import { relayOrigin } from "../../features/communities/destination"; +import { relayPartition } from "../../features/relay/partition"; import { ChatCircleIcon, LockIcon, @@ -19,17 +15,12 @@ import { CaretDownIcon, CaretRightIcon, TrashIcon, -} from "../../../shared/design-system/icons"; +} from "../../shared/design-system/icons"; import * as React from "react"; -import { - PanelHeader, - PanelHeaderLabel, -} from "../../../shared/design-system/ui/PanelHeader"; -import { Button } from "../../../shared/design-system/ui/Button"; -import { IconButton } from "../../../shared/design-system/ui/IconButton"; -import { Switch as SharedSwitch } from "../../../shared/design-system/ui/Switch"; -import { Select } from "../../../shared/design-system/ui/Select"; -import type { Context } from "../../../plugins/author"; +import { Button } from "../../shared/design-system/ui/Button"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { Switch as SharedSwitch } from "../../shared/design-system/ui/Switch"; +import type { Context } from "../../plugins/author"; import type { Agent, Watch } from "./model"; import { InstructionsMarkdown } from "./InstructionsMarkdown"; @@ -100,10 +91,15 @@ const channelIcon = (channel?: Channel) => : channel?.private ? LockIcon : HashIcon; +const suggestions = [ + "Watch this thread for new messages", + "Watch my PR and tell me when CI finishes or someone reviews it", + "Check this channel every morning at 9", +]; const button = { variant: "ghost" as const, size: "sm" as const }; /** Uses the host’s controls and surface roles. */ -export function createPage({ +export function createTab({ agents, relay, navigation, @@ -118,6 +114,8 @@ export function createPage({ function useAction() { const [pending, setPending] = React.useState(false); const [error, setError] = React.useState(""); + // Agent tabs stay open until a pending save settles, so its error shows. + useAgentSaveState(false, pending); const run = async (action: () => Promise) => { if (pending) return; setPending(true); @@ -169,7 +167,7 @@ export function createPage({ const text = h( "span", { className: "attention-destination-label" }, - "→", + "Replies in", h(channelIcon(summary), { size: 16, "aria-hidden": true }), h( "span", @@ -258,7 +256,7 @@ export function createPage({ const state = completed ? "Completed" : description.spent - ? "Spent" + ? "Used up" : !watch.enabled ? "Paused" : !agent.attentionEnabled @@ -336,9 +334,7 @@ export function createPage({ watch.kinds?.length !== 1 || watch.kinds[0] !== 9, ); - const destination = watch.channel - ? `→ ${name(watch.channel)}${watch.root ? " thread" : ""}` - : "Independent turn"; + const destination = "Wakes in a new conversation"; return h( "li", { @@ -662,13 +658,11 @@ export function createPage({ agent, channels, scope, - picture, returnFocus, }: { agent: Agent; channels: readonly Channel[]; scope?: string | undefined; - picture?: string | undefined; returnFocus: React.RefObject; }) { const action = useAction(); @@ -676,37 +670,30 @@ export function createPage({ const name = (id: string) => find(id)?.name ?? `Channel ${id.slice(0, 8)}`; const { interests, watches, orphans } = attentionOf(agent.attention); const skipped = Object.values(agent.skipped ?? {}); + const empty = + !interests.length && + !watches.length && + !orphans.length && + !skipped.length; return h( - "section", - { className: "attention-agent", "aria-label": agent.name }, + "div", + { className: "attention-agent" }, h( "div", { className: "attention-agent-head" }, h( - "div", - { className: "attention-agent-name" }, - h(Avatar, { - alt: "", - fallback: agent.name, - src: picture, - size: "compact", - shape: "squircle", - }), - h("h3", { className: "text-label" }, agent.name), - h( - "span", - { className: "text-caption text-subtle" }, - `${interests.length} interest${interests.length === 1 ? "" : "s"} · ${watches.length} watch${watches.length === 1 ? "" : "es"}`, - ), + "span", + { className: "text-caption text-subtle" }, + empty + ? "" + : `${interests.length} interest${interests.length === 1 ? "" : "s"} · ${watches.length} watch${watches.length === 1 ? "" : "es"}`, ), h( "label", { className: "attention-agent-toggle text-caption text-subtle" }, - agent.attentionEnabled - ? "Attention on this device" - : "Attention paused on this device", + "Run watches on this device", h(SharedSwitch, { - "aria-label": `Attention for ${agent.name} on this device`, + "aria-label": `Run ${agent.name}’s watches on this device`, checked: agent.attentionEnabled, disabled: action.pending, onCheckedChange: (attentionEnabled) => @@ -787,7 +774,7 @@ export function createPage({ h( "span", { className: "text-caption text-primary whitespace-nowrap" }, - "Instructions:", + "When woken:", ), h( "span", @@ -834,9 +821,9 @@ export function createPage({ "section", { className: "attention-interest", - "aria-label": "Without an interest", + "aria-label": "Stray watches", }, - h("h4", { className: "text-label" }, "Without an interest"), + h("h4", { className: "text-label" }, "Stray watches"), h( "p", { className: "text-body-sm text-subtle" }, @@ -862,14 +849,31 @@ export function createPage({ ), ), ), + empty && + h( + "div", + { className: "attention-suggestions" }, + h( + "p", + { className: "text-body-sm text-secondary" }, + `Nothing is being watched yet. Try asking ${agent.name}:`, + ), + h( + "ul", + { className: "attention-list" }, + ...suggestions.map((ask) => + h( + "li", + { key: ask, className: "attention-card text-body-sm" }, + `\u201c${ask}\u201d`, + ), + ), + ), + ), ); } - return function AttentionPage() { + return function WatchingTab({ agent: { key } }: { agent: { key: string } }) { const page = React.useRef(null); - const [adding, setAdding] = React.useState(false); - const [selected, setSelected] = React.useState(""); - const [notice, setNotice] = React.useState(""); - const action = useAction(); const snapshot = React.useSyncExternalStore( agents.subscribe, agents.snapshot, @@ -890,162 +894,72 @@ export function createPage({ queries?.list ?? emptyChannels, queries?.list ?? emptyChannels, ).channels; - const running = snapshot.running.filter((agent) => { + const agent = snapshot.running.find((agent) => agent.key === key); + const here = (() => { try { return ( + !!agent && relayPartition(relayOrigin(agent.relay), agent.owner) === - connection.scope + connection.scope ); } catch { return false; } - }); - const visible = running.filter( - (agent) => - Object.keys(agent.attention).length > 0 || - Object.keys(agent.skipped ?? {}).length > 0, - ); - const enable = () => - action.run(async () => { - const agent = running.find((agent) => agent.key === selected); - if (!agent) throw new Error("Choose an available agent."); - await agents.save(agent.key, { attentionEnabled: true }); - setNotice( - `Attention enabled for ${agent.name} on this device. Watches appear here when the agent creates them.`, - ); - setAdding(false); - }); + })(); return h( "section", { "data-buzz-ui": "", className: "attention-page", - "aria-label": "Agent Attention", + "aria-label": "Watching", ref: page, tabIndex: -1, }, - - h(PanelHeader, { - title: h(PanelHeaderLabel, { title: "Agent Attention" }), - }), h( "div", - { className: "attention-scroll" }, + { className: "attention-content" }, h( - "div", - { className: "attention-content" }, + "p", + { className: "text-body-sm text-subtle" }, + "Agents can watch for events and automatically wake to handle them. For example, you can ask your agent to watch a channel or thread for replies, watch a GitHub PR for CI changes and reviews, or do something at a set time or on a schedule. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", + ), + h( + "p", + { className: "text-caption text-subtle" }, + "Watches only run while Buzz is open on this device.", + ), + agent && + here && + snapshot.classifier !== "available" && + Object.values(agent.attention).some( + ({ value }) => value.type === "event" && value.classifier, + ) && h( "p", - { className: "text-body-sm text-subtle" }, - "Interests and watches in this community wake your agents.", + { className: "text-body-sm text-secondary" }, + "This device can't run message filters, so every message in these channels wakes the agent.", ), - snapshot.classifier !== "available" && - running.some((agent) => - Object.values(agent.attention).some( - ({ value }) => value.type === "event" && value.classifier, - ), - ) && - h( - "p", - { className: "text-body-sm text-secondary" }, - "Message classifiers cannot run on this device, so every matching event passes.", - ), - !visible.length && - running.some((agent) => !agent.attentionEnabled) && ( - - ), - adding && - h( - "form", - { - className: "attention-card attention-details", - "aria-label": "Enable agent attention", - onSubmit: (event: { preventDefault(): void }) => { - event.preventDefault(); - void enable(); - }, - }, - h(Select, { - label: "Agent", - variant: "field", - value: selected, - disabled: action.pending, - onValueChange: setSelected, - groups: [ - { - label: "Agents", - options: [ - { value: "", label: "Choose an agent" }, - ...running.map((agent) => ({ - value: agent.key, - label: agent.name, - })), - ], - }, - ], - }), - , - problem(action.error), - ), - notice && - h( - "p", - { role: "status", className: "text-body-sm text-secondary" }, - notice, - ), - !visible.length && - running.length > 0 && - h( - "p", - { className: "text-body-sm text-secondary" }, - "No watches yet. Ask an agent to watch something.", - ), - ...visible.map((agent) => - h(AgentPanel, { - key: agent.key, + agent && here + ? h(AgentPanel, { agent: agent as Agent, returnFocus: page, channels, scope: connection.scope, - picture: avatarMedia( - agent.picture, - connection.status === "ready" - ? connection.session.media - : undefined, - ), - }), - ), - !running.length && - h( + }) + : h( "p", { className: "text-body-sm text-secondary", role: snapshot.status === "loading" ? "status" : undefined, }, !connection.scope - ? "Choose a community to manage its agent watches." + ? "Choose a community to manage this agent's watches." : snapshot.status === "loading" - ? "Loading agents…" - : snapshot.error || "No local agents are available.", + ? "Loading watches…" + : agent + ? "This agent belongs to another community. Switch to it to manage its watches." + : "This agent isn't running on this device.", ), - ), ), ); }; diff --git a/src/bundled/watching/index.tsx b/src/bundled/watching/index.tsx new file mode 100644 index 0000000000..4b3980f4bf --- /dev/null +++ b/src/bundled/watching/index.tsx @@ -0,0 +1,11 @@ +import type { PluginModule } from "../../plugins/api"; +import { createTab } from "./Tab"; + +export const inject = ["agents", "relay", "navigation"]; +export const apply: PluginModule["apply"] = ({ agents, relay, navigation }) => { + agents.registerTab({ + id: "watching", + title: "Watching", + component: createTab({ agents, relay, navigation }), + }); +}; diff --git a/src/bundled/watching/manifest.json b/src/bundled/watching/manifest.json new file mode 100644 index 0000000000..5314b73625 --- /dev/null +++ b/src/bundled/watching/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "buzz.watching", + "name": "Agent Attention UI", + "apiVersion": 1, + "description": "Show what each agent is watching in its settings.", + "discovery": { + "category": "agent-tools", + "tags": ["coding-agents"] + } +} diff --git a/src/bundled/agents/attention/model.ts b/src/bundled/watching/model.ts similarity index 83% rename from src/bundled/agents/attention/model.ts rename to src/bundled/watching/model.ts index d5eccc0aeb..9eace6f8f7 100644 --- a/src/bundled/agents/attention/model.ts +++ b/src/bundled/watching/model.ts @@ -1,4 +1,4 @@ -import type { Context } from "../../../plugins/author"; +import type { Context } from "../../plugins/author"; export type AttentionValue = NonNullable< Parameters[1]["attention"] >[string]; diff --git a/src/features/agents/AttentionPanel.test.tsx b/src/features/agents/AttentionPanel.test.tsx index 78cd7117aa..1fa4a23897 100644 --- a/src/features/agents/AttentionPanel.test.tsx +++ b/src/features/agents/AttentionPanel.test.tsx @@ -60,12 +60,10 @@ it("names watches by what and where they wake", () => { ); }); -it("keeps the device runtime control and directs watch management to Agent Attention", async () => { +it("keeps the device runtime control and leaves watch management to the Agent Attention UI plugin", async () => { const save = vi.fn(async () => {}); render(); - expect( - screen.getByText(/Manage Interests and watches in Agent Attention/), - ).toBeInTheDocument(); + expect(screen.queryByText(/Manage Interests and watches/)).toBeNull(); expect(screen.queryByText("Keep releases moving.")).not.toBeInTheDocument(); await userEvent .setup() diff --git a/src/features/agents/AttentionPanel.tsx b/src/features/agents/AttentionPanel.tsx index 52f66b8578..5f0e48b4c4 100644 --- a/src/features/agents/AttentionPanel.tsx +++ b/src/features/agents/AttentionPanel.tsx @@ -1,4 +1,4 @@ -// The device-local runtime control. Agent Attention owns Interests and watches. +// The device-local runtime control. The Agent Attention UI plugin owns Interests and watches. import { useState } from "react"; import { AgentCommunityScope, @@ -133,9 +133,6 @@ export function AttentionPanel({ } /> -

- Manage Interests and watches in Agent Attention. -

); } diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 70f6080a3c..a0d48599d3 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -8310,6 +8310,44 @@ it("reserves a tool name while its plugin is still starting", async () => { owner("buzz.other").agents.addTool(tool); }); +it("adds a plugin's tab to every agent's settings until the plugin goes", async () => { + const { service, ctx } = await setup(); + const owner = (id: string) => + ctx.extend({ pluginOwner: Object.freeze({ id, revision: "bundled" }) }); + const tab = { id: "watching", title: "Watching", component: () => null }; + const before = service.snapshot(); + const listener = vi.fn(); + service.subscribe(listener); + const first = owner("buzz.watching").plugin({ + inject: ["agents"], + apply: (plugin: Context) => plugin.agents.registerTab(tab), + }); + await first; + expect(service.addedTabs()).toMatchObject([ + { + key: "buzz.watching/watching", + pluginId: "buzz.watching", + title: "Watching", + }, + ]); + // Views that read the tabs render again. + expect(service.snapshot()).not.toBe(before); + expect(listener).toHaveBeenCalled(); + for (const bad of [ + { ...tab, id: "Not An Id" }, + { ...tab, title: "" }, + { ...tab, component: undefined as never }, + ]) + expect(() => owner("buzz.other").agents.registerTab(bad)).toThrow( + /Agent tabs need/, + ); + expect(() => owner("buzz.watching").agents.registerTab(tab)).toThrow( + /already registered/, + ); + await first.dispose(); + expect(service.addedTabs()).toEqual([]); +}); + it("refuses a hosted type that also runs here", async () => { const { plugin } = await setup(); const { type } = cloud(); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 60867b59b2..c2f02e1d1e 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -292,6 +292,15 @@ export type AgentTab = Readonly<{ splitSettings?: boolean; component: ComponentType>; }>; +/** A tab a plugin adds to the settings of every agent running on this device, + * whatever its type. It reads the agent and saves through the plugin's own + * `agents`. */ +export type AgentAddedTab = Readonly<{ + id: string; + title: string; + component: ComponentType<{ agent: Agent }>; +}>; +export type RegisteredAgentAddedTab = Contribution; /** One of an agent's conversation sessions on this device. Without `root` it * is the channel's one session: a channel-scoped agent's, a DM's or a Solo * chat's; with it, the thread's. `at` is when it last took in a message @@ -980,6 +989,11 @@ export type Agents = { }>[]; register(type: AgentType): void; types(): readonly RegisteredAgentType[]; + /** Adds a tab to the settings of every agent running on this device, + * whatever its type, after Activity. It shows while the plugin is on. */ + registerTab(tab: AgentAddedTab): void; + /** The tabs active plugins add now. */ + addedTabs(): readonly RegisteredAgentAddedTab[]; /** Adds a tool to every agent session in the app, beside the Buzz tools. * The tool acts as the agent, like the rest of a trusted plugin's code * acts as its owner. A session lists @@ -1431,6 +1445,7 @@ export class AgentsService extends Service implements Agents { ); } private readonly contributions; + private readonly tabContributions; private readonly addedTools; /** Every added tool's name, its plugin active or still starting. */ private readonly toolNames = new Set(); @@ -1542,6 +1557,7 @@ export class AgentsService extends Service implements Agents { super(ctx, "agents"); this.contributions = createContributions(ctx); this.hookContributions = createAgentHooks(ctx); + this.tabContributions = createContributions(ctx); this.addedTools = createContributions(ctx); this.attentionSources = createContributions(ctx); // Each changes the copy at once and logs a failed save itself. @@ -1815,6 +1831,11 @@ export class AgentsService extends Service implements Agents { this.update(); this.notify(); }), + // A new state object, so views reading `addedTabs()` render again. + this.tabContributions.subscribe(() => { + this.state = Object.freeze({ ...this.state }); + this.notify(); + }), communities.subscribe(() => { const { viewer } = communities.snapshot(); const kept = this.requests.filter( @@ -1903,6 +1924,16 @@ export class AgentsService extends Service implements Agents { this.contributions.register(this.ctx, type as unknown as AgentType); } types = () => this.contributions.snapshot(); + registerTab(tab: AgentAddedTab) { + if ( + !/^[a-z0-9][a-z0-9._-]*$/.test(tab.id ?? "") || + !tab.title || + typeof tab.component !== "function" + ) + throw new Error("Agent tabs need an id, a title and a component"); + this.tabContributions.register(this.ctx, tab); + } + addedTabs = () => this.tabContributions.snapshot(); addTool(tool: AddedTool) { if ( !/^[a-z][a-z0-9_]{0,63}$/.test(tool.name ?? "") || diff --git a/src/features/agents/tabs.test.tsx b/src/features/agents/tabs.test.tsx index 973fdd25b2..ef0ee032aa 100644 --- a/src/features/agents/tabs.test.tsx +++ b/src/features/agents/tabs.test.tsx @@ -51,6 +51,8 @@ it("puts the device runtime control in Settings without a separate Attention tab subscribe: () => () => {}, memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents; const sections = agentSections({ agents, @@ -74,6 +76,8 @@ it("supplies the local control once to provider Settings", () => { subscribe: () => () => {}, memberships: () => [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents, agent, type: { @@ -120,6 +124,8 @@ it("shows each synced side's History as its own named section", () => { classifier: () => "available", save: async () => {}, configHistory: () => history, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents; const section = agentSections({ agents, @@ -166,6 +172,8 @@ it("shows Settings Attention scope for the agent relay and follows community upd { key: "other", origin: "https://other.example", connected: false }, ], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, } as unknown as Agents; const section = agentSections({ agents, @@ -223,3 +231,47 @@ it("shows Settings Attention scope for the agent relay and follows community upd view.unmount(); expect(listeners.size).toBe(0); }); + +it("adds plugin tabs to local agents only, before History", () => { + const added = { + id: "watching", + key: "buzz.watching/watching", + pluginId: "buzz.watching", + revision: "1", + title: "Watching", + component: ({ agent }: { agent: Agent }) =>

Watching {agent.name}

, + }; + const agents = { + save: async () => {}, + subscribe: () => () => {}, + memberships: () => [], + configHistory: () => ({ + read: async () => [], + sides: [], + revise: async () => ({ ok: true }), + }), + addedTabs: () => [added], + registerTab: () => {}, + } as unknown as Agents; + const values = (place: Agent["place"]) => + agentSections({ + agents, + agent: { ...agent, place } as Agent, + type: undefined, + memories: undefined, + }).map((tab) => tab.value); + expect(values("here")).toEqual([ + "agent:settings", + "agent:added:buzz.watching/watching", + "agent:history", + "agent:memory", + ]); + expect(values("hosted")).not.toContain("agent:added:buzz.watching/watching"); + expect(values("elsewhere")).toEqual([]); + render( + agentSections({ agents, agent, type: undefined, memories: undefined }) + .find((tab) => tab.value === "agent:added:buzz.watching/watching") + ?.render(), + ); + expect(screen.getByText("Watching Ada")).toBeInTheDocument(); +}); diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index c2f4c2850d..e79c95aa58 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -18,8 +18,9 @@ import { AgentSaveButton, useAgentSaveState } from "./AgentSaveButton"; import { AgentConfigHistory } from "./AgentConfigHistory"; export type AgentSection = Readonly<{ - /** `agent:`, `agent:settings`, `agent:activity` - * or `agent:memory`; never collides with a host tab. */ + /** `agent:`, `agent:settings`, `agent:activity`, + * `agent:added:/` or `agent:memory`; never collides with a + * host tab. */ value: `agent:${string}`; label: string; render(): ReactNode; @@ -110,6 +111,13 @@ export function agentSections({ ), }); + if (agent.place === "here") + for (const { key, title, component: Component } of agents.addedTabs()) + tabs.push({ + value: `agent:added:${key}`, + label: title, + render: () => , + }); if (agent.place === "here" && agents.configHistory(agent.pubkey)) tabs.push({ value: "agent:history", diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 8461a8ecd1..e4f0804034 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -112,6 +112,7 @@ export type { VoiceRecording } from "../features/relay/voice-media"; export type { Agents, + AgentAddedTab, AgentType, AgentSession, AgentSessions, diff --git a/tests/browser/agent-attention.spec.mjs b/tests/browser/agent-attention.spec.mjs index fd98750698..0086818923 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -1,18 +1,14 @@ import { test, expect } from "./source-fixture.mjs"; const runtimeSwitch = (page) => - page.getByRole("switch", { name: "Attention for Ada on this device" }); + page.getByRole("switch", { name: "Run Ada’s watches on this device" }); // Real browser focus and scroll geometry are outside jsdom. -test("Agent Attention fits both themes and sizes and manages existing watches by keyboard", async ({ +test("The Watching tab fits both themes and sizes and manages existing watches by keyboard", async ({ page, }) => { await page.goto("/tests/fixtures/agent-attention.html"); - const pane = page.getByRole("region", { - name: "Agent Attention", - exact: true, - }); - await expect(page.getByRole("button", { name: "New watch" })).toHaveCount(0); + const pane = page.getByRole("region", { name: "Watching", exact: true }); await expect(pane).toBeVisible(); await page .getByRole("button", { name: "Details for Release discussion" }) @@ -35,12 +31,12 @@ test("Agent Attention fits both themes and sizes and manages existing watches by } } const runtime = page.getByRole("switch", { - name: "Attention for Ada on this device", + name: "Run Ada’s watches on this device", }); await runtime.focus(); await page.keyboard.press("Space"); await expect(runtime).toHaveAttribute("aria-checked", "false"); - await expect(page.getByText("Attention paused on this device")).toBeVisible(); + await expect(page.getByText("Paused on this device").first()).toBeVisible(); await expect( page.getByRole("switch", { name: "Watch Release discussion" }), ).toBeVisible(); @@ -68,10 +64,7 @@ test("watch and interest deletion restore focus without taking it from another c page, }) => { await page.goto("/tests/fixtures/agent-attention.html"); - const pane = page.getByRole("region", { - name: "Agent Attention", - exact: true, - }); + const pane = page.getByRole("region", { name: "Watching", exact: true }); const remove = page.getByRole("button", { name: "Delete Release discussion", }); @@ -131,14 +124,14 @@ test("watch and interest deletion restore focus without taking it from another c .poll(() => page.evaluate(() => window.attentionFixture.saveStarted)) .toBe(true); await page - .getByRole("switch", { name: "Attention for Ada on this device" }) + .getByRole("switch", { name: "Run Ada’s watches on this device" }) .focus(); } finally { await page.evaluate(() => window.attentionFixture.releaseSave()); } await expect(remove).toHaveCount(0); await expect( - page.getByRole("switch", { name: "Attention for Ada on this device" }), + page.getByRole("switch", { name: "Run Ada’s watches on this device" }), ).toBeFocused(); }); diff --git a/tests/browser/focus-navigation.spec.mjs b/tests/browser/focus-navigation.spec.mjs index 1e6334d5a1..b86e361510 100644 --- a/tests/browser/focus-navigation.spec.mjs +++ b/tests/browser/focus-navigation.spec.mjs @@ -369,8 +369,13 @@ test("covered panes and notifications preserve focus and F6 recovery", async ({ const mainTab = main.getByRole("tab", { name: "Alpha", exact: true }); await mainTab.evaluate((tab) => { const bounds = tab.getBoundingClientRect(); - tab.closest("[data-shell-tone]").scrollLeft += - bounds.x + bounds.width / 2 + 1; + const shell = tab.closest("[data-shell-tone]"); + // How far the shell overflows depends on the pages in its header, so + // guarantee room to scroll the tab fully offscreen. + const room = document.createElement("div"); + room.style.cssText = "flex:none;height:0;width:200vw"; + shell.append(room); + shell.scrollLeft += bounds.x + bounds.width / 2 + 1; }); await expect .poll(() => diff --git a/tests/browser/page-placement.spec.mjs b/tests/browser/page-placement.spec.mjs index f08b78c95a..4e99eae66d 100644 --- a/tests/browser/page-placement.spec.mjs +++ b/tests/browser/page-placement.spec.mjs @@ -23,7 +23,6 @@ test("shared navigation preserves drafts and history across Solo and header page "Reminders", "Agents", "Workflows", - "Agent Attention", ]); const sidebar = page.getByRole("complementary", { name: "Sidebar", @@ -569,7 +568,6 @@ test("header pages remain reachable without overlap through scale and resize", a "Agents", "Toolbar destination", "Workflows", - "Agent Attention", ]); await page.keyboard.press("Escape"); await expect(choices).toHaveCount(0); diff --git a/tests/browser/plugin-defaults.spec.mjs b/tests/browser/plugin-defaults.spec.mjs index 862cd8d5d1..dfdbdd53d6 100644 --- a/tests/browser/plugin-defaults.spec.mjs +++ b/tests/browser/plugin-defaults.spec.mjs @@ -27,7 +27,13 @@ test("Bestie opts in without changing the other bundled defaults and survives re await plugins(page); const bestie = pluginAction(page, "Bestie"); await expect(bestie).toHaveAccessibleName(/^Enable /); - for (const name of ["GitHub", "Terminal", "Workflows", "Hosted communities"]) + for (const name of [ + "GitHub", + "Terminal", + "Workflows", + "Hosted communities", + "Agent Attention UI", + ]) await expect(pluginAction(page, name)).toHaveAccessibleName(/^Disable /); await bestie.click(); await expect(bestie).toHaveAccessibleName(/^Disable /); diff --git a/tests/fixtures/agent-attention.tsx b/tests/fixtures/agent-attention.tsx index d040e9af2d..181c352d71 100644 --- a/tests/fixtures/agent-attention.tsx +++ b/tests/fixtures/agent-attention.tsx @@ -1,7 +1,7 @@ -// The production page and shared controls, with only the host data boundary replaced. +// The production tab and shared controls, with only the host data boundary replaced. import { createRoot } from "react-dom/client"; import type { Context } from "../../src/plugins/author"; -import { createPage } from "../../src/bundled/agents/attention/Page"; +import { createTab } from "../../src/bundled/watching/Tab"; import "../../src/shared/styles/globals.css"; const channel = "11111111-1111-4111-8111-111111111111"; @@ -43,7 +43,11 @@ let snapshot = { "# Release planning\nReport blockers and keep the release moving.", }, }, - "watch/discussion": { slug: "watch/discussion", modifiedAt: 1, value }, + "watch/discussion": { + slug: "watch/discussion", + modifiedAt: 1, + value, + }, "interest/review": { slug: "interest/review", modifiedAt: 1, @@ -98,7 +102,7 @@ declare global { } } window.attentionFixture = controls; -const Page = createPage({ +const Tab = createTab({ agents: { snapshot: () => snapshot, subscribe: (listener: () => void) => { @@ -148,4 +152,4 @@ const Page = createPage({ } as unknown as Context); const root = document.getElementById("root"); if (!root) throw new Error("Missing fixture root"); -createRoot(root).render(); +createRoot(root).render(); diff --git a/tests/fixtures/agent-library.tsx b/tests/fixtures/agent-library.tsx index bb22719857..5e63991734 100644 --- a/tests/fixtures/agent-library.tsx +++ b/tests/fixtures/agent-library.tsx @@ -277,6 +277,8 @@ const agents = { ] : [], configHistory: () => undefined, + addedTabs: () => [], + registerTab: () => {}, joinable: () => (teamImport ? joining : []), join: async (key: string) => { joined.push(key);