From 39b95e7130de0c603f0b27630678fbcec390bbe8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 28 Oct 2026 14:35:25 -0400 Subject: [PATCH 1/8] feat(agents): move Watching below Inbox and explain it to newcomers The Agent Attention page assumed the reader already knew what Interests, watches and classifiers are. It now opens with a plain explanation, shows suggested asks when empty, notes that watches run only while Buzz is open on this device, and uses plainer card and warning wording. The page is renamed Watching and moves from the topbar to the sidebar, directly below Inbox, through the existing bundled page order. The page id stays `attention`, so links keep working. Signed-off-by: John Tennant Co-authored-by: Poppy Co-Authored-By: Claude Sonnet 5.5 --- docs/agents.md | 2 +- src/app/entity-navigation.test.tsx | 17 +++--- src/app/pages.integration.test.mjs | 6 +- src/app/shell/presentation.test.ts | 4 +- src/app/shell/presentation.ts | 2 +- src/bundled/agents/attention/Page.test.tsx | 39 ++++++++----- src/bundled/agents/attention/Page.tsx | 68 +++++++++++++++------- src/bundled/agents/index.tsx | 3 +- tests/browser/agent-attention.spec.mjs | 16 ++--- tests/browser/layout.spec.mjs | 2 +- tests/browser/page-placement.spec.mjs | 8 ++- 11 files changed, 105 insertions(+), 62 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 6d8704279d..6bd91008a9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -57,7 +57,7 @@ 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. +The Watching page, below Inbox in the sidebar, 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. ## Agent types and providers diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index f445a82d66..93fc340270 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -342,7 +342,7 @@ it("lists active primary pages in their sidebar and header placements", async () within(pages) .getAllByRole("button") .map((row) => row.textContent), - ).toEqual(["Inbox", "Bestie"]), + ).toEqual(["Inbox", "Watching", "Bestie"]), ); expect( @@ -350,10 +350,16 @@ 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" }), + screen.queryByRole("tab", { name: "Watching" }), + ).not.toBeInTheDocument(); + await userEvent.click( + within(pages).getByRole("button", { name: "Watching" }), + ); + expect( + await screen.findByRole("region", { + name: "What your agents are watching", + }), ).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "New watch" })).toBeNull(); @@ -372,9 +378,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..f8e862ad10 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -104,13 +104,13 @@ test("the app runtime exposes ready bundled pages and removes them on disable", const attention = services.pages .snapshot() .find((page) => page.key === "buzz.agents/attention"); - assert.equal(attention.title, "Agent Attention"); + assert.equal(attention.title, "Watching"); assert.equal(attention.layout, "workspace"); - assert.equal(attention.placement, "topbar"); + assert.equal(attention.placement, undefined); assert.equal(attention.primary, true); assert.match( renderToStaticMarkup(createElement(attention.component)), - /Agent Attention/, + /What your agents are watching/, ); const inbox = services.pages .snapshot() diff --git a/src/app/shell/presentation.test.ts b/src/app/shell/presentation.test.ts index 022ee972f0..1ea90de952 100644 --- a/src/app/shell/presentation.test.ts +++ b/src/app/shell/presentation.test.ts @@ -35,16 +35,16 @@ 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"); +const attention = page("buzz.agents/attention", "Watching"); 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, + attention, bestie, projects, - attention, ]); expect(input).toEqual([attention, projects, bestie, messages, inbox]); expect(orderPages([messages, projects])).toEqual([messages, projects]); diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index 37cc4506de..ef6c228034 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -25,12 +25,12 @@ export const shellPresentation = { const bundledOrder = [ "buzz.channels/channels", "buzz.inbox/inbox", + "buzz.agents/attention", "buzz.reminders/reminders", "buzz.bestie/bestie", ]; 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; }; diff --git a/src/bundled/agents/attention/Page.test.tsx b/src/bundled/agents/attention/Page.test.tsx index 5b5490f186..11f707c72f 100644 --- a/src/bundled/agents/attention/Page.test.tsx +++ b/src/bundled/agents/attention/Page.test.tsx @@ -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: "Watching for Ada on this device", }), ).toBeTruthy(); fireEvent.click(within(ada).getByRole("switch", { name: "Watch task" })); @@ -178,9 +178,9 @@ it("hides agents without subscriptions, reacts to changes and surfaces save erro }; changed(); }); - expect(within(ada).getByText("Attention paused on this device")).toBeTruthy(); + expect(within(ada).getByText("Watching paused on this device")).toBeTruthy(); const toggle = within(ada).getByRole("switch", { - name: "Attention for Ada on this device", + name: "Watching for Ada on this device", }); fireEvent.click(toggle); await waitFor(() => @@ -514,27 +514,40 @@ it("enables a chosen empty agent without creating subscriptions and reports fail } as unknown as Context; render(React.createElement(createPage(ctx))); expect(screen.queryByRole("region", { name: "Bo" })).toBeNull(); + expect(screen.getByText(/Agents can ask to be woken later/)).toBeTruthy(); + expect( + screen.getByText("Watches only run while Buzz is open on this device."), + ).toBeTruthy(); + expect( + within(screen.getByRole("region", { name: "Suggested asks" })) + .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", + ]); fireEvent.click( - screen.getByRole("button", { name: "Enable Attention on this device" }), + screen.getByRole("button", { name: "Turn on watching on this device" }), ); expect( ( screen.getByRole("button", { - name: "Enable Attention", + name: "Turn on watching", }) 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" })); + fireEvent.click(screen.getByRole("button", { name: "Turn on watching" })); await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Enable failed"), ); - fireEvent.click(screen.getByRole("button", { name: "Enable Attention" })); + fireEvent.click(screen.getByRole("button", { name: "Turn on watching" })); await waitFor(() => expect(screen.getByRole("status").textContent).toContain( - "Attention enabled for Bo", + "Watching turned on for Bo", ), ); expect(save).toHaveBeenLastCalledWith("membership-b", { @@ -584,7 +597,7 @@ it.each([undefined, "missing"])( relay: { snapshot: () => disconnected, subscribe: () => () => {} }, } as unknown as Context; render(React.createElement(createPage(ctx))); - const repair = screen.getByRole("region", { name: "Without an interest" }); + const repair = screen.getByRole("region", { name: "Stray watches" }); expect(within(repair).getByText("watch/check")).toBeTruthy(); expect( screen.queryByRole("switch", { name: "Watch Release check" }), @@ -745,7 +758,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 +790,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(); }, ); @@ -884,7 +897,7 @@ it("keeps overview and destination navigation in the selected community", async expect(screen.queryByRole("region", { name: "Ada" })).toBeNull(); expect(screen.getByRole("region", { name: "Bo" })).toBeTruthy(); expect(save).not.toHaveBeenCalled(); - expect(screen.queryByRole("switch", { name: "Attention for Bo" })).toBeNull(); + expect(screen.queryByRole("switch", { name: "Watching for Bo" })).toBeNull(); }); it("keeps technical data out of message summaries and avoids repeating the destination", () => { diff --git a/src/bundled/agents/attention/Page.tsx b/src/bundled/agents/attention/Page.tsx index 3ed5813d4e..6e474fe869 100644 --- a/src/bundled/agents/attention/Page.tsx +++ b/src/bundled/agents/attention/Page.tsx @@ -100,6 +100,11 @@ 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. */ @@ -169,7 +174,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 +263,7 @@ export function createPage({ const state = completed ? "Completed" : description.spent - ? "Spent" + ? "Used up" : !watch.enabled ? "Paused" : !agent.attentionEnabled @@ -336,9 +341,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", { @@ -703,10 +706,10 @@ export function createPage({ "label", { className: "attention-agent-toggle text-caption text-subtle" }, agent.attentionEnabled - ? "Attention on this device" - : "Attention paused on this device", + ? "Watching on this device" + : "Watching paused on this device", h(SharedSwitch, { - "aria-label": `Attention for ${agent.name} on this device`, + "aria-label": `Watching for ${agent.name} on this device`, checked: agent.attentionEnabled, disabled: action.pending, onCheckedChange: (attentionEnabled) => @@ -787,7 +790,7 @@ export function createPage({ h( "span", { className: "text-caption text-primary whitespace-nowrap" }, - "Instructions:", + "When woken:", ), h( "span", @@ -834,9 +837,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" }, @@ -911,7 +914,7 @@ export function createPage({ 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.`, + `Watching turned on for ${agent.name} on this device. Watches appear here when the agent creates them.`, ); setAdding(false); }); @@ -920,13 +923,13 @@ export function createPage({ { "data-buzz-ui": "", className: "attention-page", - "aria-label": "Agent Attention", + "aria-label": "What your agents are watching", ref: page, tabIndex: -1, }, h(PanelHeader, { - title: h(PanelHeaderLabel, { title: "Agent Attention" }), + title: h(PanelHeaderLabel, { title: "What your agents are watching" }), }), h( "div", @@ -937,8 +940,14 @@ export function createPage({ h( "p", { className: "text-body-sm text-subtle" }, - "Interests and watches in this community wake your agents.", + "Agents can ask to be woken later: when someone posts, at a set time, or when something changes, like CI finishing on a pull request. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", ), + running.length > 0 && + h( + "p", + { className: "text-caption text-subtle" }, + "Watches only run while Buzz is open on this device.", + ), snapshot.classifier !== "available" && running.some((agent) => Object.values(agent.attention).some( @@ -948,7 +957,7 @@ export function createPage({ h( "p", { className: "text-body-sm text-secondary" }, - "Message classifiers cannot run on this device, so every matching event passes.", + "This device can't run message filters, so every message in these channels wakes the agent.", ), !visible.length && running.some((agent) => !agent.attentionEnabled) && ( @@ -960,7 +969,7 @@ export function createPage({ onClick: () => setAdding(!adding), }} > - {"Enable Attention on this device"} + {"Turn on watching on this device"} ), adding && @@ -968,7 +977,7 @@ export function createPage({ "form", { className: "attention-card attention-details", - "aria-label": "Enable agent attention", + "aria-label": "Turn on watching", onSubmit: (event: { preventDefault(): void }) => { event.preventDefault(); void enable(); @@ -1000,7 +1009,7 @@ export function createPage({ disabled: !selected || action.pending, }} > - {"Enable Attention"} + {"Turn on watching"} , problem(action.error), ), @@ -1013,9 +1022,24 @@ export function createPage({ !visible.length && running.length > 0 && h( - "p", - { className: "text-body-sm text-secondary" }, - "No watches yet. Ask an agent to watch something.", + "section", + { "aria-label": "Suggested asks" }, + h( + "p", + { className: "text-body-sm text-secondary" }, + "No watches yet. Try asking an agent:", + ), + h( + "ul", + { className: "attention-list" }, + ...suggestions.map((ask) => + h( + "li", + { key: ask, className: "attention-card text-body-sm" }, + `\u201c${ask}\u201d`, + ), + ), + ), ), ...visible.map((agent) => h(AgentPanel, { diff --git a/src/bundled/agents/index.tsx b/src/bundled/agents/index.tsx index 3ecd195767..8126902795 100644 --- a/src/bundled/agents/index.tsx +++ b/src/bundled/agents/index.tsx @@ -37,8 +37,7 @@ export const apply: PluginModule["apply"] = (ctx) => { }); ctx.pages.register({ id: "attention", - title: "Agent Attention", - placement: "topbar", + title: "Watching", layout: "workspace", primary: true, component: createPage({ diff --git a/tests/browser/agent-attention.spec.mjs b/tests/browser/agent-attention.spec.mjs index fd98750698..350fdafa52 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -1,15 +1,15 @@ import { test, expect } from "./source-fixture.mjs"; const runtimeSwitch = (page) => - page.getByRole("switch", { name: "Attention for Ada on this device" }); + page.getByRole("switch", { name: "Watching for Ada 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 page 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", + name: "What your agents are watching", exact: true, }); await expect(page.getByRole("button", { name: "New watch" })).toHaveCount(0); @@ -35,12 +35,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: "Watching for Ada 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("Watching paused on this device")).toBeVisible(); await expect( page.getByRole("switch", { name: "Watch Release discussion" }), ).toBeVisible(); @@ -69,7 +69,7 @@ test("watch and interest deletion restore focus without taking it from another c }) => { await page.goto("/tests/fixtures/agent-attention.html"); const pane = page.getByRole("region", { - name: "Agent Attention", + name: "What your agents are watching", exact: true, }); const remove = page.getByRole("button", { @@ -131,14 +131,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: "Watching for Ada 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: "Watching for Ada on this device" }), ).toBeFocused(); }); diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index c8bb62bf00..c765916c2c 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -63,7 +63,7 @@ const companionLauncher = (page, name) => button(page, name).and(page.locator("button[aria-expanded]")); // The fixture's active plugin pages, in shell order, lead the channel sidebar. // Sidebar rows exclude header pages; Messages remains available through search. -const destinationTitles = ["Inbox", "Bestie"]; +const destinationTitles = ["Inbox", "Watching", "Bestie"]; const sidebarDestinations = (page, options = {}) => page .getByRole("complementary", { name: "Sidebar", exact: true, ...options }) diff --git a/tests/browser/page-placement.spec.mjs b/tests/browser/page-placement.spec.mjs index f08b78c95a..17808f7d96 100644 --- a/tests/browser/page-placement.spec.mjs +++ b/tests/browser/page-placement.spec.mjs @@ -23,8 +23,13 @@ test("shared navigation preserves drafts and history across Solo and header page "Reminders", "Agents", "Workflows", - "Agent Attention", ]); + // Watching is a sidebar page, ordered directly below Inbox. + const rows = page + .getByRole("navigation", { name: "Pages" }) + .getByRole("button"); + await expect(rows.nth(0)).toHaveText("Inbox"); + await expect(rows.nth(1)).toHaveText("Watching"); const sidebar = page.getByRole("complementary", { name: "Sidebar", exact: true, @@ -569,7 +574,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); From bdacb01bab5bc01cee7eebbf37d27cf9c4858f32 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 28 Oct 2026 16:27:59 -0400 Subject: [PATCH 2/8] feat(agents): move Watching into an off-by-default agent tab plugin Replace the standalone Watching page and its sidebar entry with a `buzz.watching` plugin (off by default) that adds a Watching tab to every local agent's settings through a new `ctx.agents.registerTab({ id, title, component })`. The host places plugin tabs after Activity and before History and Settings, for agents that run on this device and of any type, and removes them when the plugin unloads. The tab shows the agent's Interests and watches with the existing pause, delete and device controls, and teaches an agent with nothing to watch what to ask for. Mentions and the device switch stay in Settings, and the attention runtime, tools and providers keep working with the plugin off. The Agent Attention page registration and its sidebar order and icon are removed; an old link to it opens as an unavailable destination. The covered-pane focus test now makes room for the shell to scroll instead of relying on how many pages the header happens to hold. Signed-off-by: John Tennant Co-authored-by: Poppy Co-Authored-By: Claude Sonnet 5.5 --- crates/plugin-manager/src/lib.rs | 2 + crates/plugin-manager/tests/management.rs | 7 +- docs/agents.md | 7 +- docs/plugin-architecture.md | 11 +- src/app/entity-navigation.test.tsx | 35 ++- src/app/pages.integration.test.mjs | 21 +- src/app/shell/presentation.test.ts | 15 +- src/app/shell/presentation.ts | 3 - src/bundled/agents/AgentDetails.test.tsx | 2 + src/bundled/agents/AgentTabs.test.tsx | 51 ++++ src/bundled/agents/AgentTabs.tsx | 1 + src/bundled/agents/AgentsPage.test.tsx | 2 + src/bundled/agents/index.tsx | 12 - src/bundled/claude-code/tabs.test.tsx | 2 + src/bundled/codex/tabs.test.tsx | 2 + src/bundled/index.ts | 7 + src/bundled/pi/tabs.test.tsx | 2 + src/bundled/profiles/ProfilePanel.test.tsx | 2 + .../attention => watching}/AttentionPage.css | 17 +- .../InstructionsMarkdown.test.tsx | 0 .../InstructionsMarkdown.tsx | 0 src/bundled/watching/README.md | 9 + .../Page.test.tsx => watching/Tab.test.tsx} | 218 +++++++------- .../attention/Page.tsx => watching/Tab.tsx} | 265 +++++------------- src/bundled/watching/index.test.ts | 17 ++ src/bundled/watching/index.tsx | 11 + src/bundled/watching/manifest.json | 10 + .../{agents/attention => watching}/model.ts | 2 +- src/features/agents/AttentionPanel.test.tsx | 6 +- src/features/agents/AttentionPanel.tsx | 5 +- src/features/agents/service.test.ts | 38 +++ src/features/agents/service.ts | 31 ++ src/features/agents/tabs.test.tsx | 52 ++++ src/features/agents/tabs.tsx | 12 +- src/plugins/author.ts | 1 + tests/browser/agent-attention.spec.mjs | 25 +- tests/browser/agent-library.spec.mjs | 20 ++ tests/browser/focus-navigation.spec.mjs | 9 +- tests/browser/layout.spec.mjs | 2 +- tests/browser/page-placement.spec.mjs | 6 - tests/browser/plugin-defaults.spec.mjs | 1 + tests/fixtures/agent-attention.tsx | 85 +++--- tests/fixtures/agent-library.tsx | 14 + 43 files changed, 598 insertions(+), 442 deletions(-) rename src/bundled/{agents/attention => watching}/AttentionPage.css (96%) rename src/bundled/{agents/attention => watching}/InstructionsMarkdown.test.tsx (100%) rename src/bundled/{agents/attention => watching}/InstructionsMarkdown.tsx (100%) create mode 100644 src/bundled/watching/README.md rename src/bundled/{agents/attention/Page.test.tsx => watching/Tab.test.tsx} (85%) rename src/bundled/{agents/attention/Page.tsx => watching/Tab.tsx} (80%) create mode 100644 src/bundled/watching/index.test.ts create mode 100644 src/bundled/watching/index.tsx create mode 100644 src/bundled/watching/manifest.json rename src/bundled/{agents/attention => watching}/model.ts (83%) diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cdcb27a3a6..02ac70c242 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" )) diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 03a930f5ce..7c048f5a64 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -1146,7 +1146,12 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { let id = plugin.manifest.id.as_str(); let default = !matches!( id, - "buzz.bestie" | "buzz.pi" | "buzz.todos" | "buzz.usage" | "buzz.relay-notes" + "buzz.bestie" + | "buzz.pi" + | "buzz.todos" + | "buzz.usage" + | "buzz.relay-notes" + | "buzz.watching" ); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { diff --git a/docs/agents.md b/docs/agents.md index 6bd91008a9..35424651de 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -57,13 +57,14 @@ servers do not support these operations. ## Agent Attention -The Watching page, below Inbox in the sidebar, 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 Watching plugin (`buzz.watching`, off 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 Watching 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..da757ea3ec 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 Watching +plugin (off by default) 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 93fc340270..b8f7207188 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -37,6 +37,11 @@ vi.mock("../bundled", async () => ({ manifest: { id: "buzz.agents", name: "Agents", apiVersion: 1 }, module: await import("../bundled/agents"), }, + { + manifest: { id: "buzz.watching", name: "Watching", apiVersion: 1 }, + module: await import("../bundled/watching"), + enabledByDefault: false, + }, { manifest: { id: "buzz.inbox", name: "Inbox", apiVersion: 1 }, module: await import("../bundled/inbox"), @@ -342,7 +347,7 @@ it("lists active primary pages in their sidebar and header placements", async () within(pages) .getAllByRole("button") .map((row) => row.textContent), - ).toEqual(["Inbox", "Watching", "Bestie"]), + ).toEqual(["Inbox", "Bestie"]), ); expect( @@ -350,24 +355,26 @@ it("lists active primary pages in their sidebar and header placements", async () ).not.toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Agents" })).toBeInTheDocument(); - expect( - screen.queryByRole("tab", { name: "Watching" }), - ).not.toBeInTheDocument(); - await userEvent.click( - within(pages).getByRole("button", { name: "Watching" }), - ); - expect( - await screen.findByRole("region", { - name: "What your agents are watching", - }), - ).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", ); + // Watching is off by default, and attention keeps running without it. + const providers = () => + current.agents.attentionProviders().map(({ provider }) => provider); + 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.watching")); + await waitFor(() => expect(current.agents.addedTabs()).toEqual([])); + expect(providers()).toContain("attention/command"); + await act(() => current.plugins.change("disable", "buzz.agents")); await waitFor(() => expect( diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index f8e862ad10..43ac8e400e 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", ); @@ -84,6 +84,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.todos", "buzz.usage", "buzz.relay-notes", + "buzz.watching", ].includes(plugin.manifest.id), plugin.manifest.id, ); @@ -101,16 +102,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, "Watching"); - assert.equal(attention.layout, "workspace"); - assert.equal(attention.placement, undefined); - assert.equal(attention.primary, true); - assert.match( - renderToStaticMarkup(createElement(attention.component)), - /What your agents are watching/, + assert.equal( + services.pages.snapshot().some((p) => p.key === "buzz.agents/attention"), + false, ); const inbox = services.pages .snapshot() @@ -341,7 +335,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 +414,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 +451,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 1ea90de952..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", "Watching"); 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, - attention, - bestie, - projects, - ]); - 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 ef6c228034..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, @@ -25,7 +24,6 @@ export const shellPresentation = { const bundledOrder = [ "buzz.channels/channels", "buzz.inbox/inbox", - "buzz.agents/attention", "buzz.reminders/reminders", "buzz.bestie/bestie", ]; @@ -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 8126902795..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,17 +34,6 @@ export const apply: PluginModule["apply"] = (ctx) => { /> ), }); - ctx.pages.register({ - id: "attention", - title: "Watching", - 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..417a30b94e 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: false, + }, { 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..a0aeb2c413 --- /dev/null +++ b/src/bundled/watching/README.md @@ -0,0 +1,9 @@ +# Watching + +See what each agent is watching, and pause or remove it, from the agent's own settings. + +## Getting started + +1. Turn on **Watching** in **Settings** → **Plugins**. +2. Open **Agents**, choose an agent that runs on this computer, and open its **Watching** tab. +3. 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". diff --git a/src/bundled/agents/attention/Page.test.tsx b/src/bundled/watching/Tab.test.tsx similarity index 85% rename from src/bundled/agents/attention/Page.test.tsx rename to src/bundled/watching/Tab.test.tsx index 11f707c72f..8de14a5842 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,8 @@ 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 type { Context } from "../../plugins/author"; +import { createTab, describe } from "./Tab"; afterEach(cleanup); const viewer = "f".repeat(64); @@ -53,7 +52,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 +120,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( @@ -280,7 +279,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 +350,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 }, @@ -398,7 +401,9 @@ 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))); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ); const toggle = screen.getByRole("switch", { name: "Watch Build", }) as HTMLButtonElement; @@ -422,71 +427,7 @@ it("keeps subscription controls disabled while saving and preserves the row on f 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,49 +453,84 @@ 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(); + render( + React.createElement(createTab(ctx), { agent: { key: "membership-b" } }), + ); expect(screen.getByText(/Agents can ask to be woken later/)).toBeTruthy(); expect( screen.getByText("Watches only run while Buzz is open on this device."), ).toBeTruthy(); expect( - within(screen.getByRole("region", { name: "Suggested asks" })) - .getAllByRole("listitem") - .map((item) => item.textContent), + 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", ]); - fireEvent.click( - screen.getByRole("button", { name: "Turn on watching on this device" }), - ); - expect( - ( - screen.getByRole("button", { - name: "Turn on watching", - }) 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: "Turn on watching" })); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Watching paused on this device")).toBeTruthy(); + const toggle = screen.getByRole("switch", { + name: "Watching for Bo on this device", + }); + fireEvent.click(toggle); await waitFor(() => expect(screen.getByRole("alert").textContent).toBe("Enable failed"), ); - fireEvent.click(screen.getByRole("button", { name: "Turn on watching" })); - await waitFor(() => - expect(screen.getByRole("status").textContent).toContain( - "Watching turned on 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"])( @@ -596,7 +572,9 @@ it.each([undefined, "missing"])( 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" } }), + ); const repair = screen.getByRole("region", { name: "Stray watches" }); expect(within(repair).getByText("watch/check")).toBeTruthy(); expect( @@ -642,7 +620,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" }), @@ -749,7 +729,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}` }) @@ -873,10 +855,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" }), ); @@ -894,10 +876,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: "Watching for Bo" })).toBeNull(); + expect(screen.queryByRole("switch")).toBeNull(); }); it("keeps technical data out of message summaries and avoids repeating the destination", () => { @@ -952,7 +940,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 6e474fe869..96f32210c3 100644 --- a/src/bundled/agents/attention/Page.tsx +++ b/src/bundled/watching/Tab.tsx @@ -1,12 +1,7 @@ -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 { attentionOf, sourceCompleted } from "../../features/agents/attention"; +import { relayOrigin } from "../../features/communities/destination"; +import { relayPartition } from "../../features/relay/partition"; import { ChatCircleIcon, LockIcon, @@ -19,17 +14,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"; @@ -108,7 +98,7 @@ const suggestions = [ 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, @@ -665,13 +655,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(); @@ -679,28 +667,23 @@ 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", @@ -865,14 +848,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, @@ -893,183 +893,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( - `Watching turned on 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": "What your agents are watching", + "aria-label": "Watching", ref: page, tabIndex: -1, }, - - h(PanelHeader, { - title: h(PanelHeaderLabel, { title: "What your agents are watching" }), - }), h( "div", - { className: "attention-scroll" }, + { className: "attention-content" }, h( - "div", - { className: "attention-content" }, + "p", + { className: "text-body-sm text-subtle" }, + "Agents can ask to be woken later: when someone posts, at a set time, or when something changes, like CI finishing on a pull request. 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" }, - "Agents can ask to be woken later: when someone posts, at a set time, or when something changes, like CI finishing on a pull request. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", + { className: "text-body-sm text-secondary" }, + "This device can't run message filters, so every message in these channels wakes the agent.", ), - running.length > 0 && - h( - "p", - { className: "text-caption text-subtle" }, - "Watches only run while Buzz is open on this device.", - ), - 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" }, - "This device can't run message filters, so every message in these channels wakes the agent.", - ), - !visible.length && - running.some((agent) => !agent.attentionEnabled) && ( - - ), - adding && - h( - "form", - { - className: "attention-card attention-details", - "aria-label": "Turn on watching", - 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( - "section", - { "aria-label": "Suggested asks" }, - h( - "p", - { className: "text-body-sm text-secondary" }, - "No watches yet. Try asking an agent:", - ), - h( - "ul", - { className: "attention-list" }, - ...suggestions.map((ask) => - h( - "li", - { key: ask, className: "attention-card text-body-sm" }, - `\u201c${ask}\u201d`, - ), - ), - ), - ), - ...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.test.ts b/src/bundled/watching/index.test.ts new file mode 100644 index 0000000000..223a4990ad --- /dev/null +++ b/src/bundled/watching/index.test.ts @@ -0,0 +1,17 @@ +import { expect, it, vi } from "vitest"; +import type { Context } from "../../plugins/author"; +import { apply, inject } from "./index"; + +it("adds one Watching tab to agents' settings", () => { + const registerTab = vi.fn(); + apply({ + agents: { registerTab }, + relay: {}, + navigation: {}, + } as unknown as Context); + expect(inject).toEqual(["agents", "relay", "navigation"]); + expect(registerTab).toHaveBeenCalledOnce(); + expect(registerTab).toHaveBeenCalledWith( + expect.objectContaining({ id: "watching", title: "Watching" }), + ); +}); 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..2428220668 --- /dev/null +++ b/src/bundled/watching/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "buzz.watching", + "name": "Watching", + "apiVersion": 1, + "description": "Show what each agent is watching in its settings.", + "discovery": { + "category": "agents", + "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..e2cc866ccb 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 Watching 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..fa4de93a6d 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 Watching 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 350fdafa52..24713a55b9 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -4,15 +4,11 @@ const runtimeSwitch = (page) => page.getByRole("switch", { name: "Watching for Ada on this device" }); // Real browser focus and scroll geometry are outside jsdom. -test("The watching page 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: "What your agents are watching", - 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" }) @@ -63,15 +59,24 @@ test("The watching page fits both themes and sizes and manages existing watches ).toHaveAttribute("href", "https://github.com/example/project/pull/42"); }); +// The empty tab teaches a new agent's owner what to ask for. +test("an agent with nothing watched suggests what to ask it", async ({ + page, +}) => { + await page.goto("/tests/fixtures/agent-attention.html?empty"); + await expect( + page.getByText("Nothing is being watched yet. Try asking Ada:"), + ).toBeVisible(); + await expect(page.getByText(/Watch my PR and tell me/)).toBeVisible(); + await expect(runtimeSwitch(page)).toHaveAttribute("aria-checked", "true"); +}); + // Browser focus after removal, including async writes, cannot be proved by jsdom. test("watch and interest deletion restore focus without taking it from another control", async ({ page, }) => { await page.goto("/tests/fixtures/agent-attention.html"); - const pane = page.getByRole("region", { - name: "What your agents are watching", - exact: true, - }); + const pane = page.getByRole("region", { name: "Watching", exact: true }); const remove = page.getByRole("button", { name: "Delete Release discussion", }); diff --git a/tests/browser/agent-library.spec.mjs b/tests/browser/agent-library.spec.mjs index 677d7fec15..da653d3800 100644 --- a/tests/browser/agent-library.spec.mjs +++ b/tests/browser/agent-library.spec.mjs @@ -671,6 +671,26 @@ test("owner profile keeps its pane and caret stable during inline name editing", expect((await profile.boundingBox()).height).toBe(before.height); }); +// The host adds a plugin's tab to a local agent's profile before History and +// Settings; without the plugin the profile is unchanged. +for (const [name, query, tabs] of [ + ["without", "", ["Ada", "Identity", "Memory", "Settings"]], + ["with", "?watching", ["Ada", "Identity", "Memory", "Watching", "Settings"]], +]) + test(`a local agent's profile ${name} a plugin tab`, async ({ page }) => { + await page.goto(`/tests/fixtures/agent-library.html${query}`); + await page.getByRole("gridcell", { name: "Ada", exact: true }).click(); + const profile = page.getByRole("region", { + name: "Agent profile", + exact: true, + }); + await expect(profile.getByRole("tab")).toHaveText(tabs); + if (query) { + await profile.getByRole("tab", { name: "Watching", exact: true }).click(); + await expect(profile.getByText("Watching for Ada")).toBeVisible(); + } + }); + // A persisted interface size changes rem geometry; real pointer hit testing // must keep each split action inside its own card. test("scaled import controls keep pointer actions on their own agent", async ({ 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/layout.spec.mjs b/tests/browser/layout.spec.mjs index c765916c2c..c8bb62bf00 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -63,7 +63,7 @@ const companionLauncher = (page, name) => button(page, name).and(page.locator("button[aria-expanded]")); // The fixture's active plugin pages, in shell order, lead the channel sidebar. // Sidebar rows exclude header pages; Messages remains available through search. -const destinationTitles = ["Inbox", "Watching", "Bestie"]; +const destinationTitles = ["Inbox", "Bestie"]; const sidebarDestinations = (page, options = {}) => page .getByRole("complementary", { name: "Sidebar", exact: true, ...options }) diff --git a/tests/browser/page-placement.spec.mjs b/tests/browser/page-placement.spec.mjs index 17808f7d96..4e99eae66d 100644 --- a/tests/browser/page-placement.spec.mjs +++ b/tests/browser/page-placement.spec.mjs @@ -24,12 +24,6 @@ test("shared navigation preserves drafts and history across Solo and header page "Agents", "Workflows", ]); - // Watching is a sidebar page, ordered directly below Inbox. - const rows = page - .getByRole("navigation", { name: "Pages" }) - .getByRole("button"); - await expect(rows.nth(0)).toHaveText("Inbox"); - await expect(rows.nth(1)).toHaveText("Watching"); const sidebar = page.getByRole("complementary", { name: "Sidebar", exact: true, diff --git a/tests/browser/plugin-defaults.spec.mjs b/tests/browser/plugin-defaults.spec.mjs index 862cd8d5d1..fef24e75bd 100644 --- a/tests/browser/plugin-defaults.spec.mjs +++ b/tests/browser/plugin-defaults.spec.mjs @@ -27,6 +27,7 @@ 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 /); + await expect(pluginAction(page, "Watching")).toHaveAccessibleName(/^Enable /); for (const name of ["GitHub", "Terminal", "Workflows", "Hosted communities"]) await expect(pluginAction(page, name)).toHaveAccessibleName(/^Disable /); await bestie.click(); diff --git a/tests/fixtures/agent-attention.tsx b/tests/fixtures/agent-attention.tsx index d040e9af2d..2e9adad860 100644 --- a/tests/fixtures/agent-attention.tsx +++ b/tests/fixtures/agent-attention.tsx @@ -1,10 +1,11 @@ -// 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"; +const empty = location.search.includes("empty"); const listeners = new Set<() => void>(); const value = { type: "event", @@ -27,42 +28,50 @@ let snapshot = { name: "Ada", attentionEnabled: true, timers: {}, - attentionHistory: { - "watch/discussion": { - output: - "2026-10-27T14:00:00Z · Alex\nThe release is ready for review.", - }, - }, - attention: { - "interest/release": { - slug: "interest/release", - modifiedAt: 1, - value: { - type: "interest", - instructions: - "# Release planning\nReport blockers and keep the release moving.", + attentionHistory: empty + ? {} + : { + "watch/discussion": { + output: + "2026-10-27T14:00:00Z · Alex\nThe release is ready for review.", + }, }, - }, - "watch/discussion": { slug: "watch/discussion", modifiedAt: 1, value }, - "interest/review": { - slug: "interest/review", - modifiedAt: 1, - value: { type: "interest", instructions: "# Review checks" }, - }, - "watch/pr": { - slug: "watch/pr", - modifiedAt: 1, - value: { - type: "source", - provider: "attention/github-pr", - name: "example/project#42", - enabled: true, - interest_id: "review", - config: { pr: "https://github.com/example/project/pull/42" }, - channel, + attention: empty + ? {} + : { + "interest/release": { + slug: "interest/release", + modifiedAt: 1, + value: { + type: "interest", + instructions: + "# Release planning\nReport blockers and keep the release moving.", + }, + }, + "watch/discussion": { + slug: "watch/discussion", + modifiedAt: 1, + value, + }, + "interest/review": { + slug: "interest/review", + modifiedAt: 1, + value: { type: "interest", instructions: "# Review checks" }, + }, + "watch/pr": { + slug: "watch/pr", + modifiedAt: 1, + value: { + type: "source", + provider: "attention/github-pr", + name: "example/project#42", + enabled: true, + interest_id: "review", + config: { pr: "https://github.com/example/project/pull/42" }, + channel, + }, + }, }, - }, - }, }, ], }; @@ -98,7 +107,7 @@ declare global { } } window.attentionFixture = controls; -const Page = createPage({ +const Tab = createTab({ agents: { snapshot: () => snapshot, subscribe: (listener: () => void) => { @@ -148,4 +157,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..bf073e596b 100644 --- a/tests/fixtures/agent-library.tsx +++ b/tests/fixtures/agent-library.tsx @@ -34,6 +34,7 @@ const inheritedModel = new URLSearchParams(location.search).get( "inheritedModel", ); const saveFooter = new URLSearchParams(location.search).has("saveFooter"); +const watching = new URLSearchParams(location.search).has("watching"); const instructions = new URLSearchParams(location.search).get("instructions"); const memoryLabel = new URLSearchParams(location.search).get("memoryLabel"); @@ -277,6 +278,19 @@ const agents = { ] : [], configHistory: () => undefined, + addedTabs: () => + watching + ? [ + { + key: "buzz.watching/watching", + title: "Watching", + component: ({ agent }: { agent: { name: string } }) => ( +

