diff --git a/README.md b/README.md index 31d205d..923b90a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,62 @@ # Be concise for Paseo -A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code and Codex. Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer badge shows enforcement status and decision counts. It opens a quick preview with a workspace enforcement toggle. +A native companion to [be-concise](https://github.com/yannelli/be-concise) for Claude Code and Codex. Open **Be concise** in Paseo's sidebar, workspace panels, or Command Center. The composer pill shows the rejected and flagged total when enforcement is on and **Off** when disabled. Open it for decision details and the workspace enforcement toggle. - Live hook decisions, searchable by tool, file, session, project, and reason. Pause the feed or open an event’s request and response. -- A composer badge with a monochrome icon, enforcement status, and decision counts; a compact popup with workspace stats and an enforcement switch. Turning enforcement off sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately. +- A compact composer pill with the Be concise logo and one number. The number combines rejected and flagged decisions; the popup shows each decision count and a 30-minute chart. Turning enforcement off displays **Off** and sets the workspace's `softFail` override; flagged actions are allowed while hooks and test-output filtering remain active. Turn it on to restore enforcement. Agent environment overrides apply separately. - Call counts, interventions, sessions, average hook duration, and a 30-minute activity chart. - User and project configuration with threshold controls, check switches, writing presets, an advanced JSON editor, and test filter settings. - An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews inspect hook responses without executing the pasted command or writing the target file. +## Screenshots + +All images are 1920×1080. They come from the Paseo 0.11.0-beta.3 web app with this plugin installed, using sample activity on a separate test daemon. The first five frame real captures with a headline. See [capture details](docs/composer-pill.md#screenshots). + +### Composer pill + +![Be concise pill showing 5 next to the logo, with its popup open above the agent composer](docs/images/composer-on.png) + +### Live activity + +![Activity dashboard with the newest rejected file write expanded](docs/images/activity-desktop.png) + +### Configuration in light mode + +![User and project configuration layers in Paseo's light theme](docs/images/configuration-light.png) + +### Hook preview + +![Playground with a rejected write and its hook response](docs/images/playground.png) + +### Narrow screens + +![Composer sheet and activity feed at a 390-pixel width](docs/images/compact.png) + +### Enforcement off + +![Off pill and popup with workspace enforcement turned off](docs/images/composer-off.png) + +### Paused and filtered activity + +![Paused activity filtered to decisions that need attention](docs/images/activity-filtered.png) + +
+Connection and empty states + +### Connecting + +![Ellipsis pill and popup while the plugin connects](docs/images/composer-connecting.png) + +### Unavailable + +![Exclamation pill and popup asking to install be-concise](docs/images/composer-unavailable.png) + +### No activity + +![Empty activity dashboard waiting for the next hook](docs/images/activity-empty.png) + +
+ ## Install Requires Paseo 0.9.0 or newer (tested against the 0.10.2 plugin SDK) and be-concise 0.7.0 or newer on the daemon machine. The adapter uses the configuration, monitoring, and preview APIs shipped with be-concise 0.7.0. Paseo supplies all runtime dependencies. @@ -52,6 +101,8 @@ Tests use Node.js 24 and temporary home/project directories. They cover activity Paseo API reference: [v0.9 plugins](https://paseo.sh/docs/plugins/v0.9/reference). +See the [documentation index](docs/INDEX.md) for the current composer button contract and screenshot capture details. Consult it when changing pill labels or updating screenshots. + ## Releases GitHub Actions runs the checks and publishes a GitHub release on qualifying pushes to `main`. The first release is `v0.1.0`. The workflow updates `package.json` and `package-lock.json`, pushes an annotated tag, and publishes release notes. It uses the repository's `GITHUB_TOKEN`; no extra secrets or npm publication are required. diff --git a/client/composer.tsx b/client/composer.tsx index 8ac8521..2704809 100644 --- a/client/composer.tsx +++ b/client/composer.tsx @@ -1,3 +1,4 @@ +import type { PluginTheme } from "@getpaseo/plugin"; import { type PluginButtonContentProps, type PluginButtonIconProps, @@ -10,33 +11,51 @@ import { import { Icon } from "@getpaseo/plugin/client/react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, type ViewStyle } from "react-native"; import { readConfiguration, snapshot, writeConfiguration } from "../shared/contracts"; import { enforcementUpdate } from "../shared/enforcement"; import { BrandIcon } from "./brand"; -import { decisionColor, Label, ToggleIndicator } from "./ui"; +import { Label, ToggleIndicator } from "./ui"; const PILL_ID = "concise"; -const PILL_TITLE = "Be concise: quick preview and enforcement"; const POLL_MS = 3000; const decisionGroups = [ - { label: "allowed", names: ["allow"], icon: "Check", decision: "allow" }, - { label: "rejected", names: ["deny", "block"], icon: "X", decision: "deny" }, - { label: "flagged", names: ["ask", "flag"], icon: "TriangleAlert", decision: "flag" }, - { label: "rewritten", names: ["rewrite"], icon: "Pencil", decision: "rewrite" }, - { label: "bypassed", names: ["bypass"], icon: "SkipForward", decision: "bypass" }, - { label: "errors", names: ["error"], icon: "CircleAlert", decision: "error" }, + { label: "allowed", names: ["allow"], decision: "allow" }, + { label: "rejected", names: ["deny", "block"], decision: "deny" }, + { label: "flagged", names: ["ask", "flag"], decision: "flag" }, + { label: "rewritten", names: ["rewrite"], decision: "rewrite" }, + { label: "bypassed", names: ["bypass"], decision: "bypass" }, + { label: "errors", names: ["error"], decision: "error" }, ]; +const CHART_HEIGHT = 40; + +function attentionColor(theme: PluginTheme, decision: string) { + if (["deny", "block", "error"].includes(decision)) return theme.colors.statusDanger; + if (["ask", "flag"].includes(decision)) return theme.colors.statusWarning; + return null; +} + +const stackOrder = (decision: string) => ["deny", "block", "error"].includes(decision) ? 2 : ["ask", "flag"].includes(decision) ? 1 : 0; + +function Swatch({ theme, decision, style }: { theme: PluginTheme; decision: string; style: ViewStyle }) { + const color = attentionColor(theme, decision); + return ; +} + +function decisionCounts(decisions: { name: string; count: number }[]) { + return decisionGroups.map((group) => ({ ...group, count: decisions.reduce((sum, item) => sum + (group.names.includes(item.name) ? item.count : 0), 0) })) + .filter((item, index) => index < 2 || item.count > 0); +} -/** Renders the brand mark tinted to the host's assigned pill color; the label carries live status. */ function ConciseIcon({ theme, size, color }: PluginButtonIconProps) { return ; } -/** Short live status for the pill label, e.g. "Enabled · 3 flagged". Mirrors the poll loop's own copy in attach(). */ -function statusLabel(connected: boolean, bypassed: boolean, flagged: number): string { - const base = !connected ? "Connecting" : bypassed ? "Bypassed" : "Enabled"; - return flagged > 0 ? `${base} · ${flagged} flagged` : base; +function pillStatus(state: "Connecting" | "Enabled" | "Bypassed" | "Unavailable", decisions: { name: string; count: number }[] = []) { + const attention = decisionCounts(decisions).filter((item) => item.decision === "deny" || item.decision === "flag").reduce((sum, item) => sum + item.count, 0); + const labels = { Connecting: "…", Enabled: String(attention), Bypassed: "Off", Unavailable: "!" }; + const summaries = { Connecting: "Connecting", Enabled: `${attention} rejected or flagged`, Bypassed: "Enforcement off", Unavailable: "Unavailable" }; + return { label: labels[state], icon: ConciseIcon, title: `Be concise · ${summaries[state]}` }; } function ConciseContent(props: PluginButtonContentProps & { onOpenDetails: () => void }) { @@ -57,7 +76,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext const bypassed = config.data?.effective.softFail === true; const failed = activity.isError || config.isError; const ready = activity.data?.connected && Boolean(config.data) && !failed; - const counts = decisionGroups.map((group) => ({ ...group, count: (activity.data?.stats.decisions ?? []).reduce((sum, item) => sum + (group.names.includes(item.name) ? item.count : 0), 0) })); + const counts = decisionCounts(activity.data?.stats.decisions ?? []); const minutes = (activity.data?.stats.minutes ?? []).map((minute) => { const start = Date.parse(minute.timestamp); const decisions = new Map(); @@ -65,7 +84,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext const timestamp = Date.parse(event.timestamp); if (timestamp >= start && timestamp < start + 60_000) decisions.set(event.decision, (decisions.get(event.decision) ?? 0) + 1); } - return { ...minute, decisions: [...decisions].sort(([a], [b]) => a.localeCompare(b)) }; + return { ...minute, decisions: [...decisions].sort(([a], [b]) => stackOrder(a) - stackOrder(b) || a.localeCompare(b)) }; }); const maximum = Math.max(1, ...minutes.map((minute) => minute.count)); const mutation = useMutation({ mutationFn: async (enabled: boolean) => { @@ -77,6 +96,8 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext setNotice(data.effective.softFail === !enabled ? "" : "Saved. A daemon environment override controls enforcement."); } }); const disabled = !ready || mutation.isPending; + const problem = mutation.error?.message ?? activity.data?.message ?? activity.error?.message ?? config.error?.message; + const status = !ready ? problem ? "be-concise is unavailable." : "Connecting…" : bypassed ? "Flagged actions allowed. Filtering stays on." : "Enforcing rules in this workspace."; return { setNotice(""); mutation.mutate(bypassed); }} @@ -84,23 +105,30 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext Be concise - + - + {ready && } - {ready && - {counts.filter((item, index) => index < 2 || item.count > 0).map((item) => - - + {ready && + {counts.map((item) => + {item.count} + + + + )} } - {ready && - + {ready && + {minutes.map((minute) => `, ${count} ${decision}`).join("")}`} - style={{ flex: 1, height: 46, flexDirection: "column-reverse" }}> - {minute.decisions.map(([decision, count]) => )} + style={{ flex: 1, height: CHART_HEIGHT, flexDirection: "column-reverse" }}> + {minute.count === 0 + ? + : + {minute.decisions.map(([decision, count]) => )} + } )} @@ -109,8 +137,7 @@ function ConciseAgentContent({ theme, workspaceId, agentId, onOpenDetails }: Ext {!minutes.some((minute) => minute.count > 0) && } } - {(activity.error || config.error || mutation.error) && } - {activity.data?.message && } + {problem && } {!!notice && } ; label: string; timer: ReturnType; pending: boolean }; + type Poll = { registrations: Set; status: ReturnType; timer: ReturnType; pending: boolean }; const polls = new Map(); const pills = new Map(); let disposed = false; @@ -152,7 +179,7 @@ export function contributeComposer(client: PluginClientContext) { const registration = client.addComposerPill({ id: PILL_ID, workspaceId, agentId, button: { - title: PILL_TITLE, label: polls.get(workspaceId)?.label ?? "Connecting", icon: ConciseIcon, + ...polls.get(workspaceId)?.status ?? pillStatus("Connecting"), behavior: { kind: "popover", Content: (props) => { props.close(); client.openPanel("workspace", { workspaceId }); }} /> }, }, }); @@ -161,26 +188,25 @@ export function contributeComposer(client: PluginClientContext) { if (existing) { existing.registrations.add(registration); return; } const workspace = client.paseo.workspaces.ref(workspaceId); const timer = setInterval(() => void refresh(), POLL_MS); - const poll: Poll = { registrations: new Set([registration]), label: "Connecting", timer, pending: false }; + const poll: Poll = { registrations: new Set([registration]), status: pillStatus("Connecting"), timer, pending: false }; polls.set(workspaceId, poll); const live = () => !disposed && polls.get(workspaceId) === poll; - function update(label: string) { + function update(status: ReturnType) { if (!live()) return; - poll.label = label; - for (const item of poll.registrations) item.update({ label }); + poll.status = status; + for (const item of poll.registrations) item.update(status); } async function refresh() { if (!live() || poll.pending) return; poll.pending = true; try { const cwd = workspace.directory ?? (await workspace.refresh())?.workspaceDirectory; - if (!cwd) { update("Connecting"); return; } + if (!cwd) { update(pillStatus("Connecting")); return; } const [snap, config] = await Promise.all([client.rpc(snapshot, { cwd }), client.rpc(readConfiguration, { cwd })]); const bypassed = config.effective.softFail === true; - const flagged = snap.stats.decisions.reduce((sum, item) => sum + (["ask", "flag"].includes(item.name) ? item.count : 0), 0); - update(statusLabel(snap.connected, bypassed, flagged)); + update(pillStatus(!snap.connected ? "Connecting" : bypassed ? "Bypassed" : "Enabled", snap.stats.decisions)); } catch { - update("Unavailable"); + update(pillStatus("Unavailable")); } finally { poll.pending = false; } diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..65bc31d --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,5 @@ +# Documentation + +Created: 2026-10-04. Last updated: 2026-10-04. + +- [Composer pill and screenshots](composer-pill.md): compact status labels, the Paseo button contract, and screenshot capture details. Read this before changing labels or refreshing images. diff --git a/docs/composer-pill.md b/docs/composer-pill.md new file mode 100644 index 0000000..01ee378 --- /dev/null +++ b/docs/composer-pill.md @@ -0,0 +1,30 @@ +# Composer pill and screenshots + +Created: 2026-10-04. Last updated: 2026-10-04. + +## Button contract + +Paseo composer pills display an icon and a non-empty label. Omitting `label` displays `title`. The title also supplies the accessible label, tooltip, and sheet title. See the [Paseo composer pill reference](https://paseo.sh/docs/plugins/reference#composer-pills) and [button descriptor](https://paseo.sh/docs/plugins/reference#button-descriptor), checked on 2026-10-04. + +Every pill state uses the monochrome Be concise logo. The enabled label is the combined rejected and flagged count. The popup lists each decision count, and its 30-minute chart colors only rejected and flagged decisions. The title is a short summary, such as `Be concise · 5 rejected or flagged`, so the mobile sheet title fits. Agents in the same workspace share the polled label, icon, and title. + +| State | Label | +| --- | --- | +| Enforcing rules | One count, for example `11` | +| Workspace enforcement bypassed | `Off` | +| Connecting | `…` | +| Read failed | `!` | + +The pill counts `deny`, `block`, `ask`, and `flag`. Allowed, rewritten, bypassed, and error counts stay in the popup. + +## Screenshots + +The [README gallery](../README.md#screenshots) uses scenario headings and PNG links, following the [Shared Browser example](https://github.com/omercnet/paseo-plugins/tree/d7b3e654f364b5be72edf6fd1d914a3750b53082/paseo-shared-browser#screenshots). The promo banners come first. The connection and empty states come last. + +Every image in [images/](images/) is 1920×1080 (16:9). Captures were taken on 2026-10-04 from the Paseo 0.11.0-beta.3 web app, served by a separate test daemon with this plugin installed from the repository and be-concise 0.8.2. Sample hook records, a sample Git project, and an imported sample Claude Code session supplied the data. No prompt was sent to an agent. + +- **Banners** (`composer-on`, `activity-desktop`, `configuration-light`, `playground`, `compact`): a 960×540 HTML layout rendered at 2× around crops of 1440×900 captures at 2× pixel density. The narrow-screen banner shows two 390×844 captures. +- **Plain captures** (`composer-off`, `activity-filtered`, `composer-connecting`, `composer-unavailable`, `activity-empty`): 1280×720 viewports at 1.5× pixel density. +- **States**: Off was set through the project's `softFail` setting. Connecting was captured while the plugin process was paused. Unavailable was captured with be-concise removed from the test home. Empty was captured with no hook records. + +The test daemon reported the host name `devbox`. Native mobile apps and the desktop app were not checked. diff --git a/docs/images/activity-desktop.png b/docs/images/activity-desktop.png new file mode 100644 index 0000000..05176a1 Binary files /dev/null and b/docs/images/activity-desktop.png differ diff --git a/docs/images/activity-empty.png b/docs/images/activity-empty.png new file mode 100644 index 0000000..0c6f966 Binary files /dev/null and b/docs/images/activity-empty.png differ diff --git a/docs/images/activity-filtered.png b/docs/images/activity-filtered.png new file mode 100644 index 0000000..930e09a Binary files /dev/null and b/docs/images/activity-filtered.png differ diff --git a/docs/images/compact.png b/docs/images/compact.png new file mode 100644 index 0000000..a5e965a Binary files /dev/null and b/docs/images/compact.png differ diff --git a/docs/images/composer-connecting.png b/docs/images/composer-connecting.png new file mode 100644 index 0000000..844b4ee Binary files /dev/null and b/docs/images/composer-connecting.png differ diff --git a/docs/images/composer-off.png b/docs/images/composer-off.png new file mode 100644 index 0000000..371d9f5 Binary files /dev/null and b/docs/images/composer-off.png differ diff --git a/docs/images/composer-on.png b/docs/images/composer-on.png new file mode 100644 index 0000000..5fdd1ef Binary files /dev/null and b/docs/images/composer-on.png differ diff --git a/docs/images/composer-unavailable.png b/docs/images/composer-unavailable.png new file mode 100644 index 0000000..9b2816f Binary files /dev/null and b/docs/images/composer-unavailable.png differ diff --git a/docs/images/configuration-light.png b/docs/images/configuration-light.png new file mode 100644 index 0000000..046f5df Binary files /dev/null and b/docs/images/configuration-light.png differ diff --git a/docs/images/playground.png b/docs/images/playground.png new file mode 100644 index 0000000..13234e4 Binary files /dev/null and b/docs/images/playground.png differ diff --git a/test/composer.test.mts b/test/composer.test.mts new file mode 100644 index 0000000..7c69066 --- /dev/null +++ b/test/composer.test.mts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import type { PluginButton, PluginClientContext, PluginComposerPillContribution } from "@getpaseo/plugin/client"; +import ts from "typescript"; +import { readConfiguration, snapshot } from "../shared/contracts.ts"; + +const source = await readFile(new URL("../client/composer.tsx", import.meta.url), "utf8"); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +type Agent = { id: string; workspaceId: string; provider: string; status: string }; +type Subscription = { + snapshot(value: { entries: { agent: Agent }[] }): void; + update(value: { type: "agent_update"; payload: { kind: "upsert"; agent: Agent } | { kind: "remove"; agentId: string } }): void; +}; + +async function createHarness(directory: string | undefined = "/project") { + const timers = new Map void>(); + const buttons = new Map(); + const subscribed = Promise.withResolvers(); + const state = { connected: true, bypassed: false, failed: false, decisions: [] as { name: string; count: number }[] }; + const workspace = { directory, async refresh() { return { workspaceDirectory: workspace.directory }; } }; + const counts = { rpc: 0, removed: 0, released: 0, unsubscribed: 0 }; + let nextTimer = 0; + let updated = () => {}; + const exports = {} as { contributeComposer(client: PluginClientContext): () => void }; + const componentImports = new Set([ + "react/jsx-runtime", "@getpaseo/plugin/client", "@getpaseo/plugin/client/react-native", + "@tanstack/react-query", "react", "react-native", "../shared/enforcement", "./brand", "./ui", + ]); + runInNewContext(compiled, { + exports, AbortController, console, + require(name: string) { + if (name === "../shared/contracts") return { snapshot, readConfiguration }; + if (componentImports.has(name)) return {}; + throw new Error(`Unexpected composer import: ${name}`); + }, + setInterval(callback: () => void) { timers.set(++nextTimer, callback); return nextTimer; }, + clearInterval(id: number) { timers.delete(id); }, + }); + const cleanup = exports.contributeComposer({ + addComposerPill({ agentId, button }: PluginComposerPillContribution) { + buttons.set(agentId, { ...button }); + return { + update(patch: Partial) { Object.assign(buttons.get(agentId)!, patch); updated(); }, + remove() { buttons.delete(agentId); counts.removed++; }, + }; + }, + async rpc(contract: { name: string }, input: { cwd: string }) { + counts.rpc++; + assert.equal(input.cwd, workspace.directory); + if (state.failed) throw new Error("RPC unavailable"); + if (contract.name === snapshot.name) return { connected: state.connected, stats: { decisions: state.decisions } }; + assert.equal(contract.name, readConfiguration.name); + return { effective: { softFail: state.bypassed } }; + }, + paseo: { + workspaces: { ref(id: string) { assert.equal(id, "workspace-1"); return workspace; } }, + agents: { async list() { return { subscription: { + subscribe(subscription: Subscription) { subscribed.resolve(subscription); return () => { counts.unsubscribed++; }; }, + release() { counts.released++; }, + } }; } }, + }, + } as unknown as PluginClientContext); + const subscription = await subscribed.promise; + function nextUpdate() { return new Promise((resolve) => { updated = resolve; }); } + return { + buttons, timers, state, workspace, counts, cleanup, subscription, nextUpdate, + async poll() { + assert.equal(timers.size, 1); + const refreshed = nextUpdate(); + for (const callback of timers.values()) callback(); + await refreshed; + }, + }; +} + +const firstAgent: Agent = { id: "agent-1", workspaceId: "workspace-1", provider: "codex", status: "running" }; + +function assertStatus(button: PluginButton, label: string, summary: string) { + assert.equal(button.label, label); + assert.equal(button.title, `Be concise · ${summary}`); +} + +test("composer pill shows one attention count when enabled and Off when bypassed", { timeout: 3000 }, async (t) => { + const harness = await createHarness(); + t.after(harness.cleanup); + const firstUpdate = harness.nextUpdate(); + harness.subscription.snapshot({ entries: [{ agent: firstAgent }] }); + const button = harness.buttons.get(firstAgent.id)!; + assertStatus(button, "…", "Connecting"); + await firstUpdate; + assertStatus(button, "0", "0 rejected or flagged"); + assert.equal(typeof button.icon, "function"); + + harness.state.decisions = [{ name: "allow", count: 24 }, { name: "flag", count: 4 }, { name: "ask", count: 2 }, { name: "deny", count: 3 }, { name: "block", count: 2 }]; + await harness.poll(); + assertStatus(button, "11", "11 rejected or flagged"); + assert.equal(typeof button.icon, "function"); + + harness.state.decisions.push({ name: "rewrite", count: 3 }, { name: "bypass", count: 2 }, { name: "error", count: 1 }); + await harness.poll(); + assertStatus(button, "11", "11 rejected or flagged"); + + harness.state.bypassed = true; + await harness.poll(); + assertStatus(button, "Off", "Enforcement off"); + assert.equal(typeof button.icon, "function"); + + harness.state.connected = false; + await harness.poll(); + assertStatus(button, "…", "Connecting"); + + harness.state.failed = true; + await harness.poll(); + assertStatus(button, "!", "Unavailable"); + + Object.assign(harness.state, { connected: true, bypassed: false, failed: false, decisions: [] }); + await harness.poll(); + assertStatus(button, "0", "0 rejected or flagged"); + assert.equal(typeof button.icon, "function"); +}); + +test("agents sharing a workspace inherit the current label and title and share polling", { timeout: 3000 }, async (t) => { + const harness = await createHarness(); + t.after(harness.cleanup); + harness.state.bypassed = true; + harness.state.decisions = [{ name: "ask", count: 12 }]; + const firstUpdate = harness.nextUpdate(); + harness.subscription.snapshot({ entries: [{ agent: firstAgent }] }); + await firstUpdate; + assertStatus(harness.buttons.get(firstAgent.id)!, "Off", "Enforcement off"); + + const secondAgent = { ...firstAgent, id: "agent-2", provider: "claude" }; + harness.subscription.update({ type: "agent_update", payload: { kind: "upsert", agent: secondAgent } }); + assertStatus(harness.buttons.get(secondAgent.id)!, "Off", "Enforcement off"); + assert.equal(harness.timers.size, 1); + assert.equal(harness.counts.rpc, 2); + + harness.state.bypassed = false; + await harness.poll(); + for (const button of harness.buttons.values()) { + assertStatus(button, "12", "12 rejected or flagged"); + assert.equal(typeof button.icon, "function"); + } + assert.equal(harness.counts.rpc, 4); + + const thirdAgent = { ...firstAgent, id: "agent-3" }; + harness.subscription.update({ type: "agent_update", payload: { kind: "upsert", agent: thirdAgent } }); + assertStatus(harness.buttons.get(thirdAgent.id)!, "12", "12 rejected or flagged"); + assert.equal(typeof harness.buttons.get(thirdAgent.id)!.icon, "function"); + harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: thirdAgent.id } }); + + harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: firstAgent.id } }); + assert.equal(harness.timers.size, 1); + harness.subscription.update({ type: "agent_update", payload: { kind: "remove", agentId: secondAgent.id } }); + assert.equal(harness.timers.size, 0); + assert.equal(harness.buttons.size, 0); + assert.equal(harness.counts.removed, 3); +}); + +test("composer stays connecting until its workspace directory is available", { timeout: 3000 }, async (t) => { + const harness = await createHarness(); + t.after(harness.cleanup); + harness.workspace.directory = undefined; + const firstUpdate = harness.nextUpdate(); + harness.subscription.snapshot({ entries: [{ agent: firstAgent }] }); + await firstUpdate; + assertStatus(harness.buttons.get(firstAgent.id)!, "…", "Connecting"); + assert.equal(harness.counts.rpc, 0); + + harness.workspace.directory = "/project"; + await harness.poll(); + assertStatus(harness.buttons.get(firstAgent.id)!, "0", "0 rejected or flagged"); +});