diff --git a/.devkit.yml b/.devkit.yml index 341b04a..4f1ccec 100644 --- a/.devkit.yml +++ b/.devkit.yml @@ -1,4 +1,4 @@ -plugin_dir: usr/plugins/chat_comments +plugin_dir: . display_name: Chat Comments devkit_major: 2 diff --git a/.github/workflows/plugin-e2e.yml b/.github/workflows/plugin-e2e.yml index 587c7c9..27851ab 100644 --- a/.github/workflows/plugin-e2e.yml +++ b/.github/workflows/plugin-e2e.yml @@ -26,6 +26,10 @@ name: plugin-e2e on: pull_request: branches: ["main", "master"] + # push on main: proves the shipped code, and gives the README badge a status + # (PR-only runs leave branch=main with zero runs → "no status"). + push: + branches: ["main", "master"] workflow_dispatch: inputs: capture_all_traces: @@ -35,8 +39,8 @@ on: permissions: contents: read - pull-requests: write # merge-guard drafts a PR whose plugin-e2e is red - actions: write # DEC-074 count-based artifact retention: delete superseded e2e artifacts + pull-requests: write # merge-guard drafts a PR whose plugin-e2e is red + actions: write # DEC-074 count-based artifact retention: delete superseded e2e artifacts jobs: e2e: diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..5a67e1d --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,43 @@ +name: unit +# Python unit suite (shape/skill contract tests). +# +# The testkit submodule is PRIVATE — the default GITHUB_TOKEN cannot clone it. +# Mirror the reusable plugin-e2e workflow: checkout without submodules, mint a +# GitHub App token (same sync-App secrets), init the submodule with it. +on: + pull_request: + push: + branches: [main, master] + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - name: Mint App token (private submodule clone) + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SKILLS_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.SKILLS_SYNC_APP_PRIVATE_KEY }} + owner: agent-zero-plugins + - name: Init testkit submodule (App token) + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + AUTH=$(printf 'x-access-token:%s' "$APP_TOKEN" | base64 -w0) + # --recursive: the L1 shape tests audit the plugin against REAL A0 + # source, which the testkit resolves from its nested (public) + # .agent-zero submodule. + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH" \ + submodule update --init --recursive tests/_testkit + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install test deps + run: python -m pip install --upgrade pip pytest pytest-asyncio pyyaml + - name: Run unit suite + run: python -m pytest tests -q diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6b64e3..2809b62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for helping improve the Chat Comments plugin for Agent Zero. ## Repo layout -- `usr/plugins/chat_comments/` — the plugin itself (API handler, webui store/modal, extension mount). +- Repo root — the plugin itself (root layout, `.devkit.yml` `plugin_dir: .`): API handler, webui store/modal, extension mount. - `docs/spec/` — the four living BDD docs (`behaviour-spec.md`, `implementation-plan.md`, `e2e.feature.md`, `e2e-steps-spec.md`). Behaviour changes MUST update these. - `tests/` — L1 testkit shape suite (`pytest`) + L3 Playwright BDD (`tests/e2e/`). - `tests/_testkit/` — the plugin devkit (git submodule). Never edit here; contribute upstream. diff --git a/Makefile b/Makefile index 666be43..32cb7cf 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,3 @@ -PLUGIN_DIR := usr/plugins/chat_comments +PLUGIN_DIR := . PLUGIN_DISPLAY_NAME := Chat Comments -include tests/_testkit/e2e/Makefile.devkit diff --git a/README.md b/README.md index 3f70907..10daaaa 100644 --- a/README.md +++ b/README.md @@ -6,204 +6,84 @@ -> 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. - -`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 - -- **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 - -```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 - - 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.** - ---- - -## Usage - -Every behaviour below is covered by a BDD scenario in [`tests/e2e/features/`](tests/e2e/features/), -and [`docs/BEHAVIOUR.md`](docs/BEHAVIOUR.md) shows each one as a screenshot captured from a passing -run — so what you read here is what CI proves on every push. +> Select text in any chat message and attach a **Google-Docs-style comment** to it. Comments are highlighted inline, persisted per chat, and sendable to the prompt as one numbered review request. -### Comment on a phrase inside a message +![Chat Comments in action](docs/cc.gif) -Select any text in a message (yours or the agent's) and choose **Comment** from the selection menu. -The phrase is wrapped in a highlight and the comment records the text it refers to, so the note still -makes sense when you come back to it later. Click a highlight to reopen the note, with **Edit** and -**Delete**. +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. -One message can carry several comments, and re-anchoring is occurrence-based — the highlight survives -the re-renders A0 does as a chat grows. +## ✨ Features -### Comment on the chat as a whole +- **Anchored comments** : select a phrase in any message (yours or the agent's) → right-click → **Comment**. The phrase gets an inline highlight that survives re-renders via occurrence-based re-anchoring. +- **General comments** : chat-level notes that aren't tied to any span, for "this whole exchange went wrong" moments. +- **Manage from one place** : click a highlight for its popover (**Edit** / **Delete**), or open the toolbar modal to see every note; the toolbar button carries a live count badge. +- **Send to prompt** : collapses all comments into a single numbered instruction — anchored items quote their text — and drops it into the composer. Review the transcript, then hand the whole critique back in one turn. +- **Persistence** : comments are stored on the chat itself (`usr/chats//chat.json`), so they survive reloads and chat switches. +- **Honest refusals** : empty comments are rejected, and the backing service refuses malformed requests — both are asserted BDD scenarios. -Open the toolbar control and add a note without selecting anything first. Useful for "this whole -exchange went wrong" observations that do not belong to one phrase. +## ⚙️ How it works -### Turn your comments into a prompt +![How it works](docs/how-it-works.png) -**Send to prompt** collapses every comment on the chat into a single numbered instruction and drops it -into the composer. Anchored comments quote the text they refer to, so the agent sees both your note -and what it was about. This is the point of the plugin: review the transcript, mark it up, then hand -the whole critique back in one turn instead of retyping it. -### What persists +A `chat-top-end` webui extension plus one API handler. Every create/edit/delete saves through the plugin's own endpoint; bootstrap and chat switches load the same key back and re-anchor. No external network calls, no telemetry. -Comments are stored on the chat, not in the browser. Switch to another chat and back, or reload, and -they are still there — the `A comment added to a chat is remembered` scenario asserts exactly that, -including across a reload. +## 💬 Usage -The toolbar badge shows a live count so you can see at a glance whether a chat has been annotated. +Nothing to configure — the comment button appears in the chat top bar. Every behaviour below has an asserted [BDD scenario](tests/e2e/features/), and [`docs/BEHAVIOUR.md`](docs/BEHAVIOUR.md) shows each as a screenshot from a passing run. -### What it refuses +**Anchored comments** — select text, right-click, **Comment**. One message can carry several; the comment records which text it refers to, so the note still makes sense later. Click a highlight to reopen, edit, or delete it. -An empty comment is rejected rather than silently stored, and the backing service rejects malformed -requests instead of accepting partial data. Both are asserted scenarios, not just intentions. +**General comments & send-to-prompt** — open the toolbar modal to add a chat-level note, or hit **Send to prompt** to bundle every comment into one numbered request for the agent. The selection menu also offers _Copy text_ and a direct _Send to prompt_. ---- +## 📦 Installation -## Install +**Plugin Hub** — once listed in the [Plugin Index](https://github.com/agent0ai/a0-plugins): **Settings → Plugins → Chat Comments → 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: +**Manual** (Zip) — build it yourself, or grab the zip from the [latest release](https://github.com/agent-zero-plugins/agent-zero-plugin-chat-comments/releases/latest): ```bash -cp -r usr/plugins/chat_comments /path/to/agent-zero/usr/plugins/chat_comments +make package # → dist/chat_comments.zip ``` -Restart / reload plugins. No configuration required. - ---- - -## Configuration +Then **Settings → Plugins → Install from file** → pick the zip. Or copy the plugin into your instance (`usr/plugins/chat_comments/`) and reload plugins. -The plugin is zero-config. For reference: +## 🔧 Configuration -| 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) | +None — zero-config, no settings screen. One internal constant: `MAX_COMMENT_LENGTH = 2000` (both client and server clip to it). -Internal constant: `MAX_COMMENT_LENGTH = 2000` (both client and server clip to this). - ---- - -## Comment record schema - -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 +## 🛠️ Development ```bash -git clone --recursive https://github.com/agent-zero-plugins/agent-zero-plugin-chat-comments +git clone --recurse-submodules 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 +make verify # BDD static gates +python -m pytest tests -q # unit + L1 shape suite (needs tests/_testkit submodule) +make e2e # full behaviour BDD run on a nested disposable A0 (podman) ``` -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. +Each comment persisted on a chat: ---- +| Field | Type | Notes | +| ------------- | ---- | ----------------------------------------------------- | +| `id` | str | client-generated uuid | +| `message_id` | str | 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 | -## Security +## 🧪 Test layout -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). +| Layer | Where | What | +| ------------- | -------------------------------- | ----------------------------------------------------------------------- | +| L1 shape | `tests/test_plugin_shape.py` | static validator, deps/A0-API audits, dead hooks | +| Static gates | `make verify` | feature-purity, honesty, traceability | +| Behaviour BDD | `tests/e2e/features/` + `steps/` | anchored/general comments, edit/delete, persistence, refusals, e2e APIs | ---- +Specs live in [`docs/spec/`](docs/spec/) (BEH-1..14). CI runs the whole pyramid on every PR, including a seam-off red-proof — a scenario that passes without the plugin fails the build. -## License +## ⚖️ License -[Apache-2.0](LICENSE). \ No newline at end of file +Apache-2.0 — see [LICENSE](LICENSE). Comments never leave the machine running Agent Zero; see [SECURITY.md](SECURITY.md). diff --git a/usr/plugins/chat_comments/__init__.py b/__init__.py similarity index 100% rename from usr/plugins/chat_comments/__init__.py rename to __init__.py diff --git a/usr/plugins/chat_comments/api/comments.py b/api/comments.py similarity index 100% rename from usr/plugins/chat_comments/api/comments.py rename to api/comments.py diff --git a/usr/plugins/chat_comments/default_config.yaml b/default_config.yaml similarity index 100% rename from usr/plugins/chat_comments/default_config.yaml rename to default_config.yaml diff --git a/docs/cc.gif b/docs/cc.gif new file mode 100644 index 0000000..8d4b00a Binary files /dev/null and b/docs/cc.gif differ diff --git a/docs/how-it-works.png b/docs/how-it-works.png new file mode 100644 index 0000000..cc5e7b4 Binary files /dev/null and b/docs/how-it-works.png differ diff --git a/docs/marketplace-draft.md b/docs/marketplace-draft.md index 6b61f72..aecd0ce 100644 --- a/docs/marketplace-draft.md +++ b/docs/marketplace-draft.md @@ -33,7 +33,7 @@ screenshots: ## Thumbnail -`usr/plugins/chat_comments/webui/thumbnail.png` — square 512×512, 2,751 bytes (< 20 KB). Ships inside +`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. @@ -46,7 +46,7 @@ plugin installed, because honest marketplace screenshots must be real UI, not mo 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. + 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 diff --git a/usr/plugins/chat_comments/extensions/webui/chat-top-end/chat-comments-mount.html b/extensions/webui/chat-top-end/chat-comments-mount.html similarity index 100% rename from usr/plugins/chat_comments/extensions/webui/chat-top-end/chat-comments-mount.html rename to extensions/webui/chat-top-end/chat-comments-mount.html diff --git a/usr/plugins/chat_comments/helpers/__init__.py b/helpers/__init__.py similarity index 100% rename from usr/plugins/chat_comments/helpers/__init__.py rename to helpers/__init__.py diff --git a/usr/plugins/chat_comments/helpers/constants.py b/helpers/constants.py similarity index 100% rename from usr/plugins/chat_comments/helpers/constants.py rename to helpers/constants.py diff --git a/usr/plugins/chat_comments/meta.yaml b/meta.yaml similarity index 100% rename from usr/plugins/chat_comments/meta.yaml rename to meta.yaml diff --git a/usr/plugins/chat_comments/plugin.yaml b/plugin.yaml similarity index 100% rename from usr/plugins/chat_comments/plugin.yaml rename to plugin.yaml diff --git a/tests/_testkit b/tests/_testkit index c77ed9b..2f3e8ca 160000 --- a/tests/_testkit +++ b/tests/_testkit @@ -1 +1 @@ -Subproject commit c77ed9bd7ccb675b656caffabcf5c1e7aad77c91 +Subproject commit 2f3e8ca6f5f2a69e4a247beb27c1a6912980b9ef diff --git a/tests/conftest.py b/tests/conftest.py index 3cbdceb..32b09da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,16 @@ Point it at this plugin's source directory. """ +import sys from pathlib import Path import pytest +# Tests exec plugin modules straight from the plugin tree. Without this, the +# loader writes __pycache__ INTO the plugin dir (= repo root, root layout), +# which the static validator rightly flags as a packaging hazard. +sys.dont_write_bytecode = True + @pytest.fixture(scope="session") def plugin_dir() -> Path: @@ -17,5 +23,7 @@ def plugin_dir() -> Path: assert_extension_at_surface(plugin_dir, ...) assert_no_dead_plugin_hooks(plugin_dir) etc. + + Root layout: the repo root IS the plugin dir (.devkit.yml plugin_dir: .) """ - return Path(__file__).resolve().parent.parent / "usr" / "plugins" / "chat_comments" + return Path(__file__).resolve().parent.parent diff --git a/tests/e2e/steps/comments.steps.ts b/tests/e2e/steps/comments.steps.ts index 9f58018..1ef16d1 100644 --- a/tests/e2e/steps/comments.steps.ts +++ b/tests/e2e/steps/comments.steps.ts @@ -10,7 +10,11 @@ const AGENT_TEXT = "Certainly, here is the answer you requested about foxes."; const waitStore = (page: any) => page.waitForFunction( - () => !!((window as any).Alpine?.store && (window as any).Alpine.store("chatComments")), + () => + !!( + (window as any).Alpine?.store && + (window as any).Alpine.store("chatComments") + ), { timeout: 15000 }, ); @@ -34,9 +38,26 @@ const waitForSave = (page: any) => { timeout: 15000 }, ); +// A0 re-toasts unread notifications on every page load. The harness installs +// the plugin at runtime, so A0 fires "Plugins with frontend extensions +// updated, page reload recommended" — and the toast sits bottom-right, over +// the comments UI, swallowing clicks (a click on a toast BODY even opens the +// A0 notifications modal, which then intercepts everything). Dismiss via the +// toast's own × — `.toast-dismiss` uses @click.stop, so it never opens the +// modal. Runs after every page.goto, not once per suite, because the store +// resets and re-toasts on each load. +const dismissNotificationToasts = async (page: any) => { + for (let i = 0; i < 3; i++) { + const dismiss = page.locator(".toast-stack-container .toast-dismiss"); + if (!(await dismiss.count())) break; + await dismiss.first().click(); + } +}; + const openChat = async (page: any) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1500); + await dismissNotificationToasts(page); ctx = await page.evaluate(async () => { const { callJsonApi } = await import("/js/api.js"); const r = await callJsonApi("/chat_create", {}); @@ -45,59 +66,131 @@ const openChat = async (page: any) => { return id; }); await waitStore(page); - await page.evaluate(() => (window as any).Alpine.store("chatComments").bootstrap()); + await page.evaluate(() => + (window as any).Alpine.store("chatComments").bootstrap(), + ); }; // Render a deterministic LLM-less message via A0's real renderer, then re-bootstrap // observation. type: "user" | "agent". Returns the message container id. -const renderMessage = async (page: any, id: string, type: string, content: string) => { +const renderMessage = async ( + page: any, + id: string, + type: string, + content: string, +) => { await page.evaluate( async ({ id, type, content }: any) => { const m = await import("/js/messages.js"); - await m.setMessage({ id, no: Date.now() % 100000, type, heading: "", content, temp: false, kvps: type === "agent" ? { thoughts: content } : null }); + await m.setMessage({ + id, + no: Date.now() % 100000, + type, + heading: "", + content, + temp: false, + kvps: type === "agent" ? { thoughts: content } : null, + }); }, { id, type, content }, ); await page.waitForSelector(`#message-${id}`, { timeout: 10000 }); + injectedMessages.set(id, { type, content }); +}; + +// Injected messages exist only in the DOM, not in the server's raw log. A0 +// rebuilds the chat from that log (`setMessages`, called from poll/WS) whenever +// the backend pushes it — and our comment save (`save_tmp_chat`) triggers +// exactly that. So a message can vanish between steps. Re-inject any that +// disappeared: A0's DOM mutation then fires the plugin's own observer, which +// re-anchors highlights through the real path — no assertion is weakened. +const injectedMessages = new Map(); +const ensureInjected = async (page: any) => { + for (const [id, spec] of injectedMessages) { + if (!(await page.locator(`#message-${id}`).count())) { + await renderMessage(page, id, spec.type, spec.content); + } + } +}; + +// Healing poll: A0's async history rebuild can wipe an injected message (and +// with it the plugin's highlights) at any instant — including INSIDE a plain +// toHaveCount window, which would then fail without anything real being wrong. +// expect(callback).toPass() re-runs the whole block until it holds, and each +// run re-heals first, so a mid-poll wipe is recovered rather than fatal. +const expectHealed = async ( + page: any, + assert: (p: any) => Promise, + timeout = 10000, +) => { + await expect(async () => { + await ensureInjected(page); + await assert(page); + }).toPass({ timeout }); }; // Select `phrase` inside #message- and create an anchored comment with `note` // through the plugin's REAL selection pipeline: build the Range the way a user // selection would, open the plugin's editor via its selection entry point, type, save. -const commentOnPhrase = async (page: any, msgId: string, phrase: string, note: string) => { - const opened = await page.evaluate( - ({ msgId, phrase }: any) => { - const container = document.getElementById(`message-${msgId}`); - if (!container) return "no-container"; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - const idx = (node.textContent || "").indexOf(phrase); - if (idx >= 0) { - const range = document.createRange(); - range.setStart(node, idx); - range.setEnd(node, idx + phrase.length); - const sel = window.getSelection()!; - sel.removeAllRanges(); - sel.addRange(range); - // real trigger: the contextmenu event over the selection is what shows the menu - const rect = range.getBoundingClientRect(); - const ev = new MouseEvent("contextmenu", { - bubbles: true, cancelable: true, - clientX: rect.left + 2, clientY: rect.top + 2, - }); - (node.parentElement as HTMLElement).dispatchEvent(ev); - return "ok"; +const commentOnPhrase = async ( + page: any, + msgId: string, + phrase: string, + note: string, +) => { + // A0 rebuilds chat history from the server's raw log asynchronously (poll/WS + // push — our own comment save triggers it), so an injected message can vanish + // at ANY instant, including between a heal-check and the selection below. + // Retry until the selection lands; re-inject on each attempt. Only the race + // outcome ("no-container") is retried — "phrase-not-found" fails fast, it + // would be a real defect, not a race. + let opened = ""; + for (let attempt = 0; attempt < 5; attempt++) { + await ensureInjected(page); + opened = await page.evaluate( + ({ msgId, phrase }: any) => { + const container = document.getElementById(`message-${msgId}`); + if (!container) return "no-container"; + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + const idx = (node.textContent || "").indexOf(phrase); + if (idx >= 0) { + const range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + phrase.length); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + // real trigger: the contextmenu event over the selection is what shows the menu + const rect = range.getBoundingClientRect(); + const ev = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + 2, + clientY: rect.top + 2, + }); + (node.parentElement as HTMLElement).dispatchEvent(ev); + return "ok"; + } } - } - return "phrase-not-found"; - }, - { msgId, phrase }, - ); + return "phrase-not-found"; + }, + { msgId, phrase }, + ); + if (opened !== "no-container") break; + await page.waitForTimeout(400); + } expect(opened).toBe("ok"); // the plugin menu must be visible — then click its real "Comment" item await page.waitForSelector(".cc-menu", { timeout: 5000 }); - await page.locator('.cc-menu .cc-menu-item', { hasText: "Comment" }).first().click(); + await page + .locator(".cc-menu .cc-menu-item", { hasText: "Comment" }) + .first() + .click(); await page.waitForSelector(".cc-editor-input", { timeout: 5000 }); await page.locator(".cc-editor-input").fill(note); // The plugin's own save is fire-and-forget (`void this.persist()` in the @@ -112,9 +205,12 @@ const commentOnPhrase = async (page: any, msgId: string, phrase: string, note: s const reloadIntoChat = async (page: any, id: string) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(1500); + await dismissNotificationToasts(page); await page.evaluate((c: string) => (globalThis as any).setContext(c), id); await waitStore(page); - await page.evaluate(() => (window as any).Alpine.store("chatComments").bootstrap()); + await page.evaluate(() => + (window as any).Alpine.store("chatComments").bootstrap(), + ); }; // Open the comments modal through the real toolbar control and wait for the @@ -124,12 +220,15 @@ const openCommentsModal = async (page: any) => { // 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 dismissNotificationToasts(page); // belt-and-braces: toast over the toolbar 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()); + await page.evaluate(() => + (window as any).Alpine.store("chatComments").closeCommentsModal(), + ); }; // What the user can actually see: rows rendered in the comments modal. @@ -140,23 +239,31 @@ const visibleCommentCount = async (page: any) => { // ── Givens ─────────────────────────────────────────────────────────────────── -Given("I am in a chat", async ({ loggedInPage }: any) => { await openChat(loggedInPage); }); - -Given("the chat contains a message from me", async ({ loggedInPage }: any) => { - await renderMessage(loggedInPage, "e2e-user-1", "user", MSG_TEXT); +Given("I am in a chat", async ({ loggedInPage }: any) => { + await openChat(loggedInPage); }); -Given("the chat contains a message from me and a reply from the agent", async ({ loggedInPage }: any) => { +Given("the chat contains a message from me", async ({ loggedInPage }: any) => { await renderMessage(loggedInPage, "e2e-user-1", "user", MSG_TEXT); - // A0 taxonomy: the agent's visible reply is type "response" (renders a - // message- container). type "agent" is internal reasoning and renders - // into process-group-, which is not a commentable message surface. - await renderMessage(loggedInPage, "e2e-agent-1", "response", AGENT_TEXT); }); -Given("I have commented on a phrase inside that message", async ({ loggedInPage }: any) => { - await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", NOTE); -}); +Given( + "the chat contains a message from me and a reply from the agent", + async ({ loggedInPage }: any) => { + await renderMessage(loggedInPage, "e2e-user-1", "user", MSG_TEXT); + // A0 taxonomy: the agent's visible reply is type "response" (renders a + // message- container). type "agent" is internal reasoning and renders + // into process-group-, which is not a commentable message surface. + await renderMessage(loggedInPage, "e2e-agent-1", "response", AGENT_TEXT); + }, +); + +Given( + "I have commented on a phrase inside that message", + async ({ loggedInPage }: any) => { + await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", NOTE); + }, +); // ── Whens ──────────────────────────────────────────────────────────────────── @@ -172,25 +279,58 @@ When("I add a comment to the chat", async ({ loggedInPage }: any) => { await expect(loggedInPage.locator(".cc-modal-item")).toHaveCount(before + 1); }); -When("I comment on a phrase inside that message", async ({ loggedInPage }: any) => { - await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", NOTE); -}); - -When("I comment on two different phrases inside that message", async ({ loggedInPage }: any) => { - await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", "first note"); - await commentOnPhrase(loggedInPage, "e2e-user-1", "lazy dog", "second note"); -}); +When( + "I comment on a phrase inside that message", + async ({ loggedInPage }: any) => { + await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", NOTE); + }, +); + +When( + "I comment on two different phrases inside that message", + async ({ loggedInPage }: any) => { + await commentOnPhrase( + loggedInPage, + "e2e-user-1", + "quick brown fox", + "first note", + ); + await commentOnPhrase( + loggedInPage, + "e2e-user-1", + "lazy dog", + "second note", + ); + }, +); When("I comment on a phrase in each of them", async ({ loggedInPage }: any) => { - await commentOnPhrase(loggedInPage, "e2e-user-1", "quick brown fox", "on my message"); - await commentOnPhrase(loggedInPage, "e2e-agent-1", "answer you requested", "on the agent reply"); + await commentOnPhrase( + loggedInPage, + "e2e-user-1", + "quick brown fox", + "on my message", + ); + await commentOnPhrase( + loggedInPage, + "e2e-agent-1", + "answer you requested", + "on the agent reply", + ); }); When("I change the comment's note", async ({ loggedInPage }: any) => { // real path: click the highlight → popover → Edit → editor prefilled → save + // (healed poll: the highlight lives in the message DOM, which A0's async + // history rebuild can wipe — re-inject until one is actually clickable) + await expectHealed(loggedInPage, async (p: any) => { + expect(await p.locator(".cc-highlight").count()).toBeGreaterThan(0); + }); await loggedInPage.locator(".cc-highlight").first().click(); await loggedInPage.waitForSelector(".cc-popover", { timeout: 5000 }); - await loggedInPage.locator(".cc-popover .cc-btn", { hasText: "Edit" }).click(); + await loggedInPage + .locator(".cc-popover .cc-btn", { hasText: "Edit" }) + .click(); await loggedInPage.waitForSelector(".cc-editor-input", { timeout: 5000 }); await loggedInPage.locator(".cc-editor-input").fill(EDITED); // Same reasoning as commentOnPhrase: editComment() calls `void this.persist()`, @@ -227,7 +367,11 @@ When("I switch to a different chat", async ({ loggedInPage }: any) => { // real chat switch repaint would await loggedInPage.evaluate(() => { const h = document.getElementById("chat-history"); - if (h) { const d = document.createElement("div"); h.appendChild(d); d.remove(); } + if (h) { + const d = document.createElement("div"); + h.appendChild(d); + d.remove(); + } }); await loggedInPage.waitForFunction( (c: string) => (window as any).Alpine.store("chatComments").contextId === c, @@ -237,10 +381,17 @@ When("I switch to a different chat", async ({ loggedInPage }: any) => { }); When("I switch back to the first chat", async ({ loggedInPage }: any) => { - await loggedInPage.evaluate((c: string) => (globalThis as any).setContext(c), ctx); + await loggedInPage.evaluate( + (c: string) => (globalThis as any).setContext(c), + ctx, + ); await loggedInPage.evaluate(() => { const h = document.getElementById("chat-history"); - if (h) { const d = document.createElement("div"); h.appendChild(d); d.remove(); } + if (h) { + const d = document.createElement("div"); + h.appendChild(d); + d.remove(); + } }); await loggedInPage.waitForFunction( (c: string) => (window as any).Alpine.store("chatComments").contextId === c, @@ -260,7 +411,9 @@ When("I try to add an empty comment", async ({ loggedInPage }: any) => { }); When("I send the comments to the prompt box", async ({ loggedInPage }: any) => { - await loggedInPage.evaluate(() => (window as any).Alpine.store("chatComments").sendAllToPrompt()); + await loggedInPage.evaluate(() => + (window as any).Alpine.store("chatComments").sendAllToPrompt(), + ); }); // API negative pokes — raw fetchApi so status codes are observable @@ -277,30 +430,55 @@ const poke = async (page: any, body: any) => let lastPoke: { status: number; text: string } = { status: 0, text: "" }; -When("the comments service is asked without saying which chat", async ({ loggedInPage }: any) => { - lastPoke = await poke(loggedInPage, { action: "load" }); -}); - -When("the comments service is asked about a chat that does not exist", async ({ loggedInPage }: any) => { - lastPoke = await poke(loggedInPage, { action: "load", context: "does-not-exist-e2e" }); -}); - -When("the comments service is given comments that are not a list", async ({ loggedInPage }: any) => { - lastPoke = await poke(loggedInPage, { action: "save", context: ctx, comments: "nope" }); -}); +When( + "the comments service is asked without saying which chat", + async ({ loggedInPage }: any) => { + lastPoke = await poke(loggedInPage, { action: "load" }); + }, +); + +When( + "the comments service is asked about a chat that does not exist", + async ({ loggedInPage }: any) => { + lastPoke = await poke(loggedInPage, { + action: "load", + context: "does-not-exist-e2e", + }); + }, +); + +When( + "the comments service is given comments that are not a list", + async ({ loggedInPage }: any) => { + lastPoke = await poke(loggedInPage, { + action: "save", + context: ctx, + comments: "nope", + }); + }, +); // ── Thens ──────────────────────────────────────────────────────────────────── -Then("a comments control is available in the chat toolbar", async ({ loggedInPage }: any) => { - await expect(loggedInPage.locator(".cc-toolbar-btn")).toBeVisible({ timeout: 12000 }); -}); +Then( + "a comments control is available in the chat toolbar", + async ({ loggedInPage }: any) => { + await expect(loggedInPage.locator(".cc-toolbar-btn")).toBeVisible({ + timeout: 12000, + }); + }, +); Then("the chat shows it has one comment", async ({ loggedInPage }: any) => { - await expect(loggedInPage.locator(".cc-badge")).toHaveText("1", { timeout: 8000 }); + await expect(loggedInPage.locator(".cc-badge")).toHaveText("1", { + timeout: 8000, + }); }); Then("the chat shows it has two comments", async ({ loggedInPage }: any) => { - await expect(loggedInPage.locator(".cc-badge")).toHaveText("2", { timeout: 8000 }); + await expect(loggedInPage.locator(".cc-badge")).toHaveText("2", { + timeout: 8000, + }); }); Then("the chat shows it has no comments", async ({ loggedInPage }: any) => { @@ -314,71 +492,117 @@ Then("the chat shows it has no comments", async ({ loggedInPage }: any) => { Then("that chat shows no comments", async ({ loggedInPage }: any) => { // the freshly-switched-to chat carries none of the first chat's comments - await expect(loggedInPage.locator(".cc-badge")).toBeHidden({ timeout: 10000 }); + 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) => { - await loggedInPage.waitForTimeout(1200); - await reloadIntoChat(loggedInPage, ctx); - await expect(loggedInPage.locator(".cc-badge")).toHaveText("1", { timeout: 10000 }); -}); +Then( + "the comment is still there after a reload", + async ({ loggedInPage }: any) => { + await loggedInPage.waitForTimeout(1200); + await reloadIntoChat(loggedInPage, ctx); + await expect(loggedInPage.locator(".cc-badge")).toHaveText("1", { + timeout: 10000, + }); + }, +); Then("the comment is gone after a reload", async ({ loggedInPage }: any) => { await reloadIntoChat(loggedInPage, ctx); - await expect(loggedInPage.locator(".cc-badge")).toBeHidden({ timeout: 10000 }); + 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) => { - const mark = loggedInPage.locator("#message-e2e-user-1 mark.cc-highlight"); - await expect(mark).toHaveCount(1, { timeout: 8000 }); - await expect(mark).toHaveText("quick brown fox"); -}); - -Then("both phrases are highlighted in the message", async ({ loggedInPage }: any) => { - await expect(loggedInPage.locator("#message-e2e-user-1 mark.cc-highlight")).toHaveCount(2, { timeout: 8000 }); -}); - -Then("each commented phrase is highlighted in its own message", async ({ loggedInPage }: any) => { - await expect(loggedInPage.locator("#message-e2e-user-1 mark.cc-highlight")).toHaveCount(1, { timeout: 8000 }); - await expect(loggedInPage.locator("#message-e2e-agent-1 mark.cc-highlight")).toHaveCount(1, { timeout: 8000 }); -}); - -Then("the comment records which text it refers to", async ({ loggedInPage }: any) => { - const rec = await loggedInPage.evaluate( - () => (window as any).Alpine.store("chatComments").comments[0], - ); - expect(rec.quoted_text).toBe("quick brown fox"); - expect(rec.message_id).toBe("e2e-user-1"); -}); - -Then("the comment list attributes the comment to the quoted text", async ({ loggedInPage }: any) => { - await loggedInPage.locator(".cc-toolbar-btn").click(); - const quote = loggedInPage.locator(".cc-modal-item .cc-modal-quote"); - await expect(quote).toHaveCount(1, { timeout: 8000 }); - await expect(quote).toContainText("quick brown fox"); - await loggedInPage.evaluate(() => (window as any).Alpine.store("chatComments").closeCommentsModal()); -}); - -Then("the stored comment carries a creation timestamp", async ({ loggedInPage }: any) => { - // end state: created_at persisted as a plausible unix-seconds integer - await loggedInPage.waitForTimeout(800); - const rec = await loggedInPage.evaluate(async (c: string) => { - const { callJsonApi } = await import("/js/api.js"); - const r = await callJsonApi("/plugins/chat_comments/comments", { action: "load", context: c }); - return r.comments[0]; - }, ctx); - expect(Number.isInteger(rec.created_at)).toBe(true); - expect(rec.created_at).toBeGreaterThan(1600000000); -}); +Then( + "the phrase is highlighted in the message", + async ({ loggedInPage }: any) => { + await expectHealed(loggedInPage, async (p: any) => { + const mark = p.locator("#message-e2e-user-1 mark.cc-highlight"); + expect(await mark.count()).toBe(1); + expect(await mark.first().textContent()).toBe("quick brown fox"); + }); + }, +); + +Then( + "both phrases are highlighted in the message", + async ({ loggedInPage }: any) => { + await expectHealed(loggedInPage, async (p: any) => { + expect( + await p.locator("#message-e2e-user-1 mark.cc-highlight").count(), + ).toBe(2); + }); + }, +); + +Then( + "each commented phrase is highlighted in its own message", + async ({ loggedInPage }: any) => { + await expectHealed(loggedInPage, async (p: any) => { + expect( + await p.locator("#message-e2e-user-1 mark.cc-highlight").count(), + ).toBe(1); + expect( + await p.locator("#message-e2e-agent-1 mark.cc-highlight").count(), + ).toBe(1); + }); + }, +); + +Then( + "the comment records which text it refers to", + async ({ loggedInPage }: any) => { + const rec = await loggedInPage.evaluate( + () => (window as any).Alpine.store("chatComments").comments[0], + ); + expect(rec.quoted_text).toBe("quick brown fox"); + expect(rec.message_id).toBe("e2e-user-1"); + }, +); + +Then( + "the comment list attributes the comment to the quoted text", + async ({ loggedInPage }: any) => { + await loggedInPage.locator(".cc-toolbar-btn").click(); + const quote = loggedInPage.locator(".cc-modal-item .cc-modal-quote"); + await expect(quote).toHaveCount(1, { timeout: 8000 }); + await expect(quote).toContainText("quick brown fox"); + await loggedInPage.evaluate(() => + (window as any).Alpine.store("chatComments").closeCommentsModal(), + ); + }, +); + +Then( + "the stored comment carries a creation timestamp", + async ({ loggedInPage }: any) => { + // end state: created_at persisted as a plausible unix-seconds integer + await loggedInPage.waitForTimeout(800); + const rec = await loggedInPage.evaluate(async (c: string) => { + const { callJsonApi } = await import("/js/api.js"); + const r = await callJsonApi("/plugins/chat_comments/comments", { + action: "load", + context: c, + }); + return r.comments[0]; + }, ctx); + expect(Number.isInteger(rec.created_at)).toBe(true); + expect(rec.created_at).toBeGreaterThan(1600000000); + }, +); Then("the comment shows the updated note", async ({ loggedInPage }: any) => { await loggedInPage.locator(".cc-highlight").first().click(); - await expect(loggedInPage.locator(".cc-popover-text")).toHaveText(EDITED, { timeout: 5000 }); + await expect(loggedInPage.locator(".cc-popover-text")).toHaveText(EDITED, { + timeout: 5000, + }); await loggedInPage.locator("body").click({ position: { x: 4, y: 4 } }); }); @@ -387,7 +611,10 @@ Then("the updated note survives a reload", async ({ loggedInPage }: any) => { await reloadIntoChat(loggedInPage, ctx); const rec = await loggedInPage.evaluate(async (c: string) => { const { callJsonApi } = await import("/js/api.js"); - const r = await callJsonApi("/plugins/chat_comments/comments", { action: "load", context: c }); + const r = await callJsonApi("/plugins/chat_comments/comments", { + action: "load", + context: c, + }); return r.comments[0]; }, ctx); expect(rec.comment).toBe(EDITED); @@ -403,7 +630,9 @@ Then("it reports the chat as not found", async () => { Then("the prompt box contains my comment", async ({ loggedInPage }: any) => { const val = await loggedInPage.evaluate( - () => (document.getElementById("chat-input") as HTMLTextAreaElement)?.value || "", + () => + (document.getElementById("chat-input") as HTMLTextAreaElement)?.value || + "", ); expect(val).toContain(NOTE); }); diff --git a/usr/plugins/chat_comments/webui/anchoring.js b/webui/anchoring.js similarity index 100% rename from usr/plugins/chat_comments/webui/anchoring.js rename to webui/anchoring.js diff --git a/usr/plugins/chat_comments/webui/chat-comments-store.js b/webui/chat-comments-store.js similarity index 100% rename from usr/plugins/chat_comments/webui/chat-comments-store.js rename to webui/chat-comments-store.js diff --git a/usr/plugins/chat_comments/webui/comments-modal.html b/webui/comments-modal.html similarity index 100% rename from usr/plugins/chat_comments/webui/comments-modal.html rename to webui/comments-modal.html diff --git a/usr/plugins/chat_comments/webui/thumbnail.png b/webui/thumbnail.png similarity index 100% rename from usr/plugins/chat_comments/webui/thumbnail.png rename to webui/thumbnail.png