Skip to content

Keep plan review cards in document flow - #249

Open
LarryHu0217 wants to merge 11 commits into
johannesjo:mainfrom
LarryHu0217:codex/plan-review-comment-flow-214
Open

Keep plan review cards in document flow#249
LarryHu0217 wants to merge 11 commits into
johannesjo:mainfrom
LarryHu0217:codex/plan-review-comment-flow-214

Conversation

@LarryHu0217

@LarryHu0217 LarryHu0217 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • mount the pending input, review comments, and ask-code cards in flow slots after the selected plan block
  • preserve selection highlighting and sidebar scroll targeting while cleaning up slots on dismiss and unmount
  • anchor multi-block selections after their final block and handle list/table markup safely
  • start a fresh review session when the task, worktree, or plan content changes so stale slots and comments are not reused

Addresses the plan-review inline comment overlap item in #214.

Validation

  • npm test -- src/components/plan-review-flow.test.ts (3 passed)
  • npx vitest run src (65 files passed, 792 tests passed)
  • npm run check
  • npm run check:static
  • npm run build:frontend

@johannesjo johannesjo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two behavior regressions in the new flow-slot interaction, both reproduced in Chromium against the DOM structure introduced here:

  1. A multi-block selection that crosses an existing flow slot includes the card review/answer text in Selection.toString(), so subsequent review or Ask prompts can quote UI content as plan content.
  2. Highlight geometry is a one-time snapshot even though preceding slots now reflow, resize, and disappear, so the persisted highlight can drift away from the selected text.

The added tests only assert source strings and CSS, so they do not exercise either DOM behavior.

There is also a current integration blocker: main advanced to 1aabea4 and the PR now conflicts in PlanViewerDialog.tsx around the scroll-target guard (if (!target?.id) return). Please update the branch and preserve that guard while resolving the conflict.

Comment thread src/components/PlanViewerDialog.tsx
Comment thread src/components/PlanViewerDialog.tsx Outdated
@LarryHu0217
LarryHu0217 force-pushed the codex/plan-review-comment-flow-214 branch from 1409bbc to 65fb8d7 Compare August 4, 2026 14:09
@LarryHu0217

Copy link
Copy Markdown
Contributor Author

Addressed all requested changes in 65fb8d7.

  • Selection extraction now clones the selected ranges and removes [data-plan-review-flow-slot] descendants, so card UI text cannot enter Review or Ask prompts.
  • Highlights retain source ranges and recalculate through ResizeObserver; the previous pending slot is removed before new geometry is measured.
  • Added real DOM regression tests for a selection spanning an existing card and for highlight updates after reflow.
  • Rebased onto current main and preserved the if (!target?.id) return scroll guard.

Validation: full test suite 1,755 passed/23 skipped plus 11 client tests; type checks, static checks, and frontend build pass.

@LarryHu0217
LarryHu0217 requested a review from johannesjo August 4, 2026 14:30
@johannesjo

Copy link
Copy Markdown
Owner

Double-checked current head 65fb8d7 with two independent review passes. The two earlier behavior reports are substantively addressed: flow-slot UI text is excluded, and retained ranges plus ResizeObserver keep highlights aligned. CI is green and the PR is mergeable/clean.

I found one remaining Important issue and one Minor issue:

Important — preserve rendered selection text

src/lib/plan-selection.ts:104-107 reconstructs selectedText by walking selected text nodes and joining them with ''. That does not preserve browser-rendered selection semantics:

  • The custom Shiki and Mermaid renderers emit adjacent block elements without whitespace. In Chromium, selecting <pre><code>const x = 1;</code></pre><p>After step</p> produces "const x = 1;\nAfter step", while this implementation stores "const x = 1;After step".
  • Rendered Mermaid SVGs contain non-visible text nodes, including the injected <style> element. The tree walker includes that CSS and other hidden SVG content even though native Selection.toString() excludes it, so Review and Ask prompts can receive a large CSS/fallback payload.

This affects both Review and Ask because both consume selectedText. Please derive the prompt text using rendered/visible semantics while still excluding [data-plan-review-flow-slot], and add exact-equality regression tests for code/Mermaid followed by prose and for Mermaid SVG containing hidden style/defs text. The current test at src/lib/plan-selection.client.test.tsx:56-60 only uses containment checks and repeats the implementation's .join(''), so it cannot catch either failure.

Minor — blockquote styling leaks into review UI

For a selection inside a blockquote, the slot is inserted after the selected <p> but remains inside the <blockquote>. .plan-review-flow-slot at src/styles.css:2103 does not reset inherited presentation, so Review/Ask controls inherit font-style: italic from .plan-markdown-dialog blockquote at line 2159. Reset the slot/card font style or anchor blockquote controls outside the quote.

Verdict: Needs changes. I did not find another Critical or Important lifecycle, cleanup, scrolling, security, or performance issue in the five-file diff.

@johannesjo johannesjo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flow-slot text exclusion, reflow tracking, session cleanup, and blockquote reset now look addressed, and CI is green. One Important correctness issue remains.

