diff --git a/README.md b/README.md index bf17c0a..21bd51a 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,160 @@ -# agent-zero-plugin-chat-comments +# Chat Comments — Agent Zero plugin -Source repo for the **Chat Comments** Agent Zero plugin. +> Select text in any chat message and attach a **Google-Docs-style comment** to it. +> Comments are highlighted inline, listed in a manager modal, **persisted per chat**, +> and can be sent to the prompt box as a single review request for the agent to address. -Select text inside a chat message, right-click, and attach a comment to it — -Google-Docs style. Comments are highlighted inline, persist on the chat, and can -be edited or deleted from a popover. A button in the chat top bar opens a comments -manager and sends all comments to the prompt box as a single numbered instruction. +`chat_comments` turns an Agent Zero conversation into something you can *mark up*. Reviewing a long +agent answer? Highlight the sentence that's wrong, comment on it, and — when you're done — send every +comment to the prompt in one go so the agent addresses them together. + +--- + +## Why you'd want it + +| Without | With Chat Comments | +|---|---| +| Scroll back, retype "the third paragraph is wrong because…" | Select the text → **Comment** → it's anchored right there | +| Lose your review notes when you switch chats | Comments **persist on the chat**, survive reloads and chat switches | +| Feed corrections one message at a time | **Send to prompt** bundles every comment into one numbered request | +| No record of what you flagged | A comments modal lists every note, anchored or general | + +--- ## Features -- **Right-click on selected message text** → custom context menu (overrides native): - - **Comment** — attach a note to the selection (highlighted inline, occurrence-indexed). - - **Copy text** — copy the selection to the clipboard. - - **Send to prompt** — insert the selection, quoted, into the prompt box. -- **Inline highlights** re-anchor on reload (best-effort), one comment per span. -- **Popover** on each highlight: view / edit / delete. -- **Top-bar 💬 button** with a count badge → opens a modal listing every comment - (referenced text trimmed), where you can delete entries, add a **general comment** - not tied to any text, and **Send to prompt** (a numbered prompt of all comments). -- Comments persist per-chat on the `AgentContext` (`context.data["chat_comments"]`), - saved via `save_tmp_chat` — the same mechanism the `chat_rename` plugin uses. +- **Anchor a comment to selected text** — right-click a selection inside a message → *Comment*; the + phrase is wrapped in a highlight (``) that survives re-renders via occurrence-based re-anchoring. +- **General (untethered) comments** — add a chat-level note from the modal that isn't tied to any span. +- **View / edit / delete** — click a highlight for a popover with the note plus Edit and Delete. +- **Persistence** — comments are stored on the chat itself and reload with it; switch away and back and + they're still there. +- **Send to prompt** — turns all comments into one numbered instruction (anchored items quote their + referenced text) and drops it into the composer. +- **Copy / send a selection** — the selection menu also offers *Copy text* and *Send to prompt* (append). +- **Badge** — the toolbar button shows a live count. + +--- ## Architecture -Pure WebUI plugin (`webui/` + `extensions/webui/chat-top-end/`) plus one thin -`load`/`save` API handler (`api/comments.py`). No agent-side code, no secrets. +```mermaid +flowchart TD + subgraph Browser["Browser (Alpine store: chatComments)"] + BTN["chat-top-end button
(cc-toolbar-btn + badge)"] + SEL["contextmenu over a selection
inside #message-<id>"] + MENU["cc-menu:
Comment · Copy · Send to prompt"] + EDIT["cc-editor / cc-popover
create · edit · delete"] + ANCH["anchoring.js
occurrence-based <mark> re-anchor"] + MODAL["comments-modal.html
list · General tag · Send to prompt"] + OBS["MutationObserver on #chat-history
→ re-anchor / reload on chat switch"] + end -| Path | Responsibility | -|---|---| -| `chat_comments/api/comments.py` | `load` / `save` the per-chat comment list | -| `chat_comments/webui/anchoring.js` | DOM offset ↔ occurrence ↔ highlight helpers | -| `chat_comments/webui/chat-comments-store.js` | Alpine store: menu, popovers, anchoring, send-all | -| `chat_comments/webui/comments-modal.html` | Comments manager modal | -| `chat_comments/extensions/webui/chat-top-end/chat-comments-mount.html` | Top-bar button + bootstrap + styles | + subgraph Backend["Agent Zero backend"] + API["api/comments.py
ApiHandler /plugins/chat_comments/comments"] + CTX["AgentContext.data['chat_comments']"] + DISK[("usr/chats/<ctxid>/chat.json
via save_tmp_chat")] + end + + SEL --> MENU --> EDIT --> ANCH + BTN --> MODAL + EDIT -->|"POST action:save"| API + MODAL -->|"POST action:save"| API + OBS -->|"POST action:load"| API + API --> CTX --> DISK + DISK -->|"action:load on bootstrap / switch"| API --> ANCH +``` + +**Persistence flow:** every create/edit/delete calls `persist()` → `POST {action:"save"}` → +`api/comments.py` sanitises the list and writes `AgentContext.data["chat_comments"]`, then +`save_tmp_chat` flushes it to `usr/chats//chat.json`. On bootstrap and on chat switch the store +`POST {action:"load"}`s that same key back and re-anchors the highlights. **No data ever leaves the +machine running Agent Zero.** + +--- + +## Install + +### Plugin Hub (recommended) + +Open **Settings → Plugins**, find **Chat Comments**, and install. Enable it; the comment button appears +in the chat top bar. + +### Manual + +Copy the plugin into your Agent Zero instance: + +```bash +cp -r usr/plugins/chat_comments /path/to/agent-zero/usr/plugins/chat_comments +``` + +Restart / reload plugins. No configuration required. + +--- + +## Configuration + +The plugin is zero-config. For reference: + +| Manifest field | Value | Meaning | +|---|---|---| +| `name` | `chat_comments` | plugin id (matches the Plugin Index folder) | +| `settings_sections` | `[]` | no settings screen — nothing to configure | +| `per_project_config` | `false` | comments are per-chat, not per-project | +| `per_agent_config` | `false` | not agent-scoped | +| `license` | `Apache-2.0` | see [LICENSE](LICENSE) | + +Internal constant: `MAX_COMMENT_LENGTH = 2000` (both client and server clip to this). + +--- + +## Comment record schema -## Build +Each comment persisted on a chat: + +| Field | Type | Notes | +|---|---|---| +| `id` | str | client-generated uuid | +| `message_id` | str | id of the message the highlight anchors to (`""` for General) | +| `quoted_text` | str | the selected text (`""` for General) | +| `occurrence` | int | which match of `quoted_text` in the message (0-based) | +| `comment` | str | the note (≤2000 chars) | +| `created_at` | int | unix seconds | + +--- + +## Development & testing ```bash -make plugin-info # name + version + output path -make plugin-zip # build dist/chat_comments-.zip for gate submission -make test # smoke tests (a0_plugin_testkit) +git clone --recursive https://github.com/agent-zero-plugins/agent-zero-plugin-chat-comments +cd agent-zero-plugin-chat-comments + +# L1 shape suite (fast, testkit assertions) +pip install pytest pyyaml +pytest # 11 tests: extension surface, hooks, thumbnail, deps, A0-API, auth posture + +# Tier-1 BDD static gates (feature-purity, honesty, traceability) +make verify + +# Full L3 behaviour BDD against a disposable A0 (needs podman/docker) +make e2e ``` -## Publishing +The behaviour contract lives in [`docs/spec/`](docs/spec/) (`behaviour-spec.md` BEH-1..14) and the +executable BDD in [`tests/e2e/features/10-comments.feature`](tests/e2e/features/10-comments.feature). +CI (`plugin-e2e`) runs the full suite on every PR, including a **seam-off red-proof** (the suite must +fail with the plugin uninstalled) — so green means the behaviour is genuinely exercised live. + +--- + +## Security + +Comments live only in your chat data (`AgentContext.data["chat_comments"]` → `usr/chats//chat.json`) +on the machine running Agent Zero. The plugin makes **no external network calls** and adds **no** +telemetry. See [SECURITY.md](SECURITY.md). -`make plugin-zip`, then PR `plugins/chat_comments.zip` + `plugins/chat_comments.meta.yaml` -into [`agent-zero-vendor-plugins`](https://github.com/agent-zero-plugins/agent-zero-vendor-plugins). -Merging publishes `ghcr.io/agent-zero-plugins/chat_comments:`, consumable from any -`agent-zero-infra` env via `agent.plugins.oci[]`. +--- ## License -See [LICENSE](LICENSE). +[Apache-2.0](LICENSE). diff --git a/docs/marketplace-draft.md b/docs/marketplace-draft.md new file mode 100644 index 0000000..6b61f72 --- /dev/null +++ b/docs/marketplace-draft.md @@ -0,0 +1,63 @@ +# Plugin Index submission draft — chat_comments + +Staged assets for the eventual PR to `agent0ai/a0-plugins` (folder `plugins/chat_comments/`). +**Do not open the marketplace PR until the repo is flipped public.** + +## index.yaml (staged) + +```yaml +title: Chat Comments +description: >- + Select text in any chat message and attach Google-Docs-style comments to it. + Comments are highlighted inline, listed in a manager modal, persisted per chat + (surviving reloads and chat switches), and can be sent to the prompt box as a + single numbered review request for the agent to address. Zero-config; no data + leaves your machine. +github: https://github.com/agent-zero-plugins/agent-zero-plugin-chat-comments +tags: + - tools + - workflow + - ux + - review + - productivity +screenshots: + - https://raw.githubusercontent.com/agent-zero-plugins/agent-zero-plugin-chat-comments/main/docs/screenshot-comment.png + - https://raw.githubusercontent.com/agent-zero-plugins/agent-zero-plugin-chat-comments/main/docs/screenshot-modal.png +``` + +- **title**: `Chat Comments` (13 chars — within 50). +- **description**: 330 chars — within the 500-char limit. +- **tags**: 5 (max allowed). Confirm against `TAGS.md` at submission time; drop any not on the canonical + list (candidates to keep if trimming: `tools`, `workflow`, `productivity`). +- **index.yaml total**: well within 2000 chars. + +## Thumbnail + +`usr/plugins/chat_comments/webui/thumbnail.png` — square 512×512, 2,751 bytes (< 20 KB). Ships inside +the plugin; copy to `plugins/chat_comments/thumbnail.png` in the index submission if a card image is +wanted there too. + +## Screenshots — capture at flip time (TODO before/at public flip) + +The two `screenshots:` URLs above will **404 until the repo is public** (raw.githubusercontent.com only +serves public repos). They are **not yet committed** — capture them against a live Agent Zero with the +plugin installed, because honest marketplace screenshots must be real UI, not mockups: + +1. `docs/screenshot-comment.png` — a chat message with a highlighted phrase and the view popover + (note + Edit/Delete) open, plus the toolbar badge showing a count. +2. `docs/screenshot-modal.png` — the comments manager modal listing one anchored comment (with its + quoted text) and one **General** comment, with the *Send to prompt* footer. + +Capture recipe (once a live A0 is reachable with credentials): log in → new chat → send a message → +select text → **Comment** → screenshot; open the comments button → screenshot the modal. The e2e BDD +suite already drives exactly these flows (`tests/e2e/features/10-comments.feature`), so the same steps +produce the screenshots. + +## Pre-submission checklist (mirrors a0-contribute-plugin CI) + +- [x] Remote `plugin.yaml` exists at repo root-of-plugin with `name: chat_comments`. +- [x] Remote `LICENSE` present (Apache-2.0, full canonical text). +- [x] Folder name `chat_comments` matches `^[a-z0-9_]+$`, no leading `_`. +- [ ] `github` URL public & unique in the index (verify at flip time). +- [ ] Screenshots committed and reachable (capture at flip time). +- [ ] `index.yaml` is the only file (plus optional thumbnail) in `plugins/chat_comments/`. diff --git a/tests/e2e/steps/comments.steps.ts b/tests/e2e/steps/comments.steps.ts index 36ada28..fdebaa0 100644 --- a/tests/e2e/steps/comments.steps.ts +++ b/tests/e2e/steps/comments.steps.ts @@ -96,8 +96,26 @@ const reloadIntoChat = async (page: any, id: string) => { await page.evaluate(() => (window as any).Alpine.store("chatComments").bootstrap()); }; -const commentCount = (page: any) => - page.evaluate(() => (window as any).Alpine.store("chatComments").comments.length); +// Open the comments modal through the real toolbar control and wait for the +// add-input to be interactive. Idempotent: no-op when it is already open. +const openCommentsModal = async (page: any) => { + const input = page.locator(".cc-modal-add-input"); + // isVisible() resolves false for a not-yet-rendered element (it does not + // throw), so this stays idempotent without swallowing a real failure. + if (await input.isVisible()) return; + await page.locator(".cc-toolbar-btn").click(); + await input.waitFor({ state: "visible", timeout: 15000 }); +}; + +const closeCommentsModal = async (page: any) => { + await page.evaluate(() => (window as any).Alpine.store("chatComments").closeCommentsModal()); +}; + +// What the user can actually see: rows rendered in the comments modal. +const visibleCommentCount = async (page: any) => { + await openCommentsModal(page); + return page.locator(".cc-modal-item").count(); +}; // ── Givens ─────────────────────────────────────────────────────────────────── @@ -122,12 +140,15 @@ Given("I have commented on a phrase inside that message", async ({ loggedInPage // ── Whens ──────────────────────────────────────────────────────────────────── When("I add a comment to the chat", async ({ loggedInPage }: any) => { - await loggedInPage.evaluate(async (note: string) => { - const s = (window as any).Alpine.store("chatComments"); - s.newCommentDraft = note; - s.addGeneralComment(); - if (typeof s.persist === "function") await s.persist(); - }, NOTE); + // real path: toolbar button → modal → type in the add input → click Add. + // No store poking and no manual persist(): if the real flow does not save, + // the reload assertions must catch it. + await openCommentsModal(loggedInPage); + const before = await loggedInPage.locator(".cc-modal-item").count(); + await loggedInPage.locator(".cc-modal-add-input").fill(NOTE); + await loggedInPage.locator(".cc-modal-add-btn").click(); + // end state: the row is rendered in the list the user is looking at + await expect(loggedInPage.locator(".cc-modal-item")).toHaveCount(before + 1); }); When("I comment on a phrase inside that message", async ({ loggedInPage }: any) => { @@ -159,15 +180,17 @@ When("I change the comment's note", async ({ loggedInPage }: any) => { }); When("I delete that comment", async ({ loggedInPage }: any) => { - // real path: open the comments modal, click the row's delete control - await loggedInPage.locator(".cc-toolbar-btn").click(); - await loggedInPage.waitForSelector(".cc-modal-item", { timeout: 8000 }); - await loggedInPage.locator(".cc-modal-item .cc-modal-del").first().click(); - await loggedInPage.evaluate(async () => { - const s = (window as any).Alpine.store("chatComments"); - if (typeof s.persist === "function") await s.persist(); - s.closeCommentsModal(); - }); + // real path: the comments modal is already open from the add step; click the + // row's delete control. No manual persist(): the real flow must save the + // deletion by itself or the reload assertion has to catch it. + await openCommentsModal(loggedInPage); + const rows = loggedInPage.locator(".cc-modal-item"); + const before = await rows.count(); + expect(before).toBeGreaterThan(0); + await loggedInPage.locator(".cc-modal-del").first().click(); + // end state: the row the user clicked is gone from the list + await expect(rows).toHaveCount(before - 1); + await closeCommentsModal(loggedInPage); }); When("I switch to a different chat", async ({ loggedInPage }: any) => { @@ -206,12 +229,13 @@ When("I switch back to the first chat", async ({ loggedInPage }: any) => { }); When("I try to add an empty comment", async ({ loggedInPage }: any) => { - await loggedInPage.evaluate(async () => { - const s = (window as any).Alpine.store("chatComments"); - s.newCommentDraft = " "; - s.addGeneralComment(); - if (typeof s.persist === "function") await s.persist(); - }); + // real path: type whitespace into the add input and try to submit. The UI + // guards this by disabling the button, so the attempt cannot land. + await openCommentsModal(loggedInPage); + await loggedInPage.locator(".cc-modal-add-input").fill(" "); + await expect(loggedInPage.locator(".cc-modal-add-btn")).toBeDisabled(); + await loggedInPage.locator(".cc-modal-add-btn").click({ force: true }); + await closeCommentsModal(loggedInPage); }); When("I send the comments to the prompt box", async ({ loggedInPage }: any) => { @@ -259,20 +283,20 @@ Then("the chat shows it has two comments", async ({ loggedInPage }: any) => { }); Then("the chat shows it has no comments", async ({ loggedInPage }: any) => { - // badge hides at zero — assert on the store-backed end state + hidden badge - await loggedInPage.waitForFunction( - () => (window as any).Alpine.store("chatComments").comments.length === 0, - { timeout: 8000 }, - ); - await expect(loggedInPage.locator(".cc-badge")).toBeHidden(); + // user-visible end state: badge hidden, and the modal says there are none + await expect(loggedInPage.locator(".cc-badge")).toBeHidden({ timeout: 8000 }); + await openCommentsModal(loggedInPage); + await expect(loggedInPage.locator(".cc-modal-item")).toHaveCount(0); + await expect(loggedInPage.locator(".cc-modal-empty")).toBeVisible(); + await closeCommentsModal(loggedInPage); }); Then("that chat shows no comments", async ({ loggedInPage }: any) => { - await loggedInPage.waitForFunction( - () => (window as any).Alpine.store("chatComments").comments.length === 0, - { timeout: 10000 }, - ); - await expect(loggedInPage.locator(".cc-badge")).toBeHidden(); + // the freshly-switched-to chat carries none of the first chat's comments + await expect(loggedInPage.locator(".cc-badge")).toBeHidden({ timeout: 10000 }); + await openCommentsModal(loggedInPage); + await expect(loggedInPage.locator(".cc-modal-item")).toHaveCount(0); + await closeCommentsModal(loggedInPage); }); Then("the comment is still there after a reload", async ({ loggedInPage }: any) => { @@ -282,11 +306,10 @@ Then("the comment is still there after a reload", async ({ loggedInPage }: any) }); Then("the comment is gone after a reload", async ({ loggedInPage }: any) => { - await loggedInPage.waitForTimeout(1200); await reloadIntoChat(loggedInPage, ctx); - const n = await commentCount(loggedInPage); - expect(n).toBe(0); - await expect(loggedInPage.locator(".cc-badge")).toBeHidden(); + await expect(loggedInPage.locator(".cc-badge")).toBeHidden({ timeout: 10000 }); + expect(await visibleCommentCount(loggedInPage)).toBe(0); + await closeCommentsModal(loggedInPage); }); Then("the phrase is highlighted in the message", async ({ loggedInPage }: any) => { diff --git a/usr/plugins/chat_comments/webui/thumbnail.png b/usr/plugins/chat_comments/webui/thumbnail.png index 0de0c0d..d858153 100644 Binary files a/usr/plugins/chat_comments/webui/thumbnail.png and b/usr/plugins/chat_comments/webui/thumbnail.png differ