Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5f8d45e
fix(annotate): fall back to the startup snapshot when the HTML root i…
backnotprop Aug 27, 2026
6647498
fix(annotate): keep the HTML version diff across Refresh and reload
backnotprop Aug 27, 2026
b3c1b8b
feat(annotate): offer Refresh from disk in the compact touch shell
backnotprop Aug 27, 2026
f70cc4d
fix(ui): render the HtmlSurfaceControls refresh independently of the eye
backnotprop Aug 27, 2026
ebae086
fix(annotations): make inReplyTo threading cycle-safe and refuse cycl…
backnotprop Aug 27, 2026
4142494
docs: describe HTML Refresh, the threading rule, and the WebMCP desig…
backnotprop Aug 27, 2026
040a217
perf(core): resolve reply threads in linear time
backnotprop Aug 27, 2026
5d3564b
perf(ui): sort the annotations panel timeline from a memoized thread map
backnotprop Aug 27, 2026
0bb8e54
perf(ui): emit exported reply threads in linear size and time
backnotprop Aug 27, 2026
2e9dc9d
fix(annotate): warn once when the HTML root cannot be read
backnotprop Aug 27, 2026
0002b4a
fix(webmcp): bound the change tracker's tombstones
backnotprop Aug 27, 2026
46b5baf
fix(webmcp): bound the add_comments requestId memory
backnotprop Aug 27, 2026
e0c3a17
fix(ui): scope minted HTML annotation ids per viewer instance
backnotprop Aug 27, 2026
dc8de46
test(editor): the Unanchored chip clears on a refresh whose restore r…
backnotprop Aug 27, 2026
693125c
fix(webmcp): cap the id list on annotation nudges
backnotprop Aug 27, 2026
5c64326
fix(webmcp): clear navigation-wait timers on unmount
backnotprop Aug 27, 2026
1a7fb5a
fix(ui): one diagram Retry re-attempts every block on the shared runtime
backnotprop Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ jobs:
packages/editor/planDiffAutoExit.test.tsx
packages/editor/App.archiveReadOnly.test.tsx
packages/editor/App.htmlChrome.test.tsx
packages/editor/components/HtmlSurfaceActions.test.tsx
packages/ui/components/HtmlSurfaceControls.test.tsx
packages/ui/components/AnnotationPanel.unanchored.test.tsx
packages/ui/hooks/useHtmlRefresh.test.tsx
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan?, versionInfo?, diffCurrent?, diffHtml? }`. The last four power the per-file version diff: `previousPlan`/`versionInfo`/`diffCurrent` for the markdown diff, `diffHtml` (the previous→current page rendered with inline `<ins>`/`<del>`) for `--render-html` files. Live app sessions return `{ mode: "annotate-app", appUrl, targetUrl, liveToken, sharingEnabled: false, ... }` instead: no rawHtml, no version fields (see "Live app annotation"). |
| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan?, versionInfo?, diffCurrent?, diffHtml? }`. The last four power the per-file version diff: `previousPlan`/`versionInfo`/`diffCurrent` for the markdown diff, `diffHtml` (the previous→current page rendered with inline `<ins>`/`<del>`) for `--render-html` files. A local rendered-HTML root is served from its CURRENT bytes on every read (`readRootHtml`), with the startup snapshot as the fallback when the file is missing, unreadable, or over the 2MB cap; when the served bytes differ from the snapshot, `previousPlan`/`versionInfo` still name the saved baseline and `diffCurrent`/`diffHtml` are recomputed against the served bytes (`htmlDiff` is pure; a GET never writes history), so a reload after an agent edit keeps the version diff. `/api/doc` carries the same recomputed `previousPlan`/`versionInfo`/`diffHtml` when it serves that root document (the in-app Refresh path, `rootHtmlVersionDiff`), and nothing extra for any other document. Live app sessions return `{ mode: "annotate-app", appUrl, targetUrl, liveToken, sharingEnabled: false, ... }` instead: no rawHtml, no version fields (see "Live app annotation"). |
| `/api/plan/version` | GET | Fetch a specific stored version of the annotated file (`?v=N`) |
| `/api/plan/versions` | GET | List all stored versions of the annotated file |
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
Expand Down Expand Up @@ -639,11 +639,13 @@ Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>