Important — preserve native rendered selection semantics

getPlanSelectionVisibleText at src/lib/plan-selection.ts:74-97 still changes common Markdown selections. It walks only text nodes, drops whitespace-only nodes unless they are inside PRE, and inserts a separator only when the nearest recognized block changes.

I reproduced this in Chromium against current head eeb8fc2, using structures emitted by the project's Marked renderer:

  • **Hello** *world* renders as adjacent inline elements separated by a whitespace text node. Native selection is Hello world; the reconstruction returns Helloworld.
  • A Markdown hard break renders as <br>. Native selection is First\nSecond; the reconstruction returns FirstSecond. A soft source newline has the inverse problem: Chromium renders it as a space, while the reconstruction retains the raw newline from the text node.
  • A two-row Markdown table has native selection text A\tB\nC\tD; the reconstruction returns AB\nCD because every cell in a row maps to the same TR block.

Both Review and Ask persist this reconstructed sel.selectedText, so their prompts can misquote ordinary plan content. Please preserve browser-rendered whitespace, <br> breaks, and table-cell boundaries while continuing to exclude flow-slot UI and hidden Mermaid/SVG text. Add exact-equality regression cases for inline whitespace, soft/hard breaks, and table cells; the current happy-dom tests do not exercise these native selection semantics.

@LarryHu0217

Copy link
Copy Markdown
Contributor Author

Addressed the remaining rendered-selection semantics issue in cf08838.\n\n- Preserves inline whitespace between formatted Markdown nodes, rendered soft/hard line breaks, and Markdown table cell/row separators.\n- Keeps the existing flow-slot and hidden Mermaid/SVG exclusion behavior.\n- Added exact-equality client regressions for inline whitespace, soft/hard breaks, and table cells.\n\nValidation: npm run test:client -- src/lib/plan-selection.client.test.tsx src/components/plan-review-flow.test.ts, npm run check, npm run check:static, npm run build:frontend && npm run build:mcp, git diff --check, and the push hook full unit/client tests passed. Hosted CI is running on the pushed head.

@johannesjo johannesjo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at cf08838 in real Chromium, driving the selection APIs against HTML produced by this project's own marked instance rather than hand-written markup.

What's fixed. Rounds 1–2 hold up under that setup. A selection spanning an existing card gives Selection.toString() containing the card text while getPlanSelection returns only Before plan text\nAfter plan text. Retained ranges plus ResizeObserver re-measure correctly, including observer teardown. The hidden-SVG handling is in fact stricter than native — Chromium includes non-rendered <defs><text> in Selection.toString(), and this correctly drops it. The blockquote reset is in.

Why I'm not listing more broken cases

This is the third round on one defect class. The cases aren't the problem; the harness is. Here is the whole thing in one controlled experiment — two inputs differing by a single newline character:

markup native getPlanSelection
<tr><td>A</td><td>B</td></tr> A\tB A\tB
<tr>\n<td>A</td>\n<td>B</td>\n</tr> A\tB A B

The second row is what marked actually emits. The first is the fixture at plan-selection.client.test.tsx:146. So the table case — the exact one I named last round — is still broken in the app, and its regression test passes because it is written against markup the renderer never produces.

For reference, the mechanism: the inter-cell text node's closest(BLOCK_SELECTOR) resolves to the <tr>, so it survives the guard at plan-selection.ts:141, collapses to ' ', and sets lastCell = null at :147 — which disables the cell !== lastCell branch at :114.

Across 14 renderer-produced structures, 8 match native (tables, multi-paragraph, blockquote→prose, nested lists, and code-fence→prose diverge). I'm deliberately not itemizing them, because six more assertions would just relocate the boundary — the next structure nobody thought to enumerate fails the same way. Three changes instead:

1. Change the approach: stop reconstructing, delegate to the browser

getPlanSelectionVisibleText is a hand-rolled reimplementation of the browser's selection serializer, and it has needed a new special case per structure for three rounds. The rules it encodes (collapse whitespace, separate on block change, tab between cells) are approximations of layout, so they will keep missing structures that only differ by rendering.

Concretely: clone the range, strip [data-plan-review-flow-slot] from the fragment, mount it offscreen, read innerText — which is the browser's rendered-text algorithm:

const fragment = range.cloneContents();
fragment.querySelectorAll(PLAN_REVIEW_FLOW_SLOT_SELECTOR).forEach((n) => n.remove());
const host = document.createElement('div');
host.className = containerEl.className;                  // inherit white-space/font context
host.style.cssText = `position:absolute;left:-99999px;top:0;width:${containerEl.clientWidth}px;`;
containerEl.parentElement.append(host);
host.append(fragment);
const text = host.innerText;
host.remove();

I measured this sketch on the same 14 cases: 11/14 vs the current 8/14, and it gets tables, nested lists, blockquotes and multi-paragraph exactly right with no per-structure rules. It also drops the hidden <defs>/<style> text for free, since those aren't rendered — so it subsumes the Mermaid handling too.

