From cd3ab7765df59e9f0b3c5d4c60d4a32e7b572baf Mon Sep 17 00:00:00 2001 From: Omar Nahhas Sanchez Date: Wed, 29 Jul 2026 14:44:53 +0100 Subject: [PATCH 1/4] docs: value-prop README + mermaid architecture, real thumbnail, marketplace draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: rewritten — value-prop table, mermaid architecture incl. persistence flow (save/load round-trip to usr/chats//chat.json), install (Hub + manual), zero-config reference, comment record schema, dev/test guide, security. - thumbnail.png: replace 79-byte stub with a real square 512x512 PNG (2,751 B, <20KB). - docs/marketplace-draft.md: staged index.yaml (title/description<500/5 tags), raw screenshot URLs with 404-until-public note, thumbnail spec, honest flip-time screenshot-capture task, pre-submission checklist. Screenshots intentionally not fabricated — captured at flip time against a live A0 (the auth-gated shared dev instance here is not usable for honest capture); the e2e BDD suite drives the exact flows that produce them. --- README.md | 178 ++++++++++++++---- docs/marketplace-draft.md | 63 +++++++ usr/plugins/chat_comments/webui/thumbnail.png | Bin 79 -> 2751 bytes 3 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 docs/marketplace-draft.md 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/usr/plugins/chat_comments/webui/thumbnail.png b/usr/plugins/chat_comments/webui/thumbnail.png index 0de0c0d13677199c5d3bc2ae0ba4e98e47560003..d858153575eb7303a01431f7f3df870c519f2131 100644 GIT binary patch literal 2751 zcmb7G3s@6Z7Cti*NI>2cMZ^*W#0S)%;>traQJ$89C>GYG60HiX)S^&|@+}Oy;%X5o zx`-l6DlS?n$QH#2Y6dtL12^S%8XwbMWbib9=hXnJf^+hxs7*~HSDHt2^~Fw2{a3rZ zOP`E9*o~2E4{ru80Eh8J!S6-iL>a$*|Miz&;ael?`dM@td-=qkbFSSb#zQNy;ikhJ zJLB~k`jnsD<@ES}^_376WUfJ?k*+ayeBPmb3JVr@oqEDqT`h`RyT3;&gfKO{7rf%A zw4sQQiCQ9r{^o>jYzP&moz}juhSyZC#vKfte{SqFe2|&9exRC7yR3-fysCt>$VWOD z-w;;ZZ!mv+2@@86-!1to%i{_OHcD)3BTMTEAA>Cm2R6!D)I~dpTLgG?bzu3E?2v}GxY64=o856~>At%Q5Jp)Ui9gjx z7+tCv_)q7dZc$8e@no8n8z1KqC=k%+NLe&v?`|J6Hm z3?hOwP!U{q_KANHGpxLHV0r6!wW~9ZXT{x#tKTcK#hI;HcIekwI;$;cc)|bFnfQk? zu#5Xe@pYO^bH|MmHe_+Q$d9@#OWly%Q>HJ0BU|3XxVw!?M0Z7O&J6z z8dyTM(1)u|`r?rU?Nay|V%(WdnpOr?|N zPy1?xo+@}Ic^x?Pa>mJ4;?Er}X5}FWvZ22rR_cnj@Z9 zv9FJ7&{1%7JpzRR6w*iAXbZe`M{wC47hDR=~I$swBfF9<62k!3yYOE^r ziAuE!gV0pvG)k7p)u7;JIZnV#1Iu9RhX)@`Wj52re-+k^j}yPG062&y2@0lvf&^v znLz*n?LNx8E-h*Zly!EkmIi;8_sg9yusid3eOBf?dRTWpXamQIU;gi;l4o3MnP1-a6r-{{RMWZ_)q& literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|VxBIJAr*6yJ5Ie; Date: Wed, 29 Jul 2026 16:03:31 +0100 Subject: [PATCH 2/4] =?UTF-8?q?test(e2e):=20de-flake=20delete=20scenario?= =?UTF-8?q?=20=E2=80=94=20reach=20modal-open=20state=20instead=20of=20a=20?= =?UTF-8?q?fixed=208s=20wait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete step did waitForSelector('.cc-modal-item', {timeout: 8000}) right after clicking the toolbar button. On the slow fork the lazy modal mount + row render races that fixed window, so the scenario timed out intermittently (2 runs). Now the step treats 'modal open with its row rendered' as a state to reach: retry the open click up to 3x with a 15s visibility wait, then assert the row is visible before clicking delete. Assertions are unchanged — the scenario still asserts the end state (no comments, and gone after a reload). --- tests/e2e/steps/comments.steps.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/e2e/steps/comments.steps.ts b/tests/e2e/steps/comments.steps.ts index 36ada28..9774416 100644 --- a/tests/e2e/steps/comments.steps.ts +++ b/tests/e2e/steps/comments.steps.ts @@ -159,9 +159,22 @@ 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 }); + // real path: open the comments modal, click the row's delete control. + // The fork mounts the modal lazily and can be slow, so treat "modal is open with + // its row rendered" as the state to reach (retrying the open click) rather than + // assuming a single click lands within a fixed window. + const row = loggedInPage.locator(".cc-modal-item").first(); + for (let attempt = 0; attempt < 3; attempt++) { + if (await row.count() > 0) break; + await loggedInPage.locator(".cc-toolbar-btn").click(); + try { + await row.waitFor({ state: "visible", timeout: 15000 }); + break; + } catch { + if (attempt === 2) throw new Error("comments modal never rendered its comment row"); + } + } + await expect(row).toBeVisible(); await loggedInPage.locator(".cc-modal-item .cc-modal-del").first().click(); await loggedInPage.evaluate(async () => { const s = (window as any).Alpine.store("chatComments"); From b1d16d0c9023d40cdd791a63d67f1b7a1d3d1fe4 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 31 Jul 2026 22:54:15 +0100 Subject: [PATCH 3/4] test(e2e): drive the real UI in general-comment scenarios (no store poking) The two red scenarios (comment remembered / comment deleted) drove the Alpine store directly and manually called persist(), violating the feature file's own hard rule #2 (real triggers, no internal state-poking). Every scenario that used the real UI pipeline passed; only the store-poking ones failed. - add: toolbar -> modal -> fill .cc-modal-add-input -> click add btn - delete: click .cc-modal-del, assert the row count drops - empty: assert the add button is DISABLED on a blank draft (the real guardrail) instead of poking the store - no-comments / gone-after-reload: assert on visible .cc-modal-item count + .cc-modal-empty, not store.comments.length Manual persist() removed from both target scenarios: if the real flow does not save, the reload assertions must catch it. --- tests/e2e/steps/comments.steps.ts | 110 ++++++++++++++++-------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/tests/e2e/steps/comments.steps.ts b/tests/e2e/steps/comments.steps.ts index 9774416..e8976f9 100644 --- a/tests/e2e/steps/comments.steps.ts +++ b/tests/e2e/steps/comments.steps.ts @@ -96,8 +96,24 @@ 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"); + if (await input.isVisible().catch(() => false)) 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 +138,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,28 +178,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. - // The fork mounts the modal lazily and can be slow, so treat "modal is open with - // its row rendered" as the state to reach (retrying the open click) rather than - // assuming a single click lands within a fixed window. - const row = loggedInPage.locator(".cc-modal-item").first(); - for (let attempt = 0; attempt < 3; attempt++) { - if (await row.count() > 0) break; - await loggedInPage.locator(".cc-toolbar-btn").click(); - try { - await row.waitFor({ state: "visible", timeout: 15000 }); - break; - } catch { - if (attempt === 2) throw new Error("comments modal never rendered its comment row"); - } - } - await expect(row).toBeVisible(); - 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) => { @@ -219,12 +227,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) => { @@ -272,20 +281,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) => { @@ -295,11 +304,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) => { From b6132925535c8646bf8c03f5e82cacc6e365f99c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 31 Jul 2026 23:09:26 +0100 Subject: [PATCH 4/4] test(e2e): drop silent .catch() in openCommentsModal (bdd-lint honesty gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isVisible() already resolves false for a not-yet-rendered element, so the .catch(() => false) was swallowing real failures for no benefit — exactly what the honesty gate exists to catch. --- tests/e2e/steps/comments.steps.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/steps/comments.steps.ts b/tests/e2e/steps/comments.steps.ts index e8976f9..fdebaa0 100644 --- a/tests/e2e/steps/comments.steps.ts +++ b/tests/e2e/steps/comments.steps.ts @@ -100,7 +100,9 @@ const reloadIntoChat = async (page: any, id: string) => { // 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"); - if (await input.isVisible().catch(() => false)) return; + // 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 }); };