These surfaces are **comment-only**. `redline` (auto-DELETION) and `quickLabel` are clamped at the trust boundary, which is the parent's postMessage ingest rather than the server, covering the host mode and a page-supplied `modeOverride` alike so a hostile page cannot force a DELETION (`packages/ui/components/html-viewer/useHtmlAnnotation.ts:535-547`). Only CREATION is restricted: persisted DELETION annotations still restore and still render their deletion styling (`useHtmlAnnotation.ts:903`). The selection toolbar drops Delete behind a `commentOnly` seam and is passed no quick-label handler (`packages/ui/components/AnnotationToolbar.tsx:217-224`, `HtmlViewer.tsx:880-883`); markdown surfaces keep the full toolbar. HTML surfaces also pin the viewer input method to pinpoint (`App.tsx:5480`), so there is no floating input-method toolstrip on them at all (`toolstripVisible` is gated on `!isHtmlSurface`, `App.tsx:2788-2793`) and the `Shift+1`-`4` annotation-mode shortcuts cannot fire there. A header **eye** button immediately left of the pen toggles Show/Hide tools: hiding REMOVES all floating chrome over the page from the DOM (the sidebar tongue tabs and the comment/attachments cluster) rather than merely hiding it (`AppHeader.tsx:364-385`, `App.tsx:5193`, `HtmlViewer.tsx:810`). The toggle lives in the header, so a hidden state always has a way back, which is what makes honoring the persisted `toolsHidden` cookie safe (`packages/ui/utils/htmlChrome.ts:17-21`).