Two caveats, since I'd rather you hit them here than in review: innerText only reflects layout if the clone is genuinely rendered (offscreen, not display:none), and reading it forces a reflow. My three remaining deltas are surplus blank lines, not lost content — I expect they close with the host inheriting the container's computed style more faithfully, but I haven't tuned it, so treat 11/14 as the floor for the approach rather than its ceiling.

Worth saying: plain Selection.toString() is not a sufficient shortcut. It's correct for everything except the flow-slot UI text, but that's the one case this feature exists to handle.

2. Fix the harness

vitest.client.config.ts sets environment: 'happy-dom', so none of the .client.test.tsx tests can observe native selection semantics — they compare the implementation against strings written by the same person who wrote the implementation, in an emulator. That's why three rounds of "added exact-equality regressions" haven't caught this.

Two things have to change together:

  • Fixtures must come from the renderer, not from hand-written HTML. Any test whose markup is typed by hand can be shaped, accidentally, around the implementation — which is exactly what happened with the table.
  • The oracle must be a real browser. happy-dom cannot supply the expected value even with generated fixtures, because the thing being asserted is Chromium's behavior. This needs vitest browser mode or a Playwright-driven spec.

3. Acceptance criterion

So that passing isn't a matter of picking good examples:

For a corpus of Markdown documents rendered through the project's own renderer, for every selection tested, getPlanSelection(container, src).selectedText equals window.getSelection().toString().trim() in Chromium — with any deviation listed explicitly in the test as a named exception carrying a stated reason.

The corpus should cover at least: paragraphs, headings, nested and loose lists, tables (header + body), fenced code, blockquotes, inline emphasis/code/links, hard and soft breaks, rendered Mermaid — plus the two cases where deviation from native is intended: selections spanning a flow slot, and hidden SVG text.

This is checkable and can't be satisfied by fixture choice: expected values come from the browser, inputs come from the renderer, and anything that can't reach parity has to be visible and argued rather than quietly absent. I'd rather see it land with three named, justified exceptions than with a green suite that proves nothing.

Non-blocking, verified separately

  • plan-selection.ts:158-159isHiddenSelectionElement (a getComputedStyle walk) runs before the intersectsNode prune, so cost tracks document size rather than selection size. Selecting the same 9 characters: 0.06 ms at 20 blocks, 0.77 ms at 200, 7.3 ms at 800 — per mouseup. Swapping the two conditions fixes it.
  • PlanViewerDialog.tsx:108 — for an LI anchor, anchor.append(slot) puts the card after the entire nested sublist. Selecting the parent item renders Parent item | Child one | Child two | [CARD] | Sibling.
  • PlanViewerDialog.tsx:49-58reviewSession returns a fresh object each recompute and Show keyed remounts on identity, so a plan-watcher push (App.tsx:536) while the dialog is open discards in-progress comments and half-typed input. That matches the stated intent, but it's a real behavior change from the previous Show when={props.open} and worth an explicit call.

Verdict: needs changes — on the approach and the harness, not on another round of cases.

@johannesjo johannesjo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at cf08838 in real Chromium, driving the selection APIs against HTML produced by this project's own marked instance rather than hand-written markup.

What's fixed. Rounds 1–2 hold up under that setup. A selection spanning an existing card gives Selection.toString() containing the card text while getPlanSelection returns only Before plan text\nAfter plan text. Retained ranges plus ResizeObserver re-measure correctly, including observer teardown. The hidden-SVG handling is in fact stricter than native — Chromium includes non-rendered <defs><text> in Selection.toString(), and this correctly drops it. The blockquote reset is in.

Why I'm not listing more broken cases

This is the third round on one defect class. The cases aren't the problem; the harness is. Here is the whole thing in one controlled experiment — two inputs differing by a single newline character:

markup native getPlanSelection
<tr><td>A</td><td>B</td></tr> A\tB A\tB
<tr>\n<td>A</td>\n<td>B</td>\n</tr> A\tB A B

The second row is what marked actually emits. The first is the fixture at plan-selection.client.test.tsx:146. So the table case — the exact one I named last round — is still broken in the app, and its regression test passes because it is written against markup the renderer never produces.

For reference, the mechanism: the inter-cell text node's closest(BLOCK_SELECTOR) resolves to the <tr>, so it survives the guard at plan-selection.ts:141, collapses to ' ', and sets lastCell = null at :147 — which disables the cell !== lastCell branch at :114.

Across 14 renderer-produced structures, 8 match native (tables, multi-paragraph, blockquote→prose, nested lists, and code-fence→prose diverge). I'm deliberately not itemizing them, because six more assertions would just relocate the boundary — the next structure nobody thought to enumerate fails the same way. Three changes instead:

1. Change the approach: stop reconstructing, delegate to the browser

getPlanSelectionVisibleText is a hand-rolled reimplementation of the browser's selection serializer, and it has needed a new special case per structure for three rounds. The rules it encodes (collapse whitespace, separate on block change, tab between cells) are approximations of layout, so they will keep missing structures that only differ by rendering.