Watching for {agent.name}

+ ), + }, + ] + : [], + registerTab: () => {}, joinable: () => (teamImport ? joining : []), join: async (key: string) => { joined.push(key); From c0cc4b00ca843ab82d0058899feb359a4b8fe1e5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 28 Oct 2026 20:32:30 -0400 Subject: [PATCH 3/8] feat(agents): name the Watching plugin Agent Attention UI and turn it on The plugin that adds each agent's Watching tab is now called Agent Attention UI, sits under Agent tools in Settings, and is on by default in both the app and the desktop plugin manager. Turning it off still hides only the tab; attention keeps running. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- crates/plugin-manager/src/lib.rs | 1 + crates/plugin-manager/tests/management.rs | 7 +------ docs/agents.md | 4 ++-- docs/plugin-architecture.md | 4 ++-- src/app/entity-navigation.test.tsx | 19 +++++++++++++------ src/app/pages.integration.test.mjs | 1 - src/bundled/index.ts | 2 +- src/bundled/watching/README.md | 9 +++++---- src/bundled/watching/manifest.json | 4 ++-- src/features/agents/AttentionPanel.test.tsx | 2 +- src/features/agents/AttentionPanel.tsx | 2 +- tests/browser/plugin-defaults.spec.mjs | 9 +++++++-- 12 files changed, 36 insertions(+), 28 deletions(-) diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 02ac70c242..9662d06499 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -354,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/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 7c048f5a64..03a930f5ce 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -1146,12 +1146,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { let id = plugin.manifest.id.as_str(); let default = !matches!( id, - "buzz.bestie" - | "buzz.pi" - | "buzz.todos" - | "buzz.usage" - | "buzz.relay-notes" - | "buzz.watching" + "buzz.bestie" | "buzz.pi" | "buzz.todos" | "buzz.usage" | "buzz.relay-notes" ); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { diff --git a/docs/agents.md b/docs/agents.md index 35424651de..edd66b8bf1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -57,7 +57,7 @@ servers do not support these operations. ## Agent Attention -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 Watching plugin (`buzz.watching`, off 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. +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 @@ -1049,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 Watching 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. +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 da757ea3ec..fd984fe59e 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -297,8 +297,8 @@ running on this device, whatever its type, with `{ 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 Watching -plugin (off by default) uses it. +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` diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index b8f7207188..64d8c6cd47 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -38,9 +38,13 @@ vi.mock("../bundled", async () => ({ module: await import("../bundled/agents"), }, { - manifest: { id: "buzz.watching", name: "Watching", apiVersion: 1 }, + manifest: { + id: "buzz.watching", + name: "Agent Attention UI", + apiVersion: 1, + }, module: await import("../bundled/watching"), - enabledByDefault: false, + enabledByDefault: true, }, { manifest: { id: "buzz.inbox", name: "Inbox", apiVersion: 1 }, @@ -360,12 +364,9 @@ it("lists active primary pages in their sidebar and header placements", async () "fixture.notes/notes", ); - // Watching is off by default, and attention keeps running without it. + // The Watching tab is on by default, and attention keeps running without it. const providers = () => current.agents.attentionProviders().map(({ provider }) => provider); - 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", @@ -374,6 +375,12 @@ it("lists active primary pages in their sidebar and header placements", async () 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(() => diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 43ac8e400e..c75af3c0ac 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -84,7 +84,6 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.todos", "buzz.usage", "buzz.relay-notes", - "buzz.watching", ].includes(plugin.manifest.id), plugin.manifest.id, ); diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 417a30b94e..43b2432c15 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -179,7 +179,7 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...watchingManifest, apiVersion: 1 }, module: watching, - enabledByDefault: false, + enabledByDefault: true, }, { manifest: { ...claudeCodeManifest, apiVersion: 1 }, diff --git a/src/bundled/watching/README.md b/src/bundled/watching/README.md index a0aeb2c413..2a393855c3 100644 --- a/src/bundled/watching/README.md +++ b/src/bundled/watching/README.md @@ -1,9 +1,10 @@ -# Watching +# Agent Attention UI See what each agent is watching, and pause or remove it, from the agent's own settings. ## Getting started -1. Turn on **Watching** in **Settings** → **Plugins**. -2. Open **Agents**, choose an agent that runs on this computer, and open its **Watching** tab. -3. 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". +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/watching/manifest.json b/src/bundled/watching/manifest.json index 2428220668..5314b73625 100644 --- a/src/bundled/watching/manifest.json +++ b/src/bundled/watching/manifest.json @@ -1,10 +1,10 @@ { "id": "buzz.watching", - "name": "Watching", + "name": "Agent Attention UI", "apiVersion": 1, "description": "Show what each agent is watching in its settings.", "discovery": { - "category": "agents", + "category": "agent-tools", "tags": ["coding-agents"] } } diff --git a/src/features/agents/AttentionPanel.test.tsx b/src/features/agents/AttentionPanel.test.tsx index e2cc866ccb..1fa4a23897 100644 --- a/src/features/agents/AttentionPanel.test.tsx +++ b/src/features/agents/AttentionPanel.test.tsx @@ -60,7 +60,7 @@ it("names watches by what and where they wake", () => { ); }); -it("keeps the device runtime control and leaves watch management to the Watching plugin", 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.queryByText(/Manage Interests and watches/)).toBeNull(); diff --git a/src/features/agents/AttentionPanel.tsx b/src/features/agents/AttentionPanel.tsx index fa4de93a6d..5f0e48b4c4 100644 --- a/src/features/agents/AttentionPanel.tsx +++ b/src/features/agents/AttentionPanel.tsx @@ -1,4 +1,4 @@ -// The device-local runtime control. The Watching plugin owns Interests and watches. +// The device-local runtime control. The Agent Attention UI plugin owns Interests and watches. import { useState } from "react"; import { AgentCommunityScope, diff --git a/tests/browser/plugin-defaults.spec.mjs b/tests/browser/plugin-defaults.spec.mjs index fef24e75bd..dfdbdd53d6 100644 --- a/tests/browser/plugin-defaults.spec.mjs +++ b/tests/browser/plugin-defaults.spec.mjs @@ -27,8 +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 /); - await expect(pluginAction(page, "Watching")).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 /); From c999cf9dcb66516c96e7e8ad6f04ce43a579f5e2 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 28 Oct 2026 21:31:49 -0400 Subject: [PATCH 4/8] docs(attention): give the Agent Attention UI plugin watch management The vision still assigned the cross-agent Attention overview to the Agents plugin. Watch management now lives in the Agent Attention UI plugin's per-agent Watching tab. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- docs/VISION_ATTENTION_SUBSCRIPTIONS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. From c490af74226d4c99cfc1fcd4137b1067d387f6d1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 28 Oct 2026 21:31:49 -0400 Subject: [PATCH 5/8] test(agents): drop Watching tests that repeat component coverage The profile-tab and empty-state browser cases checked tab order and text that tabs.test.tsx, AgentTabs.test.tsx and Tab.test.tsx already cover, with no browser-only behavior. The mocked registration test repeats what entity-navigation.test.tsx checks through the real plugin service. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/bundled/watching/index.test.ts | 17 ------ tests/browser/agent-attention.spec.mjs | 12 ---- tests/browser/agent-library.spec.mjs | 20 ------- tests/fixtures/agent-attention.tsx | 81 ++++++++++++-------------- tests/fixtures/agent-library.tsx | 14 +---- 5 files changed, 39 insertions(+), 105 deletions(-) delete mode 100644 src/bundled/watching/index.test.ts diff --git a/src/bundled/watching/index.test.ts b/src/bundled/watching/index.test.ts deleted file mode 100644 index 223a4990ad..0000000000 --- a/src/bundled/watching/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect, it, vi } from "vitest"; -import type { Context } from "../../plugins/author"; -import { apply, inject } from "./index"; - -it("adds one Watching tab to agents' settings", () => { - const registerTab = vi.fn(); - apply({ - agents: { registerTab }, - relay: {}, - navigation: {}, - } as unknown as Context); - expect(inject).toEqual(["agents", "relay", "navigation"]); - expect(registerTab).toHaveBeenCalledOnce(); - expect(registerTab).toHaveBeenCalledWith( - expect.objectContaining({ id: "watching", title: "Watching" }), - ); -}); diff --git a/tests/browser/agent-attention.spec.mjs b/tests/browser/agent-attention.spec.mjs index 24713a55b9..9f3e949d26 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -59,18 +59,6 @@ test("The Watching tab fits both themes and sizes and manages existing watches b ).toHaveAttribute("href", "https://github.com/example/project/pull/42"); }); -// The empty tab teaches a new agent's owner what to ask for. -test("an agent with nothing watched suggests what to ask it", async ({ - page, -}) => { - await page.goto("/tests/fixtures/agent-attention.html?empty"); - await expect( - page.getByText("Nothing is being watched yet. Try asking Ada:"), - ).toBeVisible(); - await expect(page.getByText(/Watch my PR and tell me/)).toBeVisible(); - await expect(runtimeSwitch(page)).toHaveAttribute("aria-checked", "true"); -}); - // Browser focus after removal, including async writes, cannot be proved by jsdom. test("watch and interest deletion restore focus without taking it from another control", async ({ page, diff --git a/tests/browser/agent-library.spec.mjs b/tests/browser/agent-library.spec.mjs index da653d3800..677d7fec15 100644 --- a/tests/browser/agent-library.spec.mjs +++ b/tests/browser/agent-library.spec.mjs @@ -671,26 +671,6 @@ test("owner profile keeps its pane and caret stable during inline name editing", expect((await profile.boundingBox()).height).toBe(before.height); }); -// The host adds a plugin's tab to a local agent's profile before History and -// Settings; without the plugin the profile is unchanged. -for (const [name, query, tabs] of [ - ["without", "", ["Ada", "Identity", "Memory", "Settings"]], - ["with", "?watching", ["Ada", "Identity", "Memory", "Watching", "Settings"]], -]) - test(`a local agent's profile ${name} a plugin tab`, async ({ page }) => { - await page.goto(`/tests/fixtures/agent-library.html${query}`); - await page.getByRole("gridcell", { name: "Ada", exact: true }).click(); - const profile = page.getByRole("region", { - name: "Agent profile", - exact: true, - }); - await expect(profile.getByRole("tab")).toHaveText(tabs); - if (query) { - await profile.getByRole("tab", { name: "Watching", exact: true }).click(); - await expect(profile.getByText("Watching for Ada")).toBeVisible(); - } - }); - // A persisted interface size changes rem geometry; real pointer hit testing // must keep each split action inside its own card. test("scaled import controls keep pointer actions on their own agent", async ({ diff --git a/tests/fixtures/agent-attention.tsx b/tests/fixtures/agent-attention.tsx index 2e9adad860..181c352d71 100644 --- a/tests/fixtures/agent-attention.tsx +++ b/tests/fixtures/agent-attention.tsx @@ -5,7 +5,6 @@ import { createTab } from "../../src/bundled/watching/Tab"; import "../../src/shared/styles/globals.css"; const channel = "11111111-1111-4111-8111-111111111111"; -const empty = location.search.includes("empty"); const listeners = new Set<() => void>(); const value = { type: "event", @@ -28,50 +27,46 @@ let snapshot = { name: "Ada", attentionEnabled: true, timers: {}, - attentionHistory: empty - ? {} - : { - "watch/discussion": { - output: - "2026-10-27T14:00:00Z · Alex\nThe release is ready for review.", - }, + attentionHistory: { + "watch/discussion": { + output: + "2026-10-27T14:00:00Z · Alex\nThe release is ready for review.", + }, + }, + attention: { + "interest/release": { + slug: "interest/release", + modifiedAt: 1, + value: { + type: "interest", + instructions: + "# Release planning\nReport blockers and keep the release moving.", }, - attention: empty - ? {} - : { - "interest/release": { - slug: "interest/release", - modifiedAt: 1, - value: { - type: "interest", - instructions: - "# Release planning\nReport blockers and keep the release moving.", - }, - }, - "watch/discussion": { - slug: "watch/discussion", - modifiedAt: 1, - value, - }, - "interest/review": { - slug: "interest/review", - modifiedAt: 1, - value: { type: "interest", instructions: "# Review checks" }, - }, - "watch/pr": { - slug: "watch/pr", - modifiedAt: 1, - value: { - type: "source", - provider: "attention/github-pr", - name: "example/project#42", - enabled: true, - interest_id: "review", - config: { pr: "https://github.com/example/project/pull/42" }, - channel, - }, - }, + }, + "watch/discussion": { + slug: "watch/discussion", + modifiedAt: 1, + value, + }, + "interest/review": { + slug: "interest/review", + modifiedAt: 1, + value: { type: "interest", instructions: "# Review checks" }, + }, + "watch/pr": { + slug: "watch/pr", + modifiedAt: 1, + value: { + type: "source", + provider: "attention/github-pr", + name: "example/project#42", + enabled: true, + interest_id: "review", + config: { pr: "https://github.com/example/project/pull/42" }, + channel, }, + }, + }, }, ], }; diff --git a/tests/fixtures/agent-library.tsx b/tests/fixtures/agent-library.tsx index bf073e596b..5e63991734 100644 --- a/tests/fixtures/agent-library.tsx +++ b/tests/fixtures/agent-library.tsx @@ -34,7 +34,6 @@ const inheritedModel = new URLSearchParams(location.search).get( "inheritedModel", ); const saveFooter = new URLSearchParams(location.search).has("saveFooter"); -const watching = new URLSearchParams(location.search).has("watching"); const instructions = new URLSearchParams(location.search).get("instructions"); const memoryLabel = new URLSearchParams(location.search).get("memoryLabel"); @@ -278,18 +277,7 @@ const agents = { ] : [], configHistory: () => undefined, - addedTabs: () => - watching - ? [ - { - key: "buzz.watching/watching", - title: "Watching", - component: ({ agent }: { agent: { name: string } }) => ( -

Watching for {agent.name}

- ), - }, - ] - : [], + addedTabs: () => [], registerTab: () => {}, joinable: () => (teamImport ? joining : []), join: async (key: string) => { From d94a487a87973821f45c65b0642abde832b47c79 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 29 Oct 2026 01:57:15 -0400 Subject: [PATCH 6/8] fix(watching): keep the agent's tab open while a watch change saves Pausing or deleting a watch kept its pending state inside the Watching tab, so switching agent tabs during the save unmounted it and a later failure was never shown. Each action now reports its pending save, so the agent's tabs stay on Watching until it settles. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/bundled/watching/Tab.test.tsx | 18 ++++++++++++++++-- src/bundled/watching/Tab.tsx | 3 +++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/bundled/watching/Tab.test.tsx b/src/bundled/watching/Tab.test.tsx index 8de14a5842..55b1f97f1c 100644 --- a/src/bundled/watching/Tab.test.tsx +++ b/src/bundled/watching/Tab.test.tsx @@ -10,6 +10,7 @@ import { within, } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; +import { AgentSaveState } from "../../features/agents/AgentSaveButton"; import type { Context } from "../../plugins/author"; import { createTab, describe } from "./Tab"; @@ -360,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", @@ -401,8 +402,13 @@ 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; + const report = vi.fn(); render( - React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + React.createElement( + AgentSaveState, + { value: report }, + React.createElement(createTab(ctx), { agent: { key: "membership-a" } }), + ), ); const toggle = screen.getByRole("switch", { name: "Watch Build", @@ -415,12 +421,20 @@ 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"); diff --git a/src/bundled/watching/Tab.tsx b/src/bundled/watching/Tab.tsx index 96f32210c3..cc7460c7bb 100644 --- a/src/bundled/watching/Tab.tsx +++ b/src/bundled/watching/Tab.tsx @@ -1,4 +1,5 @@ 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"; @@ -113,6 +114,8 @@ export function createTab({ 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); From f78c1b6086f09b4f7f9fe008e4458078c66f51ec Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 29 Oct 2026 06:50:47 -0400 Subject: [PATCH 7/8] fix(watching): say what watching does and name the device switch once The intro now leads with what agents do, watch for events and wake to handle them, with two examples. The device switch keeps one label, "Run watches on this device", rather than flipping to a paused phrase, since the switch already shows whether it is on. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/bundled/watching/Tab.test.tsx | 14 ++++++++------ src/bundled/watching/Tab.tsx | 8 +++----- tests/browser/agent-attention.spec.mjs | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/bundled/watching/Tab.test.tsx b/src/bundled/watching/Tab.test.tsx index 55b1f97f1c..4721c05153 100644 --- a/src/bundled/watching/Tab.test.tsx +++ b/src/bundled/watching/Tab.test.tsx @@ -156,7 +156,7 @@ it("shows one agent's watches, reacts to changes and surfaces save errors", asyn expect(within(ada).getByText("Paused")).toBeTruthy(); expect( within(ada).getByRole("switch", { - name: "Watching 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("shows one agent's watches, reacts to changes and surfaces save errors", asyn }; changed(); }); - expect(within(ada).getByText("Watching paused on this device")).toBeTruthy(); const toggle = within(ada).getByRole("switch", { - name: "Watching 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", { @@ -470,7 +470,9 @@ it("explains Watching to a new agent and switches it on this device", async () = render( React.createElement(createTab(ctx), { agent: { key: "membership-b" } }), ); - expect(screen.getByText(/Agents can ask to be woken later/)).toBeTruthy(); + expect( + 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(); @@ -485,10 +487,10 @@ it("explains Watching to a new agent and switches it on this device", async () = "\u201cCheck this channel every morning at 9\u201d", ]); expect(screen.queryByRole("button")).toBeNull(); - expect(screen.getByText("Watching paused on this device")).toBeTruthy(); const toggle = screen.getByRole("switch", { - name: "Watching for Bo on this device", + 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"), diff --git a/src/bundled/watching/Tab.tsx b/src/bundled/watching/Tab.tsx index cc7460c7bb..1904cdabe8 100644 --- a/src/bundled/watching/Tab.tsx +++ b/src/bundled/watching/Tab.tsx @@ -691,11 +691,9 @@ export function createTab({ h( "label", { className: "attention-agent-toggle text-caption text-subtle" }, - agent.attentionEnabled - ? "Watching on this device" - : "Watching paused on this device", + "Run watches on this device", h(SharedSwitch, { - "aria-label": `Watching for ${agent.name} on this device`, + "aria-label": `Run ${agent.name}’s watches on this device`, checked: agent.attentionEnabled, disabled: action.pending, onCheckedChange: (attentionEnabled) => @@ -923,7 +921,7 @@ export function createTab({ h( "p", { className: "text-body-sm text-subtle" }, - "Agents can ask to be woken later: when someone posts, at a set time, or when something changes, like CI finishing on a pull request. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", + "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, or watch a GitHub PR for CI changes and reviews. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", ), h( "p", diff --git a/tests/browser/agent-attention.spec.mjs b/tests/browser/agent-attention.spec.mjs index 9f3e949d26..0086818923 100644 --- a/tests/browser/agent-attention.spec.mjs +++ b/tests/browser/agent-attention.spec.mjs @@ -1,7 +1,7 @@ import { test, expect } from "./source-fixture.mjs"; const runtimeSwitch = (page) => - page.getByRole("switch", { name: "Watching 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("The Watching tab fits both themes and sizes and manages existing watches by keyboard", async ({ @@ -31,12 +31,12 @@ test("The Watching tab fits both themes and sizes and manages existing watches b } } const runtime = page.getByRole("switch", { - name: "Watching 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("Watching 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(); @@ -124,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: "Watching 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: "Watching for Ada on this device" }), + page.getByRole("switch", { name: "Run Ada’s watches on this device" }), ).toBeFocused(); }); From 5a3763fe1fdefbacc10453f48d7cc391870da37c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 29 Oct 2026 06:53:51 -0400 Subject: [PATCH 8/8] fix(watching): mention timed and scheduled work in the intro Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/bundled/watching/Tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/watching/Tab.tsx b/src/bundled/watching/Tab.tsx index 1904cdabe8..b55ef57e5b 100644 --- a/src/bundled/watching/Tab.tsx +++ b/src/bundled/watching/Tab.tsx @@ -921,7 +921,7 @@ export function createTab({ h( "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, or watch a GitHub PR for CI changes and reviews. Each Interest is something an agent is keeping track of, with instructions for what to do. Its watches are what wake it.", + "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",