**HTML Refresh (#1232).** A local rendered-HTML session can re-read its file from disk without reloading the tab, for the loop where an agent edits the page while the reviewer keeps annotating. The header **Refresh** button (left of the eye, `data-html-refresh`, titled "Refresh HTML from disk") fetches the active document through `/api/doc`, hands the bytes to the app, and remounts the viewer under a bumped `reloadGeneration` key (`packages/editor/App.tsx`, viewer `key`). The engine is the published `useHtmlRefresh` (`packages/ui/hooks/useHtmlRefresh.ts`: superseded and cross-document fetches are dropped, one restore acknowledgement per generation) and Plannotator's binding over `fetchHtmlDocumentSnapshot` is `packages/editor/hooks/useHtmlRefresh.ts` (toasts for refreshed, missing, and unavailable). Committed annotations survive on their durable anchors: the remounted viewer re-resolves every element selector and text snapshot against the new page, and the ones it cannot re-anchor are reported once (`onUnanchoredChange` to `reportAnnotationRestore`), toasted, and marked with an **Unanchored** chip in the annotations panel (`htmlUnanchoredIds` in App, cleared when the document changes); their comments stay in the panel and still export. A refresh keeps the version diff: for the root document `/api/doc` carries `previousPlan`/`versionInfo`/`diffHtml` recomputed against the bytes just read (see the annotate `/api/plan` row), `applyRefreshedHtml` sets them and resets `isPlanDiffActive`, so the view returns to normal mode with "Show changes" still available; a tab reload converges on the same state because `/api/plan` serves the current bytes and recomputes the same diff. `/api/share-html` shares the current bytes too. Only local files refresh: `canRefresh` is false for `http(s)` paths and live-app sessions, and the control is absent on read-only (archive) documents. The compact touch shell renders no header controls (`HtmlSurfaceControls` returns null when `compact`), so its Options menu offers "Refresh from disk" beside the Show/Hide tools and Interact/Annotate actions (`compactDocumentActions` in App, disabled while a refresh is in flight); a host that passes `canRefresh` and `onRefresh` to `HtmlSurfaceControls` gets the Refresh button with or without the eye.

Known limitation: printing a raw-HTML annotate session prints highlight stripes from a best-effort absolute-coordinate layer and is degraded inside the iframe (pre-existing); element-only targets (SVG anchors, multi-select additional element targets) have no print representation.

## WebMCP (browser-agent tools)

Design of record: `DESIGN_webmcp-support.md` (untracked). Phase 1 makes plan review and every annotate surface a WebMCP **provider**: a browser-integrated agent (Chrome/Edge origin trial, `chrome://flags/#enable-webmcp-testing` or `--enable-features=WebMCPTesting` locally; agent-embedded browsers unflagged) calls in-page tools instead of scraping the DOM. Code review (phase 2) and consuming the annotated app's own tools (phase 3) are not built.
The design document lives outside the tree (it is not checked in); the user-facing reference is `apps/marketing/src/content/docs/reference/webmcp-tools.md`. Phase 1 makes plan review and every annotate surface a WebMCP **provider**: a browser-integrated agent (Chrome/Edge origin trial, `chrome://flags/#enable-webmcp-testing` or `--enable-features=WebMCPTesting` locally; agent-embedded browsers unflagged) calls in-page tools instead of scraping the DOM. Code review (phase 2) and consuming the annotated app's own tools (phase 3) are not built.

**Shape (mirrors the shortcut system).** Engine in `packages/ui/webmcp/`: `modelContext.ts` is the ONLY file that spells `document.modelContext`, `registerTool`, `getTools`, `executeTool`, `toolchange` and the annotation hints (local structural types, no `webmcp-types` dependency, no `declare global`); `toolset.ts` (tool specs, the `{ ok, data, nudges, error? }` envelope, `runTool`, a per-document registry with reconcile-by-name and one `AbortController` per tool, since unregistration is only by abort); `changes.ts` (per-annotation `seq`, tombstones, per-tab watermark with `since` override, `claimOwn` so the agent's own writes are never "new" to it); `nudges.ts` (the twelve codes: `annotations_new`, `annotations_removed`, `replies_new`, `composer_open`, `source_stale`, `document_edited`, `comment_only_surface`, `page_changed`, `other_document_active`, `pending_unsent`, `session_decided`, `truncated`; messages are static strings, document and comment text never enter a message); `useToolset.ts` (React hook; handlers read through refs so a re-render never touches `registerTool`); `policy.ts` (the `webmcp` seam on `configurePlannotatorUI`, `{ enabled, namePrefix }`, default enabled with prefix `plannotator.`). Catalog in `packages/editor/webmcp/`: `documentTools.ts` builds the tools over a narrow `DocumentToolAdapter` (never imports App), `documentText.ts` holds the pure outline / windowing / quote-resolution helpers, `useDocumentWebMcp.ts` builds the adapter over App state through one ref.

Expand All @@ -653,7 +655,7 @@ Design of record: `DESIGN_webmcp-support.md` (untracked). Phase 1 makes plan rev

**Folder sessions.** `list_documents` walks the file browser's loaded directories (`fileBrowser.dirs`, absolute path = `${dir.path}/${node.path}`, vault dirs excluded) so every document is listed, not only the ones already visited. The agent learns that the human navigated from `document.path` on its next response, not from a sibling flag: siblings exclude the open path, and after a sidebar click `fileBrowser.activeFile` equals `linkedDoc.filepath`, so a sibling with `open: true` (and with it `openedSinceLastRead` and the "opened" branch of `other_document_active`) exists only transiently, during the load window between the click and the document commit. `reveal { path }` answers `not_found` at once for a path that is neither in the folder tree nor in the linked-doc cache; otherwise it navigates (folder sessions through App's file-browser selection handler, so the active file, the doc URL and the linked document stay in step) and WAITS for the commit that makes that path the open document (an effect settles the waiter, and the linked-doc `error` state settles it early when the load fails, so a bad path never runs out the 5s timeout) before looking the comment or section up; reading state right after the `await` would see the pre-navigation document. After a tool mutation the adapter overlays the pending write on the last committed annotation list (`applyOverlay`) so the response's nudges and the new comment's `seq` reflect the mutation even though `setAnnotations` has not committed yet; the agent's own removals are claimed (`claimRemoved`) so they are never reported back to it as `annotations_removed`. Still not reachable in phase 1: `composerOpen` for a sibling (the composer is detected from the DOM of the open document only, so `other_document_active` never fires for a composer in another document), and writes to a sibling that is not open (see below).

**`inReplyTo`.** One additive field on `Annotation`: a reply inherits its parent's anchor, renders indented under it in the annotations panel (`threadReplies` in `AnnotationPanel.tsx`), and exports nested under the parent's entry (`**Replies:**` block in `exportAnnotations`); an annotation without it renders and exports byte-identically to before. Drafts carry it (annotations are opaque JSON to the draft transport); share links deliberately do not (a reply shares as a plain comment on the same quote, the existing text-restore contract, pinned by `sharing.inReplyTo.test.ts`). Known limitation: comments on a sibling document that is not open answer `not_available` with a hint to `reveal { path }` first, because the linked-doc cache is a copy.
**`inReplyTo`.** One additive field on `Annotation`: a reply inherits its parent's anchor, renders indented under it in the annotations panel (`threadReplies` in `AnnotationPanel.tsx`), and exports nested under the parent's entry (`**Replies:**` block in `exportAnnotations`); an annotation without it renders and exports byte-identically to before. The threading rule is shared (`resolveReplyParents` in `packages/core/annotation-threads.ts`): an annotation is a reply only when its target is a different annotation in the same list and the parent chain never returns to it; orphans, self-references, and every member of a cycle render and export as roots in original order, so nothing is ever dropped and the export's header count equals what is emitted. `PATCH /api/external-annotations` refuses an `inReplyTo` that is self, missing, or would close a cycle (`validateReplyTarget`, both runtimes, `400`). Drafts carry it (annotations are opaque JSON to the draft transport); share links deliberately do not (a reply shares as a plain comment on the same quote, the existing text-restore contract, pinned by `sharing.inReplyTo.test.ts`). Known limitation: comments on a sibling document that is not open answer `not_available` with a hint to `reveal { path }` first, because the linked-doc cache is a copy.

Docs: `apps/marketing/src/content/docs/reference/webmcp-tools.md` (the user-facing reference) and the manual five-flow checklist in `tests/UI-TESTING.md`.

Expand Down
4 changes: 4 additions & 0 deletions apps/marketing/src/content/docs/commands/annotate.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ Markdown conversion uses [Turndown](https://github.com/mixmark-io/turndown) with

HTML files must be within your current working directory. Files outside the project root return a 403 error.

### Refresh from disk

When an agent edits the HTML file while you are reviewing it, click **Refresh** in the header (next to the eye button) to re-read the file without reloading the tab. Annotations whose elements or text still exist on the new page stay where they were; any that no longer match are listed in a notice and marked **Unanchored** in the annotations panel, where their comments remain and still send. The "Show changes" toggle keeps working after a refresh, comparing the file as it is now against its previous saved version. On a phone or tablet the action is in the Options menu as **Refresh from disk**. Refresh is available for local HTML files only, not for URLs or live app sessions.

### `--markdown`

For local HTML files, `--markdown` switches from raw HTML rendering to markdown conversion. In folder mode, the same setting applies when you open `.html` or `.htm` files from the file browser.
Expand Down
2 changes: 1 addition & 1 deletion apps/marketing/src/content/docs/reference/webmcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ All tools are registered under the `plannotator.` prefix.
| `nudge_user` | Show you one short, transient message in the page (280 characters), for example "Finished: two comments, nothing blocking, ready for your approval." Not saved, not part of the feedback, dismissible. |
| `list_documents` | Folder sessions only: the document tree with per-document comment counts and what changed since the agent last read each one. |

Every response carries `nudges`: short machine-readable notices computed from state the page already holds, such as `annotations_new` (you added or edited comments since the last read), `replies_new`, `annotations_removed` (you deleted one of the agent's comments, which the agent should treat as resolved), `composer_open` (you are typing right now), `source_stale`, `document_edited`, `page_changed`, `other_document_active`, `truncated`, `pending_unsent`, and `session_decided`.
Every response carries `nudges`: short machine-readable notices computed from state the page already holds, such as `annotations_new` (you added or edited comments since the last read), `replies_new`, `annotations_removed` (you deleted one of the agent's comments, which the agent should treat as resolved), `composer_open` (you are typing right now), `source_stale`, `document_edited`, `comment_only_surface` (an HTML or live app page, where comments anchor on a text quote or the whole document and nothing can be marked for deletion), `page_changed`, `other_document_active`, `truncated`, `pending_unsent`, and `session_decided`.

Comments the agent creates appear in the annotations panel like any other external-tool comment, labeled `browser-agent`. A reply threads under the comment it answers and is exported nested under it, so the coding agent reads the exchange in order.

Expand Down
71 changes: 71 additions & 0 deletions apps/pi-extension/server/external-annotations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* External annotations (Pi/Node): PATCH ingest of `inReplyTo`.
*
* Node mirror of the PATCH describe in packages/server/external-annotations.test.ts:
* PATCH merges arbitrary fields, so it was the one way to create an inReplyTo
* self-reference or cycle; the invalid state is refused at ingest on both
* runtimes.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createServer, type Server } from "node:http";
import { createExternalAnnotationHandler } from "./external-annotations.ts";
import { requestUrl } from "./helpers.ts";

describe("pi external annotations: PATCH inReplyTo", () => {
const handler = createExternalAnnotationHandler("plan");
let server: Server;
let base = "";

beforeAll(async () => {
server = createServer(async (req, res) => {
const handled = await handler.handle(req, res, requestUrl(req));
if (!handled) {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("no port");
base = `http://127.0.0.1:${address.port}`;
});

afterAll(() => {
server.close();
});

const patch = async (id: string, body: unknown) => {
const res = await fetch(`${base}/api/external-annotations?id=${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as { error?: string; annotation?: { inReplyTo?: string } } };
};

test("refuses an inReplyTo that is self, missing, or would close a cycle; accepts a valid reply", async () => {
const added = handler.addAnnotations({
annotations: [
{ source: "tool", text: "first" },
{ source: "tool", text: "second" },
],
});
if ("error" in added) throw new Error(added.error);
const [first, second] = added.ids;

expect((await patch(first, { inReplyTo: first })).status).toBe(400);
expect((await patch(first, { inReplyTo: "nope" })).status).toBe(400);
expect((await patch(first, { inReplyTo: 7 })).status).toBe(400);

const ok = await patch(second, { inReplyTo: first });
expect(ok.status).toBe(200);
expect(ok.body.annotation?.inReplyTo).toBe(first);

const cycle = await patch(first, { inReplyTo: second });
expect(cycle.status).toBe(400);
expect(cycle.body.error).toContain("cycle");

expect((await patch(second, { inReplyTo: null })).status).toBe(200);
expect((await patch(second, { text: "still fine" })).status).toBe(200);
});
});
Loading