Concretely: clone the range, strip [data-plan-review-flow-slot] from the fragment, mount it offscreen, read innerText — which is the browser's rendered-text algorithm:

const fragment = range.cloneContents();
fragment.querySelectorAll(PLAN_REVIEW_FLOW_SLOT_SELECTOR).forEach((n) => n.remove());
const host = document.createElement('div');
host.className = containerEl.className;                  // inherit white-space/font context
host.style.cssText = `position:absolute;left:-99999px;top:0;width:${containerEl.clientWidth}px;`;
containerEl.parentElement.append(host);
host.append(fragment);
const text = host.innerText;
host.remove();

I measured this sketch on the same 14 cases: 11/14 vs the current 8/14, and it gets tables, nested lists, blockquotes and multi-paragraph exactly right with no per-structure rules. It also drops the hidden <defs>/<style> text for free, since those aren't rendered — so it subsumes the Mermaid handling too.

Two caveats, since I'd rather you hit them here than in review: innerText only reflects layout if the clone is genuinely rendered (offscreen, not display:none), and reading it forces a reflow. My three remaining deltas are surplus blank lines, not lost content — I expect they close with the host inheriting the container's computed style more faithfully, but I haven't tuned it, so treat 11/14 as the floor for the approach rather than its ceiling.

Worth saying: plain Selection.toString() is not a sufficient shortcut. It's correct for everything except the flow-slot UI text, but that's the one case this feature exists to handle.

2. Fix the harness

vitest.client.config.ts sets environment: 'happy-dom', so none of the .client.test.tsx tests can observe native selection semantics — they compare the implementation against strings written by the same person who wrote the implementation, in an emulator. That's why three rounds of "added exact-equality regressions" haven't caught this.

Two things have to change together:

  • Fixtures must come from the renderer, not from hand-written HTML. Any test whose markup is typed by hand can be shaped, accidentally, around the implementation — which is exactly what happened with the table.
  • The oracle must be a real browser. happy-dom cannot supply the expected value even with generated fixtures, because the thing being asserted is Chromium's behavior. This needs vitest browser mode or a Playwright-driven spec.

3. Acceptance criterion

So that passing isn't a matter of picking good examples:

For a corpus of Markdown documents rendered through the project's own renderer, for every selection tested, getPlanSelection(container, src).selectedText equals window.getSelection().toString().trim() in Chromium — with any deviation listed explicitly in the test as a named exception carrying a stated reason.

The corpus should cover at least: paragraphs, headings, nested and loose lists, tables (header + body), fenced code, blockquotes, inline emphasis/code/links, hard and soft breaks, rendered Mermaid — plus the two cases where deviation from native is intended: selections spanning a flow slot, and hidden SVG text.

This is checkable and can't be satisfied by fixture choice: expected values come from the browser, inputs come from the renderer, and anything that can't reach parity has to be visible and argued rather than quietly absent. I'd rather see it land with three named, justified exceptions than with a green suite that proves nothing.

Non-blocking, verified separately

  • plan-selection.ts:158-159isHiddenSelectionElement (a getComputedStyle walk) runs before the intersectsNode prune, so cost tracks document size rather than selection size. Selecting the same 9 characters: 0.06 ms at 20 blocks, 0.77 ms at 200, 7.3 ms at 800 — per mouseup. Swapping the two conditions fixes it.
  • PlanViewerDialog.tsx:108 — for an LI anchor, anchor.append(slot) puts the card after the entire nested sublist. Selecting the parent item renders Parent item | Child one | Child two | [CARD] | Sibling.
  • PlanViewerDialog.tsx:49-58reviewSession returns a fresh object each recompute and Show keyed remounts on identity, so a plan-watcher push (App.tsx:536) while the dialog is open discards in-progress comments and half-typed input. That matches the stated intent, but it's a real behavior change from the previous Show when={props.open} and worth an explicit call.

Verdict: needs changes — on the approach and the harness, not on another round of cases.

@LarryHu0217

Copy link
Copy Markdown
Contributor Author

Addressed the rendered-selection approach and harness issue in ccd08ab.

  • getPlanSelection now delegates prompt text extraction to a rendered offscreen clone and reads browser innerText, after stripping [data-plan-review-flow-slot] and hidden/style/SVG metadata nodes.
  • Kept the existing walker as a non-layout fallback for happy-dom/test environments, with table inter-cell whitespace handling fixed so renderer-emitted newline text nodes do not reset cell state.
  • Updated the regression fixtures to use Marked output for code/prose, inline formatting, soft/hard breaks, and tables instead of hand-shaped DOM.

Validation passed locally: targeted client tests (src/lib/plan-selection.client.test.tsx, src/components/plan-review-flow.test.ts), npm run check, npm run check:static, npm run build:frontend && npm run build:mcp, git diff --check, and the pre-push full gate (1,755 unit tests / 23 skipped, 16 client tests). Hosted checks are running on the pushed head.

@LarryHu0217

Copy link
Copy Markdown
Contributor Author

