mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
121082430e
* fix(opencode): consolidate V2 system parts into one composed prompt (#1114) The OpenCode 2 adapter still shipped the pre-#1114 multi-part system injection: replacePlanningSystemParts kept one part per source and the generic reminder pushed a separate part, so Qwen3.x Jinja template corruption persisted for OpenCode 2 users. Mirror the V1 entry exactly: compose the stripped existing text plus additions into a single system part via composeSystemPrompt, and compose the generic reminder into the existing text instead of appending a second part. Also adds the regression tests for the bug class flagged in #1114's review: both helpers must read/compose the existing system text BEFORE truncating the array (a reorder to 'system.length = 0' first drops the host prompt and goes red here). * perf(annotate): harden the raw-HTML overlay reconcile (dead-target backoff, cull, batching) Bridge-script hardening for mutation-heavy pages and large annotation sets, plus the lost click-to-select hover affordance: - A: dead-target re-search now carries a wall-clock backoff (300ms doubling to a 5s cap, reset on success) ON TOP of the generation gate, plus a 2-searches-per-reconcile-pass budget with a scheduled follow-up pass for budget-skipped eligible targets. A page that mutates every frame advances domGeneration every frame, so the generation gate alone re-ran the whole-document TreeWalker sweep (and anchor re-resolution) per frame forever for permanently unresolvable targets. - B1: early viewport cull (64px margin) for element and range targets: wholly offscreen targets skip targetStyleHidden / getComputedStyle / clipBoundsFor / client-rect collection entirely and just omit their markers, which is what the visible pipeline produced anyway. - B2: read/write batching in renderAnnotationOverlay: highlight rects are queued during the read phase and flushed as one write phase, so the pass no longer forces a synchronous layout per record. - B3: restoreAnnotation defers its render through the existing rAF-coalesced reconcile scheduler; restoring N annotations now renders once instead of N full passes (searches stay synchronous for the mark-applied reply). DOM tests flush the frame via the suite's standard macrotask flush. - B4: zero-work observer gate: page mutations with no records, no pending draft, and pinpoint inactive still bump domGeneration but no longer schedule a reconcile frame. - D: hover affordance for click-to-select: the rAF-throttled mousemove hit-tests the pointer against the CACHED rendered committed rects and toggles a brightness class on that annotation's rect divs inside the shadow root. No page-DOM writes, rects stay pointer-transparent, and shadow-root writes are unobserved so there is no reconcile loop. - G: while a text drag is in progress in drag mode, placed markers yield pointer input (data-pn-hittest) so the 25px bubble cannot capture a selection drag; armed only by a >4px primary-button move from a non-overlay mousedown, so marker clicks and click-to-select paths are untouched. withMarkersYielded now restores (not clears) the attribute. New regression tests for A, B1, B3, B4, D; A/B1/B3 mutation-verified (fix reverted, test observed failing, fix restored). * fix(annotate): make on-page marker numbers match exportAnnotations numbering The HtmlViewer sync excluded GLOBAL_COMMENT annotations before numbering while exportAnnotations numbers '## N.' sections across the FULL list including globals — so an on-page 'Comment 2' could be '## 3.' in the feedback the agent reads. The sync now derives each marker's number from its position in the full createdA-sorted list (globals occupy a number but ship no entry, leaving the correct gaps on-page). Export format is unchanged. New buildSyncNumbering helper + tests asserting a mixed list yields identical numbers between the sync payload and exportAnnotations output (mutation-verified against the pre-fix ordering). * chore: sync stale workspace versions in bun.lock (0.26.1 -> 0.26.7) * docs: document raw-HTML overlay model, multi-target types, and known limitations - Data Types: add htmlAdditionalTargets to the Annotation listing plus the HtmlElementAnchor (including the optional normalized point used by placed markers) and HtmlAnnotationTarget shapes. - Annotation System: describe the post-#1257 raw-HTML surface (placed comment markers + overlay-projected highlights, no inline mark mutation; durable anchors persisted, disposable markers projected) and the print-parity limitation. - URL Sharing: note that share links intentionally drop HTML element anchors and additional targets (restore is text-search based, per sharing.multiTarget.test.ts). * test: fix Range.getClientRects stub typing in the B1 cull test * fix(annotate): hover-race teardown and unbounded one-shot dead-search passes Polish round on the overlay hardening: - Hover race (1): switching into pinpoint mode (or opening a draft) now tears hover down fully via clearHoverHighlight() — cancels the pending rAF hit test and clears the tracked position and id — and the rAF callback itself refuses to paint outside drag mode / with an open draft. Previously the pending callback re-applied the class after the mode switch and every flushQueuedHighlights re-painted it from the stale hoverHighlightId, leaving a permanent phantom hover. - One-shot budgets (3): beginDeadSearchPass takes a per-pass budget. Reconcile passes keep 2 (they repeat, skipped targets get follow-up frames); print and scroll-to are user-initiated one-shots with no follow-up and now run unbounded (backoff and generation gates still apply), so printing with 3+ dead-but-recoverable targets no longer silently prints fewer highlights. Both changes carry new regression tests, mutation-verified (fix reverted, test observed failing, fix restored). * fix(annotate): number markers by array position and cap entries after dropping globals The createdA sort made the export-match invariant false with external annotations: exportAnnotations' sort keys tie for every raw-HTML annotation (blockId '', startOffset 0), so its stable sort numbers the combined [...local, ...external] list in ARRAY order — and external annotations arrive appended with server-stamped createdA values that can interleave with local timestamps. buildSyncNumbering now numbers by array position of the input (verified to be the same combined list both consumers receive from packages/editor/App.tsx allAnnotations; the viewerAnnotations diffContext filter is order-preserving and vacuous on the raw-HTML surface). Also reorders the cap: number the full list, drop globals, THEN slice 512 entries — globals no longer waste sync capacity and a non-global the export numbers past position 512 still syncs while slots remain. Numbers may now exceed 512 (array positions); the bridge's own bound (100000) accepts them and its 512-entry cap still agrees with the sender. Tests updated: interleaved-external agreement with exportAnnotations (mutation-verified against the createdA sort) and slice-after-filter capacity. * docs(opencode): note the accepted cache-hint flattening trade-off in V2 consolidation
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
/**
|
|
* On-page marker numbers must agree with the numbers the agent reads:
|
|
* exportAnnotations (packages/ui/utils/parser.ts) numbers `## N.` sections
|
|
* across the FULL annotation list including global comments, so the sync
|
|
* payload must derive each marker's number from that same ordering. A sync
|
|
* that excludes globals BEFORE numbering makes on-page "Comment 2" read
|
|
* `## 3.` in the feedback — the exact confusion this suite guards against.
|
|
*/
|
|
import { describe, expect, test } from "bun:test";
|
|
import type { Annotation } from "../../types";
|
|
import { AnnotationType } from "../../types";
|
|
import { exportAnnotations } from "../../utils/parser";
|
|
import { MAX_SYNC_ANNOTATIONS, buildSyncNumbering } from "./annotationNumbering";
|
|
|
|
function htmlComment(id: string, createdA: number, originalText: string): Annotation {
|
|
return {
|
|
id,
|
|
blockId: "",
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: AnnotationType.COMMENT,
|
|
text: `note about ${originalText}`,
|
|
originalText,
|
|
createdA,
|
|
} as Annotation;
|
|
}
|
|
|
|
function globalComment(id: string, createdA: number, text: string): Annotation {
|
|
return {
|
|
id,
|
|
blockId: "",
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: AnnotationType.GLOBAL_COMMENT,
|
|
text,
|
|
originalText: "",
|
|
createdA,
|
|
} as Annotation;
|
|
}
|
|
|
|
/** The `## N.` number of the export section containing `needle`. */
|
|
function exportNumberOf(output: string, needle: string): number {
|
|
const sections = output.split(/^## /m).slice(1);
|
|
const section = sections.find((s) => s.includes(needle));
|
|
if (!section) throw new Error(`no export section contains: ${needle}`);
|
|
return Number.parseInt(section, 10);
|
|
}
|
|
|
|
describe("buildSyncNumbering", () => {
|
|
test("a mixed list yields on-page numbers identical to exportAnnotations output", () => {
|
|
const annotations = [
|
|
htmlComment("ann-a", 100, "alpha passage"),
|
|
globalComment("glob", 200, "overall global note"),
|
|
htmlComment("ann-b", 300, "beta passage"),
|
|
];
|
|
|
|
const payload = buildSyncNumbering(annotations);
|
|
// Globals occupy a number but ship no entry (no page location): the
|
|
// on-page markers show 1 and 3, leaving the gap where the global sits.
|
|
expect(payload).toEqual([
|
|
{ id: "ann-a", number: 1 },
|
|
{ id: "ann-b", number: 3 },
|
|
]);
|
|
|
|
const output = exportAnnotations([], annotations, [], "Plan Feedback", "plan");
|
|
expect(exportNumberOf(output, "alpha passage")).toBe(1);
|
|
expect(exportNumberOf(output, "overall global note")).toBe(2);
|
|
expect(exportNumberOf(output, "beta passage")).toBe(3);
|
|
|
|
// The payload numbers ARE the export numbers, entry by entry.
|
|
const needleById: Record<string, string> = {
|
|
"ann-a": "alpha passage",
|
|
"ann-b": "beta passage",
|
|
};
|
|
for (const entry of payload) {
|
|
expect(exportNumberOf(output, needleById[entry.id]!)).toBe(entry.number);
|
|
}
|
|
});
|
|
|
|
test("interleaved external annotations: numbers follow ARRAY order and agree with the export", () => {
|
|
// External annotations arrive with server-stamped createdA values that
|
|
// can interleave with local timestamps, but they are APPENDED to the
|
|
// combined list — and exportAnnotations' sort keys tie for every
|
|
// raw-HTML annotation (blockId "", startOffset 0), so the export numbers
|
|
// the ARRAY order. A createdA sort here would renumber the external
|
|
// annotation 2 while the export calls it `## 3.`.
|
|
const external = {
|
|
...htmlComment("ext", 200, "external passage"),
|
|
source: "eslint",
|
|
} as Annotation;
|
|
const annotations = [
|
|
htmlComment("loc-a", 100, "alpha passage"),
|
|
htmlComment("loc-b", 300, "beta passage"),
|
|
external, // appended after loc-b despite the earlier createdA
|
|
];
|
|
|
|
const payload = buildSyncNumbering(annotations);
|
|
expect(payload).toEqual([
|
|
{ id: "loc-a", number: 1 },
|
|
{ id: "loc-b", number: 2 },
|
|
{ id: "ext", number: 3 },
|
|
]);
|
|
|
|
const output = exportAnnotations([], annotations, [], "Plan Feedback", "plan");
|
|
expect(exportNumberOf(output, "alpha passage")).toBe(1);
|
|
expect(exportNumberOf(output, "beta passage")).toBe(2);
|
|
expect(exportNumberOf(output, "external passage")).toBe(3);
|
|
const needleById: Record<string, string> = {
|
|
"loc-a": "alpha passage",
|
|
"loc-b": "beta passage",
|
|
ext: "external passage",
|
|
};
|
|
for (const entry of payload) {
|
|
expect(exportNumberOf(output, needleById[entry.id]!)).toBe(entry.number);
|
|
}
|
|
});
|
|
|
|
test("the entry cap applies AFTER dropping globals, so globals never waste sync capacity", () => {
|
|
// One global up front plus MAX + 1 non-globals: the global occupies
|
|
// number 1 but ships no entry, and the cap keeps a full 512 non-globals
|
|
// — including the one the export numbers at position 513. Slicing before
|
|
// the filter would ship only 511 non-globals and strand that entry.
|
|
const annotations: Annotation[] = [
|
|
globalComment("glob", 0, "leading global"),
|
|
...Array.from({ length: MAX_SYNC_ANNOTATIONS + 1 }, (_, i) =>
|
|
htmlComment(`bulk-${i}`, i + 1, `passage ${i}`),
|
|
),
|
|
];
|
|
const payload = buildSyncNumbering(annotations);
|
|
expect(payload.length).toBe(MAX_SYNC_ANNOTATIONS);
|
|
expect(payload[0]).toEqual({ id: "bulk-0", number: 2 });
|
|
expect(payload[MAX_SYNC_ANNOTATIONS - 1]).toEqual({
|
|
id: `bulk-${MAX_SYNC_ANNOTATIONS - 1}`,
|
|
number: MAX_SYNC_ANNOTATIONS + 1,
|
|
});
|
|
});
|
|
});
|