mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
d6b98f0c0d
* docs(adr): Guided Review ADR/spec/research + agent-provider fit studies
ADR 006 (Guided Review as a first-class feature), four implementation
spikes, synthesis, spec (iterated through preflight and review fixes),
recap, and the Flue/Pi provider-fit research syntheses.
* feat(review): Guided Review takeover + Pi agent-job provider
Guided Review (ADR 006) — a Linear-Guides-style chaptered review:
- guide agent-job provider (packages/server/guide/guide-review.ts):
schema-constrained sections (title/overview/file refs) over the live
patch, coverage-validated server-side (every changed file exactly once,
fabricated paths dropped, fail-closed on empty output), importance-first
ordering prompt with speed discipline (diff-first, no repo exploration)
- runs on claude/codex natively and cursor/opencode/pi via the marker
contract; guide-scoped low effort defaults for quicker generation
- takeover UI (packages/review-editor/components/guide/): Guide header
badge + Mod+Shift+G, full-width screen that CSS-hides (never unmounts)
the file tree/dock, empty state with inline model pickers, skeleton
loading, generating state with collapsed activity log, two-column
section cards (sticky prose column with internally-scrolling file list,
content-height diffs, review-independent collapse, peek semantics)
- annotation parity: guide diffs mount the real DiffViewer with
file-scoped handlers into the same CodeAnnotation state and feedback
export; per-section reviewed state persisted via /api/guide/:jobId
- reviewed/tour prompt speed sections; renderMarkdownProse shared between
tour and guide (fenced-block support, muted tone variant)
Pi agent-job provider:
- third MarkerEngine (pi --mode json --no-session --no-approve) with
live model-catalog discovery, thinking-level control (--thinking),
fail-closed marker parsing (Pi exits 0 on in-run errors by design)
- available for review + guide jobs from both launch surfaces
Shared UX:
- searchable, provider-grouped model pickers (auto over 12 options)
- full Claude version catalog with latest-resolving aliases labeled
- AgentControls primitives extracted from AgentsTab; cross-instance
settings sync in useAgentSettings
- Pi extension server hand-mirrors + vendor.sh entries throughout
* fix(guide): PR-993 review fixes + failure recovery ladder
Review fixes:
- coerce marker-engine title/intent at ingest (prompt-enforced output
could store non-strings and crash the takeover)
- keep placed files out of unplacedFiles (exactly-once guarantee)
- shared REVIEW_ENGINE_LABEL (fixes 'generated by pi' header; typed
exhaustiveness prevents recurrence)
- key ActiveGuide by jobId so per-guide focus state resets
- formatModel handles marker-engine tour/guide jobs (empty model chip)
- drop dead guideCodexFast; memoize estimateDiffHeight
- store + display Pi thinking level on job cards
Failure recovery ladder (auto -> one click -> manual -> regenerate):
- mechanical JSON repair pass (fences/slice/trailing-commas/bracket
balance) before any guide parse fail-closes
- failed payloads captured per job (200KB cap, per-engine extraction)
- 'Fix output' repair job: pure text transform on a schema-capable
engine (claude/codex preferred), forced low effort, lands as a
normal guide job; repairOf threaded through launch validation
- editable output panel: GET /api/guide/:jobId/output prefills a
textarea, POST /api/guide/:jobId/submit runs the same server-side
validation and opens the fixed guide; inline errors for iteration
- Pi extension mirrors + AGENTS.md endpoint/body docs
* fix(guide): string-aware mechanical JSON repair (self-review)
- terminate a dangling string literal before appending bracket closers
(truncation mid-string is the most common shape; closers appended
inside the string never parsed)
- strip trailing commas outside string literals only (the regex could
rewrite overview content, violating the repair contract)
* fix(guide): launch-snapshot validation, codex strict schema, repair persistence
PR-993 round-2 review fixes + live-test findings:
- validate guide completion (and manual repair) against the LAUNCH-time
changed-file set, snapshotted per job — switching diff/base/PR while a
guide generates no longer destroys valid output (client already
degrades stale refs per-file)
- codex strict structured output: unplacedFiles now required in the
guide schema (OpenAI 400s schemas with optional properties under
additionalProperties:false — live-confirmed, then live-verified fixed:
5 sections / 83 files placed on a ChatGPT-account codex)
- manual repair survives reload: successful /submit flips the job to
done via completeJobExternally + status guard on the endpoint
- codex model defaults -> gpt-5.5 (5.3-codex is deprecated; ChatGPT
accounts reject it) with one-shot migration of saved picks; guide
codex default reasoning stays low
- pi extension guide branch snapshots launch state (TOCTOU hygiene)
- paragraph collector no longer swallows an unspaced code fence
- tour hook backports: out-of-order fetch guard; save outside updater
- guide empty-state copy rewritten value-first
* fix(guide): PR-993 round-3 — repair-diff fidelity, failure surfacing, pi tool hardening
- repair jobs validate against the FAILED job's recorded file set (the
payload under repair references that changeset; validating against the
diff on screen at repair time re-introduced destroy-on-switch)
- takeover follows the NEWEST running guide (progress + Cancel), and a
failure newer than the shown guide surfaces as a dismissible strip
(error + Fix output + Details) instead of being silently masked
- pi jobs run with --exclude-tools edit,write (bash stays: the agent
needs git inspection; full read-only would break generation)
- tour/guide result ingestion fails closed on unexpected throw (done-
looking jobs no longer 404 their result)
- sidebar guide mode gated on file availability like the header badge
- drop orphaned DEFAULT_GUIDE_CODEX_FAST (fast mode intentionally not
offered for guide); retry owns its fetch cancellation; final
trailing-comma pass after bracket-closing in the repair ladder;
collapsed-row checkbox/expand are sibling buttons (a11y)
* fix(guide): PR-993 round-4 — callout text, guide job detail, toggle races
- single-line callouts (> [!IMPORTANT] message) keep their message — the
form our own prompt solicits rendered an empty labeled box; type now
derives from the [!TAG] capture, not a whole-line scan that mistyped
'> [!NOTE] this is important'
- job detail panel gets a guide status card (Open guide via a new
ReviewStateContext.openGuide) instead of mislabeling 'Guide Generated'
as a red Incorrect review
- marker-engine guide jobs get readable live logs (engine fallback in
the formatter lookup; provider stays 'guide')
- repair prefers the failed job's own schema-capable engine (proven
working on this machine) before falling back by binary presence
- toggleReviewed/toggleChecked: pure functional updaters + persistence
effect with seed-skip — race-proof AND StrictMode-safe (resolves the
tradeoff previous rounds accepted)
- dedups: formatDuration/ElapsedTime shared from AgentsTab; whichCmd
exported in the pi extension; SEARCHABLE_THRESHOLD imported by
InlinePicker
- new renderMarkdownProse tests (single-line/multi-line callouts,
paragraph passthrough)
* fix(guide): PR-993 round-5 — keep engine-default model option, label Pi jobs
- GuideEmptyState's marker catalogs mirror AgentsTab's per-engine
semantics: opencode/pi prepend the engine-managed Default ('' value)
to the discovered list (dropping it left saved-default users a blank
pill with no way back); cursor replaces (its list includes 'auto')
- job detail provider pill labels pi review jobs 'Pi' instead of the
'Shell' fallback
* fix(guide): PR-993 round-6 — context scoping, stale models, repair engine, staging gate
- guide takeover + auto-open scoped to the current review context: a
guide launched against PR A no longer shows over PR B, and a guide
finishing for an away context defers its auto-open until the reviewer
returns to that context (unmarked in the dedupe set on purpose)
- guide launcher reconciles saved cursor/opencode/pi model ids against
the live catalog at read time (picker + launch share one effective
value) — no more posting dead ids after an account switch
- repair prefers the failed job's OWN engine whenever its binary is
present (provably runnable; marker binaries resolved via
MARKER_ENGINES — cursor's CLI is 'agent'); claude/codex are fallback
only, killing the broken-claude repair doom loop
- per-file staging gate (canStagePath) in guide diffs AND the
pre-existing same gap in ReviewDiffPanel — committed-only files in
since-base reviews no longer offer a no-op Git Add
* fix(review): scope tour auto-open to current context (self-review)
Same cross-context gap just fixed for guides: a tour finishing for PR A
popped its dialog over PR B. Both auto-open effects now share one
jobMatchesCurrentContext helper with the same deferred-open semantics.
* fix(guide): PR-993 round-7 — complete the context-scoping story
Round 6 scoped guides to their review context; this closes the two
surfaces that scoping left dangling, plus a focus gap:
- switching to a context that already HAS a completed guide now shows
that guide: the takeover falls back to the context's newest done
guide job when activeGuideJobId belongs elsewhere (previously landed
on the empty state with the guide sitting unreachable)
- 'Open guide' affordances are context-gated everywhere they exist:
job cards hide it for cross-context guides (opening can't switch
PRs), and the job detail panel explains where the guide belongs
instead of offering a dead button; one shared
jobMatchesReviewContext predicate now backs App, GuideScreen,
AgentsTab, and the detail panel
- file-chip navigation retargets the guide's focus arbiter (scrolling
under a stationary pointer fires no pointerenter, so the annotation
toolbar stayed bound to the previous diff)
* fix(tour): retry owns its fetch cancellation; document prUrl-only scoping
- useTourData retry converts to the nonce-driven effect re-run pattern
(the round-4 backport carried the fetch guard but not the retry fix
useGuideData got in the same batch — half a backport)
- jobMatchesReviewContext now documents WHY it matches by prUrl only
and not diffScope/diffContext: guides/tours reference files and
degrade per-file when the diff shifts; scope-strict matching would
hide useful artifacts on layer/base/mode switches. Deliberate,
do-not-tighten-without-UX-decision
* fix(guide): PR-993 round-9 — worktree-aware context, guide-scoped marker settings
- jobMatchesReviewContext now compares diffContext.worktreePath for
local jobs: prUrl + worktree define WHERE the review is (matched
strictly); mode/base/scope remain WHAT VIEW (deliberately loose) —
a guide launched against worktree A no longer opens over worktree B.
Jobs predating the snapshot coalesce to the main tree.
- Cursor/OpenCode/Pi get guide-scoped model/thinking settings
(guideCursor/guideOpencode/guidePi), mirroring the existing
guideClaude/guideCodex isolation: tuning a guide's marker model no
longer silently changes the next code review with that engine.
Defaults are each engine's natural default, not seeded from review
settings (same precedent as guideClaudeEffort='low'). AgentsTab's
reconcile effects snap both surfaces against the shared catalogs.
* fix(review): keep worktree parsing browser-safe (round-9 build fix)
parseWorktreeDiffType lives in shared/review-core, which imports
node:path at module top — pulling it into App.tsx broke the vite
browser build (typecheck and tests don't catch it; only the bundle
does). Context matching now reuses App's existing hand-parsed
activeWorktreePath memo — one parse, and it's the same one that
drives the sections/tree UI, so matching aligns with what's on
screen. Also fixes a deps-array reference to the removed memo that
bundled fine (treated as a global) but would have thrown a
ReferenceError at runtime.
* docs(review): mark worktree-subtype lockstep between server parser and App copy
* fix(review): PR-993 round-10 — guide shortcut gate, settings sync race, heading markdown
- bare a/v staging/viewed shortcuts suspend while the guide takeover is
open (the dock is only CSS-hidden, so a diff panel stayed 'active'
underneath and bare keys acted on an invisible file)
- cross-instance settings sync replaces the was-last-update-remote
boolean with value comparison (lastSyncedJsonRef): the flag conflated
'a commit happened' with 'the last change was remote', so a local
edit batched with an incoming broadcast silently skipped its own
cookie write and rebroadcast
- ### headings in guide/tour prose run through renderInlineMarkdown
like every other block (backticked symbols rendered as raw tokens);
regression test added
* fix(guide): PR-993 round-11 — reveal channel for sidebar jumps, repair exit code
- new guideRevealFile channel closes the documented round-7 gap and its
AI sibling: sidebar jumps (annotation clicks, AI line citations) made
while the guide takeover is open no longer mutate the hidden dock's
active file; instead the GuideSectionCard containing the target file
expands its collapsed (reviewed) section, focuses the diff, and
scrolls to it — so jumps into collapsed sections stop silently
no-opping. Cleared on guide close/switch so keyed remounts don't
replay the last reveal.
- completeJobExternally resets exitCode to 0 (both servers): a
successfully repaired guide kept showing the failed run's Exit 1 chip
in the job detail panel
* fix(guide): clear reveal channel in-batch at guide-switch sites (self-review)
The clear-on-change effect fires after a switched guide's keyed cards
have already mounted (child effects run before parent effects), leaving
one commit where a stale reveal from guide A could expand+scroll a
same-named file's section in guide B. All three setActiveGuideJobId
sites now clear synchronously in the same batch; the effect stays as a
backstop for close and future set sites.
* fix(guide): PR-993 round-12 — titled sections, honest large-PR validation
- sanitizeGuideSection gives every surviving section a non-empty title
('Untitled section' fallback): a diffs-only section rendered as a
blank chapter inside a 'Guide Generated' job — no parse failure, so
no recovery flow. Keeping (titled) beats dropping: placed files are
not in unplacedFiles, so dropping would orphan them silently.
- guide launches on large PRs (layerPatchIncomplete) recompute
changedFiles from the local checkout (git diff --numstat
origin/<base>...HEAD, rename/binary handling) when available — the
PR-mode prompt tells the agent to read that full local diff, but the
changed-files block and validation snapshot came from the truncated
platform patch, under-listing files to the model and then dropping
its valid refs (or failing the guide) at validation. Falls back to
the partial list on any failure. Mirrored in the Pi extension server.
Round-12's P1 (Pi marker extraction reads message_end, 'should' read
text_delta) was refuted by live probe: Pi's assistant message_end
carries the fully-assembled content blocks, which is exactly what
piExtractText reads; the delta-reading code the finding cross-
referenced is the Ask-AI STREAMING provider, a different job.
* test(guide): first direct coverage for guide-review's pure logic (self-review)
The module's repair ladder, validation, and stream parsing had zero
unit tests — exercised only end-to-end through live agent runs. Pins
the behaviors the review rounds fixed: blank-title fallback (round 12),
first-placement-wins dedup, changedFiles filtering + fail-closed empty
guides, prose-only vs lost-diffs section handling, unplacedFiles
merge/dedup/fabrication-filtering, title/intent coercion, trailing-
comma and unbalanced-bracket repair, truncated stream-line recovery.
13 tests; server suite 374 → 387.
* fix(guide): PR-993 round-13 — full-stack recompute regression, flag snapshot, unplaced reveal
- the round-12 large-PR recompute now runs ONLY in layer scope: in
full-stack scope launchPatch is already a local full recompute, and
the layer diff (origin/<base>...HEAD) was the WRONG file set — it
omits earlier stack layers' files, dropping their refs at validation
or failing the guide. Introduced by round 12; caught before any
release. Both servers.
- layerPatchIncomplete is snapshotted with the other launch locals
(launchLayerPatchIncomplete): coherence with launchPatch was
positional (same sync segment) — now structural, so a future await
inserted upstream can't silently desync them. Both servers.
- the 'Everything else' bucket handles guideRevealFile: sidebar jumps
to unplaced files now focus + scroll their diff (the section-card
effect only covers placed files) — closes the residual gap noted in
the round-11 self-review
166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { DEMO_GUIDE, DEMO_GUIDE_ID } from '../../demoGuide';
|
|
import type { CodeGuideData } from '@plannotator/shared/guide';
|
|
|
|
export type { GuideDiffRef, GuideSection, CodeGuideOutput, CodeGuideData } from '@plannotator/shared/guide';
|
|
|
|
export interface UseGuideDataReturn {
|
|
guide: CodeGuideData | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
reviewed: boolean[];
|
|
toggleReviewed: (index: number) => void;
|
|
retry: () => void;
|
|
}
|
|
|
|
/** Pad/truncate a persisted reviewed array to the current section count — a
|
|
* regenerated guide (new jobId) starts fresh, but a stale array shorter or
|
|
* longer than `sections.length` (server restart, schema drift) shouldn't crash. */
|
|
function normalizeReviewed(reviewed: boolean[] | undefined, sectionCount: number): boolean[] {
|
|
const next = new Array(sectionCount).fill(false);
|
|
if (!reviewed) return next;
|
|
for (let i = 0; i < sectionCount; i++) next[i] = !!reviewed[i];
|
|
return next;
|
|
}
|
|
|
|
export function useGuideData(jobId: string): UseGuideDataReturn {
|
|
const [guide, setGuide] = useState<CodeGuideData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [reviewed, setReviewed] = useState<boolean[]>([]);
|
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingReviewedRef = useRef<boolean[] | null>(null);
|
|
// Gates the persistence effect below: true = the next `reviewed` change is
|
|
// a SEED (fetch resolved / demo short-circuit), not a user toggle, so skip
|
|
// the PUT. Starts true (initial empty array) and is re-armed at every seed
|
|
// site (see fetchGuide) — it's only ever cleared inside toggleReviewed
|
|
// itself (the real user action), never by the persistence effect. That
|
|
// makes it immune to React StrictMode's dev-only mount→cleanup→remount
|
|
// replay of effects: however many times the persistence effect happens to
|
|
// run for the same seeded state, the flag still reads whatever the last
|
|
// real call site (seed or toggle) wrote, so it can't be "consumed" into a
|
|
// false negative by an extra replay.
|
|
const skipNextSaveRef = useRef(true);
|
|
// Bumped by retry() to re-run the fetch effect without calling fetchGuide
|
|
// directly — a direct call would return a fresh cleanup closure that the
|
|
// caller (a button's onClick) has nowhere to store, so the in-flight
|
|
// request it just superseded would never get its `cancelled` flag set and
|
|
// could still clobber state after a later retry resolves first.
|
|
const [refreshNonce, setRefreshNonce] = useState(0);
|
|
|
|
const fetchGuide = useCallback((): (() => void) | void => {
|
|
if (!jobId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Dev short-circuit: render the demo guide without a backend.
|
|
if (jobId === DEMO_GUIDE_ID) {
|
|
skipNextSaveRef.current = true;
|
|
setGuide(DEMO_GUIDE);
|
|
setReviewed(normalizeReviewed(DEMO_GUIDE.reviewed, DEMO_GUIDE.sections.length));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Out-of-order guard: jobId can change (switching to a different completed
|
|
// guide) while a previous fetch is still in flight. Without this, a slow
|
|
// response for an OLDER jobId could resolve AFTER a newer jobId's fetch
|
|
// and clobber its state. The effect below tears this down via the
|
|
// returned cleanup whenever jobId changes (or on unmount).
|
|
let cancelled = false;
|
|
|
|
fetch(`/api/guide/${jobId}`)
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(res.status === 404 ? 'Guide not found' : `HTTP ${res.status}`);
|
|
return res.json();
|
|
})
|
|
.then((data: CodeGuideData) => {
|
|
if (cancelled) return;
|
|
setGuide(data);
|
|
skipNextSaveRef.current = true;
|
|
setReviewed(normalizeReviewed(data.reviewed, data.sections.length));
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [jobId]);
|
|
|
|
useEffect(() => {
|
|
return fetchGuide();
|
|
// refreshNonce has no bearing on WHAT is fetched (fetchGuide already
|
|
// captures jobId) — it exists purely to force this effect to re-run on
|
|
// retry(), so the cancellation guard below is owned by the same effect
|
|
// instance for every fetch, manual retries included.
|
|
}, [fetchGuide, refreshNonce]);
|
|
|
|
const saveReviewed = useCallback(
|
|
(next: boolean[]) => {
|
|
if (jobId === DEMO_GUIDE_ID) return;
|
|
pendingReviewedRef.current = next;
|
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = setTimeout(() => {
|
|
const payload = pendingReviewedRef.current;
|
|
pendingReviewedRef.current = null;
|
|
saveTimerRef.current = null;
|
|
if (!payload) return;
|
|
fetch(`/api/guide/${jobId}/reviewed`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reviewed: payload }),
|
|
}).catch(() => {});
|
|
}, 500);
|
|
},
|
|
[jobId],
|
|
);
|
|
|
|
// Pure functional updater — safe under React StrictMode's dev-only
|
|
// double-invoke of setState updaters (no side effects inside). Persistence
|
|
// is handled by the effect below instead of here.
|
|
const toggleReviewed = useCallback((index: number) => {
|
|
skipNextSaveRef.current = false;
|
|
setReviewed((prev) => {
|
|
const next = [...prev];
|
|
next[index] = !next[index];
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Persists whenever `reviewed` changes, except for the initial seed from
|
|
// fetch/demo (skipNextSaveRef — see its declaration above for why toggling
|
|
// it only from seed/toggle call sites, never from here, is StrictMode-safe).
|
|
// saveReviewed always receives the CURRENT `reviewed` (this effect's
|
|
// closure), so pendingReviewedRef.current — read by the unmount flush below
|
|
// — always reflects the latest change, not a stale one.
|
|
useEffect(() => {
|
|
if (skipNextSaveRef.current) return;
|
|
saveReviewed(reviewed);
|
|
}, [reviewed, saveReviewed]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
const payload = pendingReviewedRef.current;
|
|
pendingReviewedRef.current = null;
|
|
if (!payload || jobId === DEMO_GUIDE_ID) return;
|
|
// keepalive lets the request survive if this unmount is part of a tab close.
|
|
fetch(`/api/guide/${jobId}/reviewed`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reviewed: payload }),
|
|
keepalive: true,
|
|
}).catch(() => {});
|
|
};
|
|
}, [jobId]);
|
|
|
|
const retry = useCallback(() => setRefreshNonce((n) => n + 1), []);
|
|
|
|
return { guide, loading, error, reviewed, toggleReviewed, retry };
|
|
}
|