Addressed the remaining browser-harness and native-selection acceptance criteria in 75b9564.

  • Client DOM tests now run in headless Chromium through the Vitest Playwright provider.
  • Marked-rendered corpus coverage checks paragraphs/headings, nested and loose lists, tables, fenced code/blockquote, inline formatting/code/links, and hard/soft breaks against window.getSelection().toString().trim().
  • Flow-slot UI and non-rendered Mermaid SVG text are explicit named exceptions.
  • Prompt text now serializes a sanitized offscreen clone through Chromium native Selection behavior, restores the original selection, and removes the hand-written DOM walker fallback.

Validation passed: focused Chromium suite (13/13), full tests (1,755 unit passed / 23 skipped; 22 client passed), npm run check, npm run check:static, frontend/remote/MCP builds, and git diff --check.

@LarryHu0217

Copy link
Copy Markdown
Contributor Author

Follow-up 6ab6abc installs Playwright Chromium in the existing quality workflow. The rerun is now green: hosted quality and GitGuardian both pass.

@johannesjo

Copy link
Copy Markdown
Owner

Thanks for sticking with this through four rounds — the core restructure is right, and I want to keep it. But I don't think this should merge in its current shape: the PR has grown well past what the fix needs, and one of the newer changes is a behavior regression.

What's good. The bug is real: cards absolutely positioned at top: cardOffsets[id] overlap each other and go stale on any reflow. Flow slots + Portal is the correct answer, and that part of PlanViewerDialog.tsx is a net simplification (the whole selectionY / cardOffsets / captureSelectionGeometry machinery goes away). CI is green.

Four things I'd block on.


1. Keying the session on plan content destroys in-progress reviews

reviewSession is a createMemo over planContent feeding <Show keyed>, so any change to plan content disposes and remounts ReviewProvider. I verified the remount semantics directly against the Solid runtime:

after initial mount:        { mounts: 1, disposals: 0 }
after planContent v1->v2:   { mounts: 2, disposals: 1 }
after planContent v2->v3:   { mounts: 3, disposals: 2 }

Every piece of review state is a component-local createSignal inside ReviewProvider (annotations, pendingSelection, activeQuestions, …) with no store backing, so a remount loses all of it.

That matters here because plan content is live-watched, not static. startPlanWatcher (electron/ipc/plans.ts:175) runs fs.watch over .claude/plans/ and docs/plans/, debounces 200 ms, reads the newest .md by mtime, and pushes IPC.PlanContent; src/App.tsx:536 feeds that straight into setPlanContent → store → TaskPanel → this dialog. So an agent touching the plan file while I'm reviewing it wipes every comment I've typed, the pending inline input mid-keystroke, and any in-flight ask-code card.

On main the provider survives that — the cards just end up mispositioned. This PR trades a layout bug for data loss, which is the worse of the two.

2. ReviewProvider already has the session-reset API this reimplements

ReviewProvider takes reviewIdentity and open props and handles exactly this (src/components/ReviewProvider.tsx:189-205): an identity change runs clearReviewState(), and an open transition runs resetTransientState(). DiffViewerDialog.tsx:78-94 is the existing precedent — it builds an identity with createReviewIdentity({ taskId, worktreePath, projectRoot, branchName }) (src/lib/diff-review-lifecycle.ts:30) and passes open. PlanViewerDialog just never opted in.

So the idiomatic version of bullet 4 in your description is to pass reviewIdentity={createReviewIdentity({ taskId: props.taskId, worktreePath: props.worktreePath ?? '' })} and open={props.open}, and drop the Show keyed wrapper entirely. That gives you a fresh session on task/worktree change, matches the sibling dialog, and doesn't reset on content.

To be fair to the problem you were actually solving: when planContent changes, innerHTML is replaced and every entry in flowSlots becomes a detached node, so the inline cards do need handling. The narrower fix is an effect on props.planContent that clears pendingFlowSlot, flowSlots, and the highlight rects — the annotations themselves survive in the sidebar (which auto-opens once annotations exist), so you lose the inline anchors, not the user's writing.

3. The client-test infra swap should be its own PR

vitest.client.config.ts moves from happy-dom to real headless Chromium for all src/**/*.client.test.tsx, which includes the two pre-existing suites (ChangedFilesList, ReviewProvider), and adds playwright + @vitest/browser-playwright plus an uncached npx playwright install --with-deps chromium step to the quality job.

playwright@1.62.1 publishes no install script (confirmed against the registry, and the lockfile entry has no hasInstallScript), so npm ci does not fetch browser binaries. After this lands, a fresh clone + npm ci + npm test fails for anyone who doesn't already happen to have Chromium in ~/.cache/ms-playwright, and nothing in postinstall, the README, or contributor docs covers it.

Moving the browser-DOM suite to real Chromium may well be the right call — it's just a repo-wide decision with a contributor-setup cost, and it shouldn't ride along on a plan-viewer fix. Please split it out so it can be judged on its own.

4. getPlanSelectionVisibleText hijacks the live selection on every mouseup

