diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9797b90..34bc40c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,10 @@ jobs: - name: Typecheck run: pnpm -r typecheck - - name: Test - run: pnpm -r test + # Runs every package's tests under v8 coverage and fails if any package + # drops below its per-package thresholds (see each vitest.config.ts). + - name: Test + coverage + run: pnpm coverage - name: Lint run: pnpm lint diff --git a/CLAUDE.md b/CLAUDE.md index 43c2dfa..f62198f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ A **pnpm workspace** of OpenCode plugins under the `@vymalo` npm scope. There ar | `packages/opencode-oauth2` → `@vymalo/opencode-oauth2` | OAuth2 / OIDC auth + dynamic model discovery for OpenAI-compatible providers. The mature plugin; five auth flows (`authorization_code`, `device_code`, `client_credentials`, `jwt_bearer`, `token_exchange`), persistent token cache, periodic sync scheduler. PKCE is on by default for the two interactive flows (`pkce: false` opts out per server). | | `packages/opencode-models-info` → `@vymalo/opencode-models-info` | **Auth-agnostic** metadata enrichment plugin: fetches OpenRouter-shaped `/models` JSON and merges `limit` / `cost` / `modalities` / capability flags onto existing provider model entries. Runs as a `Hooks.config` hook *after* other plugins. | | `packages/opencode-ratelimit` → `@vymalo/opencode-ratelimit` | **Auth-agnostic** rate-limit awareness plugin: in its `Hooks.config` hook it injects a custom `fetch` onto opted-in providers (`options.meta.rateLimit`) that reads Envoy Gateway / IETF draft-03 rate-limit headers (`x-ratelimit-limit/remaining/reset`), proactively throttles when `remaining` hits 0, and backs off + retries on `429`. Supports `tiers` (reset-magnitude policy bands with `wait`/`error` actions, so a 60s burst waits but a multi-day budget reset errors fast) and `scope: "model"\|"provider"` (per-model cooldown buckets for per-model gateway limits). The only response-observing plugin — OpenCode has no post-response hook, so wrapping `options.fetch` is the sole interception point. In-memory state only (no `cache.ts`). See [`docs/ratelimit.md`](docs/ratelimit.md). | -| `packages/opencode-browser` → `@vymalo/opencode-browser` | **Auth-agnostic** browser-automation plugin: registers `browser_*` **tools** (`Hooks.tool`) the model calls (open, click, type, scroll, screenshot, snapshot, …) and hosts a localhost WebSocket **bridge** (via the Node `ws` package, so it runs under both Bun and Node) that the companion extension dials. **33 tools** in three groups (`page`/`control`/`debug`, gated by the `groups` option); tabs are organized into **named groups**. The single source of truth for the tool surface is `catalog.ts` (shared with the MCP server). The bridge is an **auto-elect broker** (`broker.ts`) routing between **agents** (plugin/MCP/sessions) and **executors** (extensions) by named-group ownership — so multiple browsers and multiple agents can share one bridge. The only tool-registering plugin. Screenshots are written to disk (tool output is text-only). See [`docs/browser.md`](docs/browser.md) and [`plans/multi-client-routing.md`](plans/multi-client-routing.md). | +| `packages/opencode-browser` → `@vymalo/opencode-browser` | **Auth-agnostic** browser-automation plugin: registers `browser_*` **tools** (`Hooks.tool`) the model calls (open, click, type, scroll, screenshot, snapshot, …) and hosts a localhost WebSocket **bridge** (via the Node `ws` package, so it runs under both Bun and Node) that the companion extension dials. **34 tools** in four groups (`page`/`control`/`debug`/`interactive`, gated by the `groups` option; `debug` and `interactive` are opt-in); tabs are organized into **named groups**. The single source of truth for the tool surface is `catalog.ts` (shared with the MCP server). The bridge is an **auto-elect broker** (`broker.ts`) routing between **agents** (plugin/MCP/sessions) and **executors** (extensions) by named-group ownership — so multiple browsers and multiple agents can share one bridge. The only tool-registering plugin. Screenshots are written to disk (tool output is text-only). The `interactive` group adds **human-in-the-loop** feedback (`browser_request_feedback`): a blocking, branded in-page overlay (point/confirm/choose) that the broker can tear down via a `cancel` frame on abort/timeout — see [`plans/ui-feedback.md`](plans/ui-feedback.md). See [`docs/browser.md`](docs/browser.md) and [`plans/multi-client-routing.md`](plans/multi-client-routing.md). | | `packages/opencode-browser-mcp` → `@vymalo/opencode-browser-mcp` | **MCP stdio server** (a `bin`) that hosts the same bridge (Node `ws` transport) and exposes the same group-filtered `browser_*` catalog over the Model Context Protocol — so non-OpenCode agents (Claude Code, Cursor, Cline, …) can drive the extension. Reuses `@vymalo/opencode-browser`'s catalog + JSON-Schema via `./lib`; returns screenshots as inline MCP image content. | | `apps/browser-extension` → `@vymalo/opencode-browser-extension` (private) | Companion Chromium MV3 + Firefox extension for the browser plugin/MCP server. WXT + React + Tailwind + shadcn-style UI + TanStack Query + Dexie/IndexedDB. Its background worker connects out to the bridge and drives tabs via CDP (`chrome.debugger`) or a content-script fallback. **Not** published to npm. | | `packages/plugin-bundle` → `@vymalo/opencode-oauth2-bundle` (private) | Rolldown build that ships a single-file distribution of the oauth2 plugin. | @@ -24,13 +24,16 @@ The plugins are deliberately decoupled: `opencode-models-info`, `opencode-rateli pnpm install # bootstrap workspace pnpm -r build # compile all packages (tsc → dist/) pnpm -r typecheck # tsc --noEmit across packages -pnpm -r test # vitest run in each package that has tests +pnpm -r test # vitest run in each package that has tests (fast, no coverage) +pnpm coverage # vitest run --coverage per package; FAILS below per-package thresholds pnpm lint # biome lint (full repo) pnpm format # biome format --write pnpm format:check # biome format (no write) — part of the pre-push gate ``` -Pre-push gate (run all five before opening a PR): `pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint && pnpm format:check`. +Pre-push gate (run all five before opening a PR): `pnpm -r build && pnpm -r typecheck && pnpm coverage && pnpm lint && pnpm format:check`. (`pnpm coverage` runs the tests **and** enforces coverage; CI runs the same. Use the faster `pnpm -r test` for local iteration.) + +Coverage thresholds are per-package, declared in each `vitest.config.ts` (`test.coverage.thresholds`), set a few points below current so a regression fails CI without exact-match churn. `@vymalo/opencode-browser` is the bar (~88%+); `opencode-browser-mcp` excludes its stdio `bin` (`mcp.ts`) from the metric (it's e2e-only); the **browser extension floor is intentionally low** (chrome/DOM/React glue is verified manually — raise it once a fake-browser harness lands). Per-package iteration (much faster): @@ -95,7 +98,7 @@ packages// `opencode-ratelimit` follows the same shape **minus `cache.ts`** (its rate-limit state is in-memory only — a reset window is seconds, so persisting it would only serve stale data) and **plus `headers.ts`** (a pure parser for the `x-ratelimit-*` triple). It is also the only plugin that injects a custom `fetch` into `provider.options.fetch` (the sole way to observe response status/headers, since OpenCode has no post-response hook) rather than only reading/merging config. -`opencode-browser` follows the same shape **minus `cache.ts`** (broker state is in-memory) and **plus**: `protocol.ts` (the dependency-free wire-frame contract, mirrored into the extension), `transport.ts` (the `BridgeTransport` seam + `isAddrInUse`) and `node-transport.ts` (the `ws`-backed host transport + guest socket, runs under Bun *and* Node — shared with the MCP server via `./lib`; async `listen` for bind-based election), `broker.ts` (role-aware broker: executors + agents + group-ownership routing, DI-tested), `agent-client.ts` (guest-agent WS client), `endpoint.ts` (try-bind → host-or-guest auto-election with failover), `token-file.ts` (shared `bridge.json`), `catalog.ts` + `schema.ts` (the neutral tool surface), and `tools.ts` (the OpenCode `Hooks.tool` adapter over the catalog). It is the only plugin that **registers tools** and **hosts a server**. The companion extension under `apps/browser-extension` is a WXT project (not the per-package `src/` layout) — its engine lives in `src/background/` (bridge client, command router, group registry, CDP + content executors, `page-actions` injected via `chrome.scripting.executeScript`) and its UI in `src/entrypoints/{popup,options}` over Dexie. +`opencode-browser` follows the same shape **minus `cache.ts`** (broker state is in-memory) and **plus**: `protocol.ts` (the dependency-free wire-frame contract, mirrored into the extension), `transport.ts` (the `BridgeTransport` seam + `isAddrInUse`) and `node-transport.ts` (the `ws`-backed host transport + guest socket, runs under Bun *and* Node — shared with the MCP server via `./lib`; async `listen` for bind-based election), `broker.ts` (role-aware broker: executors + agents + group-ownership routing, DI-tested), `agent-client.ts` (guest-agent WS client), `endpoint.ts` (try-bind → host-or-guest auto-election with failover), `token-file.ts` (shared `bridge.json`), `catalog.ts` + `schema.ts` (the neutral tool surface), and `tools.ts` (the OpenCode `Hooks.tool` adapter over the catalog). It is the only plugin that **registers tools** and **hosts a server**. The companion extension under `apps/browser-extension` is a WXT project (not the per-package `src/` layout) — its engine lives in `src/background/` (bridge client, command router, group registry, CDP + content executors, `page-actions` injected via `chrome.scripting.executeScript`, `feedback`/`feedback-overlay`/`feedback-side-panel` for the `interactive` HITL flow) and its UI in `src/entrypoints/{popup,options,sidepanel}` over Dexie (the `sidepanel` is the docked annotation fallback for overlay-blocked pages). **Important — two entry points per published package:** @@ -119,7 +122,7 @@ When changing the mapping in [`packages/opencode-models-info/src/mapping.ts`](pa - **Biome, not ESLint/Prettier.** Config in [`biome.json`](biome.json) — double quotes, 100-col, no trailing commas, semicolons always. `noNonNullAssertion` is a warning the existing code stays clean of; mirror that in new code (`@vymalo/opencode-oauth2` has 0 warnings, treat that as the bar). - **Strict TS.** Base config is in [`tsconfig.base.json`](tsconfig.base.json) — `ES2022` + `NodeNext` + `strict: true`. Per-package tsconfig only sets `rootDir`/`outDir`. `lib.ts` re-exports are the public surface. -- **Vitest** is the test runner; each package owns a `vitest.config.ts`. Tests live in `test/`, not co-located. +- **Vitest** is the test runner; each package owns a `vitest.config.ts` (with a `coverage` block + per-package thresholds enforced by `pnpm coverage`). Tests live in `test/`, not co-located. Coverage uses the v8 provider (`@vitest/coverage-v8`). - **Node ≥ 22** for the runtime packages (set in each package.json `engines`). Use `node:` prefixed imports for built-ins (`node:fs/promises`, `node:crypto`). - **Logging pattern**: every plugin emits structured events through both a JSON console fallback and `client.app.log` (so the host log stream picks them up). Event names use `snake_case` (`models_info_cache_hit`, `oauth2_token_refreshed`). Add new events to that pattern, not ad-hoc `console.log`. - **Cache layout** mirrors per-OS conventions — `~/Library/Caches//` on macOS, `XDG_CACHE_HOME` on Linux, `LOCALAPPDATA` on Windows. Each plugin uses its own namespace (`opencode-oauth2`, `opencode-models-info`). Disk writes are atomic-rename + `0o600`. @@ -138,5 +141,7 @@ Versions are bumped **manually** — there are no changesets and no release scri - [`plans/prd.md`](plans/prd.md) — original oauth2 PRD with the phased roadmap. - [`plans/models-info-plan.md`](plans/models-info-plan.md) — design doc for the metadata plugin, including the OpenRouter→OpenCode field mapping table. +- [`plans/multi-client-routing.md`](plans/multi-client-routing.md) — design (final) for the browser bridge's auto-elect broker: multi-executor + multi-agent routing by group ownership, host-or-guest election, failover. +- [`plans/ui-feedback.md`](plans/ui-feedback.md) — design (draft) for human-in-the-loop browser feedback: a `browser_request_feedback` tool that paints an annotation overlay and blocks on the user; needs a `CancelFrame` + per-command timeout first. - [`docs/`](docs/) — the architecture doc is canonical for hook behavior. Also: [`well-known.md`](docs/well-known.md) (`.well-known/opencode` distribution), [`models-info.md`](docs/models-info.md) (enrichment composition + caching), [`ratelimit.md`](docs/ratelimit.md) (rate-limit policy/tiers), [`browser.md`](docs/browser.md) (browser-automation dual plugin — topology, wire protocol, tool reference, executors, security), [`troubleshooting.md`](docs/troubleshooting.md) (symptom-keyed fixes), plus GitHub Actions / Kubernetes cookbooks and local-dev setup. - [`docs/adr/`](docs/adr/) — Architecture Decision Records: load-bearing, non-obvious decisions and *why* (e.g. [ADR-0001](docs/adr/0001-bridge-transport-ws-not-bun-serve-or-socketio.md) — the browser bridge uses `ws`, not `Bun.serve` or socket.io). Add one when a choice closes off alternatives someone would reasonably reach for. diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index e8c841a..49adf49 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -15,6 +15,7 @@ "submit:firefox": "wxt submit --firefox-zip .output/*-firefox.zip --firefox-sources-zip .output/*-sources.zip", "typecheck": "wxt prepare && tsc --noEmit", "test": "vitest run", + "coverage": "vitest run --coverage", "lint": "biome lint .", "format": "biome format --write .", "format:check": "biome format ." diff --git a/apps/browser-extension/src/background/bridge-client.ts b/apps/browser-extension/src/background/bridge-client.ts index 73aba15..a705931 100644 --- a/apps/browser-extension/src/background/bridge-client.ts +++ b/apps/browser-extension/src/background/bridge-client.ts @@ -30,6 +30,11 @@ export interface BridgeClientDeps { getConfig: () => Promise; /** Execute one command and resolve with its result data. */ onCommand: (frame: CommandFrame) => Promise; + /** + * The broker abandoned an in-flight command (agent abort / timeout / agent + * gone) — tear down any UI/work for that command id. No result is sent. + */ + onCancel?: (id: string) => void; /** Executor kind to publish in the status row, for the UI. */ executorKind: () => ExecutorKind; /** @@ -169,6 +174,9 @@ export class BridgeClient { case "release": await this.deps.onRelease?.(); return; + case "cancel": + this.deps.onCancel?.(frame.id); + return; case "ping": ws.send(encodeFrame({ v: PROTOCOL_VERSION, type: "pong" })); return; diff --git a/apps/browser-extension/src/background/command-router.ts b/apps/browser-extension/src/background/command-router.ts index c2a1810..5d87c25 100644 --- a/apps/browser-extension/src/background/command-router.ts +++ b/apps/browser-extension/src/background/command-router.ts @@ -1,6 +1,8 @@ import { recordAction, recordScreenshot } from "../shared/db"; import type { CommandFrame } from "../shared/protocol"; import type { Executor, Viewport } from "./executor"; +import { startFeedback } from "./feedback"; +import type { FeedbackMode, FeedbackRequest } from "./feedback-overlay"; import type { GroupRegistry } from "./group-registry"; import { runPageAction, type Target } from "./page-actions"; @@ -19,11 +21,34 @@ function target(params: Record): Target { * (and screenshot) to IndexedDB for the dashboard — including failures. */ export class CommandRouter { + /** + * Teardown callbacks for in-flight cancellable commands, keyed by command id. + * Long-running interactive commands (e.g. a feedback overlay) register here so + * a broker `cancel` can abort them; ordinary commands never register. + */ + private readonly cancellers = new Map void>(); + constructor( private readonly registry: GroupRegistry, private readonly executor: Executor ) {} + /** Register a teardown for a cancellable command; returns a disposer. */ + registerCanceller(id: string, teardown: () => void): () => void { + this.cancellers.set(id, teardown); + return () => this.cancellers.delete(id); + } + + /** Broker abandoned command `id` — run and drop its teardown if present. */ + cancel(id: string): void { + const teardown = this.cancellers.get(id); + if (!teardown) { + return; + } + this.cancellers.delete(id); + teardown(); + } + async handle(frame: CommandFrame): Promise { const start = Date.now(); try { @@ -48,6 +73,9 @@ export class CommandRouter { durationMs: Date.now() - start }); throw err; + } finally { + // The command settled on its own; drop any teardown it registered. + this.cancellers.delete(frame.id); } } @@ -291,6 +319,27 @@ export class CommandRouter { await this.executor.releaseAll(); return { data: { ok: true }, summary: "released control" }; } + case "request_feedback": { + const tabId = this.tab(group, params); + const req: FeedbackRequest = { + mode: (params.mode as FeedbackMode) ?? "confirm", + prompt: params.prompt as string | undefined, + options: Array.isArray(params.options) ? (params.options as string[]) : undefined, + timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 120_000 + }; + const handle = startFeedback(tabId, frame.id, req); + // Register before awaiting so a broker `cancel` mid-wait can tear it down. + this.registerCanceller(frame.id, handle.cancel); + const result = await handle.result; + const summary = result.error + ? `feedback unavailable: ${result.error}` + : result.timedOut + ? "feedback timed out" + : result.responded + ? `feedback: ${result.annotations.map((a) => a.kind).join(",") || "none"}` + : "feedback dismissed"; + return { data: result, summary }; + } default: throw new Error(`unknown action: ${action}`); } diff --git a/apps/browser-extension/src/background/feedback-overlay.ts b/apps/browser-extension/src/background/feedback-overlay.ts new file mode 100644 index 0000000..21564f7 --- /dev/null +++ b/apps/browser-extension/src/background/feedback-overlay.ts @@ -0,0 +1,471 @@ +/** + * In-page feedback overlay for `interactive` requests. Like `page-actions`, the + * overlay function is injected via `chrome.scripting.executeScript({ func })`, + * so it is serialized and re-evaluated in the page with **no module scope** — + * everything it needs lives inside the single self-contained `feedbackOverlay`. + * + * It does NOT block: it paints the overlay, wires listeners, and returns. The + * user's response (or a dismissal) comes back to the background worker via + * `chrome.runtime.sendMessage` (the background owns the timeout + correlation). + * Teardown is a second tiny injection that removes the overlay element, so the + * background can cancel a request without a page-side message channel. + */ + +export type FeedbackMode = "confirm" | "choose" | "point" | "element" | "region" | "comment"; + +export interface FeedbackRequest { + mode: FeedbackMode; + prompt?: string; + options?: string[]; + /** Background-owned deadline (ms); the overlay itself does not self-expire. */ + timeoutMs: number; +} + +/** One mark the user made; mirrors the plugin's `Annotation` wire shape. */ +export type FeedbackAnnotation = + | { kind: "confirm"; value: boolean } + | { kind: "choice"; value: string } + | { kind: "point"; x: number; y: number; ref?: string; selector?: string; text?: string } + | { kind: "element"; ref?: string; selector?: string; text?: string } + | { + kind: "region"; + rect: { x: number; y: number; width: number; height: number }; + refs: string[]; + text?: string; + }; + +/** Message the injected overlay posts back to the background worker. */ +export interface FeedbackMessage { + type: "ocb-feedback-result"; + id: string; + /** False when the user dismissed/skipped rather than answering. */ + responded: boolean; + annotations: FeedbackAnnotation[]; +} + +/** Paint the overlay in the page. Resolves once injected (does not wait for input). */ +export async function showFeedbackOverlay( + tabId: number, + id: string, + req: FeedbackRequest +): Promise { + await chrome.scripting.executeScript({ + target: { tabId }, + func: feedbackOverlay, + args: [id, req.mode, req.prompt ?? "", req.options ?? []] + }); +} + +/** Remove the overlay for `id` from the page (best-effort). */ +export async function hideFeedbackOverlay(tabId: number, id: string): Promise { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (overlayId: string) => { + document.getElementById(`ocb-feedback-${overlayId}`)?.remove(); + }, + args: [id] + }); + } catch { + /* tab navigated/closed — nothing to remove */ + } +} + +/** + * THE injected overlay. Self-contained (all helpers inside). Runs in the page's + * isolated content-script world, so `chrome.runtime.sendMessage` is available. + */ +function feedbackOverlay(id: string, mode: string, prompt: string, options: string[]): void { + const elementId = `ocb-feedback-${id}`; + document.getElementById(elementId)?.remove(); + + const send = (responded: boolean, annotations: unknown[]): void => { + chrome.runtime.sendMessage({ type: "ocb-feedback-result", id, responded, annotations }); + }; + + const ACCENT = "#3b82f6"; + const root = document.createElement("div"); + root.id = elementId; + root.style.cssText = [ + "position:fixed", + "inset:0", + "z-index:2147483647", + "font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif", + "color:#0f172a" + ].join(";"); + + // Brand bar (anti-spoof: clearly identifies the source of the prompt). + const bar = document.createElement("div"); + bar.style.cssText = [ + "position:absolute", + "top:0", + "left:0", + "right:0", + "display:flex", + "align-items:center", + "gap:8px", + "padding:8px 12px", + "background:#0f172a", + "color:#f8fafc", + "font-size:13px", + "box-shadow:0 1px 6px rgba(0,0,0,.25)" + ].join(";"); + const dot = document.createElement("span"); + dot.style.cssText = `width:8px;height:8px;border-radius:9999px;background:${ACCENT};flex:none`; + const brand = document.createElement("span"); + brand.style.cssText = "font-weight:600"; + brand.textContent = "opencode-browser"; + const msg = document.createElement("span"); + msg.style.cssText = "opacity:.85;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; + msg.textContent = prompt || "is asking for your input"; + bar.append(dot, brand, msg); + root.appendChild(bar); + + const skip = (): void => { + cleanup(); + send(false, []); + }; + + const finish = (annotations: unknown[]): void => { + cleanup(); + send(true, annotations); + }; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + skip(); + } + }; + function cleanup(): void { + document.removeEventListener("keydown", onKey, true); + root.remove(); + } + document.addEventListener("keydown", onKey, true); + + const btn = (label: string, primary: boolean): HTMLButtonElement => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + b.style.cssText = [ + "padding:6px 14px", + "border-radius:8px", + "font-size:13px", + "font-weight:600", + "cursor:pointer", + `border:1px solid ${primary ? ACCENT : "#cbd5e1"}`, + `background:${primary ? ACCENT : "#fff"}`, + `color:${primary ? "#fff" : "#0f172a"}` + ].join(";"); + return b; + }; + + // Resolve the nearest ancestor carrying a snapshot ref (so the agent can act). + const resolveRef = (el: HTMLElement | null): string | undefined => { + let node: HTMLElement | null = el; + while (node && !node.getAttribute("data-ocb-ref")) { + node = node.parentElement; + } + return node?.getAttribute("data-ocb-ref") ?? undefined; + }; + // The page element under a point — overlay momentarily hidden so it doesn't win. + const elementUnder = (x: number, y: number): HTMLElement | null => { + root.style.display = "none"; + const el = document.elementFromPoint(x, y) as HTMLElement | null; + root.style.display = ""; + return el; + }; + const describe = (el: HTMLElement | null, a: Record): void => { + if (!el) { + return; + } + const ref = resolveRef(el); + if (ref) { + a.ref = ref; + } + a.selector = cssPath(el); + const label = (el.textContent || "").trim().slice(0, 80); + if (label) { + a.text = label; + } + }; + + const PAGE_MODES = ["point", "element", "region", "comment"]; + if (PAGE_MODES.includes(mode)) { + const capture = document.createElement("div"); + capture.style.cssText = [ + "position:absolute", + "inset:36px 0 0 0", + "cursor:crosshair", + "background:rgba(59,130,246,.06)" + ].join(";"); + root.appendChild(capture); + + if (mode === "element") { + capture.appendChild(hintBar("Hover and click the element — Esc to skip")); + const hl = marker("2px solid"); + root.appendChild(hl); + capture.addEventListener("mousemove", (e: MouseEvent) => { + const el = elementUnder(e.clientX, e.clientY); + if (!el) { + hl.style.display = "none"; + return; + } + place(hl, el.getBoundingClientRect()); + }); + capture.addEventListener("click", (e: MouseEvent) => { + const a: Record = { kind: "element" }; + describe(elementUnder(e.clientX, e.clientY), a); + finish([a]); + }); + } else if (mode === "region") { + capture.appendChild(hintBar("Drag a box over the area — Esc to skip")); + const band = marker("2px dashed"); + root.appendChild(band); + let sx = 0; + let sy = 0; + let dragging = false; + capture.addEventListener("mousedown", (e: MouseEvent) => { + dragging = true; + sx = e.clientX; + sy = e.clientY; + place(band, rectOf(sx, sy, sx, sy)); + }); + capture.addEventListener("mousemove", (e: MouseEvent) => { + if (dragging) { + place(band, rectOf(sx, sy, e.clientX, e.clientY)); + } + }); + capture.addEventListener("mouseup", (e: MouseEvent) => { + if (!dragging) { + return; + } + dragging = false; + const rect = rectOf(sx, sy, e.clientX, e.clientY); + if (rect.width < 4 || rect.height < 4) { + band.style.display = "none"; + return; + } + const refs: string[] = []; + root.style.display = "none"; + for (const node of Array.from(document.querySelectorAll("[data-ocb-ref]"))) { + if (intersects(rect, node.getBoundingClientRect())) { + const ref = node.getAttribute("data-ocb-ref"); + if (ref) { + refs.push(ref); + } + } + } + root.style.display = ""; + finish([{ kind: "region", rect, refs }]); + }); + } else { + // point | comment — click a spot; comment then asks for a note. + capture.appendChild( + hintBar( + mode === "comment" + ? "Click a spot, then add a note — Esc to skip" + : "Click the element you mean — Esc to skip" + ) + ); + capture.addEventListener("click", (e: MouseEvent) => { + const a: Record = { kind: "point", x: e.clientX, y: e.clientY }; + describe(elementUnder(e.clientX, e.clientY), a); + if (mode === "comment") { + capture.remove(); + promptComment(a); + } else { + finish([a]); + } + }); + } + } else { + const panel = document.createElement("div"); + panel.style.cssText = [ + "position:absolute", + "top:50%", + "left:50%", + "transform:translate(-50%,-50%)", + "min-width:280px", + "max-width:420px", + "background:#fff", + "border:1px solid #e2e8f0", + "border-radius:14px", + "box-shadow:0 12px 40px rgba(2,6,23,.28)", + "padding:18px" + ].join(";"); + const q = document.createElement("div"); + q.style.cssText = "font-size:14px;line-height:1.4;margin-bottom:14px"; + q.textContent = prompt || (mode === "confirm" ? "Confirm?" : "Choose one:"); + panel.appendChild(q); + + const actions = document.createElement("div"); + actions.style.cssText = "display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end"; + + if (mode === "choose") { + for (const opt of options.length ? options : ["OK"]) { + const b = btn(opt, false); + b.addEventListener("click", () => finish([{ kind: "choice", value: opt }])); + actions.appendChild(b); + } + const skipBtn = btn("Skip", false); + skipBtn.style.marginLeft = "auto"; + skipBtn.addEventListener("click", skip); + actions.appendChild(skipBtn); + } else { + const no = btn("No", false); + no.addEventListener("click", () => finish([{ kind: "confirm", value: false }])); + const yes = btn("Yes", true); + yes.addEventListener("click", () => finish([{ kind: "confirm", value: true }])); + actions.append(no, yes); + } + panel.appendChild(actions); + root.appendChild(panel); + } + + document.documentElement.appendChild(root); + + /** Minimal stable-ish CSS selector for an element (id → nth-of-type path). */ + function cssPath(el: HTMLElement): string { + if (el.id) { + return `#${CSS.escape(el.id)}`; + } + const parts: string[] = []; + let node: HTMLElement | null = el; + let depth = 0; + while (node && node.nodeType === 1 && depth < 4) { + const current: HTMLElement = node; + let part = current.tagName.toLowerCase(); + const parent: HTMLElement | null = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + (c): c is Element => c.tagName === current.tagName + ); + if (siblings.length > 1) { + part += `:nth-of-type(${siblings.indexOf(current) + 1})`; + } + } + parts.unshift(part); + if (current.id) { + parts[0] = `#${CSS.escape(current.id)}`; + break; + } + node = parent; + depth++; + } + return parts.join(" > "); + } + + /** A floating instruction pill near the bottom of the capture layer. */ + function hintBar(textStr: string): HTMLDivElement { + const hint = document.createElement("div"); + hint.style.cssText = [ + "position:absolute", + "bottom:16px", + "left:50%", + "transform:translateX(-50%)", + "background:#0f172a", + "color:#f8fafc", + "padding:6px 12px", + "border-radius:9999px", + "font-size:12px", + "pointer-events:none" + ].join(";"); + hint.textContent = textStr; + return hint; + } + + /** A non-interactive highlight/selection box overlaid on the page. */ + function marker(border: string): HTMLDivElement { + const m = document.createElement("div"); + m.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:${border} ${ACCENT}`, + "background:rgba(59,130,246,.12)", + "z-index:2147483646", + "display:none" + ].join(";"); + return m; + } + + /** Position a marker over a viewport rectangle. */ + function place(m: HTMLElement, r: { x: number; y: number; width: number; height: number }): void { + m.style.display = "block"; + m.style.left = `${r.x}px`; + m.style.top = `${r.y}px`; + m.style.width = `${r.width}px`; + m.style.height = `${r.height}px`; + } + + /** Normalize two corners into a positive-size rect. */ + function rectOf( + ax: number, + ay: number, + bx: number, + by: number + ): { x: number; y: number; width: number; height: number } { + return { + x: Math.min(ax, bx), + y: Math.min(ay, by), + width: Math.abs(bx - ax), + height: Math.abs(by - ay) + }; + } + + function intersects( + a: { x: number; y: number; width: number; height: number }, + b: DOMRect + ): boolean { + return !(b.right < a.x || b.left > a.x + a.width || b.bottom < a.y || b.top > a.y + a.height); + } + + /** After a point click in `comment` mode, collect an optional free-text note. */ + function promptComment(annotation: Record): void { + const panel = document.createElement("div"); + panel.style.cssText = [ + "position:absolute", + "top:50%", + "left:50%", + "transform:translate(-50%,-50%)", + "min-width:300px", + "max-width:440px", + "background:#fff", + "border:1px solid #e2e8f0", + "border-radius:14px", + "box-shadow:0 12px 40px rgba(2,6,23,.28)", + "padding:18px" + ].join(";"); + const label = document.createElement("div"); + label.style.cssText = "font-size:13px;margin-bottom:8px"; + label.textContent = "Add a note about what you pointed at:"; + const ta = document.createElement("textarea"); + ta.style.cssText = [ + "width:100%", + "box-sizing:border-box", + "min-height:72px", + "padding:8px", + "border:1px solid #cbd5e1", + "border-radius:8px", + "font:inherit", + "font-size:13px", + "resize:vertical" + ].join(";"); + const actions = document.createElement("div"); + actions.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:12px"; + const skipBtn = btn("No note", false); + skipBtn.addEventListener("click", () => finish([annotation])); + const add = btn("Add", true); + add.addEventListener("click", () => { + const t = ta.value.trim(); + if (t) { + annotation.text = t; + } + finish([annotation]); + }); + actions.append(skipBtn, add); + panel.append(label, ta, actions); + root.appendChild(panel); + ta.focus(); + } +} diff --git a/apps/browser-extension/src/background/feedback-side-panel.ts b/apps/browser-extension/src/background/feedback-side-panel.ts new file mode 100644 index 0000000..d759aa9 --- /dev/null +++ b/apps/browser-extension/src/background/feedback-side-panel.ts @@ -0,0 +1,105 @@ +/** + * Side-panel fallback for `interactive` feedback. When the in-page overlay can't + * be injected (restricted / CSP page), we capture a screenshot of the tab and + * route the request to the extension's own side panel instead — the panel can't + * be blocked by the page. Cross-browser: Chromium `chrome.sidePanel`, Firefox + * `sidebarAction`; neither can be force-opened without a user gesture, so we + * enable it + raise the badge and the user opens it from the toolbar. + * + * The panel reads the pending request via `feedback:get-pending` and posts the + * answer with the same `ocb-feedback-result` shape the overlay uses, so the + * background's per-request listener correlates both surfaces identically. + */ +import type { FeedbackSession } from "../shared/messages"; + +/** The single in-flight side-panel request (one at a time keeps the UX simple). */ +let activeSession: FeedbackSession | null = null; +let activeTabId: number | null = null; + +/** The request the side panel should render right now, if any. */ +export function getActiveFeedbackSession(): FeedbackSession | null { + return activeSession; +} + +// Minimal structural views of the two browsers' panel APIs (the @types differ +// per target, so we narrow through `unknown` rather than depend on either). +interface SidePanelApi { + setOptions(opts: { tabId?: number; path?: string; enabled?: boolean }): Promise; + setPanelBehavior?(behavior: { openPanelOnActionClick: boolean }): Promise; +} +interface SidebarActionApi { + setPanel(details: { panel: string }): Promise | void; +} +const sidePanelApi = (chrome as unknown as { sidePanel?: SidePanelApi }).sidePanel; +const sidebarActionApi = (chrome as unknown as { sidebarAction?: SidebarActionApi }).sidebarAction; + +const SIDE_PANEL_PATH = "sidepanel.html"; + +function broadcastPendingChanged(): void { + // No receiver (panel closed) rejects — that's fine, the panel queries on open. + chrome.runtime.sendMessage({ type: "feedback:pending-changed" }).catch(() => {}); +} + +/** + * Set up the side-panel fallback for a request: activate + screenshot the tab, + * stash the session, and enable the panel. Returns true if the panel path is + * ready (caller keeps waiting for the user); false if it couldn't be set up + * (caller should settle with an error). + */ +export async function openSidePanelFallback( + tabId: number, + id: string, + req: { mode: string; prompt?: string; options?: string[] } +): Promise { + if (!sidePanelApi && !sidebarActionApi) { + return false; + } + let screenshot: string; + try { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + screenshot = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" }); + } catch { + return false; // can't even screenshot (e.g. a blocked page) — let caller error out + } + + activeSession = { id, mode: req.mode, prompt: req.prompt, options: req.options, screenshot }; + activeTabId = tabId; + + try { + if (sidePanelApi) { + await sidePanelApi.setOptions({ tabId, path: SIDE_PANEL_PATH, enabled: true }); + // While a request is pending, clicking the toolbar icon opens the panel + // (restored to the normal popup behavior in clearFeedbackSession). We + // can't open the panel ourselves — it needs the user's click. + await sidePanelApi.setPanelBehavior?.({ openPanelOnActionClick: true }); + } else if (sidebarActionApi) { + await sidebarActionApi.setPanel({ panel: chrome.runtime.getURL(SIDE_PANEL_PATH) }); + } + } catch { + activeSession = null; + activeTabId = null; + return false; + } + + broadcastPendingChanged(); + return true; +} + +/** Clear the session for `id` (on answer / timeout / cancel) and notify the panel. */ +export function clearFeedbackSession(id: string): void { + if (activeSession?.id !== id) { + return; + } + const tabId = activeTabId; + activeSession = null; + activeTabId = null; + // Restore the toolbar's normal popup behavior and retire the panel. + if (sidePanelApi) { + void sidePanelApi.setPanelBehavior?.({ openPanelOnActionClick: false }).catch(() => {}); + if (tabId !== null) { + void sidePanelApi.setOptions({ tabId, enabled: false }).catch(() => {}); + } + } + broadcastPendingChanged(); +} diff --git a/apps/browser-extension/src/background/feedback.ts b/apps/browser-extension/src/background/feedback.ts new file mode 100644 index 0000000..22e6845 --- /dev/null +++ b/apps/browser-extension/src/background/feedback.ts @@ -0,0 +1,135 @@ +/** + * Background-side orchestration for `interactive` feedback requests. Owns the + * timeout, correlation, and attention signal; the page-side overlay (injected + * by `feedback-overlay`) reports the user's answer via `chrome.runtime.sendMessage`. + * + * Flow: paint overlay → flag attention (badge + focus the tab) → await either a + * result message, the timeout, or a `cancel()` (broker abandoned the command). + * Whatever ends it, the overlay is torn down and the attention flag cleared. + */ +import { + type FeedbackAnnotation, + type FeedbackMessage, + type FeedbackRequest, + hideFeedbackOverlay, + showFeedbackOverlay +} from "./feedback-overlay"; +import { clearFeedbackSession, openSidePanelFallback } from "./feedback-side-panel"; + +export interface FeedbackResult { + responded: boolean; + timedOut?: boolean; + /** The overlay couldn't be shown (restricted/CSP page) — distinct from timeout. */ + error?: string; + annotations: FeedbackAnnotation[]; +} + +export interface FeedbackHandle { + /** Resolves when the user answers, the request times out, or it's cancelled. */ + result: Promise; + /** Broker abandoned the command — tear down the overlay and settle. */ + cancel: () => void; +} + +function isFeedbackMessage(msg: unknown, id: string): msg is FeedbackMessage { + return ( + typeof msg === "object" && + msg !== null && + (msg as { type?: unknown }).type === "ocb-feedback-result" && + (msg as { id?: unknown }).id === id + ); +} + +let attentionCount = 0; + +/** Raise the toolbar badge and bring the driven tab forward. */ +async function flagAttention(tabId: number): Promise { + attentionCount++; + try { + await chrome.action.setBadgeBackgroundColor({ color: "#3b82f6" }); + await chrome.action.setBadgeText({ text: "?" }); + } catch { + /* action API unavailable */ + } + try { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + if (tab.windowId !== undefined) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + } catch { + /* tab/window gone */ + } +} + +/** Clear the badge once no feedback requests are outstanding. */ +async function clearAttention(): Promise { + attentionCount = Math.max(0, attentionCount - 1); + if (attentionCount > 0) { + return; + } + try { + await chrome.action.setBadgeText({ text: "" }); + } catch { + /* action API unavailable */ + } +} + +/** + * Start a feedback request: paint the overlay, signal attention, and return a + * handle whose `result` settles exactly once. + */ +export function startFeedback(tabId: number, id: string, req: FeedbackRequest): FeedbackHandle { + let settle: (r: FeedbackResult) => void = () => {}; + const result = new Promise((resolve) => { + settle = resolve; + }); + + let done = false; + const timer = setTimeout( + () => finish({ responded: false, timedOut: true, annotations: [] }), + req.timeoutMs + ); + + const onMessage = (msg: unknown): void => { + if (isFeedbackMessage(msg, id)) { + finish({ responded: msg.responded, annotations: msg.annotations ?? [] }); + } + }; + chrome.runtime.onMessage.addListener(onMessage); + + function finish(r: FeedbackResult): void { + if (done) { + return; + } + done = true; + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(onMessage); + void hideFeedbackOverlay(tabId, id); + clearFeedbackSession(id); + void clearAttention(); + settle(r); + } + + void flagAttention(tabId); + // Overlay injection failed (restricted / CSP page). Fall back to the side + // panel over a screenshot; only if that can't be set up either do we settle + // with a distinct error so the agent falls back to a screenshot/snapshot. + void showFeedbackOverlay(tabId, id, req).catch(async (err: unknown) => { + if (done) { + return; + } + const ok = await openSidePanelFallback(tabId, id, req); + if (!ok && !done) { + const reason = err instanceof Error ? err.message : "the overlay could not be shown"; + finish({ responded: false, error: reason, annotations: [] }); + } + // When ok: keep waiting — the user opens the panel and the existing message + // listener / timeout settle the request. + }); + + return { + result, + cancel: () => finish({ responded: false, annotations: [] }) + }; +} diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index 6970266..7f5de59 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -5,9 +5,10 @@ import { CdpExecutor } from "../background/cdp-executor"; import { CommandRouter } from "../background/command-router"; import { ContentExecutor } from "../background/content-executor"; import { detectCapabilities, type Executor, resolveExecutorKind } from "../background/executor"; +import { getActiveFeedbackSession } from "../background/feedback-side-panel"; import { GroupRegistry } from "../background/group-registry"; import { getSettings, getStatus, saveSettings, setStatus } from "../shared/db"; -import type { UiMessage, UiMessageResponse } from "../shared/messages"; +import type { FeedbackPendingResponse, UiMessage, UiMessageResponse } from "../shared/messages"; import type { ExecutorKind, ExecutorMode } from "../shared/types"; export default defineBackground(() => { @@ -44,6 +45,7 @@ export default defineBackground(() => { }; }, onCommand: (frame) => router.handle(frame), + onCancel: (id) => router.cancel(id), executorKind: () => executorKind, // The plugin-side `executor` option (when set) wins over the dashboard // choice on each connect — rebuild the stack if it differs. @@ -70,19 +72,30 @@ export default defineBackground(() => { await client.connect(); })(); - // Control channel from the popup / dashboard. - chrome.runtime.onMessage.addListener((message: UiMessage, _sender, sendResponse) => { - void (async () => { - if (message.type === "reconnect") { - await rebuild(); - await client.reconnect(); - } else if (message.type === "disconnect") { - client.disconnect(); - } - const status = await getStatus(); - sendResponse({ status } satisfies UiMessageResponse); - })(); - return true; // keep the message channel open for the async response + // Control channel from the popup / dashboard / side panel. + chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => { + const type = (message as { type?: string })?.type; + // Side panel asks what request (if any) it should render. + if (type === "feedback:get-pending") { + sendResponse({ session: getActiveFeedbackSession() } satisfies FeedbackPendingResponse); + return true; + } + if (type === "reconnect" || type === "disconnect" || type === "get_status") { + void (async () => { + const m = message as UiMessage; + if (m.type === "reconnect") { + await rebuild(); + await client.reconnect(); + } else if (m.type === "disconnect") { + client.disconnect(); + } + const status = await getStatus(); + sendResponse({ status } satisfies UiMessageResponse); + })(); + return true; // keep the message channel open for the async response + } + // Not ours (e.g. ocb-feedback-result) — handled by the per-request listener. + return false; }); // Keep the registry honest when the user closes a driven tab by hand. diff --git a/apps/browser-extension/src/entrypoints/sidepanel/App.tsx b/apps/browser-extension/src/entrypoints/sidepanel/App.tsx new file mode 100644 index 0000000..a226c03 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/sidepanel/App.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "../../components/ui/button"; +import { naturalRect, type Rect, rectFromCorners, toNatural } from "../../lib/annotate"; +import type { + FeedbackPendingResponse, + FeedbackResultMessage, + FeedbackSession +} from "../../shared/messages"; + +/** Modes that annotate over the screenshot (vs. the button-only confirm/choose). */ +const SPATIAL = new Set(["point", "element", "comment", "region"]); + +export function App() { + const session = usePendingSession(); + + return ( +
+
+ + OpenCode Browser + feedback +
+ {session ? ( + + ) : ( +

+ No feedback request right now. When an agent asks for input on a page it can't draw on, it + appears here. +

+ )} +
+ ); +} + +/** Track the request the background says is pending, re-querying on change. */ +function usePendingSession(): FeedbackSession | null { + const [session, setSession] = useState(null); + useEffect(() => { + let alive = true; + const load = (): void => { + void chrome.runtime + .sendMessage({ type: "feedback:get-pending" }) + .then((r: FeedbackPendingResponse | undefined) => { + if (alive) { + setSession(r?.session ?? null); + } + }) + .catch(() => {}); + }; + load(); + const onMsg = (m: unknown): void => { + if ((m as { type?: string })?.type === "feedback:pending-changed") { + load(); + } + }; + chrome.runtime.onMessage.addListener(onMsg); + return () => { + alive = false; + chrome.runtime.onMessage.removeListener(onMsg); + }; + }, []); + return session; +} + +function respond(id: string, responded: boolean, annotations: unknown[]): void { + void chrome.runtime + .sendMessage({ + type: "ocb-feedback-result", + id, + responded, + annotations + } satisfies FeedbackResultMessage) + .catch(() => {}); +} + +function Request({ session }: { session: FeedbackSession }) { + const [done, setDone] = useState(false); + const finish = useCallback( + (responded: boolean, annotations: unknown[]) => { + respond(session.id, responded, annotations); + setDone(true); + }, + [session.id] + ); + + if (done) { + return ( +

Thanks — you can close this panel.

+ ); + } + + return ( +
+

{session.prompt || defaultPrompt(session.mode)}

+ {session.mode === "confirm" ? ( +
+ + +
+ ) : session.mode === "choose" ? ( +
+ {(session.options ?? []).map((opt) => ( + + ))} + +
+ ) : SPATIAL.has(session.mode) ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +function defaultPrompt(mode: string): string { + if (mode === "region") { + return "Drag a box over the area you mean."; + } + if (mode === "comment") { + return "Click a spot, then add a note."; + } + return "Click the element you mean."; +} + +/** Click/drag over the captured screenshot; coordinates map to screenshot pixels. */ +function ScreenshotAnnotator({ + session, + onFinish +}: { + session: FeedbackSession; + onFinish: (responded: boolean, annotations: unknown[]) => void; +}) { + const imgRef = useRef(null); + const isRegion = session.mode === "region"; + const [point, setPoint] = useState<{ dx: number; dy: number } | null>(null); // display px + const [band, setBand] = useState(null); // display px + const drag = useRef<{ x: number; y: number } | null>(null); + const [note, setNote] = useState(""); + + // Live image box + natural dimensions, fed to the pure mapping helpers. + const metrics = (): { + box: { width: number; height: number }; + natural: { width: number; height: number }; + } => { + const img = imgRef.current; + const r = img?.getBoundingClientRect(); + return { + box: { width: r?.width ?? 0, height: r?.height ?? 0 }, + natural: { width: img?.naturalWidth ?? 0, height: img?.naturalHeight ?? 0 } + }; + }; + const local = (e: { clientX: number; clientY: number }): { dx: number; dy: number } => { + const r = imgRef.current?.getBoundingClientRect(); + return { dx: e.clientX - (r?.left ?? 0), dy: e.clientY - (r?.top ?? 0) }; + }; + + const onDown = (e: React.MouseEvent): void => { + const { dx, dy } = local(e); + if (isRegion) { + drag.current = { x: dx, y: dy }; + setBand({ x: dx, y: dy, width: 0, height: 0 }); + } else { + setPoint({ dx, dy }); + } + }; + const onMove = (e: React.MouseEvent): void => { + if (!isRegion || !drag.current) { + return; + } + const { dx, dy } = local(e); + const s = drag.current; + setBand(rectFromCorners(s.x, s.y, dx, dy)); + }; + const onUp = (): void => { + drag.current = null; + }; + + const canSubmit = isRegion ? band !== null && band.width > 3 : point !== null; + + const submit = (): void => { + const text = note.trim(); + const { box, natural } = metrics(); + if (isRegion && band) { + const rect = naturalRect(band, box, natural); + onFinish(true, [{ kind: "region", rect, refs: [], ...(text ? { text } : {}) }]); + } else if (point) { + const p = toNatural({ dx: point.dx, dy: point.dy }, box, natural); + onFinish(true, [{ kind: "point", x: p.x, y: p.y, ...(text ? { text } : {}) }]); + } + }; + + return ( +
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: an annotation canvas over a screenshot */} +
+ Page screenshot — click or drag to mark what you mean + {point && !isRegion ? ( + + ) : null} + {band && isRegion ? ( + + ) : null} +
+
+ + {session.mode === "comment" ? ( +