It clears the user's selection, selects an offscreen clone to read toString(), then restores in a finally. Two problems:

  • The restore is load-bearing, not defensive. handleMouseUp calls getPlanSelectionFlowAnchor(contentRef) and getPlanSelectionTextRanges(contentRef) immediately afterwards, and both re-read window.getSelection(). If setBaseAndExtent throws and the addRange fallback doesn't reproduce the original selection, those return null / [] and the feature silently no-ops with no error anywhere.
  • It fires selectionchange at every listener on the document, twice, on every mouseup inside the plan.

Your earlier ccd08ab approach — read innerText off the sanitized offscreen clone — gets the same rendered-text semantics without touching the live selection at all. The host is position:absolute; left:-99999px and attached, so it's laid out and innerText reflects rendering. I'd go back to that.


Minor

src/components/plan-review-flow.test.ts readFileSyncs PlanViewerDialog.tsx and asserts on its source text (toContain("slot.className = 'plan-review-flow-slot'"), not.toContain('cardOffsets'), plus a regex over styles.css). It passes when the behavior is broken and fails when someone renames a local or Prettier reflows a line. I'd delete it — plan-selection.client.test.tsx is where the real coverage lives.

On that file: the six matches Chromium native selection for $name cases compare getPlanSelection().selectedText against window.getSelection().toString().trim(), while the implementation is toString().trim() on a clone — so they're low-signal for the current code. They do pin the approach against a regression back to a hand-written walker, which is worth something given the history, so I'd keep them; just noting they aren't carrying the weight the count suggests. The two exclusion tests (flow-slot UI, hidden Mermaid text) are the ones doing real work.


Where that leaves it. I'd like the flow-slot restructure — that part is good work. What I'm asking for: split the Playwright/browser-mode change into a separate PR, drop plan-review-flow.test.ts, swap the content-keyed session for reviewIdentity + open with a slot-clearing effect on content change, and go back to innerText on the offscreen clone. That should also shrink the diff substantially.

If you'd rather not do another round, say so and I'll take it from here — no hard feelings either way, and thanks for the persistence on this one.

@johannesjo

Copy link
Copy Markdown
Owner

Round 5's four asks are genuinely addressed at 920c855, and the diff is down to 3 files / +334−119. Confirming each:

  1. Content-keyed sessionShow keyed is gone, replaced by a slot-clearing effect on props.planContent (PlanViewerDialog.tsx:189-203). Annotations survive a plan update. ✅
  2. reviewIdentity + open — props are wired, but inert in this position (see Important 6). ⚠️
  3. Playwright/browser-mode split outvitest.client.config.ts, package.json and .github/ are byte-identical to main. ✅
  4. Live-selection hijack — back to innerText on the sanitized offscreen clone; window.getSelection() is never mutated during extraction. ✅

The flow-slot restructure itself still looks right, and dropping selectionY/cardOffsets/captureSelectionGeometry is a real simplification. But going back to the offscreen clone introduced one regression that I'd call blocking, and re-driving the selection APIs in Chromium turned up a second problem in the anchoring that I missed in earlier rounds — it's mine, not something you introduced this round.


Critical — selections inside a fenced code block lose every newline and all indentation

plan-selection.ts:39 (cloneContents()) + :58 (host.innerText)

cloneContents() only clones ancestors that are partially contained — it stops at commonAncestorContainer. Whitespace preservation in a code block comes solely from .plan-markdown pre.shiki-block code { white-space: pre } (styles.css:2037-2041), a descendant selector that needs <pre class="shiki-block"> in the tree.

When a selection sits entirely inside one code block, the common ancestor is <code>, so the clone is bare span.line elements — no <pre> wrapper — and the offscreen host renders the literal \ns with normal white-space. Chromium 151, against marked-shiki.ts:38's actual output:

selection = lines 1-3 of a ```ts block   (commonAncestorContainer = CODE)
native / main : "function a() {\n  return 1;\n}"
this PR        : "function a() { return 1; }"

main was correct here — selection.toString() preserves it. selectedText feeds compilePlanReview (PlanViewerDialog.tsx:41, '> ' + selectedText.split('\n').join('\n> ')) and AskCodeCard's fenced prompt, so commenting on a code snippet now sends the agent a mangled one-liner with the indentation gone.

The paragraph→code→paragraph case still works, because there <pre> is partially contained and lands in the clone — which is exactly what the deleted preserves rendered block breaks between selected code and prose test covered. That's why the suite was blind to it.

Fix that I measured: rebuild the ancestor chain before mounting — walk from commonAncestorContainer up to containerEl, shallow-clone each ancestor (cloneNode(false) keeps tag + class), and nest the fragment inside them.

                 native                              current   fixed
code block       "function a() {\n  return 1;\n}"    DIFFER    MATCH
table cells      "A\tB"                              MATCH     MATCH
within paragraph "paragraph"                         MATCH     MATCH
spanning blocks  "Intro\n\n…\nA\tB\nOutro"           DIFFER    DIFFER  (surplus blank line)

The remaining spanning delta is the surplus-blank-line class I flagged as acceptable in round 4 — not lost content.

Important

1. Triple-clicking a paragraph anchors the card to the next block. plan-selection.ts:155-158

I drove real Input.dispatchMouseEvent drags over CDP against Chromium 151 rather than constructing ranges by hand, and the endContainer assumption doesn't hold for the most common gesture:

triple-click on p1        → endContainer = <P#p2> @ 0 → flow anchor = <P#p2>
drag p1 → gap below p1    → endContainer = <P#p2> @ 0 → flow anchor = <P#p2>
drag p1 → 1px below p1    → endContainer = <P#p2> @ 0 → flow anchor = <P#p2>
horizontal overshoot only → endContainer = #text in p1 → flow anchor = <P#p1>   ✅

Chromium sets endContainer to the following block element at offset 0, closest(BLOCK_SELECTOR) resolves it to p2, and nothing cross-checks the anchor against the ranges getPlanSelectionTextRanges actually produced. Second-order effect: zero highlight rects land in p2, yet the persisted annotation records startLine=0, endLine=1. So triple-click — the standard select-a-paragraph gesture — puts the card one block too low and mis-records its range. Cheapest guard: derive the anchor from the last non-empty text range rather than from range.endContainer.

2. LI anchor still nests the card after the whole sublist. PlanViewerDialog.tsx:102-105

Unaddressed from round 4. Through the project's own marked config:

marked      : <li>Parent item one<ul><li>Child A</li><li>Child B</li></ul></li>
after append: <li>Parent item one<ul>…</ul><div data-plan-review-flow-slot>CARD</div></li>

.plan-review-flow-slot is display: flow-root, so it renders below every child item. Insert before the first nested list instead:

if (anchor.tagName === 'LI') {
  const nested = anchor.querySelector(':scope > ul, :scope > ol');
  nested ? nested.before(slot) : anchor.append(slot);
  return slot;
}

Correcting myself on a sub-point: I expected the skipped sibling-slot walk to invert repeat comments on one item, and it does not — append() already lands after existing slots, so CARD-1 then CARD-2, same as the <p> path. The one real inversion is cross-level: a card on a child li renders above a card on its parent li.

3. Zero tests — and the two that matter don't need Playwright. 920c855 deletes plan-selection.client.test.tsx (267 lines) and plan-review-flow.test.ts (40 lines) along with the harness revert. Deleting plan-review-flow.test.ts was my ask; deleting the browser suite was not — I asked for the infra to be split out.

plan-selection.ts has gone 96 → 240 lines with defects found in four consecutive rounds, and now has no coverage on either branch. The native-parity corpus does have to wait for the split-out PR. The exclusion tests do not: the exclusions happen by removing nodes from the cloned fragment, before layout is consulted. Against the current happy-dom config, importing the real head module:

✓ drops flow-slot text that native Selection.toString() would include
✓ produces no highlight range inside the flow slot
✓ does not count flow-slot blocks in startLine/endLine and anchors to a real block
Test Files 1 passed  Tests 3 passed

Negative control: the same file against main's module fails all three. Not a tautology. Please land those three plus a case for a selection contained within a single code block — that last one is the Critical above, and it's the shape that keeps getting through.

4. Plan-watcher push destroys the in-progress inline input. PlanViewerDialog.tsx:189-203

The effect clears pendingFlowSlot but not review.pendingSelection(). Since InlineInput renders off pendingFlowSlot() (:392-399) and its text signal is owner-local, an fs.watch push while someone is typing unmounts the input and silently drops the text:

input still in DOM after content change : false
review.pendingSelection() after change  : {…, "selectedText":"some plan paragraph"}

The dangling pendingSelection is inert (the only plan-path consumer at :186 inverts it), so the damage is the lost keystrokes, not the stale state. The reverse desync is worse though: anything that runs resetTransientState() nulls pendingSelection while leaving pendingFlowSlot set — the input stays mounted, Enter calls handleSubmit, it returns null, and handleSubmitInFlow bails at if (!id) return (:245). Permanently dead input box, no error. Driving the slot off pendingSelection() fixes both directions.

5. After a plan update the inline cards never come back, and the sidebar jump goes silent. :189-203 + :152-163 + :402-417

setFlowSlots({}) empties the map while annotations() is deliberately preserved, so every <Show when={flowSlots()[annotation.id]}> goes falsy with nothing able to re-create it. That part is the trade I proposed in round 5 and I stand by it. The sharp edge I didn't think through: the scroll effect reads flowSlots()[target.id], gets undefined, and does nothing — so clicking a comment in the sidebar after a plan update produces zero feedback. On main the stale cardOffsets[id] at least still scrolled. Worth either re-anchoring off the stored block index or telling the user the comment detached.

6. open and reviewIdentity are inert where they sit. PlanViewerDialog.tsx:73-74

My round-5 ask, taken literally, but ReviewProvider is a child of Dialog, and Dialog already wraps props.children in <Show when={props.open}> (Dialog.tsx:85) — which Solid evaluates lazily. So the provider only exists while open. Compiling the file through babel-preset-solid confirms the get children() getter, and a full open→close→open cycle gives:

open→true : provider:create      identity change while open  : clearReviewState  ✅
open→false: provider:dispose     identity change while closed: (nothing)
open→true : provider:create      openTransitions: []

wasOpen (ReviewProvider.tsx:160) always seeds true, so :196-205 can never fire. reviewIdentity (:189-194) only fires on a taskId/worktreePath change under a live dialog — and TaskPanel.tsx:664 binds both from one task. This is not a regression you introduced: main already had <Dialog><Show when={open}><ReviewProvider>, and you correctly dropped the redundant inner Show. But my suggestion was wrong as stated. DiffViewerDialog.tsx:85 puts ReviewProvider outside Dialog, which is why the props do work there. Either lift it out the same way (and review state survives an accidental close, which I'd prefer), or drop both props. Bullet 4 of the PR description describes the old design either way.

Minor

  • plan-selection.ts:70 — the text-node walk is O(document), not O(selection), and isInPlanReviewFlowSlot (a closest() per node) runs before intersectsNode at :73. Per mouseup in Chromium, 5-char intra-paragraph selection: 0.54ms at 402 text nodes → 3.13ms at 1602 → 47ms at 6402. Rooting the walker at commonAncestorContainer (using parentNode when it's a text node — a TreeWalker never yields its own root) takes that to 0.003ms. Reordering the && alone only gets 47 → 46ms. I checked equivalence rather than assuming: 0 mismatches over 1656 random ranges plus 6 explicit boundary cases, since intersectsNode is strict at boundaries and the only intersecting non-descendants of the CAC are its ancestors, which are never text nodes. Caveat: no-op for selections that span top-level blocks.
  • plan-selection.ts:41-43 — the strip list has exactly one live entry, and it's an undocumented deviation. In Chromium, style/script/[hidden]/SVG defs/title/desc/metadata all make zero difference to the result — innerText already drops them. [aria-hidden="true"] is the one that changes it: native gives "AARIATEXTB", this gives "AB". That's a deviation from native selection semantics with no comment on it. (Keep the list — under happy-dom, [hidden] and [aria-hidden] are both load-bearing — but say why.)
  • plan-selection.ts:15data-plan-review-flow-slot survives DOMPurify (ALLOW_DATA_ATTR defaults true; verified through the real marked + sanitize pipeline). Plan markdown containing a raw <div data-plan-review-flow-slot> gets treated as review UI: its text is stripped from selectedText and commenting inside it is disabled. A Set<HTMLElement> of created slots would be immune.
  • PlanViewerDialog.tsx:257-274dismissAnnotation/dismissQuestion/removeFlowSlot are dead weight. The reaper at :167-182 already handles it — that's why sidebar dismissal works, and ReviewSidebarPanel.tsx:151 already passes review.dismissAnnotation straight through. I checked for an ordering dependency and there isn't one: Solid 1.9 doesn't batch inside delegated handlers, so the reaper flushes before removeFlowSlot runs and the wrapper's setFlowSlots hits its own if (!(id in prev)) return prev no-op. End state is identical in all three orderings, and orphan ids are reaped too. ~18 lines.
  • ReviewCommentCard.tsx:10-11, 54-57, 73overlay is now dead. PlanViewerDialog was its only consumer. Worth deleting in the same PR that removes the overlay.
  • PlanViewerDialog.tsx:375-390getPlanSelectionRects returns fresh objects each tick, so For tears down and rebuilds every overlay div on each ResizeObserver callback. Index is the right primitive.
  • PlanViewerDialog.tsx:152-163 — the scroll effect tracks flowSlots(), so once a sidebar item has been clicked, later comment adds/dismissals re-run a smooth scroll back to the old target. Pre-exists in shape via cardOffsets, but this PR adds write sites. untrack both this and the slot reads at :198-201.
  • PlanViewerDialog.tsx:213-216 — this guard is unreachable. Solid's delegated-event walk jumps at the Portal container's _$host to marker.parentNode (the position:relative wrapper), skipping contentRef, so mouseups inside a card never reach handleMouseUp. Harmless, but it reads as load-bearing.
  • PlanViewerDialog.tsx:221 — a rejected selection leaves the native highlight up. When a drag's document-order end lands inside an existing card, the early return precedes removeAllRanges() at :228, so the text stays visibly selected and nothing happens. Reads as "the app ignored me."

Where that leaves it. The restructure is right and I want it. Blocking: the code-block regression, the triple-click anchor, the LI nesting, and the three happy-dom exclusion tests plus a code-block case. The rest is cleanup that can ride along.

The anchor bug is one I should have caught in round 4 when I was already driving Chromium — sorry for surfacing it this late. Round 5's offer stands: if you'd rather not do another pass, say the word and I'll pick it up from here.

Verification notes: Chromium 151 via CDP for the selection/anchor/innerText measurements; babel-preset-solid + solid-js 1.9.11 for the lifecycle claims; scratch tests against the repo's existing happy-dom config, run against both the PR head module and main as a negative control. Nothing written to the repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants