From 945f008c95103bc7886d82d01104fdf45beb6142 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 13:49:38 -0700 Subject: [PATCH 01/27] feat(reference): per-session PendingReference context Holds the 'Ask Claude about this' reference as state instead of composer text. Parked per session like InputBar's draftsRef so a reference cannot leak from one conversation into another's next message. Co-Authored-By: Claude Opus 5 (1M context) --- desktop/src/renderer/App.tsx | 12 ++- .../renderer/state/reference-context.test.tsx | 71 +++++++++++++++++ .../src/renderer/state/reference-context.tsx | 77 +++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 desktop/src/renderer/state/reference-context.test.tsx create mode 100644 desktop/src/renderer/state/reference-context.tsx diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 573b39a74..092edd994 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -30,6 +30,7 @@ import TerminalRightSlot from './components/TerminalRightSlot'; import { ChatProvider, useChatDispatch, useChatStore } from './state/chat-context'; import { artifactReducer, initialArtifactState } from './state/artifact-tracker'; import { ArtifactProvider } from './state/ArtifactContext'; +import { ReferenceProvider } from './state/reference-context'; import { categorizeArtifact } from '../shared/artifacts/categorization'; import { resolveTrackedPath } from '../shared/artifacts/resolve-tracked-path'; // Central slash-command router — also used by the drawer so drawer-initiated @@ -2616,9 +2617,13 @@ function AppInner() { ) : null; return ( - // ArtifactProvider: exposes artifact state + dispatch to the entire AppInner - // subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider, - // etc.) because artifact operations may eventually consume chat/theme context. + // ReferenceProvider: holds the "Ask Claude about this" pending reference, + // parked per session so it can't leak between conversations. Outside + // ArtifactProvider because the artifact viewer is one of its two sources. + + {/* ArtifactProvider: exposes artifact state + dispatch to the entire AppInner + subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider, + etc.) because artifact operations may eventually consume chat/theme context. */}
{/* Mount-only: listens for chat:export-snapshot from main, serializes @@ -3397,6 +3402,7 @@ function AppInner() { />
+
); } diff --git a/desktop/src/renderer/state/reference-context.test.tsx b/desktop/src/renderer/state/reference-context.test.tsx new file mode 100644 index 000000000..af5e443f7 --- /dev/null +++ b/desktop/src/renderer/state/reference-context.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +// Pins the per-session parking contract: a held reference belongs to the session +// it was created in, exactly like InputBar's draftsRef (InputBar.tsx:132). Switching +// away must NOT leak the reference into another session's composer, and switching +// back must restore it. +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ReferenceProvider, useReference, type PendingReference } from './reference-context'; + +const REF_A: PendingReference = { + kind: 'chat-text', label: '"alpha"', promptText: 'Regarding alpha:\n', anchor: null, +}; +const REF_B: PendingReference = { + kind: 'artifact', label: 'lines 1-2 of x.ts', promptText: 'Referencing x.ts:\n', anchor: null, +}; + +let api: ReturnType; +function Probe() { + api = useReference(); + return null; +} +function Harness({ sessionId }: { sessionId: string }) { + return ( + + + + ); +} + +describe('reference-context', () => { + it('starts with no reference', () => { + render(); + expect(api.reference).toBeNull(); + }); + + it('holds a reference that was set', () => { + render(); + act(() => api.setReference(REF_A)); + expect(api.reference).toEqual(REF_A); + }); + + it('clearReference empties it', () => { + render(); + act(() => api.setReference(REF_A)); + act(() => api.clearReference()); + expect(api.reference).toBeNull(); + }); + + it('setting a second reference REPLACES the first (no multi-reference in v1)', () => { + render(); + act(() => api.setReference(REF_A)); + act(() => api.setReference(REF_B)); + expect(api.reference).toEqual(REF_B); + }); + + it('parks the reference per session and restores it on return', () => { + const { rerender } = render(); + act(() => api.setReference(REF_A)); + + rerender(); + expect(api.reference).toBeNull(); // s2 must not inherit s1's reference + + act(() => api.setReference(REF_B)); + rerender(); + expect(api.reference).toEqual(REF_A); // s1's is restored + + rerender(); + expect(api.reference).toEqual(REF_B); // s2's is still there + }); +}); diff --git a/desktop/src/renderer/state/reference-context.tsx b/desktop/src/renderer/state/reference-context.tsx new file mode 100644 index 000000000..fe9a41c12 --- /dev/null +++ b/desktop/src/renderer/state/reference-context.tsx @@ -0,0 +1,77 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +/** + * The "Ask Claude about this" held reference (spec 2026-07-26). + * + * Replaces the v1 approach of pasting a prompt scaffold straight into the + * composer. The scaffold now lives HERE as `promptText` and is prepended at + * send time, so the textarea only ever contains the user's own words. + */ +export type ReferenceAnchor = { + /** CSS selector re-finding the element the reference came from. */ + hostSelector: string; + /** Selector for the selected runs inside the host, or null for a whole-element reference. */ + runSelector: string | null; +}; + +export type PendingReference = { + kind: 'chat-text' | 'chat-code' | 'artifact'; + /** Placeholder copy, ALREADY truncated by the builder. */ + label: string; + /** Prepended at send. Never rendered in the composer. */ + promptText: string; + /** + * How to re-find the source. Selectors, NOT a DOMRect[] snapshot: stored rects + * go stale the moment the transcript scrolls, the window resizes, or a drawer + * opens, so geometry is re-derived on every measure pass instead. + */ + anchor: ReferenceAnchor | null; +}; + +type ReferenceApi = { + reference: PendingReference | null; + setReference: (r: PendingReference | null) => void; + clearReference: () => void; +}; + +const ReferenceContext = createContext(null); + +export function ReferenceProvider({ sessionId, children }: { sessionId: string; children: React.ReactNode }) { + const [reference, setReferenceState] = useState(null); + + // Per-session parking, mirroring InputBar's draftsRef (InputBar.tsx:132): a held + // reference belongs to the session it was created in. Without this, switching + // sessions would silently apply session A's reference to session B's next message. + const parked = useRef>(new Map()); + const prevSession = useRef(sessionId); + + useEffect(() => { + const prev = prevSession.current; + if (prev === sessionId) return; + // Park the outgoing session's reference, restore the incoming one. + setReferenceState((current) => { + if (current) parked.current.set(prev, current); + else parked.current.delete(prev); + return parked.current.get(sessionId) ?? null; + }); + prevSession.current = sessionId; + }, [sessionId]); + + const setReference = useCallback((r: PendingReference | null) => setReferenceState(r), []); + const clearReference = useCallback(() => setReferenceState(null), []); + + // Memoized: this context changes only on set/clear, so consumers must not + // re-render on unrelated parent renders (react-renderer rule). + const value = useMemo( + () => ({ reference, setReference, clearReference }), + [reference, setReference, clearReference], + ); + + return {children}; +} + +export function useReference(): ReferenceApi { + const ctx = useContext(ReferenceContext); + if (!ctx) throw new Error('useReference must be used inside a ReferenceProvider'); + return ctx; +} From f2e93f66a92e80584a01f671f4cfb842acff1bb5 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 13:58:45 -0700 Subject: [PATCH 02/27] feat(reference): pure PendingReference builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverts v1's askAboutThis()/scaffold() — same prompt strings, returned as data rather than dispatched at the composer as text. Tags the source element and (where possible) the selected runs with marker attributes so the overlay can re-measure geometry later without storing stale DOMRects. describeArtifactSelection moves to build-reference.ts as module-private (not exported, to avoid a circular import once Task 3 has build-menu.ts import the builders). build-menu.ts's artifactMenu keeps a temporary pure duplicate (describeArtifactSelectionForAskMenu) rather than importing buildArtifactReference back, because that builder also tags DOM markers for the future overlay — calling it from every right-click, not just an actual "Ask" click, would give the live menu a new DOM-mutation side effect before Task 3 is ready for it. Task 3 deletes the duplicate when it rewires the call site. Also fixes a bug in the buildChatReference draft: it fell back to only bubble?.textContent when locating the quote, so a null bubble (target has no bubble ancestor) always produced an empty quote even when target itself had text — inconsistent with the `host = bubble ?? target` fallback used two lines later for anchoring. Now uses the same bubble-or-target fallback. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/context-menu/build-menu.ts | 31 ++-- .../context-menu/build-reference.test.ts | 128 ++++++++++++++ .../context-menu/build-reference.ts | 167 ++++++++++++++++++ 3 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 desktop/src/renderer/components/context-menu/build-reference.test.ts create mode 100644 desktop/src/renderer/components/context-menu/build-reference.ts diff --git a/desktop/src/renderer/components/context-menu/build-menu.ts b/desktop/src/renderer/components/context-menu/build-menu.ts index e6f3bc516..7e22ac6b0 100644 --- a/desktop/src/renderer/components/context-menu/build-menu.ts +++ b/desktop/src/renderer/components/context-menu/build-menu.ts @@ -189,26 +189,17 @@ function codeMenu(pre: HTMLElement, target: HTMLElement): MenuEntry[] { ]; } -// Best-effort: match the selection against the artifact's rendered
 text to
-// report source line numbers. Only attempted for 'raw' viewers (CodeView, and
-// MarkdownView on non-.md files) where the 
 is a verbatim copy of the file —
-// rendered markdown prose doesn't map 1:1 back to source lines, so it always
-// falls through to a quote. Line matching is first-occurrence indexOf, so a
-// selection that also appears earlier in the file can report the wrong line —
-// an acceptable miss for a prompt scaffold the user reviews before sending.
-//
-// textContent, NOT innerText: innerText is layout-dependent (forces a reflow, and
-// its line handling follows *rendered* boxes) — on a `whitespace-pre-wrap` 
-// that risks counting soft-wrap breaks as source newlines. textContent walks the
-// highlight.js spans and yields the file's exact characters. It's also the only
-// one jsdom implements, so this stays unit-testable.
-function describeArtifactSelection(sel: string, container: HTMLElement): string {
+// TEMP stopgap (Task 2 of the ask-reference plan): describeArtifactSelection MOVED
+// to build-reference.ts (module-private there — see that file for the full
+// rationale comment). artifactMenu below still needs the described-line string for
+// its own "Ask about this" entry, and Task 3 hasn't rewired this call site yet, so
+// this is a deliberate duplicate kept pure/side-effect-free (unlike
+// build-reference.ts's buildArtifactReference, which also tags DOM markers for the
+// reference overlay — calling that here would tag the DOM on every right-click,
+// not just when the user picks "Ask"). Delete this once Task 3 rewires artifactMenu
+// to hold a PendingReference instead of dispatching a compose-insert string.
+function describeArtifactSelectionForAskMenu(sel: string, container: HTMLElement): string {
   const source = container.getAttribute('data-artifact-source');
-  // CodeMirror viewers NEVER use the textContent path below: CM6 virtualizes,
-  // so only viewport lines exist in the DOM and an indexOf count reports a
-  // plausible WRONG line (a selection at line 800 cites "line 41") straight
-  // into a prompt scaffold (spec §5.3). state.doc.lineAt() is
-  // virtualization-immune; the live view comes from the editor registry.
   if (source === 'cm6') {
     const view = editorViewFor(container);
     const range = view?.state.selection.main;
@@ -238,7 +229,7 @@ function artifactMenu(container: HTMLElement): MenuEntry[] {
   const sel = selectionText().trim();
   const entries: MenuEntry[] = [];
   if (sel && path) {
-    const ref = describeArtifactSelection(sel, container);
+    const ref = describeArtifactSelectionForAskMenu(sel, container);
     entries.push({
       type: 'item',
       id: 'ask',
diff --git a/desktop/src/renderer/components/context-menu/build-reference.test.ts b/desktop/src/renderer/components/context-menu/build-reference.test.ts
new file mode 100644
index 000000000..fe2d0d8cb
--- /dev/null
+++ b/desktop/src/renderer/components/context-menu/build-reference.test.ts
@@ -0,0 +1,128 @@
+// @vitest-environment jsdom
+// Pins the reference BUILDER — the pure half of "Ask Claude about this".
+// The strings here are the v1 scaffold strings, moved verbatim from
+// build-menu.ts's askAboutThis()/scaffold() so the prompt Claude receives
+// does not change; only where it lives does.
+import { describe, it, expect, afterEach } from 'vitest';
+import { buildChatReference, buildCodeReference, buildArtifactReference, truncateLabel } from './build-reference';
+
+function mountBubble(cls: 'assistant-bubble' | 'user-bubble', text: string) {
+  const el = document.createElement('div');
+  el.className = cls;
+  el.textContent = text;
+  document.body.appendChild(el);
+  return el;
+}
+
+function selectWithin(node: Node, start: number, end: number) {
+  const range = document.createRange();
+  range.setStart(node.firstChild!, start);
+  range.setEnd(node.firstChild!, end);
+  const sel = window.getSelection()!;
+  sel.removeAllRanges();
+  sel.addRange(range);
+}
+
+afterEach(() => {
+  document.body.innerHTML = '';
+  window.getSelection()?.removeAllRanges();
+});
+
+describe('truncateLabel', () => {
+  it('leaves short text alone', () => {
+    expect(truncateLabel('alpha bravo')).toBe('alpha bravo');
+  });
+
+  it('truncates with an ellipsis at the limit', () => {
+    expect(truncateLabel('a'.repeat(80), 10)).toBe('aaaaaaaaaa…');
+  });
+
+  it('collapses newlines so the placeholder stays one line', () => {
+    expect(truncateLabel('alpha\nbravo')).toBe('alpha bravo');
+  });
+});
+
+describe('buildChatReference', () => {
+  it('quotes an assistant bubble with the assistant lead-in', () => {
+    const el = mountBubble('assistant-bubble', 'the reducer preserves Map refs');
+    const ref = buildChatReference(el, el)!;
+    expect(ref.kind).toBe('chat-text');
+    expect(ref.promptText).toBe(
+      'In an earlier message, you said:\n"the reducer preserves Map refs"\n\nThe user has a follow-up: ',
+    );
+    expect(ref.label).toBe('"the reducer preserves Map refs"');
+  });
+
+  it('flips the lead-in for the user\'s own bubble', () => {
+    const el = mountBubble('user-bubble', 'why does memo work');
+    expect(buildChatReference(el, el)!.promptText).toBe(
+      'Earlier I wrote:\n"why does memo work"\n\nThe user has a follow-up: ',
+    );
+  });
+
+  it('stays neutral when the bubble class is unknown', () => {
+    const el = document.createElement('div');
+    el.textContent = 'floating text';
+    document.body.appendChild(el);
+    expect(buildChatReference(null, el)!.promptText).toBe(
+      'Regarding this:\n"floating text"\n\nThe user has a follow-up: ',
+    );
+  });
+
+  it('prefers the live selection over the whole bubble', () => {
+    const el = mountBubble('assistant-bubble', 'alpha bravo charlie');
+    selectWithin(el, 6, 11); // "bravo"
+    const ref = buildChatReference(el, el)!;
+    expect(ref.promptText).toContain('"bravo"');
+    expect(ref.anchor?.runSelector).not.toBeNull();
+  });
+
+  it('returns null when there is nothing to quote', () => {
+    const el = mountBubble('assistant-bubble', '   ');
+    expect(buildChatReference(el, el)).toBeNull();
+  });
+});
+
+describe('buildCodeReference', () => {
+  it('fences the code block and strips trailing newlines', () => {
+    const pre = document.createElement('pre');
+    pre.append(document.createTextNode('const x = 1;\n\n'));
+    document.body.appendChild(pre);
+    Object.defineProperty(pre, 'innerText', { value: 'const x = 1;\n\n', configurable: true });
+    const ref = buildCodeReference(pre);
+    expect(ref.kind).toBe('chat-code');
+    expect(ref.promptText).toBe(
+      'Earlier, you shared this code:\n```\nconst x = 1;\n```\n\nThe user has a follow-up: ',
+    );
+  });
+});
+
+describe('buildArtifactReference', () => {
+  function mountViewer(body: string) {
+    const container = document.createElement('div');
+    container.setAttribute('data-artifact-viewer', 'true');
+    container.setAttribute('data-doc-path', 'docs/notes.txt');
+    container.setAttribute('data-artifact-source', 'raw');
+    const pre = document.createElement('pre');
+    pre.textContent = body;
+    container.appendChild(pre);
+    document.body.appendChild(container);
+    return { container, pre };
+  }
+
+  it('cites source lines and labels them for the placeholder', () => {
+    const { container, pre } = mountViewer('alpha\nbravo\ncharlie');
+    selectWithin(pre, 6, 11); // "bravo" — line 2
+    const ref = buildArtifactReference(container)!;
+    expect(ref.kind).toBe('artifact');
+    expect(ref.promptText).toBe(
+      'The user is referencing line 2 from "docs/notes.txt". Respond to the following prompt accordingly:\n\n',
+    );
+    expect(ref.label).toBe('line 2 of notes.txt');
+  });
+
+  it('returns null with no selection — never reference a whole file', () => {
+    const { container } = mountViewer('alpha\nbravo');
+    expect(buildArtifactReference(container)).toBeNull();
+  });
+});
diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts
new file mode 100644
index 000000000..fb9ea0851
--- /dev/null
+++ b/desktop/src/renderer/components/context-menu/build-reference.ts
@@ -0,0 +1,167 @@
+import { editorViewFor } from '../artifact-views/cm/editor-registry';
+import type { PendingReference } from '../../state/reference-context';
+
+// describeArtifactSelection MOVES here from build-menu.ts:205 (with its full
+// comment block) — see Task 2 Step 1. Module-private on purpose: exporting it
+// would make build-menu.ts and build-reference.ts circular.
+
+// Best-effort: match the selection against the artifact's rendered 
 text to
+// report source line numbers. Only attempted for 'raw' viewers (CodeView, and
+// MarkdownView on non-.md files) where the 
 is a verbatim copy of the file —
+// rendered markdown prose doesn't map 1:1 back to source lines, so it always
+// falls through to a quote. Line matching is first-occurrence indexOf, so a
+// selection that also appears earlier in the file can report the wrong line —
+// an acceptable miss for a prompt scaffold the user reviews before sending.
+//
+// textContent, NOT innerText: innerText is layout-dependent (forces a reflow, and
+// its line handling follows *rendered* boxes) — on a `whitespace-pre-wrap` 
+// that risks counting soft-wrap breaks as source newlines. textContent walks the
+// highlight.js spans and yields the file's exact characters. It's also the only
+// one jsdom implements, so this stays unit-testable.
+function describeArtifactSelection(sel: string, container: HTMLElement): string {
+  const source = container.getAttribute('data-artifact-source');
+  // CodeMirror viewers NEVER use the textContent path below: CM6 virtualizes,
+  // so only viewport lines exist in the DOM and an indexOf count reports a
+  // plausible WRONG line (a selection at line 800 cites "line 41") straight
+  // into a prompt scaffold (spec §5.3). state.doc.lineAt() is
+  // virtualization-immune; the live view comes from the editor registry.
+  if (source === 'cm6') {
+    const view = editorViewFor(container);
+    const range = view?.state.selection.main;
+    if (view && range && !range.empty) {
+      const startLine = view.state.doc.lineAt(range.from).number;
+      const endLine = view.state.doc.lineAt(range.to).number;
+      return startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
+    }
+    return `"${sel}"`;
+  }
+  const pre = source === 'raw' ? container.querySelector('pre') : null;
+  const full = pre?.textContent ?? '';
+  const idx = pre ? full.indexOf(sel) : -1;
+  if (idx !== -1) {
+    const startLine = (full.slice(0, idx).match(/\n/g) || []).length + 1;
+    const endLine = startLine + (sel.match(/\n/g) || []).length;
+    return startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
+  }
+  return `"${sel}"`;
+}
+
+/**
+ * Builds the "Ask Claude about this" reference (spec 2026-07-26).
+ *
+ * This is v1's askAboutThis()/scaffold() INVERTED: the same prompt strings, but
+ * RETURNED AS DATA instead of dispatched at the composer as text. Keeping it pure
+ * is what makes it testable — and keeps build-menu.ts a pure DOM-inspection module.
+ */
+
+/** Marks the element a reference came from, so the overlay can re-find it. */
+const HOST_ATTR = 'data-reference-host';
+const RUN_ATTR = 'data-reference-run';
+let hostSeq = 0;
+
+function tagHost(el: Element): string {
+  const id = String(++hostSeq);
+  el.setAttribute(HOST_ATTR, id);
+  return `[${HOST_ATTR}="${id}"]`;
+}
+
+/**
+ * Wraps the current selection in marker spans so the overlay can re-measure it
+ * later. getClientRects() on these spans returns ONE RECT PER LINE BOX — the
+ * same shape Range.getClientRects() gives — which is what the union outline
+ * (Task 5) traces. Returns null when the selection can't be wrapped (it crosses
+ * element boundaries, which surroundContents rejects).
+ */
+function tagSelectionRuns(hostId: string): string | null {
+  const sel = window.getSelection();
+  if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
+  try {
+    const span = document.createElement('span');
+    span.setAttribute(RUN_ATTR, hostId);
+    sel.getRangeAt(0).surroundContents(span);
+    return `[${RUN_ATTR}="${hostId}"]`;
+  } catch {
+    // Selection spans multiple elements — fall back to a whole-element outline.
+    return null;
+  }
+}
+
+/** One-line, bounded placeholder copy. Newlines collapse so it can't wrap. */
+export function truncateLabel(text: string, max = 42): string {
+  const flat = text.replace(/\s+/g, ' ').trim();
+  return flat.length <= max ? flat : flat.slice(0, max) + '…';
+}
+
+function scaffold(lead: string, body: string, fenced: boolean): string {
+  const quoted = fenced ? '```\n' + body + '\n```' : `"${body}"`;
+  return `${lead}\n${quoted}\n\nThe user has a follow-up: `;
+}
+
+function selectionText(): string {
+  return window.getSelection()?.toString() ?? '';
+}
+
+function baseName(p: string): string {
+  return p.replace(/\\/g, '/').split('/').pop() || p;
+}
+
+export function buildChatReference(bubble: Element | null, target: HTMLElement): PendingReference | null {
+  // Fix vs. the original textMenu(): fall back to TARGET's text, not just bubble's,
+  // when there's no bubble ancestor — otherwise a floating (non-bubble) text node
+  // with no live selection always produced an empty quote and a silent null return.
+  const quote = (selectionText().trim() || (bubble ?? target).textContent?.trim()) ?? '';
+  if (!quote) return null;
+
+  // "you said" reads right for an assistant message; flip it for the user's own
+  // bubble, and stay neutral if we can't tell. (Moved verbatim from build-menu.ts.)
+  const lead = bubble?.classList.contains('assistant-bubble')
+    ? 'In an earlier message, you said:'
+    : bubble?.classList.contains('user-bubble')
+      ? 'Earlier I wrote:'
+      : 'Regarding this:';
+
+  const host = (bubble ?? target) as Element;
+  const hostSelector = tagHost(host);
+  const hostId = host.getAttribute(HOST_ATTR)!;
+  const runSelector = selectionText().trim() ? tagSelectionRuns(hostId) : null;
+
+  return {
+    kind: 'chat-text',
+    label: `"${truncateLabel(quote)}"`,
+    promptText: scaffold(lead, quote, false),
+    anchor: { hostSelector, runSelector },
+  };
+}
+
+export function buildCodeReference(pre: HTMLElement): PendingReference {
+  const code = pre.innerText.replace(/\n+$/, '');
+  return {
+    kind: 'chat-code',
+    label: truncateLabel(code),
+    promptText: scaffold('Earlier, you shared this code:', code, true),
+    anchor: { hostSelector: tagHost(pre), runSelector: null },
+  };
+}
+
+export function buildArtifactReference(container: HTMLElement): PendingReference | null {
+  // data-doc-path, not data-artifact-path: the latter stays reserved for the
+  // deferred image sub-menu's absolute path on  elements.
+  const path = container.getAttribute('data-doc-path') || '';
+  const sel = selectionText().trim();
+  // No selection → no reference. Falling back to the whole file would paste an
+  // entire document (deliberate, carried over from v1).
+  if (!sel || !path) return null;
+
+  const ref = describeArtifactSelection(sel, container);
+  const hostSelector = tagHost(container);
+  const hostId = container.getAttribute(HOST_ATTR)!;
+
+  return {
+    kind: 'artifact',
+    // `ref` is either "line 2" / "lines 2-4" or a quoted excerpt; only the
+    // line form reads well with "of ".
+    label: ref.startsWith('line') ? `${ref} of ${baseName(path)}` : truncateLabel(ref),
+    promptText: `The user is referencing ${ref} from "${path}". Respond to the following prompt accordingly:\n\n`,
+    anchor: { hostSelector, runSelector: tagSelectionRuns(hostId) },
+  };
+}

From abb7831e5dcdffb56343edc1cb23d88e45712cb6 Mon Sep 17 00:00:00 2001
From: Destin 
Date: Sun, 26 Jul 2026 14:09:29 -0700
Subject: [PATCH 03/27] fix(reference): hold live DOM handles instead of
 mutating selectors

Reviewer caught a design defect in the PendingReference anchor: tagHost()/
tagSelectionRuns() set a data-reference-host attribute and wrapped the
selection in a marker  via Range.surroundContents(). Chat bubbles
render their text as plain React-managed JSX, so surroundContents() splits
a text node out from under React's fiber, and the next reconcile throws
NotFoundError: Failed to execute 'removeChild', crashing the chat view.
Repeated right-clicks also nested marker spans with no cleanup.

Anchor now holds a live host Element + a cloned Range instead of CSS
selectors, so building a reference never mutates the DOM. Safe because this
state is renderer-local and never serialized/persisted/sent over IPC.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../context-menu/build-reference.test.ts      | 18 ++++++-
 .../context-menu/build-reference.ts           | 52 ++++++-------------
 .../src/renderer/state/reference-context.tsx  | 24 ++++++---
 3 files changed, 50 insertions(+), 44 deletions(-)

diff --git a/desktop/src/renderer/components/context-menu/build-reference.test.ts b/desktop/src/renderer/components/context-menu/build-reference.test.ts
index fe2d0d8cb..9740a6e87 100644
--- a/desktop/src/renderer/components/context-menu/build-reference.test.ts
+++ b/desktop/src/renderer/components/context-menu/build-reference.test.ts
@@ -74,7 +74,23 @@ describe('buildChatReference', () => {
     selectWithin(el, 6, 11); // "bravo"
     const ref = buildChatReference(el, el)!;
     expect(ref.promptText).toContain('"bravo"');
-    expect(ref.anchor?.runSelector).not.toBeNull();
+    expect(ref.anchor?.range).not.toBeNull();
+  });
+
+  it('does not mutate the DOM (no surroundContents split, no host attribute)', () => {
+    // This is the whole point of the fix: the old implementation tagged the
+    // host with data-reference-host and wrapped the selection in a marker
+    //  via Range.surroundContents(), which changes outerHTML and (on a
+    // real React tree) crashes the next reconcile with
+    // `NotFoundError: Failed to execute 'removeChild'`. Byte-identical
+    // outerHTML before/after proves the new anchor is DOM-mutation-free.
+    const el = mountBubble('assistant-bubble', 'alpha bravo charlie');
+    selectWithin(el, 6, 11); // "bravo"
+    const before = el.outerHTML;
+    const ref = buildChatReference(el, el)!;
+    expect(el.outerHTML).toBe(before);
+    expect(ref.anchor?.host).toBe(el);
+    expect(ref.anchor?.range).not.toBeNull();
   });
 
   it('returns null when there is nothing to quote', () => {
diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts
index fb9ea0851..5c1e8011d 100644
--- a/desktop/src/renderer/components/context-menu/build-reference.ts
+++ b/desktop/src/renderer/components/context-menu/build-reference.ts
@@ -54,36 +54,20 @@ function describeArtifactSelection(sel: string, container: HTMLElement): string
  * is what makes it testable — and keeps build-menu.ts a pure DOM-inspection module.
  */
 
-/** Marks the element a reference came from, so the overlay can re-find it. */
-const HOST_ATTR = 'data-reference-host';
-const RUN_ATTR = 'data-reference-run';
-let hostSeq = 0;
-
-function tagHost(el: Element): string {
-  const id = String(++hostSeq);
-  el.setAttribute(HOST_ATTR, id);
-  return `[${HOST_ATTR}="${id}"]`;
-}
-
-/**
- * Wraps the current selection in marker spans so the overlay can re-measure it
- * later. getClientRects() on these spans returns ONE RECT PER LINE BOX — the
- * same shape Range.getClientRects() gives — which is what the union outline
- * (Task 5) traces. Returns null when the selection can't be wrapped (it crosses
- * element boundaries, which surroundContents rejects).
- */
-function tagSelectionRuns(hostId: string): string | null {
+// WHY no DOM tagging here: the original design re-found the host/selection via
+// a `data-reference-host` attribute plus a marker `` wrapped around the
+// selection with Range.surroundContents(). Chat bubbles (UserMessage.tsx,
+// AssistantTurnBubble.tsx) render their text as plain React-managed JSX, so
+// splitting that text node out from under React left its fiber pointing at a
+// node that no longer existed in the expected shape — the next reconcile threw
+// `NotFoundError: Failed to execute 'removeChild'` and crashed the chat view.
+// Holding the live host Element and a cloned Range instead needs no mutation.
+function captureRange(): Range | null {
   const sel = window.getSelection();
   if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
-  try {
-    const span = document.createElement('span');
-    span.setAttribute(RUN_ATTR, hostId);
-    sel.getRangeAt(0).surroundContents(span);
-    return `[${RUN_ATTR}="${hostId}"]`;
-  } catch {
-    // Selection spans multiple elements — fall back to a whole-element outline.
-    return null;
-  }
+  // cloneRange: the live selection is cleared the moment focus moves to the
+  // composer, which would empty a borrowed reference out from under us.
+  return sel.getRangeAt(0).cloneRange();
 }
 
 /** One-line, bounded placeholder copy. Newlines collapse so it can't wrap. */
@@ -121,15 +105,13 @@ export function buildChatReference(bubble: Element | null, target: HTMLElement):
       : 'Regarding this:';
 
   const host = (bubble ?? target) as Element;
-  const hostSelector = tagHost(host);
-  const hostId = host.getAttribute(HOST_ATTR)!;
-  const runSelector = selectionText().trim() ? tagSelectionRuns(hostId) : null;
+  const range = selectionText().trim() ? captureRange() : null;
 
   return {
     kind: 'chat-text',
     label: `"${truncateLabel(quote)}"`,
     promptText: scaffold(lead, quote, false),
-    anchor: { hostSelector, runSelector },
+    anchor: { host, range },
   };
 }
 
@@ -139,7 +121,7 @@ export function buildCodeReference(pre: HTMLElement): PendingReference {
     kind: 'chat-code',
     label: truncateLabel(code),
     promptText: scaffold('Earlier, you shared this code:', code, true),
-    anchor: { hostSelector: tagHost(pre), runSelector: null },
+    anchor: { host: pre, range: null },
   };
 }
 
@@ -153,8 +135,6 @@ export function buildArtifactReference(container: HTMLElement): PendingReference
   if (!sel || !path) return null;
 
   const ref = describeArtifactSelection(sel, container);
-  const hostSelector = tagHost(container);
-  const hostId = container.getAttribute(HOST_ATTR)!;
 
   return {
     kind: 'artifact',
@@ -162,6 +142,6 @@ export function buildArtifactReference(container: HTMLElement): PendingReference
     // line form reads well with "of ".
     label: ref.startsWith('line') ? `${ref} of ${baseName(path)}` : truncateLabel(ref),
     promptText: `The user is referencing ${ref} from "${path}". Respond to the following prompt accordingly:\n\n`,
-    anchor: { hostSelector, runSelector: tagSelectionRuns(hostId) },
+    anchor: { host: container, range: captureRange() },
   };
 }
diff --git a/desktop/src/renderer/state/reference-context.tsx b/desktop/src/renderer/state/reference-context.tsx
index fe9a41c12..aea8cc7f4 100644
--- a/desktop/src/renderer/state/reference-context.tsx
+++ b/desktop/src/renderer/state/reference-context.tsx
@@ -8,10 +8,10 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR
  * send time, so the textarea only ever contains the user's own words.
  */
 export type ReferenceAnchor = {
-  /** CSS selector re-finding the element the reference came from. */
-  hostSelector: string;
-  /** Selector for the selected runs inside the host, or null for a whole-element reference. */
-  runSelector: string | null;
+  /** The element the reference came from. Held directly — see the note below. */
+  host: Element;
+  /** Live Range over the selection, or null for a whole-element reference. */
+  range: Range | null;
 };
 
 export type PendingReference = {
@@ -21,9 +21,19 @@ export type PendingReference = {
   /** Prepended at send. Never rendered in the composer. */
   promptText: string;
   /**
-   * How to re-find the source. Selectors, NOT a DOMRect[] snapshot: stored rects
-   * go stale the moment the transcript scrolls, the window resizes, or a drawer
-   * opens, so geometry is re-derived on every measure pass instead.
+   * Live DOM handles, NOT selectors and NOT a DOMRect[] snapshot. Rects go stale
+   * the moment the transcript scrolls, the window resizes, or a drawer opens, so
+   * geometry must be re-derived on every measure pass anyway — which only needs
+   * a live node, not a selector. Selectors were the original design, but
+   * re-finding a host meant tagging it with a `data-reference-host` attribute,
+   * and re-finding a selection meant wrapping it in a marker `` via
+   * `Range.surroundContents()` — a DOM mutation. Chat bubbles render their text
+   * as plain React-managed JSX, so React's fiber still points at the original
+   * text node after surroundContents() splits it; the next reconcile throws
+   * `NotFoundError: Failed to execute 'removeChild'` and takes down the chat
+   * view. Holding the live node/Range instead needs no mutation at all. This is
+   * safe only because this state is renderer-local — never serialized,
+   * persisted, or sent over IPC.
    */
   anchor: ReferenceAnchor | null;
 };

From e7a8f0a01add786f53fa4e4ec223b622b185492c Mon Sep 17 00:00:00 2001
From: Destin 
Date: Sun, 26 Jul 2026 14:17:16 -0700
Subject: [PATCH 04/27] test(reference): extend no-mutation guard to
 buildCodeReference and buildArtifactReference

Code review flagged that only buildChatReference had a regression test proving
the old DOM-mutating implementation (setAttribute + Range.surroundContents)
wasn't reintroduced. The other two builders had no equivalent guard, so a
reintroduced mutation would pass every existing test for them.

Verified by temporarily reintroducing setAttribute('data-reference-host', ...)
into both builders: both new assertions failed as expected, then reverted.
---
 .../context-menu/build-reference.test.ts      | 33 +++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/desktop/src/renderer/components/context-menu/build-reference.test.ts b/desktop/src/renderer/components/context-menu/build-reference.test.ts
index 9740a6e87..afb2a1fea 100644
--- a/desktop/src/renderer/components/context-menu/build-reference.test.ts
+++ b/desktop/src/renderer/components/context-menu/build-reference.test.ts
@@ -111,6 +111,22 @@ describe('buildCodeReference', () => {
       'Earlier, you shared this code:\n```\nconst x = 1;\n```\n\nThe user has a follow-up: ',
     );
   });
+
+  it('does not mutate the DOM (no surroundContents split, no host attribute)', () => {
+    // Same guarantee as buildChatReference's equivalent test, extended to this
+    // builder: the old buildCodeReference() also tagged `pre` with
+    // data-reference-host unconditionally. buildCodeReference never reads the
+    // live selection (it always quotes the whole 
), so there's no
+    // selection setup needed here — just prove the host is untouched.
+    const pre = document.createElement('pre');
+    pre.append(document.createTextNode('const x = 1;\n\n'));
+    document.body.appendChild(pre);
+    Object.defineProperty(pre, 'innerText', { value: 'const x = 1;\n\n', configurable: true });
+    const before = pre.outerHTML;
+    const ref = buildCodeReference(pre);
+    expect(pre.outerHTML).toBe(before);
+    expect(ref.anchor?.host).toBe(pre);
+  });
 });
 
 describe('buildArtifactReference', () => {
@@ -141,4 +157,21 @@ describe('buildArtifactReference', () => {
     const { container } = mountViewer('alpha\nbravo');
     expect(buildArtifactReference(container)).toBeNull();
   });
+
+  it('does not mutate the DOM (no surroundContents split, no host attribute)', () => {
+    // Same guarantee as buildChatReference's equivalent test, extended to this
+    // builder: the old buildArtifactReference() also tagged the container with
+    // data-reference-host and wrapped the selection in a marker . A real
+    // selection is required here — with no selection the function returns null
+    // before it would ever reach the old mutation code path, which would make
+    // this assertion pass trivially without proving anything.
+    const { container, pre } = mountViewer('alpha\nbravo\ncharlie');
+    selectWithin(pre, 6, 11); // "bravo" — line 2
+    const before = container.outerHTML;
+    const ref = buildArtifactReference(container)!;
+    expect(ref).not.toBeNull();
+    expect(container.outerHTML).toBe(before);
+    expect(ref.anchor?.host).toBe(container);
+    expect(ref.anchor?.range).not.toBeNull();
+  });
 });

From 3f4379319c02a9c1af373eba352dee3670172498 Mon Sep 17 00:00:00 2001
From: Destin 
Date: Sun, 26 Jul 2026 14:29:08 -0700
Subject: [PATCH 05/27] feat(reference): menu produces a PendingReference;
 disable on streaming turns
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

buildContextMenu now takes an onReference callback instead of dispatching
youcoded:compose-insert. Ask about this is DISABLED (not hidden) on the
in-flight turn, with a title hint explaining why — the reference card is a
static clone and would freeze a streaming message mid-sentence.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../components/AssistantTurnBubble.tsx        |  10 +-
 desktop/src/renderer/components/ChatView.tsx  |   3 +
 .../components/context-menu/ContextMenu.tsx   |   1 +
 .../context-menu/ContextMenuHost.tsx          |  10 +-
 .../context-menu/build-menu-cm6.test.tsx      |  36 +++---
 .../context-menu/build-menu.test.tsx          |  51 ++++++---
 .../components/context-menu/build-menu.ts     | 106 +++++++-----------
 7 files changed, 122 insertions(+), 95 deletions(-)

diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx
index f994a5ca8..3adcf70ff 100644
--- a/desktop/src/renderer/components/AssistantTurnBubble.tsx
+++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx
@@ -18,6 +18,9 @@ interface Props {
   /** Session provider — drives provider-aware stop-reason copy (native vs Claude). */
   provider?: SessionProvider;
   showTimestamps: boolean;
+  /** True only for the turn currently being written. Gates "Ask about this" —
+   *  the reference card is a static clone and would freeze mid-sentence. */
+  streaming?: boolean;
 }
 
 // Non-end_turn stop reasons rendered inline under the affected turn.
@@ -343,7 +346,7 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean {
   return true;
 }
 
-export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps }: Props) {
+export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps, streaming }: Props) {
   // Read opt-in metadata preference here so the strip below only renders when
   // the user has explicitly turned it on in PreferencesPopup (default false).
   const { showTurnMetadata } = useTheme();
@@ -371,7 +374,10 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC
         const isLastBubble = i === bubbles.length - 1;
         return (
           
-
+
{bubble.reasoning && ( )} diff --git a/desktop/src/renderer/components/ChatView.tsx b/desktop/src/renderer/components/ChatView.tsx index 626d1d587..7a418bc4c 100644 --- a/desktop/src/renderer/components/ChatView.tsx +++ b/desktop/src/renderer/components/ChatView.tsx @@ -730,6 +730,9 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane sessionId={sessionId} provider={provider} showTimestamps={showTimestamps} + // Only the LAST entry can be mid-stream; everything above + // it is complete and safe to reference. + streaming={state.isThinking && idx === state.timeline.length - 1} /> ); break; diff --git a/desktop/src/renderer/components/context-menu/ContextMenu.tsx b/desktop/src/renderer/components/context-menu/ContextMenu.tsx index 1e26fe755..b7b75ee6f 100644 --- a/desktop/src/renderer/components/context-menu/ContextMenu.tsx +++ b/desktop/src/renderer/components/context-menu/ContextMenu.tsx @@ -139,6 +139,7 @@ export function ContextMenu({ role="menuitem" disabled={entry.disabled} aria-disabled={entry.disabled || undefined} + title={entry.hint} tabIndex={-1} onClick={() => run(entry)} className={[ diff --git a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx index c8a0de1ab..2c00ce60b 100644 --- a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx +++ b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { buildContextMenu, type MenuEntry } from './build-menu'; import { ContextMenu } from './ContextMenu'; +import { useReference } from '../../state/reference-context'; // Single app-wide right-click host. Listens for `contextmenu` (capture) on the // document, asks build-menu what (if anything) applies to the target, and — only @@ -12,19 +13,24 @@ type MenuState = { x: number; y: number; entries: MenuEntry[] }; export function ContextMenuHost() { const [menu, setMenu] = useState(null); + // "Ask about this" now hands the menu a PendingReference instead of + // dispatching the old composer-scaffold CustomEvent — see reference-context.tsx. + const { setReference } = useReference(); useEffect(() => { const onContextMenu = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; - const entries = buildContextMenu(target); + const entries = buildContextMenu(target, setReference); if (!entries) return; // not our surface — leave the default behavior alone e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY, entries }); }; document.addEventListener('contextmenu', onContextMenu, true); return () => document.removeEventListener('contextmenu', onContextMenu, true); - }, []); + // setReference is useCallback-stable (reference-context.tsx), so listing it + // here does not re-subscribe the listener on every render. + }, [setReference]); if (!menu) return null; return setMenu(null)} />; diff --git a/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx b/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx index 3c9de93c4..a196cd787 100644 --- a/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx +++ b/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx @@ -13,6 +13,7 @@ import { render, act } from '@testing-library/react'; import { buildContextMenu } from './build-menu'; import { CodeEditorView } from '../artifact-views/CodeEditorView'; import { editorViewWithin } from '../artifact-views/cm/editor-registry'; +import type { PendingReference } from '../../state/reference-context'; // Minimal geometry shims CM6 needs under jsdom (it measures constantly; jsdom // implements none of it). Zero-rects are fine — we never assert layout. @@ -59,21 +60,32 @@ function selectLine(view: any, lineNo: number, throughLine?: number) { view.dispatch({ selection: { anchor: from, head: to } }); }); const text = view.state.sliceDoc(from, to); - vi.spyOn(window, 'getSelection').mockReturnValue({ toString: () => text } as any); + // Fuller Selection stub: buildArtifactReference (Task 3 wiring) also calls + // captureRange() to populate the reference's anchor, which needs rangeCount / + // isCollapsed / getRangeAt — not just toString(). A real DOM Selection always + // has these; only this synthetic CM6 stub didn't. + vi.spyOn(window, 'getSelection').mockReturnValue({ + toString: () => text, + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => ({ cloneRange: () => ({} as Range) }), + } as any); } describe('CM6 artifact context menu (real component)', () => { it('cites the TRUE line number for a selection far beyond any rendered viewport', () => { const { container, view } = mountEditor(); selectLine(view, 800); - const entries = buildContextMenu(container)!; + // Object wrapper, not a bare `let`: a `let` reassigned only inside the + // callback narrows to `never` at this read site under TS 5.9's control + // flow analysis (confirmed in isolation) — property access on it then + // fails to typecheck even though the runtime value is correct. + const captured: { ref: PendingReference | null } = { ref: null }; + const entries = buildContextMenu(container, (r) => { captured.ref = r; })!; const ask = entries.find((e: any) => e.id === 'ask') as any; expect(ask, 'Ask about this must exist for a CM6 selection').toBeTruthy(); - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - const composed = (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? ''; + const composed = captured.ref?.promptText ?? ''; expect(composed).toContain('line 800'); expect(composed).toContain('"src/big.ts"'); }); @@ -81,13 +93,11 @@ describe('CM6 artifact context menu (real component)', () => { it('cites a range across lines', () => { const { container, view } = mountEditor(); selectLine(view, 42, 45); - const entries = buildContextMenu(container)!; + const captured: { ref: PendingReference | null } = { ref: null }; + const entries = buildContextMenu(container, (r) => { captured.ref = r; })!; const ask = entries.find((e: any) => e.id === 'ask') as any; - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - const composed = (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? ''; + const composed = captured.ref?.promptText ?? ''; expect(composed).toContain('lines 42-45'); }); @@ -97,7 +107,7 @@ describe('CM6 artifact context menu (real component)', () => { // Right-click lands on a node inside .cm-content (contenteditable=false in // read mode) — the artifact branch must win. const target = (container.querySelector('.cm-content') as HTMLElement) ?? container; - const entries = buildContextMenu(target)!; + const entries = buildContextMenu(target, () => {})!; expect(entries.some((e: any) => e.id === 'ask')).toBe(true); expect(entries.some((e: any) => e.id === 'paste')).toBe(false); }); @@ -132,7 +142,7 @@ describe('CM6 artifact context menu (real component)', () => { ); const content = utils.container.querySelector('.cm-content[contenteditable="true"]') as HTMLElement; expect(content, 'editing CM6 must expose an editable .cm-content').toBeTruthy(); - const entries = buildContextMenu(content)!; + const entries = buildContextMenu(content, () => {})!; expect(entries.some((e: any) => e.id === 'paste')).toBe(true); expect(entries.some((e: any) => e.id === 'select-all')).toBe(true); }); diff --git a/desktop/src/renderer/components/context-menu/build-menu.test.tsx b/desktop/src/renderer/components/context-menu/build-menu.test.tsx index 6a41a2117..a9a3e572c 100644 --- a/desktop/src/renderer/components/context-menu/build-menu.test.tsx +++ b/desktop/src/renderer/components/context-menu/build-menu.test.tsx @@ -2,8 +2,9 @@ // Pins the artifact-viewer branch of the right-click menu: the "Ask about this" // scaffold must cite SOURCE LINE NUMBERS for raw text/code views and fall back to // a quote for rendered markdown (whose DOM doesn't map back to source lines). -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { buildContextMenu } from './build-menu'; +import type { PendingReference } from '../../state/reference-context'; // Builds the DOM shape MarkdownView emits for raw text (txt) and rendered md. // CODE files no longer use this shape — CodeMirror replaced CodeView, and its @@ -32,17 +33,16 @@ function selectWithin(node: Node, start: number, end: number) { sel.addRange(range); } -// Runs the menu's "Ask about this" action and returns the text it would insert -// into the composer (delivered via the youcoded:compose-insert CustomEvent). -function composedTextFor(container: HTMLElement): string | null { - const entries = buildContextMenu(container); +// Runs the menu's "Ask about this" action and returns the reference it produces. +// (v1 delivered a string via the youcoded:compose-insert CustomEvent; that event +// is retired — the action now hands a PendingReference to the host's callback.) +function referenceFor(container: HTMLElement): PendingReference | null { + let captured: PendingReference | null = null; + const entries = buildContextMenu(container, (r) => { captured = r; }); const ask = entries?.find((e) => e.type === 'item' && e.id === 'ask'); if (!ask || ask.type !== 'item') return null; - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - return (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? null; + return captured; } const FILE = 'alpha\nbravo\ncharlie\ndelta'; @@ -56,7 +56,7 @@ describe('artifact viewer context menu', () => { it('cites a single source line for a one-line selection', () => { const { container, pre } = mountViewer({ path: 'docs/notes.txt', source: 'raw', body: FILE }); selectWithin(pre, 6, 11); // "bravo" — second line - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing line 2 from "docs/notes.txt". Respond to the following prompt accordingly:\n\n', ); }); @@ -64,7 +64,7 @@ describe('artifact viewer context menu', () => { it('cites a line RANGE for a multi-line selection', () => { const { container, pre } = mountViewer({ path: 'src/app.ts', source: 'raw', body: FILE }); selectWithin(pre, 6, 19); // "bravo\ncharlie" — lines 2-3 - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing lines 2-3 from "src/app.ts". Respond to the following prompt accordingly:\n\n', ); }); @@ -72,21 +72,21 @@ describe('artifact viewer context menu', () => { it('falls back to a quote for rendered markdown (no reliable source mapping)', () => { const { container, pre } = mountViewer({ path: 'README.md', source: 'rendered', body: FILE }); selectWithin(pre, 6, 11); - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing "bravo" from "README.md". Respond to the following prompt accordingly:\n\n', ); }); it('offers no "Ask about this" without a selection — the whole file is never implied', () => { const { container } = mountViewer({ path: 'docs/notes.txt', source: 'raw', body: FILE }); - const entries = buildContextMenu(container); + const entries = buildContextMenu(container, () => {}); expect(entries?.some((e) => e.type === 'item' && e.id === 'ask')).toBe(false); }); it('leaves non-artifact, non-chat surfaces alone (no menu hijack)', () => { const stray = document.createElement('div'); document.body.appendChild(stray); - expect(buildContextMenu(stray)).toBeNull(); + expect(buildContextMenu(stray, () => {})).toBeNull(); }); it('gives the artifact edit textarea a cut/copy/paste menu', () => { @@ -94,7 +94,28 @@ describe('artifact viewer context menu', () => { ta.className = 'artifact-edit-textarea'; ta.value = 'draft text'; document.body.appendChild(ta); - const ids = buildContextMenu(ta)?.filter((e) => e.type === 'item').map((e: any) => e.id); + const ids = buildContextMenu(ta, () => {})?.filter((e) => e.type === 'item').map((e: any) => e.id); expect(ids).toEqual(['cut', 'copy', 'paste', 'select-all']); }); }); + +describe('streaming turns', () => { + it('disables Ask about this with a hint while the turn is still writing', () => { + const bubble = document.createElement('div'); + bubble.className = 'assistant-bubble'; + bubble.setAttribute('data-streaming', 'true'); + bubble.textContent = 'partial resp'; + const scroll = document.createElement('div'); + scroll.className = 'chat-scroll'; + scroll.appendChild(bubble); + document.body.appendChild(scroll); + + const entries = buildContextMenu(bubble, () => {})!; + const ask = entries.find((e) => e.type === 'item' && e.id === 'ask'); + expect(ask).toBeDefined(); // disabled, NOT removed + expect(ask!.type === 'item' && ask!.disabled).toBe(true); + expect(ask!.type === 'item' && ask!.hint).toBe( + 'Unavailable while Claude is still writing this message', + ); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/build-menu.ts b/desktop/src/renderer/components/context-menu/build-menu.ts index 7e22ac6b0..e17812802 100644 --- a/desktop/src/renderer/components/context-menu/build-menu.ts +++ b/desktop/src/renderer/components/context-menu/build-menu.ts @@ -2,6 +2,10 @@ import { isAndroid, isRemoteMode } from '../../platform'; import { copyText, readText } from './clipboard'; import { editorViewFor } from '../artifact-views/cm/editor-registry'; import type { MenuIconName } from './menu-icons'; +import { buildChatReference, buildCodeReference, buildArtifactReference } from './build-reference'; +import type { PendingReference } from '../../state/reference-context'; + +type OnReference = (r: PendingReference) => void; // Builds the chat right-click menu for a given DOM target. Pure inspection of // the DOM + current selection → a list of entries; the host owns positioning, @@ -17,6 +21,10 @@ export type MenuEntry = kbd?: string; primary?: boolean; disabled?: boolean; + /** Hover hint, rendered as `title`. Used to explain a DISABLED row. + * Native `title=` is the documented tool for plain hover hints; + * AnchorTip is for rich click-open info (AnchorTip.tsx:23-25). */ + hint?: string; run: () => void | Promise; } | { type: 'sep' }; @@ -39,6 +47,14 @@ function closestBubble(el: Element): Element | null { return el.closest('.assistant-bubble, .user-bubble'); } +// The lifted reference card is a static clone, so a still-streaming message +// would freeze mid-sentence inside it. Disabled (not hidden) per Destin +// 2026-07-26 — a vanishing row reads worse than a greyed one. +const STREAMING_HINT = 'Unavailable while Claude is still writing this message'; +function isStreaming(el: Element | null): boolean { + return el?.closest('[data-streaming="true"]') != null; +} + function baseName(p: string): string { return p.replace(/\\/g, '/').split('/').pop() || p; } @@ -52,19 +68,6 @@ function selectElementContents(el: Element): void { sel.addRange(range); } -// "Ask about this" drops a quoted reference + follow-up scaffold into the -// composer (InputBar listens for this CustomEvent — see InputBar.tsx). Simple v1 -// per Destin (2026-07-17): plain prompt text, no new plumbing. The caret lands -// right after the scaffold so any existing draft becomes the follow-up. -function askAboutThis(text: string): void { - window.dispatchEvent(new CustomEvent('youcoded:compose-insert', { detail: { text } })); -} - -function scaffold(lead: string, body: string, fenced: boolean): string { - const quoted = fenced ? '```\n' + body + '\n```' : `"${body}"`; - return `${lead}\n${quoted}\n\nThe user has a follow-up: `; -} - // Copy + Select all — shared tail for every read-only chat menu. function textBasics(bubble: Element | null): MenuEntry[] { const sel = selectionText(); @@ -179,49 +182,22 @@ function linkMenu(a: HTMLAnchorElement, target: HTMLElement): MenuEntry[] { ]; } -function codeMenu(pre: HTMLElement, target: HTMLElement): MenuEntry[] { +function codeMenu(pre: HTMLElement, target: HTMLElement, onReference: OnReference): MenuEntry[] { const code = pre.innerText.replace(/\n+$/, ''); return [ - { type: 'item', id: 'ask', label: 'Ask about this', icon: 'ask', primary: true, disabled: !code, run: () => askAboutThis(scaffold('Earlier, you shared this code:', code, true)) }, + { + type: 'item', id: 'ask', label: 'Ask about this', icon: 'ask', primary: true, + disabled: !code || isStreaming(target), + hint: isStreaming(target) ? STREAMING_HINT : undefined, + run: () => onReference(buildCodeReference(pre)), + }, { type: 'item', id: 'copy-code', label: 'Copy code block', icon: 'code', disabled: !code, run: () => void copyText(code) }, { type: 'sep' }, ...textBasics(closestBubble(target)), ]; } -// TEMP stopgap (Task 2 of the ask-reference plan): describeArtifactSelection MOVED -// to build-reference.ts (module-private there — see that file for the full -// rationale comment). artifactMenu below still needs the described-line string for -// its own "Ask about this" entry, and Task 3 hasn't rewired this call site yet, so -// this is a deliberate duplicate kept pure/side-effect-free (unlike -// build-reference.ts's buildArtifactReference, which also tags DOM markers for the -// reference overlay — calling that here would tag the DOM on every right-click, -// not just when the user picks "Ask"). Delete this once Task 3 rewires artifactMenu -// to hold a PendingReference instead of dispatching a compose-insert string. -function describeArtifactSelectionForAskMenu(sel: string, container: HTMLElement): string { - const source = container.getAttribute('data-artifact-source'); - if (source === 'cm6') { - const view = editorViewFor(container); - const range = view?.state.selection.main; - if (view && range && !range.empty) { - const startLine = view.state.doc.lineAt(range.from).number; - const endLine = view.state.doc.lineAt(range.to).number; - return startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`; - } - return `"${sel}"`; - } - const pre = source === 'raw' ? container.querySelector('pre') : null; - const full = pre?.textContent ?? ''; - const idx = pre ? full.indexOf(sel) : -1; - if (idx !== -1) { - const startLine = (full.slice(0, idx).match(/\n/g) || []).length + 1; - const endLine = startLine + (sel.match(/\n/g) || []).length; - return startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`; - } - return `"${sel}"`; -} - -function artifactMenu(container: HTMLElement): MenuEntry[] { +function artifactMenu(container: HTMLElement, onReference: OnReference): MenuEntry[] { // data-doc-path, not data-artifact-path: the latter is reserved by the deferred // image sub-menu roadmap item for an ABSOLUTE path on elements. This one // is the project-relative artifact path, which is what reads well in a prompt. @@ -229,39 +205,43 @@ function artifactMenu(container: HTMLElement): MenuEntry[] { const sel = selectionText().trim(); const entries: MenuEntry[] = []; if (sel && path) { - const ref = describeArtifactSelectionForAskMenu(sel, container); entries.push({ type: 'item', id: 'ask', label: 'Ask about this', icon: 'ask', primary: true, - run: () => askAboutThis(`The user is referencing ${ref} from "${path}". Respond to the following prompt accordingly:\n\n`), + run: () => { + const ref = buildArtifactReference(container); + if (ref) onReference(ref); + }, }); } entries.push(...textBasics(container)); return entries; } -function textMenu(target: HTMLElement): MenuEntry[] { +function textMenu(target: HTMLElement, onReference: OnReference): MenuEntry[] { const bubble = closestBubble(target); + const streaming = isStreaming(target); const quote = (selectionText().trim() || bubble?.textContent?.trim()) ?? ''; - // "you said" reads right for an assistant message; flip it for the user's own - // bubble, and stay neutral if we can't tell. - const lead = bubble?.classList.contains('assistant-bubble') - ? 'In an earlier message, you said:' - : bubble?.classList.contains('user-bubble') - ? 'Earlier I wrote:' - : 'Regarding this:'; const entries: MenuEntry[] = []; if (quote) { - entries.push({ type: 'item', id: 'ask', label: 'Ask about this', icon: 'ask', primary: true, run: () => askAboutThis(scaffold(lead, quote, false)) }); + entries.push({ + type: 'item', id: 'ask', label: 'Ask about this', icon: 'ask', primary: true, + disabled: streaming, + hint: streaming ? STREAMING_HINT : undefined, + run: () => { + const ref = buildChatReference(bubble, target); + if (ref) onReference(ref); + }, + }); } entries.push(...textBasics(bubble)); return entries; } -export function buildContextMenu(target: HTMLElement): MenuEntry[] | null { +export function buildContextMenu(target: HTMLElement, onReference: OnReference): MenuEntry[] | null { // Editable text surfaces (Cut/Copy/Paste/Select all) live outside .chat-scroll: // the composer, and the artifact viewer's edit-mode textarea. Electron ships no // default context menu, so without this branch right-click in the artifact @@ -282,7 +262,7 @@ export function buildContextMenu(target: HTMLElement): MenuEntry[] | null { // Artifact viewer (SessionDrawer / ProjectView file tab) lives outside // .chat-scroll, so it's checked before that gate. const artifactViewer = target.closest('[data-artifact-viewer]'); - if (artifactViewer instanceof HTMLElement) return finalize(artifactMenu(artifactViewer)); + if (artifactViewer instanceof HTMLElement) return finalize(artifactMenu(artifactViewer, onReference)); // Everything else is scoped to chat content — never hijack the terminal, the // settings panels, or other chrome. @@ -296,9 +276,9 @@ export function buildContextMenu(target: HTMLElement): MenuEntry[] | null { if (link instanceof HTMLAnchorElement) return finalize(linkMenu(link, target)); const pre = target.closest('pre'); - if (pre instanceof HTMLElement) return finalize(codeMenu(pre, target)); + if (pre instanceof HTMLElement) return finalize(codeMenu(pre, target, onReference)); - return finalize(textMenu(target)); + return finalize(textMenu(target, onReference)); } // Drop a menu with no actionable (enabled) item — e.g. a right-click on empty From fe124b1798b1cb21aaa7d69b4767ce1073b74040 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 14:43:24 -0700 Subject: [PATCH 06/27] fix(chat): memo comparator must compare `streaming` prop SESSION_PROCESS_EXITED and NATIVE_SESSION_ERROR call endTurn(session), flipping isThinking false without replacing the turn object in session.assistantTurns (unlike TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, which both create a new turn reference). For a text-only turn the memo comparator's group-ID loop never runs, so prev.turn === next.turn let a streaming true->false change through undetected, leaving data-streaming stuck at "true" and "Ask about this" permanently disabled after a crash mid-response. Add streaming to assistantTurnPropsAreEqual, and a real-React-render test that keeps the same turn reference across renders to prove the memo actually re-renders when only streaming flips. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/AssistantTurnBubble.test.tsx | 44 +++++++++++++++++++ .../components/AssistantTurnBubble.tsx | 8 ++++ 2 files changed, 52 insertions(+) diff --git a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx index ef708f1d4..b549e7388 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx @@ -304,6 +304,50 @@ describe('AssistantTurnBubble — memo comparator (streaming perf)', () => { expect(mdRenders.length).toBeGreaterThan(rendersAfterMount); }); + + it('DOES re-render when `streaming` flips with the SAME turn object reference (crash/error path)', () => { + // Pins the bug: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session), + // which flips isThinking false WITHOUT replacing the turn object in + // session.assistantTurns (unlike TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, + // which both do assistantTurns.set(id, {...turn, ...})). For a text-only turn with + // no tool groups, `turn` stays === across the re-render and the per-group loop in + // the comparator never runs — `streaming` must be compared explicitly or the memo + // silently blocks the update and data-streaming stays "true" forever, permanently + // disabling "Ask about this" on a finished message. + const turn: AssistantTurn = { + id: 'turn_streaming', + segments: [{ type: 'text' as const, content: 'hello world', messageId: 'turn_streaming-msg' }], + timestamp: 0, + stopReason: null, + model: null, + usage: null, + anthropicRequestId: null, + }; + const toolGroups = new Map(); + const toolCalls = new Map(); + + const props = { turn, toolGroups, toolCalls, sessionId: 'test', showTimestamps: false }; + const { container, rerender } = render( + + + + ); + + const bubbleEl = container.querySelector('.assistant-bubble'); + expect(bubbleEl).not.toBeNull(); + expect(bubbleEl).toHaveAttribute('data-streaming', 'true'); + + // Same `turn` object reference — only `streaming` changes, exactly what + // endTurn() produces on the crash/error paths. + rerender( + + + + ); + + const bubbleElAfter = container.querySelector('.assistant-bubble'); + expect(bubbleElAfter).not.toHaveAttribute('data-streaming'); + }); }); describe('splitIntoBubbles — BUG A (tool group mis-attribution after interleaved reasoning)', () => { diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index 3adcf70ff..37c9be03c 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -330,6 +330,14 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean { if (prev.sessionId !== next.sessionId) return false; if (prev.provider !== next.provider) return false; if (prev.showTimestamps !== next.showTimestamps) return false; + // WHY: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session), + // which flips isThinking false WITHOUT replacing the turn object (unlike + // TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, which both do + // assistantTurns.set(id, {...turn, ...})). For a text-only turn with no + // tool groups, `streaming` going true->false is otherwise the ONLY prop + // that changes — miss it here and data-streaming stays "true" forever, + // permanently disabling "Ask about this" on a finished message. + if (prev.streaming !== next.streaming) return false; // Same turn object (checked above) ⇒ same segments ⇒ same group IDs. We only // need to walk one side's IDs. From 946b2ccd0540a099df2f7e1c48339f8b58df3a65 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 14:53:30 -0700 Subject: [PATCH 07/27] feat(reference): composer placeholder + send-time scaffold assembly The composer now holds ONLY the user's words: placeholderFor() announces the held reference, composeOutgoing() prepends its promptText at send, and the youcoded:compose-insert CustomEvent is retired (0 producers, 0 consumers). Feature is functionally complete here; Tasks 5-9 add the visual layer. Also updates InputBar.test.tsx: InputBar now calls useReference() unconditionally, so every render site needs a ReferenceProvider ancestor to avoid throwing (matches how App.tsx scopes the real provider by sessionId). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/InputBar.reference.test.tsx | 63 +++++++++++ .../src/renderer/components/InputBar.test.tsx | 35 ++++-- desktop/src/renderer/components/InputBar.tsx | 101 +++++++++++------- 3 files changed, 150 insertions(+), 49 deletions(-) create mode 100644 desktop/src/renderer/components/InputBar.reference.test.tsx diff --git a/desktop/src/renderer/components/InputBar.reference.test.tsx b/desktop/src/renderer/components/InputBar.reference.test.tsx new file mode 100644 index 000000000..dedd8a010 --- /dev/null +++ b/desktop/src/renderer/components/InputBar.reference.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +// Pins the two composer contracts of the held reference (spec 2026-07-26 §3.5): +// 1. the placeholder announces the reference, and +// 2. promptText is prepended EXACTLY ONCE at send, then the reference clears — +// while the user's own draft is never touched by a cancel. +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ReferenceProvider, useReference, type PendingReference } from '../state/reference-context'; +import { composeOutgoing, placeholderFor } from './InputBar'; + +const REF: PendingReference = { + kind: 'chat-text', + label: '"the reducer preserves…"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, +}; + +describe('placeholderFor', () => { + it('falls back to the default with no reference', () => { + expect(placeholderFor(null, false)).toBe('Message Claude...'); + }); + + it('announces the held reference', () => { + expect(placeholderFor(REF, false)).toBe('Ask Claude about "the reducer preserves…"'); + }); + + it('the approval gate still wins over a held reference', () => { + expect(placeholderFor(REF, true)).toBe('Waiting for approval...'); + }); +}); + +describe('composeOutgoing', () => { + it('returns the draft unchanged with no reference', () => { + expect(composeOutgoing('why?', null)).toBe('why?'); + }); + + it('prepends promptText exactly once', () => { + expect(composeOutgoing('why?', REF)).toBe(REF.promptText + 'why?'); + }); + + it('sends the scaffold alone when the draft is empty', () => { + expect(composeOutgoing('', REF)).toBe(REF.promptText); + }); +}); + +// Hoisted to module scope (matches reference-context.test.tsx's Probe idiom): +// tsc's definite-assignment check only exempts USAGE inside a nested closure +// (e.g. act(() => api.foo())) — a direct `expect(api.reference)` in the SAME +// scope as a local `let api` declaration still trips TS2454 "used before +// being assigned", even though render() has synchronously run Probe by then. +let api: ReturnType; +function Probe() { api = useReference(); return null; } + +describe('cancel does not touch the draft', () => { + it('clearReference leaves composer state alone', () => { + render(); + act(() => api.setReference(REF)); + act(() => api.clearReference()); + // The context owns ONLY the reference — it has no draft to clobber. + expect(api.reference).toBeNull(); + }); +}); diff --git a/desktop/src/renderer/components/InputBar.test.tsx b/desktop/src/renderer/components/InputBar.test.tsx index 9f9795cd7..f53b7694c 100644 --- a/desktop/src/renderer/components/InputBar.test.tsx +++ b/desktop/src/renderer/components/InputBar.test.tsx @@ -5,6 +5,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react'; import { ChatProvider, useChatDispatch, useChatStore } from '../state/chat-context'; import { SkillProvider } from '../state/skill-context'; +// Task 4: InputBar now calls useReference() unconditionally (placeholder + +// send-time scaffold assembly), which throws outside a ReferenceProvider — +// every render site below needs the wrapper, same sessionId as the InputBar +// under test (App.tsx scopes ReferenceProvider by sessionId the same way). +import { ReferenceProvider } from '../state/reference-context'; import InputBar, { InputBarHandle } from './InputBar'; // jsdom (per this repo's vitest.config.ts) has no global setupFiles/polyfills — @@ -74,7 +79,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -114,7 +121,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -150,7 +159,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -211,8 +222,10 @@ describe('InputBar — stop button (Task 10 placement)', () => { render( - - + + + + , ); @@ -298,7 +311,9 @@ describe('InputBar — InputBarHandle hasDraft/fillDraft (Task 11)', () => { render( - + + + , ); @@ -370,9 +385,11 @@ describe('InputBar native send — queued ack dispatches QUEUED_MESSAGE_ADDED, n render( - - - + + + + + , ); diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index 9a8f2207a..e42bff3d7 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -19,18 +19,19 @@ import type { NativeSendResult } from '../../shared/types'; import { useScrollFade } from '../hooks/useScrollFade'; import { useStreamingGate } from '../hooks/useStreamingGate'; import { isAndroid } from '../platform'; +import { useReference, type PendingReference } from '../state/reference-context'; export interface InputBarHandle { clear: () => void; // Task 11 (cancel/edit queued messages): the edit-refill idiom. There was no // existing "external surface reads/replaces InputBar's draft" mechanism — - // `initialInput` fills once per session id (already consumed for an ACTIVE - // session) and the `youcoded:compose-insert` CustomEvent only prepends - // fire-and-forget (no way to check emptiness first, which the brief's - // ordering — refuse BEFORE removing the queued entry — requires). Extending - // this existing ref (already used by App for `clear()`) with a synchronous - // read + an unconditional replace was the smallest addition that supports - // the required check-then-act sequence; see task-11-report.md. + // `initialInput` only fills once per session id (already consumed for an + // ACTIVE session), which can't support a check-then-act sequence (the + // brief's ordering — refuse BEFORE removing the queued entry — requires + // reading emptiness first). Extending this existing ref (already used by + // App for `clear()`) with a synchronous read + an unconditional replace was + // the smallest addition that supports the required sequence; see + // task-11-report.md. /** True when the composer currently holds a non-empty (trimmed) draft. */ hasDraft: () => boolean; /** Replace the composer's content with `text` and focus it. Caller must call @@ -102,6 +103,26 @@ function sendFailureCopy(result: NativeSendResult | undefined): string { return 'The message could not be sent — no response from the session host.'; } +/** + * Composer placeholder. A held reference replaces "Message Claude..." so the + * empty box states what the next message is about (spec 2026-07-26 §2.1). + * The approval gate outranks it — that copy is a hard block, not a hint. + */ +export function placeholderFor(reference: PendingReference | null, disabled: boolean): string { + if (disabled) return 'Waiting for approval...'; + if (reference) return `Ask Claude about ${reference.label}`; + return 'Message Claude...'; +} + +/** + * Assembles what actually goes to Claude. The scaffold lives in the reference, + * NOT in the textarea — this is the whole point of the 2026-07-26 redesign, so + * the user's draft is only ever their own words. + */ +export function composeOutgoing(draft: string, reference: PendingReference | null): string { + return reference ? reference.promptText + draft : draft; +} + const InputBar = forwardRef(function InputBar({ sessionId, disabled, minimal, compact, view, onOpenDrawer, onCloseDrawer, onDrawerSearch, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker, initialInput, provider }, ref) { const [text, setText] = useState(''); const [attachments, setAttachments] = useState([]); @@ -127,6 +148,11 @@ const InputBar = forwardRef(function InputBar({ sessionId // predicate Task 6 used in ChatView (isThinking && attentionState==='ok'). const showStop = useStreamingGate(sessionId); + // The "Ask Claude about this" held reference (spec 2026-07-26). Scoped by + // ReferenceProvider per-session (App.tsx), same parking idiom as draftsRef + // below — reference and draft are independent pieces of per-session state. + const { reference, clearReference } = useReference(); + // Per-session draft store — keeps input text and attachments separate // across sessions so switching away and back preserves your draft. const draftsRef = useRef>(new Map()); @@ -293,27 +319,6 @@ const InputBar = forwardRef(function InputBar({ sessionId return () => window.removeEventListener('buddy:attach-file', listener); }, [addFiles]); - // External "insert into composer" entry point — the chat right-click menu's - // "Ask about this" action dispatches this window CustomEvent with a pre-built - // quote + follow-up scaffold. Mirrors buddy:attach-file so no prop threading - // is needed. We PREPEND the scaffold and drop the caret right after it, so any - // draft the user was already typing survives as the follow-up text. - useEffect(() => { - const listener = (e: Event) => { - const insert = (e as CustomEvent<{ text?: string }>).detail?.text; - if (!insert) return; - setText((prev) => insert + prev); - requestAnimationFrame(() => { - const el = inputRef.current; - if (!el) return; - el.focus(); - el.setSelectionRange(insert.length, insert.length); - }); - }; - window.addEventListener('youcoded:compose-insert', listener); - return () => window.removeEventListener('youcoded:compose-insert', listener); - }, []); - const removeAttachment = useCallback((path: string) => { setAttachments((prev) => prev.filter((a) => a.path !== path)); }, []); @@ -382,12 +387,21 @@ const InputBar = forwardRef(function InputBar({ sessionId // Dispatcher may rewrite the message (e.g. strip escape-hatch backslash) const effectiveMessage = dispatchResult.rewritten ?? message; - // One sanitized source string for BOTH the optimistic bubble and the PTY - // send. The transcript confirms the bubble by EXACT content match, so if - // the bubble kept newlines the send stripped, a multiline message could - // never be confirmed — `pending` stayed set forever and - // useSubmitConfirmation fired a stray recovery \r. See outgoing-message.ts. - const outgoing = buildOutgoingMessage(effectiveMessage, files.map((f) => f.path)); + // Bubble content is built from the user's own words only — never the + // held reference's scaffold. The chat bubble must show what the user + // typed, not the assembled outgoing string (spec §7). This intentionally + // duplicates the sanitize-and-join buildOutgoingMessage does below — + // see its header comment for why bubble and PTY text used to share + // exactly one string; TRANSCRIPT_USER_MESSAGE dedup now matches on the + // pending flag, not content, so the two are free to diverge here. + const bubbleMessage = buildOutgoingMessage(effectiveMessage, files.map((f) => f.path)); + + // The held reference's scaffold is prepended HERE, at send — it was + // never in the textarea. On a refused send (the gate above, or + // `disabled` below) we return before this point, so the reference + // survives alongside the draft (spec §7) — clearReference() only runs + // on the success path in `send()`. + const outgoing = buildOutgoingMessage(composeOutgoing(effectiveMessage, reference), files.map((f) => f.path)); if (!outgoing) return true; // nothing to send — treat as consumed if (disabled) return false; @@ -445,14 +459,16 @@ const InputBar = forwardRef(function InputBar({ sessionId type: 'QUEUED_MESSAGE_ADDED', sessionId, queueId: result.queueId, - content: outgoing.content, + // bubbleMessage, not outgoing — see the bubbleMessage comment above. + content: bubbleMessage?.content ?? '', timestamp: Date.now(), }); } else { dispatch({ type: 'USER_PROMPT', sessionId, - content: outgoing.content, + // bubbleMessage, not outgoing — see the bubbleMessage comment above. + content: bubbleMessage?.content ?? '', timestamp: Date.now(), attachments: files.map((f) => f.path), }); @@ -467,7 +483,8 @@ const InputBar = forwardRef(function InputBar({ sessionId dispatch({ type: 'USER_PROMPT', sessionId, - content: outgoing.content, + // bubbleMessage, not outgoing — see the bubbleMessage comment above. + content: bubbleMessage?.content ?? '', timestamp: Date.now(), // Exact attachment paths so UserMessage can render each as a clickable // pill — file-picker paths routinely contain spaces, which the joined @@ -505,7 +522,7 @@ const InputBar = forwardRef(function InputBar({ sessionId }, submitStart); return true; }, - [sessionId, disabled, dispatch, view, provider, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], + [sessionId, disabled, dispatch, view, provider, reference, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], ); // Auto-resize textarea to fit content, up to 3 lines then scroll @@ -550,10 +567,14 @@ const InputBar = forwardRef(function InputBar({ sessionId setText(''); setAttachments([]); draftsRef.current.delete(sessionId); // Clear stored draft after sending + // Reference is consumed once its scaffold has been sent (spec §7). A + // refused send returns above, before this line, so the reference + // survives alongside the draft — see sendMessage's outgoing comment. + clearReference(); onCloseDrawer?.(); // Reset height after clearing if (inputRef.current) inputRef.current.style.height = 'auto'; - }, [text, attachments, sendMessage, onCloseDrawer, sessionId]); + }, [text, attachments, sendMessage, onCloseDrawer, sessionId, clearReference]); // Keep sendRef pointing at the latest send so the global keydown handler // (which can't depend on send without thrashing the listener) stays current @@ -771,7 +792,7 @@ const InputBar = forwardRef(function InputBar({ sessionId } }} onPaste={handlePaste} - placeholder={disabled ? 'Waiting for approval...' : 'Message Claude...'} + placeholder={placeholderFor(reference, !!disabled)} disabled={disabled} // Text color is transparent so the mirror div behind it shows // through (with animated keyword spans). caret-color keeps the From d7b972f24cef3a356d99567acf99f765a1bd08ba Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 15:00:49 -0700 Subject: [PATCH 08/27] fix(inputbar): keep held reference and draft as one unit across two gaps Gap 1: a failed async native send restored the draft but not the held reference (clearReference() had already run synchronously before the ack settled). Restore both together now, guarded against clobbering a newer reference set during the round-trip. Gap 2: minimal (terminal view) send paths write straight to the PTY and never consume a held reference, so it stayed parked invisibly if the user switched from chat to terminal view. placeholderFor no longer announces a reference in minimal mode, and an effect clears it outright rather than attempting to prepend the scaffold to a PTY write (which desktop/CLAUDE.md documents as unsafe under ConPTY chunking). Widened ReferenceApi.setReference to also accept a (prev) => next updater, needed for the gap-1 guarded restore. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/InputBar.reference.test.tsx | 10 + .../src/renderer/components/InputBar.test.tsx | 222 +++++++++++++++++- desktop/src/renderer/components/InputBar.tsx | 48 +++- .../src/renderer/state/reference-context.tsx | 13 +- 4 files changed, 285 insertions(+), 8 deletions(-) diff --git a/desktop/src/renderer/components/InputBar.reference.test.tsx b/desktop/src/renderer/components/InputBar.reference.test.tsx index dedd8a010..bcb870463 100644 --- a/desktop/src/renderer/components/InputBar.reference.test.tsx +++ b/desktop/src/renderer/components/InputBar.reference.test.tsx @@ -28,6 +28,16 @@ describe('placeholderFor', () => { it('the approval gate still wins over a held reference', () => { expect(placeholderFor(REF, true)).toBe('Waiting for approval...'); }); + + // Gap 2 (task-4-report.md "Concerns" #2): minimal (terminal view) send + // paths write straight to the PTY and never call composeOutgoing, so a + // reference can never be consumed there. Announcing it in the placeholder + // would promise a scaffold that will never be sent — minimal must silence + // it even though a reference IS held (default `minimal` arg is `false`, + // which is why every other test above still sees it announced). + it('does not announce a held reference in minimal (terminal view) mode', () => { + expect(placeholderFor(REF, false, true)).toBe('Message Claude...'); + }); }); describe('composeOutgoing', () => { diff --git a/desktop/src/renderer/components/InputBar.test.tsx b/desktop/src/renderer/components/InputBar.test.tsx index f53b7694c..f34324b2d 100644 --- a/desktop/src/renderer/components/InputBar.test.tsx +++ b/desktop/src/renderer/components/InputBar.test.tsx @@ -9,9 +9,21 @@ import { SkillProvider } from '../state/skill-context'; // send-time scaffold assembly), which throws outside a ReferenceProvider — // every render site below needs the wrapper, same sessionId as the InputBar // under test (App.tsx scopes ReferenceProvider by sessionId the same way). -import { ReferenceProvider } from '../state/reference-context'; +// useReference/PendingReference are additionally needed by the gap-1/gap-2 +// regression tests below, which read/set the held reference directly via a +// Probe component (same idiom as InputBar.reference.test.tsx). +import { ReferenceProvider, useReference, type PendingReference } from '../state/reference-context'; import InputBar, { InputBarHandle } from './InputBar'; +// Hoisted to module scope (mirrors InputBar.reference.test.tsx's Probe idiom): +// tsc's definite-assignment check only exempts USAGE inside a nested closure +// relative to the declaration — a local `let api` inside an `it(...)` body +// with a direct `expect(api...)` in the same scope still trips TS2454, even +// though render() has synchronously run Probe by then. Module scope makes +// every usage (inside a nested `it()` closure) exempt. +let referenceApi: ReturnType; +function ReferenceProbe() { referenceApi = useReference(); return null; } + // jsdom (per this repo's vitest.config.ts) has no global setupFiles/polyfills — // useScrollFade (mounted unconditionally by InputBar's textarea) reaches for // ResizeObserver, which jsdom doesn't implement. A no-op stub is enough since @@ -185,6 +197,214 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi }); }); +// Gap 1 (task-4-report.md "Concerns" #1): a failed async native send restores +// the draft (tested above) but, before this fix, silently dropped the held +// reference — clearReference() already ran synchronously in send() right +// after the optimistic sendMessage() returned true, before the ack settled. +// The user got their text back with the "Ask Claude about X" scaffold gone, +// and resending would have silently omitted it. +describe('InputBar native send — failure also restores the held reference (gap 1 fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"earlier text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { + supported: true, + send: vi.fn(), + }, + session: { + sendInput: vi.fn(), + }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('restores the reference alongside the draft when the ack is failed', async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "earlier text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'hello world' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + // send() clears the reference synchronously on the optimistic path — + // this is correct and unchanged (spec §7's success-path clear). + expect(referenceApi.reference).toBeNull(); + expect(textarea.value).toBe(''); + + resolveAck!({ status: 'failed', reason: 'not-live' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(textarea.value).toBe('hello world'); + }); + // The fix: the reference travels back with the draft instead of staying + // gone. Without it, this stays null and the test fails. + expect(referenceApi.reference).toEqual(REF); + }); + + it('does NOT clobber a newer reference the user set during the ack round-trip', async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "earlier text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'first message' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + expect(referenceApi.reference).toBeNull(); + + // User picks a NEW reference while the first send's ack is still in flight. + const REF2: PendingReference = { ...REF, label: '"newer text"' }; + act(() => { referenceApi.setReference(REF2); }); + + resolveAck!({ status: 'failed', reason: 'queue-full' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + // The guard (`cur ?? reference`) must have refused to overwrite — the + // newer reference survives, the lost first reference does not reappear. + expect(referenceApi.reference).toEqual(REF2); + }); +}); + +// Gap 2 (task-4-report.md "Concerns" #2): terminal view's send paths +// (handleSubmit's `minimal` branch, the textarea onKeyDown's `minimal` +// branch) write straight to the PTY and never call sendMessage/ +// composeOutgoing, so a reference held in chat view would otherwise survive +// invisibly if the user switched to terminal view and sent from there — the +// scaffold is never sent AND the reference never clears. This suite covers +// the "clearing behavior" half (placeholderFor's silencing is covered by the +// pure-function test in InputBar.reference.test.tsx); driving an actual PTY +// send in jsdom isn't attempted here per the brief's guidance. +describe('InputBar — minimal (terminal) mode clears a held reference (gap 2 fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"earlier text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { supported: true, send: vi.fn() }, + session: { sendInput: vi.fn() }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('clears a reference held from chat view when the composer switches into minimal mode', () => { + const { rerender } = render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + expect(referenceApi.reference).toEqual(REF); + expect(screen.getByPlaceholderText('Ask Claude about "earlier text"')).toBeInTheDocument(); + + // Same sessionId, same provider instances — only `minimal` flips, exactly + // like the real chat-view -> terminal-view toggle (Ctrl+`). + rerender( + + + + + + + + , + ); + + // Without the fix, this stays REF — nothing ever clears it, and the + // "Ask Claude about ..." placeholder would keep promising a scaffold + // that terminal view's send path can never deliver. + expect(referenceApi.reference).toBeNull(); + }); + + it('clears a reference set while the composer is already in minimal mode', () => { + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + + // The effect's dep array includes `reference` (not just `minimal`), so it + // re-fires on this set and clears it right back out rather than only + // catching the chat-to-terminal transition. + expect(referenceApi.reference).toBeNull(); + }); +}); + // Task 10 (Destin placement ruling): the stop control moved from beside the // ThinkingIndicator in ChatView into the composer row, immediately left of // the send button. Same visibility gate (isThinking && attentionState === diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index e42bff3d7..75cba7039 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -108,9 +108,16 @@ function sendFailureCopy(result: NativeSendResult | undefined): string { * empty box states what the next message is about (spec 2026-07-26 §2.1). * The approval gate outranks it — that copy is a hard block, not a hint. */ -export function placeholderFor(reference: PendingReference | null, disabled: boolean): string { +export function placeholderFor(reference: PendingReference | null, disabled: boolean, minimal = false): string { if (disabled) return 'Waiting for approval...'; - if (reference) return `Ask Claude about ${reference.label}`; + // Fix (gap 2, task-4-report.md): `minimal` (terminal view) send paths write + // straight to the PTY and never call composeOutgoing (see handleSubmit / + // the textarea's onKeyDown below) — the reference can never be consumed + // there, so announcing it here would promise a scaffold that will never be + // sent. The reference is a chat-composer concept; it goes silent in + // minimal mode. See the clearReference-on-minimal effect in the component + // for why nothing stays held invisibly either. + if (reference && !minimal) return `Ask Claude about ${reference.label}`; return 'Message Claude...'; } @@ -151,7 +158,27 @@ const InputBar = forwardRef(function InputBar({ sessionId // The "Ask Claude about this" held reference (spec 2026-07-26). Scoped by // ReferenceProvider per-session (App.tsx), same parking idiom as draftsRef // below — reference and draft are independent pieces of per-session state. - const { reference, clearReference } = useReference(); + const { reference, setReference, clearReference } = useReference(); + + // Fix (gap 2, task-4-report.md): terminal view's send paths (handleSubmit's + // minimal branch, the textarea's onKeyDown minimal branch) write straight + // to the PTY and never touch composeOutgoing/sendMessage, so a reference + // held while the composer is in minimal mode can never be consumed or + // announced (placeholderFor also silences it above, in minimal mode). A + // reference held while its source is off-screen and unconsumable is worse + // than no reference — it would look live in chat view but do nothing here, + // then resurface in chat view later as a stale, forgotten scaffold. + // Deliberately NOT attempting to prepend the scaffold to the PTY write + // instead: desktop/CLAUDE.md documents Windows-ConPTY chunking constraints + // (SAFE_ATOMIC_LEN / echo-driven submit) that a multi-line scaffold prepend + // would trip. Clearing outright is the explicit, honest behavior. + // `reference` is in the dep array (not just `minimal`) so this also covers + // a reference getting set WHILE already in minimal mode (e.g. some future + // caller of setReference that isn't chat-view-gated) — not only the + // chat-to-terminal transition. + useEffect(() => { + if (minimal && reference) clearReference(); + }, [minimal, reference, clearReference]); // Per-session draft store — keeps input text and attachments separate // across sessions so switching away and back preserves your draft. @@ -446,6 +473,17 @@ const InputBar = forwardRef(function InputBar({ sessionId // clobber newer input. setText((cur) => (cur.trim() ? cur : effectiveMessage)); setAttachments((cur) => (cur.length > 0 ? cur : files)); + // Fix (gap self-reported in task-4-report.md): `send()` also ran + // clearReference() synchronously in that same post-return block, + // so a failed ack was restoring the draft but leaving the + // reference gone — the user got their text back with the "Ask + // Claude about X" scaffold silently dropped, and resending would + // no longer include it. The draft and its reference are one unit + // (composeOutgoing prepends the scaffold to the draft at send + // time): restoring one without the other silently changes what + // gets sent. Guarded the same way as text/attachments above — + // only refill if nothing newer was set during the round-trip. + setReference((cur) => cur ?? reference); return; } // Task 12: a 'queued' ack dispatches QUEUED_MESSAGE_ADDED instead of @@ -522,7 +560,7 @@ const InputBar = forwardRef(function InputBar({ sessionId }, submitStart); return true; }, - [sessionId, disabled, dispatch, view, provider, reference, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], + [sessionId, disabled, dispatch, view, provider, reference, setReference, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], ); // Auto-resize textarea to fit content, up to 3 lines then scroll @@ -792,7 +830,7 @@ const InputBar = forwardRef(function InputBar({ sessionId } }} onPaste={handlePaste} - placeholder={placeholderFor(reference, !!disabled)} + placeholder={placeholderFor(reference, !!disabled, !!minimal)} disabled={disabled} // Text color is transparent so the mirror div behind it shows // through (with animated keyword spans). caret-color keeps the diff --git a/desktop/src/renderer/state/reference-context.tsx b/desktop/src/renderer/state/reference-context.tsx index aea8cc7f4..7d5fa9e7e 100644 --- a/desktop/src/renderer/state/reference-context.tsx +++ b/desktop/src/renderer/state/reference-context.tsx @@ -40,7 +40,11 @@ export type PendingReference = { type ReferenceApi = { reference: PendingReference | null; - setReference: (r: PendingReference | null) => void; + // Accepts a value OR a React-style updater (prev) => next — the updater form + // lets callers restore a reference conditionally (e.g. "only if nothing newer + // was set meanwhile") without a stale-closure read of `reference`. See the + // native-send-failure restore in InputBar.tsx for the motivating case. + setReference: (r: PendingReference | null | ((prev: PendingReference | null) => PendingReference | null)) => void; clearReference: () => void; }; @@ -67,7 +71,12 @@ export function ReferenceProvider({ sessionId, children }: { sessionId: string; prevSession.current = sessionId; }, [sessionId]); - const setReference = useCallback((r: PendingReference | null) => setReferenceState(r), []); + // Forwards straight to useState's setter, which already accepts both a plain + // value and an updater function — no extra branching needed here. + const setReference = useCallback( + (r: PendingReference | null | ((prev: PendingReference | null) => PendingReference | null)) => setReferenceState(r), + [], + ); const clearReference = useCallback(() => setReferenceState(null), []); // Memoized: this context changes only on set/clear, so consumers must not From 9e6eb3bcd633647e2b22a294039004b289134658 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 15:15:19 -0700 Subject: [PATCH 09/27] fix(inputbar): soft-fail useReference() in Buddy windows + guard cross-session reference leak Critical: BuddyChatApp/BuddyOverlayApp mount InputBar under ThemeProvider > ChatProvider with no ReferenceProvider ancestor, so useReference() threw on every render and the Buddy companion window rendered blank. It now soft-fails to an inert API, matching the useEscClose convention. Important: a native-send failure restored a held reference via setReference((cur) => cur ?? reference) unconditionally. If the user switched sessions while the send was in flight, this wrote the old session's reference into the new session's live slot on the single app-wide ReferenceProvider. Guarded on session identity via a live activeSessionIdRef. Adds the first tests under src/renderer/components/buddy/, plus a session-switch regression test in InputBar.test.tsx. Each test verified to fail against the unfixed code (see task-4-report.md). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/renderer/components/InputBar.test.tsx | 96 +++++++++++++++++++ desktop/src/renderer/components/InputBar.tsx | 29 +++++- .../components/buddy/BuddyChat.test.tsx | 60 ++++++++++++ .../src/renderer/state/reference-context.tsx | 24 ++++- 4 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 desktop/src/renderer/components/buddy/BuddyChat.test.tsx diff --git a/desktop/src/renderer/components/InputBar.test.tsx b/desktop/src/renderer/components/InputBar.test.tsx index f34324b2d..3222ebf93 100644 --- a/desktop/src/renderer/components/InputBar.test.tsx +++ b/desktop/src/renderer/components/InputBar.test.tsx @@ -313,6 +313,102 @@ describe('InputBar native send — failure also restores the held reference (gap }); }); +// Reviewer Important fix: the ONE app-wide ReferenceProvider is keyed to +// whichever session is currently active (App.tsx wraps it around the whole +// tree, scoped by the active sessionId — see reference-context.tsx's +// per-session parking effect). If the user switches sessions while a native +// send's ack is still in flight, the gap-1 restore above (`setReference((cur) +// => cur ?? reference)`) would otherwise write the ORIGINAL session's +// reference into the NEW session's live slot — quoted content from a +// different conversation silently attaching to the next message sent from +// the session now on screen. This harness renders InputBar AND +// ReferenceProvider both keyed off one `sessionId` prop (mirroring how +// App.tsx wires them together) so `rerender` with a new sessionId reproduces +// an actual session switch, not just a prop tweak on InputBar alone. +describe('InputBar native send — session switch during flight must not leak the reference (reviewer Critical fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"session A text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { + supported: true, + send: vi.fn(), + }, + session: { + sendInput: vi.fn(), + }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + function Harness({ sessionId, onToast }: { sessionId: string; onToast: (m: string) => void }) { + return ( + + + + + + + + + ); + } + + it("does NOT attach session A's reference to session B after the user switches sessions mid-flight", async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + const { rerender } = render(); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "session A text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'hello from A' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + // Optimistic send already cleared the reference (unchanged, correct + // success-path behavior) — the failure branch's restore is what's under + // test here. + expect(referenceApi.reference).toBeNull(); + + // The user switches to a different session BEFORE session A's native + // ack comes back — sessionId prop changes on BOTH InputBar and + // ReferenceProvider together, exactly as App.tsx wires them. + rerender(); + // Session B has never held a reference — its live slot is null. + expect(referenceApi.reference).toBeNull(); + + // NOW session A's send fails. + resolveAck!({ status: 'failed', reason: 'not-live' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + // The fix: session A's reference must NOT leak into session B's live + // slot. Without the sessionId guard, `setReference((cur) => cur ?? + // reference)` sees session B's null slot and unconditionally refills it + // with session A's REF — this assertion catches exactly that leak. + expect(referenceApi.reference).toBeNull(); + }); +}); + // Gap 2 (task-4-report.md "Concerns" #2): terminal view's send paths // (handleSubmit's `minimal` branch, the textarea onKeyDown's `minimal` // branch) write straight to the PTY and never call sendMessage/ diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index 75cba7039..deb0eb640 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -184,6 +184,14 @@ const InputBar = forwardRef(function InputBar({ sessionId // across sessions so switching away and back preserves your draft. const draftsRef = useRef>(new Map()); const prevSessionRef = useRef(sessionId); + // Fix (reviewer Critical, cross-session reference leak): always mirrors the + // CURRENTLY rendered sessionId prop — unlike the `sessionId` a send's async + // callback closes over (which is frozen at the moment that particular send + // started), this ref is live. The native-send failure path below compares + // its closure's sessionId against this ref to detect "the user switched + // sessions while the ack was still in flight" before restoring a reference. + const activeSessionIdRef = useRef(sessionId); + useEffect(() => { activeSessionIdRef.current = sessionId; }, [sessionId]); useEffect(() => { const prev = prevSessionRef.current; if (prev === sessionId) return; @@ -483,7 +491,26 @@ const InputBar = forwardRef(function InputBar({ sessionId // time): restoring one without the other silently changes what // gets sent. Guarded the same way as text/attachments above — // only refill if nothing newer was set during the round-trip. - setReference((cur) => cur ?? reference); + // + // Fix (reviewer Critical, cross-session reference leak): ALSO + // guarded on session identity. There is exactly one + // ReferenceProvider for the whole app, keyed to whichever session + // is currently active — its per-session parking effect swaps the + // live slot the instant the user switches sessions. `sessionId` + // here is frozen at the moment THIS send started (closure); if + // the user switched sessions before this ack came back, + // `activeSessionIdRef.current` now names the NEW session while + // `reference` still holds the OLD session's quoted content. + // Restoring unconditionally would silently write session A's + // reference into session B's live slot — the next message sent + // from session B would silently inject another conversation's + // quoted content into Claude's context. Only restore when the + // send's session is still the one on screen. (The setText/ + // setAttachments restores just above have this same race but are + // out of scope for this fix — see task-4-report.md.) + if (sessionId === activeSessionIdRef.current) { + setReference((cur) => cur ?? reference); + } return; } // Task 12: a 'queued' ack dispatches QUEUED_MESSAGE_ADDED instead of diff --git a/desktop/src/renderer/components/buddy/BuddyChat.test.tsx b/desktop/src/renderer/components/buddy/BuddyChat.test.tsx new file mode 100644 index 000000000..1962ace60 --- /dev/null +++ b/desktop/src/renderer/components/buddy/BuddyChat.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +// Regression test for the CRITICAL finding: opening a Buddy companion window +// crashed blank. useReference() (reference-context.tsx) used to throw +// unconditionally when no ReferenceProvider ancestor was mounted, and +// InputBar calls useReference() on every render (placeholder text + send-time +// scaffold assembly). Both Buddy hosting strategies — BuddyChatApp.tsx +// (separate-window: Windows/macOS/X11) and BuddyOverlayApp.tsx (Linux +// Wayland overlay) — mount BuddyChat's `` under +// exactly `ThemeProvider > ChatProvider`, with NO ReferenceProvider (App.tsx: +// "Buddy windows render as isolated placeholders without main-app +// providers"), and the buddy early-returns happen before App.tsx's +// wrap with no ErrorBoundary around them. This test pins +// the exact provider stack the Buddy windows actually ship so it can't drift +// from BuddyChatApp.tsx/BuddyOverlayApp.tsx without someone noticing. +// +// There were previously NO tests anywhere under +// src/renderer/components/buddy/ — this is why the full green suite missed +// the regression; this file is the first. +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ThemeProvider } from '../../state/theme-context'; +import { ChatProvider } from '../../state/chat-context'; +import InputBar from '../InputBar'; + +// useScrollFade (mounted unconditionally by InputBar's textarea) reaches for +// ResizeObserver, which jsdom doesn't implement — same stub InputBar.test.tsx +// uses. +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('InputBar under the Buddy provider stack (no ReferenceProvider)', () => { + afterEach(() => { + cleanup(); + }); + + it('mounts without throwing — matches BuddyChatApp/BuddyOverlayApp exactly (ThemeProvider > ChatProvider, no ReferenceProvider)', () => { + (global as any).ResizeObserver = NoopResizeObserver; + + // `compact` matches BuddyChat.tsx's actual usage (``) — compact hides QuickChips, + // which is the one other renderer piece under InputBar that reads a + // context (SkillProvider) neither Buddy tree provides, so this render + // faithfully reproduces what ships rather than papering over a second + // missing provider. + expect(() => { + render( + + + + + , + ); + }).not.toThrow(); + }); +}); diff --git a/desktop/src/renderer/state/reference-context.tsx b/desktop/src/renderer/state/reference-context.tsx index 7d5fa9e7e..8fc465962 100644 --- a/desktop/src/renderer/state/reference-context.tsx +++ b/desktop/src/renderer/state/reference-context.tsx @@ -89,8 +89,28 @@ export function ReferenceProvider({ sessionId, children }: { sessionId: string; return {children}; } +// Stable inert no-ops shared by every call site missing a provider — same +// module-level singleton per render tree, so they're safe to sit in effect +// dependency arrays (InputBar's minimal-mode clearReference effect) without +// retriggering on every render the way a freshly-allocated `() => {}` would. +const INERT_API: ReferenceApi = { + reference: null, + setReference: () => {}, + clearReference: () => {}, +}; + +// Soft-fail when no provider is mounted: the hook becomes a no-op rather than +// throwing. Production always has a ReferenceProvider around InputBar's main- +// app tree, so the real path is always exercised there. The soft-fail exists +// for two other trees that mount InputBar with NO ReferenceProvider ancestor: +// the Buddy companion windows (BuddyChatApp, BuddyOverlayApp — see App.tsx's +// "Buddy windows render as isolated placeholders without main-app providers" +// comment) and isolated component tests. Before this fix, opening the Buddy +// window crashed it blank — useReference() threw on InputBar's very first +// render, with no ErrorBoundary between the buddy early-return and the throw. +// Follows the same pattern as React Router hooks, and the same pattern +// already established by useEscClose (use-esc-close.tsx). export function useReference(): ReferenceApi { const ctx = useContext(ReferenceContext); - if (!ctx) throw new Error('useReference must be used inside a ReferenceProvider'); - return ctx; + return ctx ?? INERT_API; } From 701af2eb151ef1aa88b851b955350eee5176a021 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 15:23:10 -0700 Subject: [PATCH 10/27] feat(reference): pure stepped-union outline geometry buildUnionPath walks down the right edges of every line box then back up the left, giving a partial selection its real notched shape instead of a bounding box. Pure so the trickiest logic in the feature is testable without a DOM. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/reference-geometry.test.ts | 56 +++++++++++++++++++ .../reference/reference-geometry.ts | 43 ++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 desktop/src/renderer/components/reference/reference-geometry.test.ts create mode 100644 desktop/src/renderer/components/reference/reference-geometry.ts diff --git a/desktop/src/renderer/components/reference/reference-geometry.test.ts b/desktop/src/renderer/components/reference/reference-geometry.test.ts new file mode 100644 index 000000000..8314839f0 --- /dev/null +++ b/desktop/src/renderer/components/reference/reference-geometry.test.ts @@ -0,0 +1,56 @@ +// Pure geometry for the traced selection outline (spec 2026-07-26 §5.6). +// getClientRects() on a multi-line selection returns ONE RECT PER LINE BOX; +// the outline is the stepped union of those boxes — down the right edges, +// back up the left. No DOM needed, so this runs in the default node env. +import { describe, it, expect } from 'vitest'; +import { buildUnionPath, toBoxes, type Box } from './reference-geometry'; + +const box = (l: number, t: number, r: number, b: number): Box => ({ l, t, r, b }); + +describe('buildUnionPath', () => { + it('returns empty string for no boxes', () => { + expect(buildUnionPath([])).toBe(''); + }); + + it('traces a single line box as a closed rectangle', () => { + expect(buildUnionPath([box(10, 0, 90, 20)])).toBe( + 'M 90 0 L 90 20 L 10 20 L 10 0 Z', + ); + }); + + it('steps down the right edges then back up the left', () => { + // Classic 3-line selection: starts mid-line, full middle, ends mid-line. + const d = buildUnionPath([box(40, 0, 100, 20), box(0, 20, 100, 40), box(0, 40, 60, 60)]); + expect(d).toBe( + 'M 100 0 L 100 20 L 100 20 L 100 40 L 60 40 L 60 60 ' + + 'L 0 60 L 0 40 L 0 40 L 0 20 L 40 20 L 40 0 Z', + ); + }); + + it('closes the path', () => { + expect(buildUnionPath([box(0, 0, 10, 10)]).endsWith('Z')).toBe(true); + }); +}); + +describe('toBoxes', () => { + const host = { left: 100, top: 50 } as DOMRect; + const rect = (l: number, t: number, w: number, h: number) => + ({ left: l, top: t, right: l + w, bottom: t + h, width: w, height: h }) as DOMRect; + + it('converts to host-relative coordinates', () => { + expect(toBoxes([rect(120, 70, 40, 20)], host, 0)).toEqual([box(20, 20, 60, 40)]); + }); + + it('applies padding outward on all four sides', () => { + expect(toBoxes([rect(120, 70, 40, 20)], host, 2)).toEqual([box(18, 18, 62, 42)]); + }); + + it('drops zero-area rects (collapsed ranges produce them)', () => { + expect(toBoxes([rect(120, 70, 0, 20), rect(120, 90, 40, 20)], host, 0)).toHaveLength(1); + }); + + it('sorts by top so unsorted input still steps downward', () => { + const out = toBoxes([rect(120, 90, 40, 20), rect(120, 70, 40, 20)], host, 0); + expect(out[0].t).toBeLessThan(out[1].t); + }); +}); diff --git a/desktop/src/renderer/components/reference/reference-geometry.ts b/desktop/src/renderer/components/reference/reference-geometry.ts new file mode 100644 index 000000000..b4cc6e369 --- /dev/null +++ b/desktop/src/renderer/components/reference/reference-geometry.ts @@ -0,0 +1,43 @@ +/** + * Geometry for the traced selection outline (spec 2026-07-26 §5.6). + * + * Pure on purpose: this is the trickiest logic in the feature and the part most + * likely to be silently wrong, so it must be testable without a DOM or a render. + */ + +export type Box = { l: number; r: number; t: number; b: number }; + +/** + * Host-relative, padded, sorted line boxes from raw client rects. + * Zero-area rects are dropped — a collapsed range emits them and they would + * add a degenerate spike to the outline. + */ +export function toBoxes(rects: DOMRect[], host: DOMRect, pad = 2): Box[] { + return rects + .filter((r) => r.width > 0 && r.height > 0) + .map((r) => ({ + l: r.left - host.left - pad, + r: r.right - host.left + pad, + t: r.top - host.top - pad, + b: r.bottom - host.top + pad, + })) + .sort((a, b) => a.t - b.t); +} + +/** + * The stepped union outline: walk DOWN the right edges of every line box, then + * back UP the left edges. For a selection that starts mid-line and ends + * mid-line this produces the familiar notched shape rather than a bounding box. + */ +export function buildUnionPath(boxes: Box[]): string { + if (boxes.length === 0) return ''; + const cmds: string[] = []; + boxes.forEach((bx, i) => { + cmds.push(`${i === 0 ? 'M' : 'L'} ${bx.r} ${bx.t}`, `L ${bx.r} ${bx.b}`); + }); + for (let i = boxes.length - 1; i >= 0; i--) { + cmds.push(`L ${boxes[i].l} ${boxes[i].b}`, `L ${boxes[i].l} ${boxes[i].t}`); + } + cmds.push('Z'); + return cmds.join(' '); +} From 35c5777adb32b3cf0bd5320a2b36be1b7249c0f5 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 15:31:41 -0700 Subject: [PATCH 11/27] feat(reference): window-wide scrim + cancel affordances Reuses the L2 band rather than inventing an L5, with REFERENCE_COMPOSER_Z exported from Overlay.tsx so design rule 11 holds. Opening any overlay on top cancels the reference (new useEscStackDepth), which makes the two states mutually exclusive and sidesteps the z-ordering question entirely. Fixes an off-by-one in the depth-baseline capture: useEscStackDepth() reads the stack BEFORE this component's own useEscClose registers, so comparing later (post-registration) depth against that pre-registration baseline made the overlay cancel itself the instant it opened. Verified with a throwaway scratch test against the original formula before landing the +1 fix. Co-Authored-By: Claude Opus 5 (1M context) --- desktop/src/renderer/App.tsx | 4 + desktop/src/renderer/components/InputBar.tsx | 8 ++ .../renderer/components/overlays/Overlay.tsx | 8 ++ .../reference/ReferenceOverlay.test.tsx | 81 +++++++++++++++++++ .../components/reference/ReferenceOverlay.tsx | 63 +++++++++++++++ .../src/renderer/hooks/use-esc-close.test.tsx | 24 +++++- desktop/src/renderer/hooks/use-esc-close.tsx | 24 ++++++ 7 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx create mode 100644 desktop/src/renderer/components/reference/ReferenceOverlay.tsx diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 092edd994..bc1cff02b 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -93,6 +93,7 @@ import { ZoomOverlay } from './components/ZoomOverlay'; import { RemoteSnapshotExporter } from './components/RemoteSnapshotExporter'; import RemoteUnsupportedNotice from './components/RemoteUnsupportedNotice'; import { ContextMenuHost } from './components/context-menu/ContextMenuHost'; +import { ReferenceOverlay } from './components/reference/ReferenceOverlay'; import { BuddyMascotApp } from './components/buddy/BuddyMascotApp'; import { BuddyChatApp } from './components/buddy/BuddyChatApp'; import { BuddyBarApp } from './components/buddy/BuddyBarApp'; @@ -2637,6 +2638,9 @@ function AppInner() { (copy/paste, Ask about this, file-pill actions). Opens only over surfaces it owns; leaves the terminal and other chrome untouched. */} + {/* Mount-only: the held "Ask Claude about this" reference — window-wide + dim, traced outline, and the lifted source card. */} + {/* Main area — relative so bottom-float chrome can position against it. When a Phase-2 full-screen destination is active, hide the chat chrome entirely. Unmounting via `hidden` is cleaner than z-index diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index deb0eb640..1a2a4d1b9 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -20,6 +20,9 @@ import { useScrollFade } from '../hooks/useScrollFade'; import { useStreamingGate } from '../hooks/useStreamingGate'; import { isAndroid } from '../platform'; import { useReference, type PendingReference } from '../state/reference-context'; +// The composer must stay live and clickable ABOVE the reference scrim (L2, +// z-60) while a reference is held — see ReferenceOverlay.tsx and design rule 11. +import { REFERENCE_COMPOSER_Z } from './overlays/Overlay'; export interface InputBarHandle { clear: () => void; @@ -718,6 +721,11 @@ const InputBar = forwardRef(function InputBar({ sessionId return (
diff --git a/desktop/src/renderer/components/overlays/Overlay.tsx b/desktop/src/renderer/components/overlays/Overlay.tsx index 09f112951..0ddea57b3 100644 --- a/desktop/src/renderer/components/overlays/Overlay.tsx +++ b/desktop/src/renderer/components/overlays/Overlay.tsx @@ -21,6 +21,14 @@ const SCRIM_Z: Record = { 1: 40, 2: 60, 3: 70, 4: 100 }; // design rule 11 is that this file is the only place a layer number is decided. export const CONTENT_Z: Record = { 1: 50, 2: 61, 3: 71, 4: 100 }; +// The composer stays LIVE and interactive above the reference scrim while an +// "Ask Claude about this" reference is held (spec 2026-07-26 §6) — you type +// your question while the source sits pinned behind the dim. One above L2 +// content so it clears the lifted card without a magic number at the call site. +// Declared HERE because Overlay.tsx is the only place a layer number is decided +// (design rule 11, guarded by tests/overlay-layer-authority.test.ts). +export const REFERENCE_COMPOSER_Z = CONTENT_Z[2] + 1; + // Popover escape-hatch tier: a floating menu/panel SPAWNED FROM a host that // lives in the z-9000 exception band (SessionStrip dropdown, ProjectHero, // OverflowMenu — see docs/shared-ui-architecture.md → Overlay Layer System). diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx new file mode 100644 index 000000000..6b08c01ee --- /dev/null +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// Fix: pin jsdom explicitly (see use-esc-close.test.tsx for why) — this file +// lives under src/**/*.test.tsx, outside vitest.config.ts's tests/**/*.tsx +// auto-jsdom glob. +import React, { useEffect } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, act, fireEvent } from '@testing-library/react'; +import { EscCloseProvider } from '../../hooks/use-esc-close'; +import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; +import { ReferenceOverlay } from './ReferenceOverlay'; + +// ReferenceOverlay portals straight to document.body (window-wide, not scoped +// to RTL's per-test container), so an un-cleaned-up previous test's scrim +// stays in the DOM and a bare `querySelector('.reference-scrim')` in a LATER +// test can match the wrong instance. Explicit cleanup — this project doesn't +// register RTL's auto-cleanup globally (see InputBar.test.tsx, ToolCard.test.tsx). +afterEach(() => cleanup()); + +const mockReference: PendingReference = { + kind: 'chat-text', + label: 'a held reference', + promptText: 'About this: hello world', + anchor: null, +}; + +// Test-only bridge: ReferenceOverlay has no props, it reads context. To drive +// `reference` from a test we need a sibling that calls setReference — mirrors +// how a real caller (e.g. the context menu's "Ask about this" action) would. +function SetsReference({ value }: { value: PendingReference | null }) { + const { setReference } = useReference(); + useEffect(() => { setReference(value); }, [value]); + return null; +} + +function renderOverlay(initial: PendingReference | null) { + return render( + + + + + + , + ); +} + +describe('ReferenceOverlay', () => { + it('renders nothing when no reference is held', () => { + renderOverlay(null); + expect(document.querySelector('.reference-scrim')).toBeNull(); + }); + + it('renders a window-wide scrim when a reference is held', () => { + renderOverlay(mockReference); + act(() => {}); // settle the effect that pushes onto the Esc stack + const scrim = document.querySelector('.reference-scrim'); + expect(scrim).not.toBeNull(); + // Portaled straight to document.body, not nested under the app tree — + // that's what makes it window-wide rather than pane-scoped. + expect(scrim?.parentElement).toBe(document.body); + }); + + it('clicking the scrim clears the reference', () => { + renderOverlay(mockReference); + act(() => {}); + const scrim = document.querySelector('.reference-scrim'); + expect(scrim).not.toBeNull(); + fireEvent.click(scrim as Element); + act(() => {}); // clearReference→setReferenceState is async through context; flush it + expect(document.querySelector('.reference-scrim')).toBeNull(); + }); + + it('the cancel button clears the reference', () => { + renderOverlay(mockReference); + act(() => {}); + const cancelButton = document.querySelector('[aria-label="Cancel reference"]'); + expect(cancelButton).not.toBeNull(); + fireEvent.click(cancelButton as Element); + act(() => {}); // same flush as above + expect(document.querySelector('.reference-scrim')).toBeNull(); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx new file mode 100644 index 000000000..8c5766b32 --- /dev/null +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Scrim } from '../overlays/Overlay'; +import { CloseButton } from '../ui/CloseButton'; +import { useReference } from '../../state/reference-context'; +import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close'; + +/** + * The held "Ask Claude about this" reference (spec 2026-07-26). + * + * One app-wide instance. Owns the window-wide dim; Tasks 7 and 8 add the traced + * outline and the lifted clone on top of this shell. + * + * Window-wide, not pane-scoped — Destin's 10B call: "dim should apply to the + * whole window so it's obvious what is being highlighted / what the user is + * asking about." Both the chat and artifact surfaces share this one scrim. + */ +export function ReferenceOverlay() { + const { reference, clearReference } = useReference(); + const depth = useEscStackDepth(); + const depthAtOpen = useRef(null); + + // Esc cancels. LIFO, so if a drawer opened on top, Esc closes that first. + useEscClose(!!reference, clearReference); + + useEffect(() => { + if (!reference) { depthAtOpen.current = null; return; } + // Fix: capture the baseline AFTER this component's own useEscClose push has + // registered, not before. useEscStackDepth() is read at render time, one + // tick ahead of the push effect (which runs later in the same passive-effect + // flush, in hook-declaration order) — so the FIRST render where reference is + // truthy sees `depth` from before self-registration. Comparing later renders + // (which correctly include the self-push) against that pre-push baseline + // made this effect fire on its own registration and immediately clear the + // reference it had just opened. +1 accounts for the self-push this + // component's own useEscClose(!!reference, ...) call is about to add. + if (depthAtOpen.current === null) { depthAtOpen.current = depth + 1; return; } + // Something opened ON TOP of us. We live in the L2 band, so an L1 drawer + // (z-40/50) would render UNDER this scrim. Cancel instead of painting over + // it — the two states are mutually exclusive by design (spec §6). + if (depth > depthAtOpen.current) clearReference(); + }, [reference, depth, clearReference]); + + // Mark the document so the composer can lift above the scrim (globals.css). + useEffect(() => { + if (!reference) return; + document.body.setAttribute('data-reference-held', 'true'); + return () => document.body.removeAttribute('data-reference-held'); + }, [reference]); + + if (!reference) return null; + + return createPortal( + + {/* Cancel affordance. Positioned by Task 8 against the lifted card; until + then it parks top-right so the state is always escapable by mouse. */} +
+ +
+
, + document.body, + ); +} diff --git a/desktop/src/renderer/hooks/use-esc-close.test.tsx b/desktop/src/renderer/hooks/use-esc-close.test.tsx index 721be277c..f0f58ddd2 100644 --- a/desktop/src/renderer/hooks/use-esc-close.test.tsx +++ b/desktop/src/renderer/hooks/use-esc-close.test.tsx @@ -5,7 +5,7 @@ import React, { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, fireEvent, act } from '@testing-library/react'; -import { EscCloseProvider, useEscClose, useEscStackEmpty, useDismissTop } from './use-esc-close'; +import { EscCloseProvider, useEscClose, useEscStackEmpty, useEscStackDepth, useDismissTop } from './use-esc-close'; function pressEsc() { act(() => { @@ -188,4 +188,26 @@ describe('useEscClose', () => { ); expect(() => act(() => { dismiss(); })).not.toThrow(); }); + + it('useEscStackDepth reports how many overlays are registered', () => { + const captured: number[] = []; + function Probe() { + captured.push(useEscStackDepth()); + return null; + } + function Harness({ extra }: { extra: boolean }) { + return ( + + + {}} /> + {extra && {}} />} + + ); + } + const { rerender } = render(); + rerender(); + // Depth grows when a second overlay opens on top — this is the signal the + // reference overlay uses to cancel itself rather than paint under a drawer. + expect(Math.max(...captured)).toBe(2); + }); }); diff --git a/desktop/src/renderer/hooks/use-esc-close.tsx b/desktop/src/renderer/hooks/use-esc-close.tsx index bf11cb22d..bb3113c7c 100644 --- a/desktop/src/renderer/hooks/use-esc-close.tsx +++ b/desktop/src/renderer/hooks/use-esc-close.tsx @@ -57,6 +57,10 @@ class EscStore { return this.stack.length === 0; } + get depth(): number { + return this.stack.length; + } + subscribe(l: StoreListener): () => void { this.listeners.add(l); return () => this.listeners.delete(l); @@ -128,6 +132,26 @@ export function useEscStackEmpty(): boolean { ); } +/** + * How many overlays are currently registered. + * + * Added for the "Ask Claude about this" reference overlay: it reuses the L2 + * band (scrim z-60), so an L1 drawer at z-40/50 would open UNDERNEATH its + * scrim. Rather than invent a new layer, the reference cancels itself when the + * stack grows past its own registration depth — the two states become mutually + * exclusive and the z-ordering question never arises. + */ +export function useEscStackDepth(): number { + const store = useContext(EscStoreContext); + // Same soft-fail + useSyncExternalStore shape as useEscStackEmpty above: + // no provider (isolated component tests) means no stack, so depth 0. + return useSyncExternalStore( + useCallback((l) => (store ? store.subscribe(l) : () => {}), [store]), + useCallback(() => (store ? store.depth : 0), [store]), + useCallback(() => 0, []), + ); +} + // Imperative dismissal trigger — pops the top of the stack and invokes its // onClose. Used by the Android hardware-back bridge so back press doesn't // synthesize a keyboard event. ESC keydown listener and this hook share From 9bbc765082a1fc59bf1f1a75ebdbdc82525cac1c Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 15:57:17 -0700 Subject: [PATCH 12/27] fix(reference): lift composer above scrim + document depth-race, per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (Critical): InputBar.tsx's inline z-index on .input-bar-container could never beat the reference scrim — it's a descendant of .bottom-float, which already forms its own stacking context (position:absolute + z-index + transform/will-change), so a descendant's z-index only orders it against siblings inside that context, never against .bottom-float itself. Fix lifts the actual stacking-context-forming ancestor instead: globals.css now raises .bottom-float's z-index via var(--reference-composer-z) whenever body[data-reference-held] is set, and ReferenceOverlay.tsx publishes that var (Overlay.tsx stays the sole source of the layer number, design rule 11). Finding 2: the data-reference-held attribute is now actually consumed by CSS (was dead code before). Finding 3: documented the same-commit useEscClose registration race as an accepted, deliberate behavior (any contention for the L2 band yields — safer than a rewrite of the shared, app-wide Esc stack) and pinned it with a test that reproduces the exact race via React 18 batching. Finding 4: added tests for the attribute/CSS-var contract and a source-text guard on the consuming CSS rule. Real paint order still needs a dev-instance visual check (not provable in jsdom, and out of scope per task constraints). Co-Authored-By: Claude Opus 5 (1M context) --- desktop/src/renderer/components/InputBar.tsx | 19 +-- .../reference/ReferenceOverlay.test.tsx | 112 +++++++++++++++++- .../components/reference/ReferenceOverlay.tsx | 36 +++++- desktop/src/renderer/styles/globals.css | 23 ++++ 4 files changed, 176 insertions(+), 14 deletions(-) diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index 1a2a4d1b9..aa99dd8c3 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -20,9 +20,6 @@ import { useScrollFade } from '../hooks/useScrollFade'; import { useStreamingGate } from '../hooks/useStreamingGate'; import { isAndroid } from '../platform'; import { useReference, type PendingReference } from '../state/reference-context'; -// The composer must stay live and clickable ABOVE the reference scrim (L2, -// z-60) while a reference is held — see ReferenceOverlay.tsx and design rule 11. -import { REFERENCE_COMPOSER_Z } from './overlays/Overlay'; export interface InputBarHandle { clear: () => void; @@ -721,11 +718,17 @@ const InputBar = forwardRef(function InputBar({ sessionId return (
diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index 6b08c01ee..6d1315855 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -2,12 +2,15 @@ // Fix: pin jsdom explicitly (see use-esc-close.test.tsx for why) — this file // lives under src/**/*.test.tsx, outside vitest.config.ts's tests/**/*.tsx // auto-jsdom glob. -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { describe, it, expect, afterEach } from 'vitest'; -import { render, cleanup, act, fireEvent } from '@testing-library/react'; -import { EscCloseProvider } from '../../hooks/use-esc-close'; +import { render, cleanup, act, fireEvent, screen } from '@testing-library/react'; +import { EscCloseProvider, useEscClose } from '../../hooks/use-esc-close'; import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; import { ReferenceOverlay } from './ReferenceOverlay'; +import { REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; // ReferenceOverlay portals straight to document.body (window-wide, not scoped // to RTL's per-test container), so an un-cleaned-up previous test's scrim @@ -79,3 +82,106 @@ describe('ReferenceOverlay', () => { expect(document.querySelector('.reference-scrim')).toBeNull(); }); }); + +// Review Finding 4: no test asserted the composer-lift mechanism at all. +// jsdom has no real layout engine, so none of this can prove actual paint +// order (i.e. that a click really lands on the textarea instead of the +// scrim) — that needs a real browser. What IS provable here, and what these +// tests pin: (1) the attribute + CSS var ReferenceOverlay publishes while a +// reference is held, cleaned up when it's cleared, and (2) that globals.css +// actually contains a rule consuming them. A real dev-instance visual check +// (click the composer while a reference is held; confirm it types/sends +// instead of dismissing the reference) is still required before shipping — +// see the task report for what was checked there. +describe('composer lift (review Finding 1/2/4)', () => { + it('publishes data-reference-held + --reference-composer-z on body while held, clears both when cleared', () => { + renderOverlay(mockReference); + act(() => {}); + expect(document.body.getAttribute('data-reference-held')).toBe('true'); + expect(document.body.style.getPropertyValue('--reference-composer-z')).toBe(String(REFERENCE_COMPOSER_Z)); + + const cancelButton = document.querySelector('[aria-label="Cancel reference"]'); + fireEvent.click(cancelButton as Element); + act(() => {}); + expect(document.body.hasAttribute('data-reference-held')).toBe(false); + // jsdom returns '' for a removed custom property, not undefined/null. + expect(document.body.style.getPropertyValue('--reference-composer-z')).toBe(''); + }); + + it('globals.css raises .bottom-float above the scrim only while held, reading the number from the published var (not hardcoded)', () => { + // Source-text assertion, same idiom as tests/overlay-layer-authority.test.ts: + // greps for the rule rather than rendering, since jsdom can't compute real + // stacking order. Guards against a future edit reintroducing a magic + // z-index literal here instead of var(--reference-composer-z), which would + // both violate design rule 11 and silently stop tracking Overlay.tsx if + // REFERENCE_COMPOSER_Z's value ever changes. + const css = readFileSync(join(__dirname, '..', '..', 'styles', 'globals.css'), 'utf8'); + expect(css).toMatch(/body\[data-reference-held\]\s+\.bottom-float\s*\{[^}]*z-index:\s*var\(--reference-composer-z/); + }); +}); + +// Review Finding 3: the depth-cancel effect compares Esc-stack DEPTH, not +// closer IDENTITY. Judgement call (see the WHY comment in ReferenceOverlay.tsx +// above the `if (depth > depthAtOpen.current)` check): when this component's +// own useEscClose push and some OTHER overlay's first-ever push land in the +// exact same React commit (one event handler synchronously triggering both, +// batched by React 18 into one passive-effect flush), the count-based check +// can't tell whether the other push landed above or below this one in the +// LIFO stack — it only sees depth grow by 2 instead of the expected 1, so it +// cancels regardless of ordering. This was judged an ACCEPTABLE, SAFE +// behavior (any contention for the L2 band yields, same-commit or not) rather +// than worth an identity-based rewrite of the shared, app-wide useEscClose +// stack. This test PINS that choice against regression — it is not a "this is +// definitely optimal" claim, just "this is what the codebase has decided, +// don't silently change it." +describe('depth-cancel race (review Finding 3 — documented, accepted behavior)', () => { + it('reference yields when another overlay registers its first useEscClose push in the SAME commit', () => { + // Registers unconditionally-but-controlled by `open`, mirroring how a real + // overlay wires useEscClose — the point is that flipping `open` to true is + // this component's FIRST push, same as ReferenceOverlay's own first push + // when `reference` goes from null to non-null. + function ConcurrentOverlay({ open }: { open: boolean }) { + useEscClose(open, () => {}); + return null; + } + + function Trigger({ value }: { value: PendingReference }) { + const { setReference } = useReference(); + const [otherOpen, setOtherOpen] = useState(false); + return ( + <> + + + + ); + } + + render( + + + + + + , + ); + fireEvent.click(screen.getByText('trigger')); + act(() => {}); + + expect(document.querySelector('.reference-scrim')).toBeNull(); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 8c5766b32..e62b99a27 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Scrim } from '../overlays/Overlay'; +import { Scrim, REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; import { CloseButton } from '../ui/CloseButton'; import { useReference } from '../../state/reference-context'; import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close'; @@ -38,14 +38,44 @@ export function ReferenceOverlay() { // Something opened ON TOP of us. We live in the L2 band, so an L1 drawer // (z-40/50) would render UNDER this scrim. Cancel instead of painting over // it — the two states are mutually exclusive by design (spec §6). + // + // Known, ACCEPTED edge case (review Finding 3): this is a COUNT comparison, + // not an identity check on "what's above us in the stack." If some OTHER + // useEscClose-registering overlay opens in the exact SAME React commit as + // this one's own registration (e.g. one event handler synchronously flips + // both an overlay's `open` state and calls setReference — React 18 batches + // that into one commit), both pushes land in the same passive-effect flush, + // and the depth baseline captured just above can't tell whether the other + // push landed above or below ours in the LIFO stack — it only sees total + // depth grow by 2 instead of the expected 1, so this fires and cancels the + // reference even in the (rare) ordering where ours ended up on top. + // Deliberately NOT distinguishing that ordering: the L2 band is already + // documented as mutually-exclusive-with-anything-else by design (spec §6), + // and "silently drop a reference that could have safely coexisted with + // itself on top" is a strictly safer failure mode than the alternative + // (an identity-based rewrite of the shared, app-wide useEscClose stack — + // touching that has a much larger blast radius than one feature's edge + // case). So: any contention for the L2 band — sequential OR same-commit — + // makes the reference yield. Pinned by + // ReferenceOverlay.test.tsx's "same-commit L2 contention" test. if (depth > depthAtOpen.current) clearReference(); }, [reference, depth, clearReference]); - // Mark the document so the composer can lift above the scrim (globals.css). + // Mark the document so the composer can lift above the scrim (globals.css' + // `body[data-reference-held] .bottom-float` rule — review Finding 1/2 fix). + // The layer NUMBER is not hardcoded in CSS: Overlay.tsx is the one place a + // layer number is decided (design rule 11), so publish REFERENCE_COMPOSER_Z + // as a CSS custom property here and let the stylesheet consume var(...). + // Both the attribute and the var are cleaned up on unmount/clear so nothing + // about normal (no-reference-held) chrome ordering is ever affected. useEffect(() => { if (!reference) return; document.body.setAttribute('data-reference-held', 'true'); - return () => document.body.removeAttribute('data-reference-held'); + document.body.style.setProperty('--reference-composer-z', String(REFERENCE_COMPOSER_Z)); + return () => { + document.body.removeAttribute('data-reference-held'); + document.body.style.removeProperty('--reference-composer-z'); + }; }, [reference]); if (!reference) return null; diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index 98c66b1b4..01559e209 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -564,6 +564,29 @@ body[data-mode="buddy-chat"] #theme-bg { will-change: transform; } +/* Review Finding 1/2 fix: while an "Ask Claude about this" reference is held, + the composer must paint ABOVE the reference scrim (Overlay.tsx's Scrim + layer={2}, z-60) so the textarea/Send button stay clickable. `.bottom-float` + already forms its OWN stacking context (position:absolute + z-index + the + transform/will-change above each independently qualify), so its z-index:20 + only competes against OTHER stacking contexts at the SAME ancestor level as + the reference scrim — which is portalled straight onto document.body, + outside .app-shell entirely. Raising a z-index on some element NESTED + inside .bottom-float (the old approach, InputBar.tsx) can never win that + fight: a descendant's z-index only orders it against ITS OWN siblings + inside .bottom-float's stacking context, never against .bottom-float + itself. So this rule raises the stacking-context-forming box directly, and + ONLY while [data-reference-held] is set on (ReferenceOverlay.tsx), + so normal chrome ordering (under every other L1/L3/L4 scrim) is untouched + otherwise. The actual number is NOT hardcoded here — Overlay.tsx is the + sole place a layer number is decided (design rule 11, guarded by + tests/overlay-layer-authority.test.ts); ReferenceOverlay.tsx publishes + REFERENCE_COMPOSER_Z as --reference-composer-z on alongside the + attribute, and this rule just consumes the var. */ +body[data-reference-held] .bottom-float { + z-index: var(--reference-composer-z, 20); +} + /* Jump-to-bottom button — chrome floats over the content area, so push the button up above it. Task 12 review fix: + --queued-strip-height (published by ChatView.tsx's measurement effect, 0px when the queued strip From f2b1ec00ae95fbe6e3d96592e855097f4a96cdea Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 16:10:13 -0700 Subject: [PATCH 13/27] feat(reference): traced selection outline Traces the real selection when there is one and the whole host element when there isn't (Destin's 9B call). Geometry is re-derived from the live DOM on scroll/resize rather than snapshotted, because stored rects go stale the instant anything moves. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/reference/ReferenceOverlay.tsx | 15 ++ .../reference/use-reference-geometry.test.ts | 238 ++++++++++++++++++ .../reference/use-reference-geometry.ts | 74 ++++++ desktop/src/renderer/styles/globals.css | 30 +++ 4 files changed, 357 insertions(+) create mode 100644 desktop/src/renderer/components/reference/use-reference-geometry.test.ts create mode 100644 desktop/src/renderer/components/reference/use-reference-geometry.ts diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index e62b99a27..060f86af9 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -4,6 +4,7 @@ import { Scrim, REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; import { CloseButton } from '../ui/CloseButton'; import { useReference } from '../../state/reference-context'; import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close'; +import { useReferenceGeometry } from './use-reference-geometry'; /** * The held "Ask Claude about this" reference (spec 2026-07-26). @@ -19,6 +20,9 @@ export function ReferenceOverlay() { const { reference, clearReference } = useReference(); const depth = useEscStackDepth(); const depthAtOpen = useRef(null); + // Task 7: the traced outline around the referenced content. `rects` is + // unused here — Task 8 needs it to redraw the selected runs above the scrim. + const { d } = useReferenceGeometry(reference?.anchor ?? null); // Esc cancels. LIFO, so if a drawer opened on top, Esc closes that first. useEscClose(!!reference, clearReference); @@ -82,6 +86,17 @@ export function ReferenceOverlay() { return createPortal( + {/* Traced outline around the referenced selection/element (Task 7). + pathLength={100} normalizes both paths' length to 100 units so the + fixed 100-unit stroke-dasharray/breathe animation in globals.css + works regardless of the actual traced perimeter. Empty when the + source is gone (host disconnected) — nothing renders in that case. */} + {d && ( + + )} {/* Cancel affordance. Positioned by Task 8 against the lifted card; until then it parks top-right so the state is always escapable by mouse. */}
diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts new file mode 100644 index 000000000..7ff77df0f --- /dev/null +++ b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom +// Fix: pin jsdom here because vitest.config.ts only auto-applies jsdom to +// tests under `tests/**/*.tsx`; this file lives under `src/**/*.test.ts` +// and would otherwise run in the default `node` env with no `window`. +// +// What's testable here vs. not: jsdom has no real layout engine, so every +// DOMRect it hands back (getBoundingClientRect) is zeroed and Range does not +// even implement getClientRects() at all (confirmed empirically against this +// repo's jsdom version) — there is no way to assert real pixel coordinates or +// spy through the prototype in this environment. What IS provable, and what +// these tests pin: (1) which CODE PATH the hook takes — range-in-host vs. +// containment-fallback vs. no-anchor — proven by stubbing an own-property +// getClientRects directly on the Range instance (spyOn can't wrap a method +// jsdom never defines) and checking which stub the hook actually called, and +// (2) that every listener + the ResizeObserver registered on mount is torn +// down on unmount. A real dev-instance visual check of the traced outline +// (does it actually wrap the selection, not the whole bubble) is still +// required before shipping — see the task report. +// +// IMPORTANT test-authoring gotcha hit while writing this file: passing +// `useReferenceGeometry(makeAnchor(host, range))` INLINE inside the +// `renderHook(() => ...)` callback creates a brand-new `anchor` object +// identity on every internal re-render. The hook's effect depends on +// `anchor` BY REFERENCE, and `measure()` always calls `setGeom({ ...new +// object... })` even when content is unchanged — so a fresh identity each +// render drove an unbounded render loop (reproduced as a multi-GB OOM, not a +// hang) that has nothing to do with the hook's real behavior: production +// `anchor` comes from stable context state. Every anchor below is therefore +// constructed ONCE, outside the renderHook callback. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, cleanup } from '@testing-library/react'; +import { useReferenceGeometry } from './use-reference-geometry'; +import type { ReferenceAnchor } from '../../state/reference-context'; + +// jsdom doesn't implement ResizeObserver (same stub as InputBar.test.tsx / +// PreferencesPopup.test.tsx). Spy-able here (not a bare no-op) because the +// cleanup test needs to prove disconnect() actually fires on unmount. +class SpyResizeObserver { + static instances: SpyResizeObserver[] = []; + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + constructor(_cb: ResizeObserverCallback) { + SpyResizeObserver.instances.push(this); + } +} + +afterEach(() => { + cleanup(); + SpyResizeObserver.instances = []; + vi.restoreAllMocks(); +}); + +function makeAnchor(host: Element, range: Range | null): ReferenceAnchor { + return { host, range }; +} + +// jsdom's Range has no getClientRects at all (not even a no-op) — vi.spyOn +// requires the property to already exist, so wrap it as a plain own-property +// stub instead. Returns a vi.fn() the test can assert on directly. +function stubGetClientRects(range: Range, rects: DOMRect[]) { + const stub = vi.fn(() => rects as unknown as DOMRectList); + (range as unknown as { getClientRects: typeof stub }).getClientRects = stub; + return stub; +} + +describe('useReferenceGeometry', () => { + it('returns an empty path when anchor is null', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const { result } = renderHook(() => useReferenceGeometry(null)); + expect(result.current.d).toBe(''); + expect(result.current.rects).toEqual([]); + }); + + it('uses the range rects when the range is contained in the host', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); + document.body.appendChild(host); + const text = document.createTextNode('hello world'); + host.appendChild(text); + + const range = document.createRange(); + range.selectNodeContents(text); + // Prove the range branch ran (not the host-box fallback) by checking + // which stub the hook actually called. + const rectsStub = stubGetClientRects(range, [ + { left: 1, right: 2, top: 3, bottom: 4, width: 1, height: 1 } as DOMRect, + ]); + const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect'); + + const anchor = makeAnchor(host, range); // constructed once — see file header + const { result } = renderHook(() => useReferenceGeometry(anchor)); + + expect(rectsStub).toHaveBeenCalled(); + expect(hostRectSpy).not.toHaveBeenCalled(); + expect(result.current.rects).toEqual([{ left: 1, right: 2, top: 3, bottom: 4, width: 1, height: 1 }]); + + document.body.removeChild(host); + }); + + // The containment guard is the subtle, load-bearing requirement carried + // over from the withdrawn surroundContents() design (see the WHY comment in + // use-reference-geometry.ts): a Range whose commonAncestorContainer is NOT + // inside the host must be treated as if there were no selection at all, and + // the hook must fall back to the whole-host box instead of tracing the + // (out-of-bounds) range. + it('falls back to the whole-host box when the range escapes the host (containment guard)', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); + host.appendChild(document.createTextNode('inside host')); + document.body.appendChild(host); + + // A range over content that lives OUTSIDE host — host.contains(...) is false. + const outside = document.createElement('div'); + const outsideText = document.createTextNode('outside host'); + outside.appendChild(outsideText); + document.body.appendChild(outside); + + const range = document.createRange(); + range.selectNodeContents(outsideText); + expect(host.contains(range.commonAncestorContainer)).toBe(false); // sanity check on the fixture itself + + const rectsStub = stubGetClientRects(range, [{ left: 999, right: 999, top: 999, bottom: 999, width: 1, height: 1 } as DOMRect]); + const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({ + left: 10, right: 20, top: 30, bottom: 40, width: 10, height: 10, + } as DOMRect); + + const anchor = makeAnchor(host, range); // constructed once — see file header + const { result } = renderHook(() => useReferenceGeometry(anchor)); + + // The range must never even be consulted once containment fails. + expect(rectsStub).not.toHaveBeenCalled(); + expect(hostRectSpy).toHaveBeenCalled(); + expect(result.current.rects).toEqual([{ left: 10, right: 20, top: 30, bottom: 40, width: 10, height: 10 }]); + + document.body.removeChild(host); + document.body.removeChild(outside); + }); + + it('falls back to the whole-host box when anchor.range is null (whole-element reference)', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); + document.body.appendChild(host); + const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({ + left: 0, right: 5, top: 0, bottom: 5, width: 5, height: 5, + } as DOMRect); + + const anchor = makeAnchor(host, null); // constructed once — see file header + const { result } = renderHook(() => useReferenceGeometry(anchor)); + + expect(hostRectSpy).toHaveBeenCalled(); + expect(result.current.rects).toEqual([{ left: 0, right: 5, top: 0, bottom: 5, width: 5, height: 5 }]); + + document.body.removeChild(host); + }); + + it('returns an empty path when the host has been disconnected from the DOM', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); // never appended -> isConnected === false + const anchor = makeAnchor(host, null); // constructed once — see file header + const { result } = renderHook(() => useReferenceGeometry(anchor)); + expect(result.current.d).toBe(''); + expect(result.current.rects).toEqual([]); + }); + + it('registers resize/scroll listeners and a ResizeObserver on mount, and tears every one of them down on unmount', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); + document.body.appendChild(host); + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({ + left: 0, right: 1, top: 0, bottom: 1, width: 1, height: 1, + } as DOMRect); + + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const anchor = makeAnchor(host, null); // constructed once — see file header + const { unmount } = renderHook(() => useReferenceGeometry(anchor)); + + expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + // capture: true is load-bearing — scroll doesn't bubble, and the chat / + // artifact panes are the actual scrollers, not window itself. + expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); + expect(SpyResizeObserver.instances).toHaveLength(1); + expect(SpyResizeObserver.instances[0].observe).toHaveBeenCalledWith(host); + expect(SpyResizeObserver.instances[0].disconnect).not.toHaveBeenCalled(); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); + expect(SpyResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1); + + document.body.removeChild(host); + }); + + it('does not register any listeners when anchor is null (nothing to leak)', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const addSpy = vi.spyOn(window, 'addEventListener'); + const { unmount } = renderHook(() => useReferenceGeometry(null)); + expect(addSpy).not.toHaveBeenCalledWith('scroll', expect.any(Function), true); + expect(SpyResizeObserver.instances).toHaveLength(0); + unmount(); // must not throw with nothing to clean up + }); + + it('swapping anchor from a live host to null tears down the previous listeners (no post-unmount setState leak)', () => { + (global as any).ResizeObserver = SpyResizeObserver; + const host = document.createElement('div'); + document.body.appendChild(host); + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({ + left: 0, right: 1, top: 0, bottom: 1, width: 1, height: 1, + } as DOMRect); + + const initialAnchor = makeAnchor(host, null); // constructed once — see file header + // Explicit generic args: renderHook infers its Props type param from + // BOTH the callback's parameter AND `initialProps` together and narrows + // to the non-null `{ anchor: ReferenceAnchor }` from initialProps alone + // even with the callback annotated `| null` — leaving the later + // `rerender({ anchor: null })` call failing to typecheck. Pinning the + // generics directly sidesteps the inference instead of fighting it. + const { result, rerender } = renderHook<{ d: string; rects: DOMRect[] }, { anchor: ReferenceAnchor | null }>( + ({ anchor }) => useReferenceGeometry(anchor), + { initialProps: { anchor: initialAnchor } }, + ); + expect(result.current.d).not.toBe(''); + expect(SpyResizeObserver.instances[0].disconnect).not.toHaveBeenCalled(); + + rerender({ anchor: null }); + + // The effect cleanup for the PREVIOUS (non-null) anchor must have run + // before the new (null) effect body — React guarantees this ordering — + // so the old ResizeObserver is disconnected and geometry is cleared. + expect(SpyResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1); + expect(result.current.d).toBe(''); + + document.body.removeChild(host); + }); +}); diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.ts b/desktop/src/renderer/components/reference/use-reference-geometry.ts new file mode 100644 index 000000000..bef2005fd --- /dev/null +++ b/desktop/src/renderer/components/reference/use-reference-geometry.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from 'react'; +import { toBoxes, buildUnionPath } from './reference-geometry'; +import type { ReferenceAnchor } from '../../state/reference-context'; + +/** + * Live geometry for the traced outline. + * + * Re-derives rects from the DOM on every measure pass rather than storing a + * DOMRect[] snapshot — stored rects go stale the instant the transcript + * scrolls, the window resizes, or the drawer opens (spec §3.1). + * + * Returns an empty path when the source is gone; the overlay falls back to a + * non-anchored centred card in that case (spec §7). + */ +export function useReferenceGeometry(anchor: ReferenceAnchor | null): { d: string; rects: DOMRect[] } { + const [geom, setGeom] = useState<{ d: string; rects: DOMRect[] }>({ d: '', rects: [] }); + + const measure = useCallback(() => { + if (!anchor) { setGeom({ d: '', rects: [] }); return; } + const host = anchor.host; + if (!host.isConnected) { setGeom({ d: '', rects: [] }); return; } + + // Trace the SELECTION when there is one (Destin's 9B call); fall back to + // the whole host element's box when there isn't — which is exactly the + // no-selection case that already references the entire message. + // A live Range re-measures itself as the page scrolls — no stored rects, no + // DOM mutation. If React ever replaces these nodes the Range yields no rects + // and we fall through to the whole-host outline, which is the designed + // fallback (spec 7). + // + // The containment check is load-bearing. The withdrawn surroundContents() + // design REJECTED a selection spanning element boundaries (it throws), so a + // cross-bubble drag produced a null anchor automatically. cloneRange() + // accepts it happily, so that signal is gone and we must re-derive it here: + // a Range escaping its host would otherwise trace an outline around content + // the reference does not actually cover. + const inHost = !!anchor.range && host.contains(anchor.range.commonAncestorContainer); + // Array.from, not a spread: this project's tsconfig lib list is + // ["ES2022", "DOM"] without "DOM.Iterable", so DOMRectList has no + // Symbol.iterator in the type system (tsc TS2488) even though it's + // array-like at runtime. Array.from works off .length/index access + // instead of iteration, so it needs no lib change. Matches this + // codebase's existing idiom for DOM collections (see + // html-inline-assets.ts, MascotRig.tsx). + const runRects = inHost ? Array.from(anchor.range!.getClientRects()) : []; + const rects = runRects.length ? runRects : [host.getBoundingClientRect()]; + + // Viewport-relative: the trace SVG is position:fixed, so the "host" origin + // for toBoxes is the viewport itself. + const origin = { left: 0, top: 0 } as DOMRect; + // rects is returned too — the artifact case re-draws the selected runs above + // the scrim from these (Task 8), since the originals are behind the dim. + setGeom({ d: buildUnionPath(toBoxes(rects as DOMRect[], origin)), rects: rects as DOMRect[] }); + }, [anchor]); + + useEffect(() => { + measure(); + if (!anchor) return; + window.addEventListener('resize', measure); + // capture:true so scrolling ANY ancestor scroller (chat-scroll, the artifact + // pane) re-measures — scroll does not bubble. + window.addEventListener('scroll', measure, true); + const ro = new ResizeObserver(measure); + const host = anchor.host; + if (host) ro.observe(host); + return () => { + window.removeEventListener('resize', measure); + window.removeEventListener('scroll', measure, true); + ro.disconnect(); + }; + }, [anchor, measure]); + + return geom; +} diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index 01559e209..778685504 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -903,6 +903,36 @@ body[data-reference-held] .bottom-float { background-color: color-mix(in srgb, var(--panel) calc(var(--panels-opacity, 1) * 100%), transparent); } +/* ═══════════════════════════════════════════════════════════════════════════ + "Ask Claude about this" held reference (spec 2026-07-26) + All values derive from theme tokens so the glow re-themes with everything + else — a literal colour here would break every community theme. + ═══════════════════════════════════════════════════════════════════════════ */ +.reference-scrim { + --ref-stroke: var(--accent); + --ref-wash: color-mix(in oklab, var(--accent) 12%, transparent); + --ref-glow: color-mix(in oklab, var(--accent) 45%, transparent); + /* 2.5x the standard overlay shadow: reads as lifted on light themes + (0.2 -> 0.5) without going murky on dark ones (0.1 -> 0.25). */ + --ref-lift-shadow: 0 24px 64px rgba(0, 0, 0, calc(var(--shadow-strength, 0.15) * 2.5)); +} + +.reference-trace { position: fixed; inset: 0; pointer-events: none; overflow: visible; } +.reference-trace path.wash { fill: var(--ref-wash); stroke: none; } +.reference-trace path.outline { + fill: none; + stroke: var(--ref-stroke); + stroke-width: 1.5; + stroke-linejoin: round; + filter: drop-shadow(0 0 5px var(--ref-glow)); + stroke-dasharray: 100; + stroke-dashoffset: 100; + animation: reference-trace-in 620ms 120ms cubic-bezier(.4, 0, .2, 1) forwards, + reference-breathe 2800ms 740ms ease-in-out infinite; +} +@keyframes reference-trace-in { to { stroke-dashoffset: 0; } } +@keyframes reference-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } + /* Marketplace rail cards — tighter shadow scoped to the rail scroll container. The default 0 8px 32px shadow needs ~40px of bottom clearance to render uncut inside overflow-x-auto's clip box; that From bf7ce6339a4cfee685f49595b943e512c2eac221 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 16:39:20 -0700 Subject: [PATCH 14/27] feat(reference): FLIP the referenced message to screen centre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clones the source and transforms it from its real rect to the viewport centre. A clone rather than a scroll because the newest message — the most likely right-click target — sits directly above the composer with no scroll room and can never reach centre by scrolling. Artifact references don't travel; the clone stays pinned over the source and is clipped to the selection instead, so only the referenced lines read at full --fg above the dim. Fixed a real coordinate bug along the way: clip-path: path() resolves against the clipped element's OWN border box, not the viewport, so the viewport-relative outline path (`d`) must be shifted by the source's own rect before use (new `shiftPath` helper in reference-geometry.ts) — using it unshifted, as the task brief's literal snippet did, would have clipped the wrong region whenever the source isn't pinned at the viewport origin. Also deletes `rects` from useReferenceGeometry's return value: nothing outside the hook's own tests ever consumed it once the artifact case switched from redrawing selected runs to clipping the clone. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/ReferenceOverlay.test.tsx | 150 +++++++++++++++++- .../components/reference/ReferenceOverlay.tsx | 123 +++++++++++++- .../reference/reference-geometry.test.ts | 28 +++- .../reference/reference-geometry.ts | 24 +++ .../reference/use-reference-geometry.test.ts | 14 +- .../reference/use-reference-geometry.ts | 17 +- desktop/src/renderer/styles/globals.css | 29 ++++ 7 files changed, 363 insertions(+), 22 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index 6d1315855..e6056fc58 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -5,12 +5,25 @@ import React, { useEffect, useState } from 'react'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, cleanup, act, fireEvent, screen } from '@testing-library/react'; import { EscCloseProvider, useEscClose } from '../../hooks/use-esc-close'; import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; import { ReferenceOverlay } from './ReferenceOverlay'; import { REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; +import { toBoxes, buildUnionPath, shiftPath } from './reference-geometry'; + +// jsdom doesn't implement ResizeObserver (same stub as +// use-reference-geometry.test.ts). Only the Task 8 lift tests below drive a +// real (non-null) anchor through useReferenceGeometry, which is what +// actually constructs one — but defining it once at module scope is simpler +// than duplicating the stub per-test for just those cases. +class NoopResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} +(global as any).ResizeObserver = NoopResizeObserver; // ReferenceOverlay portals straight to document.body (window-wide, not scoped // to RTL's per-test container), so an un-cleaned-up previous test's scrim @@ -185,3 +198,138 @@ describe('depth-cancel race (review Finding 3 — documented, accepted behavior) expect(document.querySelector('.reference-scrim')).toBeNull(); }); }); + +// Task 8: the lift. jsdom has no real layout engine, so getBoundingClientRect +// on an unmocked element is all-zeros and there is no way to assert the FLIP +// transition's actual transform PIXEL values here — a real dev-instance +// visual check (does the newest message, right above the composer, actually +// glide to centre; does a long message scroll internally instead of +// overflowing; does a multi-line artifact clip track scrolling) is still +// required before shipping, per the task brief. What IS provable in jsdom, +// and what these tests pin: (1) which element kinds travel vs. don't, (2) +// that the lifted card is a `cloneNode` copy — not the source itself, not an +// innerHTML re-parse — and that the source is left byte-identical, and (3) +// that the artifact clip-path is `d` SHIFTED by the source's own rect, not +// `d` used as-is (the coordinate-system bug the brief's literal `path(d)` +// would have shipped — see the WHY comment on the `shiftPath` call in +// ReferenceOverlay.tsx for the CSS Shapes spec citation), and (4) that +// nothing survives in the DOM once the reference clears. +describe('lift (Task 8: FLIP travel + artifact clip)', () => { + function makeHost(text: string): HTMLElement { + const host = document.createElement('div'); + host.setAttribute('data-test-marker', 'source'); + host.textContent = text; + document.body.appendChild(host); + return host; + } + + it('a chat reference clones the source via cloneNode, travels, and leaves the source unmutated', () => { + const host = makeHost('the referenced message'); + const originalOuterHTML = host.outerHTML; + + renderOverlay({ + kind: 'chat-text', + label: 'x', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + + const lift = document.querySelector('.reference-lift'); + expect(lift).not.toBeNull(); + expect(lift?.getAttribute('data-travels')).toBe('true'); + + const card = document.querySelector('.reference-lift-card'); + const clone = card?.firstElementChild; + expect(clone).not.toBeNull(); + // A DIFFERENT node than the source — proves this is cloneNode output, + // not a re-parent/move of the original (which would detach it from the + // transcript) and not a live reference to it. + expect(clone).not.toBe(host); + expect(clone?.outerHTML).toBe(originalOuterHTML); + expect(clone?.getAttribute('data-test-marker')).toBe('source'); + + // No DOM mutation in the reference path — the invariant this whole + // feature is built around (see reference-context.tsx's WHY comment on + // the withdrawn Range.surroundContents() design, which crashed the + // renderer). The source keeps its exact original markup and stays + // attached exactly where it always was. + expect(host.outerHTML).toBe(originalOuterHTML); + expect(host.isConnected).toBe(true); + expect(host.parentElement).toBe(document.body); + + document.body.removeChild(host); + }); + + it('an artifact reference does NOT travel and clips the clone to the selection, shifted into the clone\'s own coordinate space', () => { + const host = makeHost('const x = 1;'); + // jsdom's own getBoundingClientRect is all-zero (no layout engine) — + // stub a real rect so the clip-path math has something non-degenerate to + // shift, same idiom as use-reference-geometry.test.ts. + const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect; + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect); + + renderOverlay({ + kind: 'artifact', + label: 'lines 1-1 of x.ts', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift).not.toBeNull(); + // Absent, not merely falsy — globals.css' `:not([data-travels="true"])` + // selector depends on the attribute not being present at all. + expect(lift.hasAttribute('data-travels')).toBe(false); + expect(lift.style.transform).toBe('translate(0, 0)'); + + // The clip-path must be `d` SHIFTED by the source's own rect, not `d` + // used as-is. `clip-path: path()` resolves its coordinates against the + // CLIPPED ELEMENT's own border box (confirmed against the CSS Shapes + // spec), and this element's border box starts at (rect.left, rect.top), + // not (0, 0) — so the raw viewport-relative `d` would clip the wrong + // region if used unshifted. This is exactly the "reuses buildUnionPath + // unchanged" pipeline the geometry hook already runs, recomputed here + // and compared against what the effect actually wrote. + const expectedD = buildUnionPath(toBoxes([rect], { left: 0, top: 0 } as DOMRect)); + const expectedClip = `path('${shiftPath(expectedD, -rect.left, -rect.top)}')`; + expect(lift.style.clipPath).toBe(expectedClip); + // Sanity check that the shift is load-bearing: the unshifted path is a + // DIFFERENT string, so a regression back to `path(d)` (no shift) would + // fail the assertion above rather than accidentally still pass. + expect(expectedD).not.toBe(shiftPath(expectedD, -rect.left, -rect.top)); + + document.body.removeChild(host); + }); + + it('clearing the reference unmounts the whole lift — no leaked clone left in the DOM', () => { + const host = makeHost('goodbye'); + const { rerender } = renderOverlay({ + kind: 'chat-text', + label: 'x', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + expect(document.querySelector('.reference-lift-card')?.firstElementChild).not.toBeNull(); + + rerender( + + + + + + , + ); + act(() => {}); + + // ReferenceOverlay returns null (and its portal with it) once `reference` + // clears, so .reference-lift and its card unmount together — nothing of + // the clone survives detached in the DOM. + expect(document.querySelector('.reference-lift')).toBeNull(); + expect(document.querySelector('.reference-lift-card')).toBeNull(); + + document.body.removeChild(host); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 060f86af9..ade6bdf8a 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -5,6 +5,7 @@ import { CloseButton } from '../ui/CloseButton'; import { useReference } from '../../state/reference-context'; import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close'; import { useReferenceGeometry } from './use-reference-geometry'; +import { shiftPath } from './reference-geometry'; /** * The held "Ask Claude about this" reference (spec 2026-07-26). @@ -20,10 +21,104 @@ export function ReferenceOverlay() { const { reference, clearReference } = useReference(); const depth = useEscStackDepth(); const depthAtOpen = useRef(null); - // Task 7: the traced outline around the referenced content. `rects` is - // unused here — Task 8 needs it to redraw the selected runs above the scrim. + // Task 7: the traced outline around the referenced content. Task 8 also + // reuses `d` directly for the artifact clip-path (see the lift effect + // below) — the hook used to return raw `rects` too, for a redraw approach + // that was dropped in favor of clipping the clone, so that field was + // deleted as dead code. const { d } = useReferenceGeometry(reference?.anchor ?? null); + // Task 8: chat references lift a clone to the viewport centre; artifact + // references stay put and get clipped to the selection instead (spec 2.2). + const travels = reference?.kind === 'chat-text' || reference?.kind === 'chat-code'; + const liftRef = useRef(null); + const holderRef = useRef(null); + + // Clone the source node ONCE per reference, so the lifted card survives the + // original unmounting (e.g. the transcript virtualizes it away, or a new + // turn pushes it out of the rendered window). + // + // cloneNode(true), NOT innerHTML: no HTML re-parsing, no XSS surface, and + // canvas/img/scroll state comes across intact. The clone is a static + // snapshot — safe because Task 3's `data-streaming` guard disables the + // "Ask about this" menu row on the turn still in flight, so every message + // that CAN become a reference is already complete text; nothing under the + // clone can still be mutated by the transcript watcher. The one thing that + // *can* still change after cloning is theme/appearance (font, syntax + // theme, `--fg` etc.) — those are CSS custom properties read at paint time, + // not baked into the cloned markup, so a theme switch while a reference is + // held re-styles the clone identically to the original. + useEffect(() => { + const holder = holderRef.current; + if (!holder || !reference?.anchor) return; + const src = reference.anchor.host; + if (!src) return; + const copy = src.cloneNode(true) as HTMLElement; + // (nothing to strip — the anchor never wrote attributes onto the source) + holder.replaceChildren(copy); + return () => holder.replaceChildren(); + }, [reference]); + + // FLIP: place the clone exactly over the real element (First), then + // transform it to the viewport centre (Last) — chat references only. + // Scroll-to-centre is NOT an option: the most likely right-click target is + // the newest message, which sits directly above the composer with no + // scroll room beneath it and can never reach centre by scrolling (spec + // 2.1). Artifact references don't travel at all (spec 2.2) — the clone + // stays pinned over the source and is clipped to the selection instead, so + // it reads at full --fg above the dim while the rest of the window dims. + useEffect(() => { + const node = liftRef.current; + if (!node || !reference?.anchor) return; + const src = reference.anchor.host; + if (!src) return; + + const s = src.getBoundingClientRect(); + node.style.left = `${s.left}px`; + node.style.top = `${s.top}px`; + node.style.width = `${s.width}px`; + node.style.transform = 'translate(0, 0)'; + // Clear any clip-path a PREVIOUS artifact reference left on this node. + // The node is reused (not remounted) when `reference` changes kind + // without passing through null in between — e.g. the session-switch + // park/restore in reference-context.tsx swapping straight from one + // held reference to another. + node.style.clipPath = 'none'; + + if (!travels) { + // Artifact reference: no travel. Pin the clone exactly over the + // original and clip it to the selection, so only the selected lines + // read at full --fg while the rest of the window dims. Clipping the + // clone beats re-drawing the text: multi-line selections (the + // headline case — "lines 12-18 of engine.ts") keep exact glyphs, + // fonts, and highlighting. + // + // `d` is built in VIEWPORT coordinates (use-reference-geometry.ts's + // `origin = {left:0,top:0}`), which lines up for free with the trace + // SVG (`.reference-trace` is `position:fixed; inset:0`, so ITS border + // box origin IS the viewport origin). It does NOT line up for free + // here: `clip-path: path()` resolves its coordinates against the + // clipped element's OWN border box — this node's box starts at + // (s.left, s.top), not (0,0), because it's pinned over the source. + // Verified against the CSS Shapes spec (path() uses the same + // reference-box rule polygon()/circle() use for percentages), not + // assumed. Without shiftPath the clip silently lands offset by the + // source's own position — correct only for a source pinned at the + // viewport origin, which is not the general case. + node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none'; + return; + } + + // Next frame so the browser paints the First position before transitioning. + const raf = requestAnimationFrame(() => { + const h = node.offsetHeight; + const dx = (window.innerWidth - s.width) / 2 - s.left; + const dy = (window.innerHeight - h) / 2 - s.top; + node.style.transform = `translate(${dx}px, ${dy}px)`; + }); + return () => cancelAnimationFrame(raf); + }, [reference, travels, d]); + // Esc cancels. LIFO, so if a drawer opened on top, Esc closes that first. useEscClose(!!reference, clearReference); @@ -97,11 +192,27 @@ export function ReferenceOverlay() { )} - {/* Cancel affordance. Positioned by Task 8 against the lifted card; until - then it parks top-right so the state is always escapable by mouse. */} -
- + {/* Task 8: the clone. Chat kinds travel to centre (`data-travels` + drives the CSS transition + the non-clipping shadow/scroll rules); + artifact kinds stay pinned over the source and get clipped instead + (see the positioning effect above). */} +
+
+ {/* Cancel affordance pinned to the lifted card itself, not the + viewport corner, once there IS a card to pin it to. */} + {travels && ( +
+ +
+ )}
+ {/* Artifact case has no travelling card, so the cancel affordance stays + parked in the viewport corner — always escapable by mouse. */} + {!travels && ( +
+ +
+ )} , document.body, ); diff --git a/desktop/src/renderer/components/reference/reference-geometry.test.ts b/desktop/src/renderer/components/reference/reference-geometry.test.ts index 8314839f0..0dd217ce5 100644 --- a/desktop/src/renderer/components/reference/reference-geometry.test.ts +++ b/desktop/src/renderer/components/reference/reference-geometry.test.ts @@ -3,7 +3,7 @@ // the outline is the stepped union of those boxes — down the right edges, // back up the left. No DOM needed, so this runs in the default node env. import { describe, it, expect } from 'vitest'; -import { buildUnionPath, toBoxes, type Box } from './reference-geometry'; +import { buildUnionPath, toBoxes, shiftPath, type Box } from './reference-geometry'; const box = (l: number, t: number, r: number, b: number): Box => ({ l, t, r, b }); @@ -54,3 +54,29 @@ describe('toBoxes', () => { expect(out[0].t).toBeLessThan(out[1].t); }); }); + +// Task 8: clip-path correction. clip-path: path() resolves against the +// clipped element's OWN border box, not the viewport, so a viewport-relative +// `d` must be re-expressed relative to the clone's own rect before use. +describe('shiftPath', () => { + it('shifts every M/L coordinate pair by (dx, dy)', () => { + const d = buildUnionPath([box(10, 0, 90, 20)]); // 'M 90 0 L 90 20 L 10 20 L 10 0 Z' + expect(shiftPath(d, -10, -5)).toBe('M 80 -5 L 80 15 L 0 15 L 0 -5 Z'); + }); + + it('leaves a multi-box union path fully shifted, command-by-command', () => { + const d = buildUnionPath([box(40, 0, 100, 20), box(0, 20, 100, 40)]); + expect(shiftPath(d, 5, 5)).toBe( + 'M 105 5 L 105 25 L 105 25 L 105 45 L 5 45 L 5 25 L 45 25 L 45 5 Z', + ); + }); + + it('passes an empty path through unchanged', () => { + expect(shiftPath('', -10, -5)).toBe(''); + }); + + it('is a no-op with a zero offset', () => { + const d = buildUnionPath([box(0, 0, 10, 10)]); + expect(shiftPath(d, 0, 0)).toBe(d); + }); +}); diff --git a/desktop/src/renderer/components/reference/reference-geometry.ts b/desktop/src/renderer/components/reference/reference-geometry.ts index b4cc6e369..c28f0f9cb 100644 --- a/desktop/src/renderer/components/reference/reference-geometry.ts +++ b/desktop/src/renderer/components/reference/reference-geometry.ts @@ -41,3 +41,27 @@ export function buildUnionPath(boxes: Box[]): string { cmds.push('Z'); return cmds.join(' '); } + +/** + * Shifts every coordinate pair in a `buildUnionPath` string by (dx, dy). + * + * Task 8's artifact-reference clip-path needs this: `d` is built in VIEWPORT + * coordinates (use-reference-geometry.ts's `origin = {left:0,top:0}`), which + * is exactly right for the trace SVG (`.reference-trace` is `position:fixed; + * inset:0`, so its own border box origin IS the viewport origin). But CSS + * `clip-path: path(...)` resolves its coordinates against the top-left of the + * CLIPPED ELEMENT'S OWN reference box (border-box by default) — the same rule + * `polygon()`/`circle()` use for percentages — NOT the viewport. The lifted + * clone's box is pinned at the source's rect, not at (0,0), so its path must + * be re-expressed relative to that box's own origin: pass + * `shiftPath(d, -rect.left, -rect.top)`. + * + * Only handles the M/L pairs `buildUnionPath` emits (no curves, no relative + * commands) — sufficient because it is the only producer of this path format. + */ +export function shiftPath(d: string, dx: number, dy: number): string { + if (!d) return d; + return d.replace(/([ML]) (-?[\d.]+) (-?[\d.]+)/g, (_match, cmd: string, x: string, y: string) => + `${cmd} ${Number(x) + dx} ${Number(y) + dy}`, + ); +} diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts index 7ff77df0f..ad4d2e065 100644 --- a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts +++ b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts @@ -69,7 +69,6 @@ describe('useReferenceGeometry', () => { (global as any).ResizeObserver = SpyResizeObserver; const { result } = renderHook(() => useReferenceGeometry(null)); expect(result.current.d).toBe(''); - expect(result.current.rects).toEqual([]); }); it('uses the range rects when the range is contained in the host', () => { @@ -93,7 +92,11 @@ describe('useReferenceGeometry', () => { expect(rectsStub).toHaveBeenCalled(); expect(hostRectSpy).not.toHaveBeenCalled(); - expect(result.current.rects).toEqual([{ left: 1, right: 2, top: 3, bottom: 4, width: 1, height: 1 }]); + // `d` is non-empty proof the range branch's rect fed the path builder — + // the raw rects themselves are no longer exposed (Task 8 deleted the + // unused `rects` field; nothing outside this hook ever consumed it once + // the artifact clip-path switched to reusing `d` directly). + expect(result.current.d).not.toBe(''); document.body.removeChild(host); }); @@ -131,7 +134,7 @@ describe('useReferenceGeometry', () => { // The range must never even be consulted once containment fails. expect(rectsStub).not.toHaveBeenCalled(); expect(hostRectSpy).toHaveBeenCalled(); - expect(result.current.rects).toEqual([{ left: 10, right: 20, top: 30, bottom: 40, width: 10, height: 10 }]); + expect(result.current.d).not.toBe(''); document.body.removeChild(host); document.body.removeChild(outside); @@ -149,7 +152,7 @@ describe('useReferenceGeometry', () => { const { result } = renderHook(() => useReferenceGeometry(anchor)); expect(hostRectSpy).toHaveBeenCalled(); - expect(result.current.rects).toEqual([{ left: 0, right: 5, top: 0, bottom: 5, width: 5, height: 5 }]); + expect(result.current.d).not.toBe(''); document.body.removeChild(host); }); @@ -160,7 +163,6 @@ describe('useReferenceGeometry', () => { const anchor = makeAnchor(host, null); // constructed once — see file header const { result } = renderHook(() => useReferenceGeometry(anchor)); expect(result.current.d).toBe(''); - expect(result.current.rects).toEqual([]); }); it('registers resize/scroll listeners and a ResizeObserver on mount, and tears every one of them down on unmount', () => { @@ -218,7 +220,7 @@ describe('useReferenceGeometry', () => { // even with the callback annotated `| null` — leaving the later // `rerender({ anchor: null })` call failing to typecheck. Pinning the // generics directly sidesteps the inference instead of fighting it. - const { result, rerender } = renderHook<{ d: string; rects: DOMRect[] }, { anchor: ReferenceAnchor | null }>( + const { result, rerender } = renderHook<{ d: string }, { anchor: ReferenceAnchor | null }>( ({ anchor }) => useReferenceGeometry(anchor), { initialProps: { anchor: initialAnchor } }, ); diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.ts b/desktop/src/renderer/components/reference/use-reference-geometry.ts index bef2005fd..62278b9f5 100644 --- a/desktop/src/renderer/components/reference/use-reference-geometry.ts +++ b/desktop/src/renderer/components/reference/use-reference-geometry.ts @@ -12,13 +12,13 @@ import type { ReferenceAnchor } from '../../state/reference-context'; * Returns an empty path when the source is gone; the overlay falls back to a * non-anchored centred card in that case (spec §7). */ -export function useReferenceGeometry(anchor: ReferenceAnchor | null): { d: string; rects: DOMRect[] } { - const [geom, setGeom] = useState<{ d: string; rects: DOMRect[] }>({ d: '', rects: [] }); +export function useReferenceGeometry(anchor: ReferenceAnchor | null): { d: string } { + const [geom, setGeom] = useState<{ d: string }>({ d: '' }); const measure = useCallback(() => { - if (!anchor) { setGeom({ d: '', rects: [] }); return; } + if (!anchor) { setGeom({ d: '' }); return; } const host = anchor.host; - if (!host.isConnected) { setGeom({ d: '', rects: [] }); return; } + if (!host.isConnected) { setGeom({ d: '' }); return; } // Trace the SELECTION when there is one (Destin's 9B call); fall back to // the whole host element's box when there isn't — which is exactly the @@ -46,11 +46,12 @@ export function useReferenceGeometry(anchor: ReferenceAnchor | null): { d: strin const rects = runRects.length ? runRects : [host.getBoundingClientRect()]; // Viewport-relative: the trace SVG is position:fixed, so the "host" origin - // for toBoxes is the viewport itself. + // for toBoxes is the viewport itself. Task 8's artifact clip-path also + // consumes `d` in this same viewport coordinate system (see the WHY + // comment on ReferenceOverlay.tsx's shiftPath call for how it's + // re-expressed relative to the clone's own box before use). const origin = { left: 0, top: 0 } as DOMRect; - // rects is returned too — the artifact case re-draws the selected runs above - // the scrim from these (Task 8), since the originals are behind the dim. - setGeom({ d: buildUnionPath(toBoxes(rects as DOMRect[], origin)), rects: rects as DOMRect[] }); + setGeom({ d: buildUnionPath(toBoxes(rects as DOMRect[], origin)) }); }, [anchor]); useEffect(() => { diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index 778685504..a32d789ed 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -933,6 +933,35 @@ body[data-reference-held] .bottom-float { @keyframes reference-trace-in { to { stroke-dashoffset: 0; } } @keyframes reference-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } +/* The lifted clone. Chat references travel to centre; artifact references do + NOT (their selection is already where the user is looking) — spec 2.2. */ +.reference-lift { + position: fixed; + z-index: 61; /* CONTENT_Z[2]; the composer sits one above */ + pointer-events: none; + transition: transform 460ms cubic-bezier(.22, 1, .36, 1); + will-change: transform; +} +/* Fix (deviation from the brief's literal `.reference-lift > *`): the cancel + button's wrapper is ALSO a direct child of .reference-lift (it's pinned to + the card at -top-3 -right-3), so a bare `> *` selector would hand the + button wrapper the same box-shadow/70vh-scroll treatment as the cloned + card. Scope to .reference-lift-card, the clone holder only. */ +.reference-lift > .reference-lift-card { + box-shadow: var(--ref-lift-shadow); + /* A message longer than the viewport must not overflow off-screen. */ + max-height: 70vh; + overflow-y: auto; + pointer-events: auto; +} +/* Non-travelling (artifact) clone: pinned over the original and clipped to the + selection, so only the referenced lines stay bright above the dim. */ +.reference-lift:not([data-travels="true"]) > .reference-lift-card { + box-shadow: none; + max-height: none; + overflow: visible; +} + /* Marketplace rail cards — tighter shadow scoped to the rail scroll container. The default 0 8px 32px shadow needs ~40px of bottom clearance to render uncut inside overflow-x-auto's clip box; that From 83f7c6bf7fdde1bc989a3de557cc1dc26d95a92f Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 16:47:32 -0700 Subject: [PATCH 15/27] fix(reference-overlay): stop scroll from restarting the travel FLIP The FLIP positioning effect depended on `d` (the traced-outline path, recomputed on every scroll/resize), but travelling chat references never read `d` at all -- only the artifact clip-path branch does. Sharing one effect meant scrolling mid-travel reset `transform` back to the source position and re-scheduled the RAF, visibly restarting the 460ms lift. Split into two effects: the travel FLIP now keys on `[reference, travels]` only, so it runs exactly once per reference; the artifact clip-path keeps `[reference, travels, d]` so it still tracks the selection as the page scrolls. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/ReferenceOverlay.test.tsx | 132 ++++++++++++++++++ .../components/reference/ReferenceOverlay.tsx | 84 +++++++---- 2 files changed, 188 insertions(+), 28 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index e6056fc58..5af6364e8 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -333,3 +333,135 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => { document.body.removeChild(host); }); }); + +// Task 8 defect fix: the FLIP positioning effect used to depend on `d` +// (`[reference, travels, d]`). `d` is recomputed by useReferenceGeometry on +// EVERY scroll/resize event (see its measure() + window listeners), so +// scrolling at any point during the 460ms travel re-ran the whole effect: +// reset `transform` back to the source position and re-scheduled the RAF +// that glides it to centre, restarting the travel from scratch. `d` is only +// ever consumed by the non-travelling (artifact) branch's clip-path — the +// travelling (chat) branch never reads it. These tests pin the mechanism +// (effect re-run / transform rewrite), not pixel values — jsdom has no +// layout engine, so `window.innerWidth/innerHeight` and `node.offsetHeight` +// are whatever jsdom defaults to, not real numbers; what's provable is +// WHETHER the transform gets rewritten, not what it's rewritten TO. +describe('lift: scroll must not restart the travel animation (task-8 defect fix)', () => { + function makeHost(text: string): HTMLElement { + const host = document.createElement('div'); + host.setAttribute('data-test-marker', 'source'); + host.textContent = text; + document.body.appendChild(host); + return host; + } + + // Real per-call rect, mutable so a later "scroll" can change what + // getBoundingClientRect returns without changing the anchor's identity — + // exactly what happens for a real scroll (the source's viewport position + // moves; the reference itself doesn't change). + function stubMovingRect(host: HTMLElement, initial: DOMRect) { + let current = initial; + vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() => current); + return { + move(next: DOMRect) { current = next; }, + }; + } + + it('a chat (travelling) reference does not rewrite `transform` when only `d` changes (i.e. on scroll)', async () => { + const host = makeHost('the referenced message'); + const rectCtl = stubMovingRect(host, { + left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50, + } as DOMRect); + + renderOverlay({ + kind: 'chat-text', + label: 'x', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + // Let the RAF-scheduled "Last" transform apply (real timers — see the + // raf-sanity check this fix was verified against: a real ~50ms wait is + // enough for jsdom's requestAnimationFrame to fire). + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift).not.toBeNull(); + const transformAfterTravel = lift.style.transform; + // Sanity: the RAF callback ran and actually rewrote transform away from + // the FLIP "First" position — otherwise the test below would trivially + // pass for the wrong reason (nothing ever changed the string at all). + expect(transformAfterTravel).not.toBe('translate(0, 0)'); + + // Simulate a scroll: the source's viewport position changes AND + // useReferenceGeometry's window-level 'scroll' listener (capture: true) + // fires, forcing a re-measure that changes `d` — reference/travels are + // untouched. + rectCtl.move({ left: 400, top: 300, right: 500, bottom: 350, width: 100, height: 50 } as DOMRect); + await act(async () => { + fireEvent.scroll(window); + }); + // Give any (wrongly) re-scheduled RAF a chance to fire too, so a + // regression can't hide behind "the assertion ran before the RAF + // callback landed." A separate act() call from the fireEvent above — + // the scroll-triggered state update (measure() -> setGeom) needs its own + // flush cycle before the effect that reads the new `d` even runs, so a + // single combined act() can starve the RAF of time within one window. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // The whole point of the fix: scrolling mid-travel must not touch + // `transform` again. Before the fix, the effect re-ran on the `d` + // change, reset `transform` to 'translate(0, 0)' synchronously, then + // re-scheduled a fresh RAF — an observable restart of the glide. + expect(lift.style.transform).toBe(transformAfterTravel); + + document.body.removeChild(host); + }); + + it('an artifact (non-travelling) reference DOES update its clip-path when `d` changes (i.e. on scroll)', async () => { + const host = makeHost('const x = 1;'); + const rectCtl = stubMovingRect(host, { + left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50, + } as DOMRect); + + renderOverlay({ + kind: 'artifact', + label: 'lines 1-1 of x.ts', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift).not.toBeNull(); + const firstClip = lift.style.clipPath; + expect(firstClip).not.toBe('none'); + + // Move the source (simulating the artifact pane scrolling) and fire the + // same window 'scroll' event useReferenceGeometry listens for. Width/ + // height are deliberately DIFFERENT from the initial rect, not just the + // position: shiftPath re-expresses the path relative to the clone's own + // box origin, so a same-size rect moved to a new (left, top) produces the + // exact same shifted string — a real scroll can also change the box's + // size (line wrap, reflow), and this is the only way to prove the effect + // actually re-measured rather than coincidentally matching. + const movedRect = { left: 400, top: 300, right: 540, bottom: 360, width: 140, height: 60 } as DOMRect; + rectCtl.move(movedRect); + act(() => { fireEvent.scroll(window); }); + + // Unlike the travelling case above, the artifact clip MUST track the + // new rect — the selection moved with the page, so a stale clip would + // reveal the wrong lines. Recompute independently (same idiom as the + // existing artifact test above) rather than just asserting "changed". + const expectedD = buildUnionPath(toBoxes([movedRect], { left: 0, top: 0 } as DOMRect)); + const expectedClip = `path('${shiftPath(expectedD, -movedRect.left, -movedRect.top)}')`; + expect(lift.style.clipPath).toBe(expectedClip); + expect(lift.style.clipPath).not.toBe(firstClip); + + document.body.removeChild(host); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index ade6bdf8a..a7935a9f6 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -64,12 +64,22 @@ export function ReferenceOverlay() { // Scroll-to-centre is NOT an option: the most likely right-click target is // the newest message, which sits directly above the composer with no // scroll room beneath it and can never reach centre by scrolling (spec - // 2.1). Artifact references don't travel at all (spec 2.2) — the clone - // stays pinned over the source and is clipped to the selection instead, so - // it reads at full --fg above the dim while the rest of the window dims. + // 2.1). + // + // Bug fix (task-8 review): this used to be ONE effect, deps + // `[reference, travels, d]`, shared with the artifact clip-path logic + // below. `d` is recomputed by useReferenceGeometry on EVERY scroll/resize + // (it's the traced-outline path), but a travelling clone never reads `d` + // at all — only the artifact branch does, for its clip-path. Sharing one + // effect meant scrolling at any point during the 460ms travel re-ran the + // whole thing: reset `transform` back to the source position and + // re-scheduled the RAF that glides it to centre, visibly restarting the + // travel. Splitting into two effects — this one keyed on + // `[reference, travels]` only — means a travelling reference's FLIP runs + // exactly once per reference and is inert to scroll/resize. useEffect(() => { const node = liftRef.current; - if (!node || !reference?.anchor) return; + if (!node || !reference?.anchor || !travels) return; const src = reference.anchor.host; if (!src) return; @@ -85,30 +95,6 @@ export function ReferenceOverlay() { // held reference to another. node.style.clipPath = 'none'; - if (!travels) { - // Artifact reference: no travel. Pin the clone exactly over the - // original and clip it to the selection, so only the selected lines - // read at full --fg while the rest of the window dims. Clipping the - // clone beats re-drawing the text: multi-line selections (the - // headline case — "lines 12-18 of engine.ts") keep exact glyphs, - // fonts, and highlighting. - // - // `d` is built in VIEWPORT coordinates (use-reference-geometry.ts's - // `origin = {left:0,top:0}`), which lines up for free with the trace - // SVG (`.reference-trace` is `position:fixed; inset:0`, so ITS border - // box origin IS the viewport origin). It does NOT line up for free - // here: `clip-path: path()` resolves its coordinates against the - // clipped element's OWN border box — this node's box starts at - // (s.left, s.top), not (0,0), because it's pinned over the source. - // Verified against the CSS Shapes spec (path() uses the same - // reference-box rule polygon()/circle() use for percentages), not - // assumed. Without shiftPath the clip silently lands offset by the - // source's own position — correct only for a source pinned at the - // viewport origin, which is not the general case. - node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none'; - return; - } - // Next frame so the browser paints the First position before transitioning. const raf = requestAnimationFrame(() => { const h = node.offsetHeight; @@ -117,6 +103,48 @@ export function ReferenceOverlay() { node.style.transform = `translate(${dx}px, ${dy}px)`; }); return () => cancelAnimationFrame(raf); + }, [reference, travels]); + + // Artifact clip: pin the clone over the source and clip it to the + // selection (spec 2.2), so only the selected lines read at full --fg while + // the rest of the window dims. Clipping the clone beats re-drawing the + // text: multi-line selections (the headline case — "lines 12-18 of + // engine.ts") keep exact glyphs, fonts, and highlighting. + // + // Deliberately a SEPARATE effect from the travel FLIP above, and + // deliberately keeps `d` in its deps: unlike the travel case, there is no + // animation here to interrupt, and the selection genuinely moves with the + // page as the artifact pane scrolls — a stale clip-path would keep + // clipping to where the selection USED to be, revealing the wrong lines. + // Re-running this effect on every scroll/resize tick is exactly the + // desired behaviour (this mirrors what the pre-split effect already did + // for the non-travelling branch; only the travelling branch's re-run-on- + // scroll was the bug). + useEffect(() => { + const node = liftRef.current; + if (!node || !reference?.anchor || travels) return; + const src = reference.anchor.host; + if (!src) return; + + const s = src.getBoundingClientRect(); + node.style.left = `${s.left}px`; + node.style.top = `${s.top}px`; + node.style.width = `${s.width}px`; + node.style.transform = 'translate(0, 0)'; + + // `d` is built in VIEWPORT coordinates (use-reference-geometry.ts's + // `origin = {left:0,top:0}`), which lines up for free with the trace + // SVG (`.reference-trace` is `position:fixed; inset:0`, so ITS border + // box origin IS the viewport origin). It does NOT line up for free + // here: `clip-path: path()` resolves its coordinates against the + // clipped element's OWN border box — this node's box starts at + // (s.left, s.top), not (0,0), because it's pinned over the source. + // Verified against the CSS Shapes spec (path() uses the same + // reference-box rule polygon()/circle() use for percentages), not + // assumed. Without shiftPath the clip silently lands offset by the + // source's own position — correct only for a source pinned at the + // viewport origin, which is not the general case. + node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none'; }, [reference, travels, d]); // Esc cancels. LIFO, so if a drawer opened on top, Esc closes that first. From 1efccbb172aca3745b41c31ea569571499cdb1f0 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 17:00:31 -0700 Subject: [PATCH 16/27] fix(reference): restore pointer-events on Cancel button; correct cloneNode comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cancel button's wrapper div is a sibling of .reference-lift-card inside .reference-lift, which sets pointer-events: none. Only .reference-lift-card had it restored, so the button was never a real hit-test target — clicks appeared to work only because they fell through to the full-viewport scrim behind it. Add pointer-events-auto to both the travelling and non-travelling wrapper (same idiom as Toast.tsx's action slot). Also corrects a WHY comment on the cloneNode(true) call that incorrectly claimed canvas/scroll state survives cloning — it doesn't (cloneNode copies DOM attributes only, not drawn bitmaps or scrollTop/scrollLeft). Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/ReferenceOverlay.test.tsx | 55 +++++++++++++++++++ .../components/reference/ReferenceOverlay.tsx | 38 +++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index 5af6364e8..c0d9a97f8 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -96,6 +96,61 @@ describe('ReferenceOverlay', () => { }); }); +// Review finding: the cancel button's wrapper is a SIBLING of +// .reference-lift-card inside .reference-lift, which globals.css sets +// pointer-events: none on. Only .reference-lift-card gets pointer-events: +// auto restored (globals.css ~line 955), so the wrapper — and everything in +// it, including the button — was NOT a hit-test target: hover never showed +// and the button's own onClick never fired. It only LOOKED like it worked +// because the click fell through to the full-viewport scrim underneath, +// whose onClick={clearReference} does the same thing for a different reason. +// +// jsdom has no real layout/paint engine, so it cannot honestly prove a click +// "reaches" the button through pointer-events — that's a hit-testing +// question a real browser answers, jsdom does not. What CAN be proven here, +// and what this test pins, is the mechanism the fix relies on: the wrapper +// carries the same `pointer-events-auto` class Toast.tsx's action slot uses +// to restore hit-testing under a pointer-events: none ancestor. A real +// dev-instance check (hover + click the Cancel button; confirm :hover +// actually paints, not just that SOMETHING dismisses the reference) is still +// required before shipping — see the task report. +describe('cancel button pointer-events (review finding)', () => { + function wrapperOf(cancelButton: Element): Element { + const wrapper = cancelButton.parentElement; + if (!wrapper) throw new Error('cancel button has no parent wrapper'); + return wrapper; + } + + it('travelling (chat) case: the cancel button wrapper carries pointer-events-auto', () => { + renderOverlay(mockReference); // kind: 'chat-text' -> travels + act(() => {}); + + const lift = document.querySelector('.reference-lift'); + expect(lift?.getAttribute('data-travels')).toBe('true'); // sanity: this IS the travelling case + + const cancelButton = document.querySelector('[aria-label="Cancel reference"]'); + expect(cancelButton).not.toBeNull(); + const wrapper = wrapperOf(cancelButton as Element); + // The wrapper must restore pointer-events, since .reference-lift (its + // ancestor) sets pointer-events: none and only .reference-lift-card — a + // SIBLING of this wrapper, not an ancestor of it — gets it restored. + expect(wrapper.className).toMatch(/\bpointer-events-auto\b/); + }); + + it('non-travelling (artifact) case: the cancel button wrapper carries pointer-events-auto', () => { + renderOverlay({ kind: 'artifact', label: 'x', promptText: 'x', anchor: null }); + act(() => {}); + + const lift = document.querySelector('.reference-lift'); + expect(lift?.hasAttribute('data-travels')).toBe(false); // sanity: this IS the non-travelling case + + const cancelButton = document.querySelector('[aria-label="Cancel reference"]'); + expect(cancelButton).not.toBeNull(); + const wrapper = wrapperOf(cancelButton as Element); + expect(wrapper.className).toMatch(/\bpointer-events-auto\b/); + }); +}); + // Review Finding 4: no test asserted the composer-lift mechanism at all. // jsdom has no real layout engine, so none of this can prove actual paint // order (i.e. that a click really lands on the textarea instead of the diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index a7935a9f6..4300fd31a 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -38,10 +38,21 @@ export function ReferenceOverlay() { // original unmounting (e.g. the transcript virtualizes it away, or a new // turn pushes it out of the rendered window). // - // cloneNode(true), NOT innerHTML: no HTML re-parsing, no XSS surface, and - // canvas/img/scroll state comes across intact. The clone is a static - // snapshot — safe because Task 3's `data-streaming` guard disables the - // "Ask about this" menu row on the turn still in flight, so every message + // cloneNode(true), NOT innerHTML: innerHTML would serialise the source to a + // string and re-parse it, which is both wasted work and an XSS surface; + // cloneNode copies the live DOM nodes directly, no parsing involved. NOTE + // this does NOT mean everything about the source comes across — a 's + // drawn bitmap is GPU/raster state, not a DOM attribute, so cloning one + // yields a blank canvas; likewise scrollTop/scrollLeft on a scrolled + // descendant are runtime state that cloneNode does not copy (the clone + // reads scrollTop 0). Neither matters TODAY — the app's one is an + // unrelated background layer, not referenceable content — but a future + // reference source with either will need more than cloneNode to snapshot + // faithfully. + // + // The clone is a static snapshot — safe because Task 3's `data-streaming` + // guard disables the "Ask about this" menu row on the turn still in flight, + // so every message // that CAN become a reference is already complete text; nothing under the // clone can still be mutated by the transcript watcher. The one thing that // *can* still change after cloning is theme/appearance (font, syntax @@ -229,7 +240,15 @@ export function ReferenceOverlay() { {/* Cancel affordance pinned to the lifted card itself, not the viewport corner, once there IS a card to pin it to. */} {travels && ( -
+ // pointer-events-auto (review finding): `.reference-lift` itself is + // pointer-events: none (globals.css) so mouse events fall through to + // the scrim beneath everywhere except the explicitly-restored + // `.reference-lift-card`. This wrapper is a SIBLING of that card, not + // a descendant, so it doesn't inherit the restore — without this + // class the button is un-hoverable and un-clickable; the click that + // "worked" was really landing on the scrim's own onClick behind it. + // Same idiom as Toast.tsx's action slot. +
)} @@ -237,7 +256,14 @@ export function ReferenceOverlay() { {/* Artifact case has no travelling card, so the cancel affordance stays parked in the viewport corner — always escapable by mouse. */} {!travels && ( -
+ // pointer-events-auto: not strictly load-bearing today (this wrapper + // sits directly under `.reference-scrim`, which has no pointer-events + // rule of its own, so it's already hit-testable) — but declared + // explicitly anyway so the invariant doesn't depend on staying + // outside `.reference-lift`'s pointer-events: none subtree. Matches + // the travelling-case wrapper above rather than relying on a CSS + // ancestry detail a future refactor could silently change. +
)} From d4775ec60ab332d527961e143600896d492abe46 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 17:07:18 -0700 Subject: [PATCH 17/27] feat(reference): reduced-effects and reduced-motion fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outline only — no trace animation, breathing pulse, glow, or travel easing. Stamps data-reduced on .reference-trace/.reference-lift from useTheme()'s reducedEffects (there's no data-reduced-effects attribute on to key CSS off directly). Corrected the plan brief's stale `.reference-lift[data- reduced="true"] > *` selector (predates Task 8's clone-markup split) to target .reference-lift-card, and scoped the lift-shadow override to the travelling case only so it doesn't clobber the artifact clone's unconditional box-shadow: none. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/ReferenceOverlay.test.tsx | 100 +++++++++++++++++- .../components/reference/ReferenceOverlay.tsx | 19 +++- desktop/src/renderer/styles/globals.css | 40 +++++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index c0d9a97f8..784425904 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -5,10 +5,11 @@ import React, { useEffect, useState } from 'react'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { render, cleanup, act, fireEvent, screen } from '@testing-library/react'; import { EscCloseProvider, useEscClose } from '../../hooks/use-esc-close'; import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; +import { ThemeProvider } from '../../state/theme-context'; import { ReferenceOverlay } from './ReferenceOverlay'; import { REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; import { toBoxes, buildUnionPath, shiftPath } from './reference-geometry'; @@ -520,3 +521,100 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix) document.body.removeChild(host); }); }); + +// Task 9: reduced-effects fallback. globals.css keys the whole branch off a +// `data-reduced` attribute stamped by THIS component (there's no +// data-reduced-effects attribute on to select on — theme-engine.ts +// only zeroes blur vars for this setting, verified in the task brief), so the +// only honestly-checkable-in-jsdom claim is the mechanism: does the attribute +// land on `.reference-trace` and `.reference-lift` when reducedEffects is +// true, and is it ABSENT (not merely falsy — globals.css's `[data-reduced= +// "true"]` selector needs the attribute gone entirely) when it's false. What +// this can NOT prove — computed styles, whether the animation/glow/transition +// actually stop, whether the lift shadow visually drops back — needs a real +// dev-instance check (see the task report). +describe('reduced effects (Task 9)', () => { + const REDUCED_EFFECTS_KEY = 'youcoded-reduced-effects'; + + // Fix: Node 22+ ships a stub globalThis.localStorage that lacks real + // methods and throws without --localstorage-file — same fix as + // useSessionTasks.test.tsx. Scoped to this describe block (not file-wide) + // since it's the only place in this file that touches localStorage. + function makeLocalStorageMock() { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = String(value); }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + get length() { return Object.keys(store).length; }, + key: (n: number) => Object.keys(store)[n] ?? null, + }; + } + const lsMock = makeLocalStorageMock(); + beforeAll(() => { vi.stubGlobal('localStorage', lsMock); }); + afterAll(() => { vi.unstubAllGlobals(); }); + + afterEach(() => { + try { localStorage.removeItem(REDUCED_EFFECTS_KEY); } catch {} + }); + + function renderOverlayWithTheme(initial: PendingReference | null) { + return render( + + + + + + + + , + ); + } + + // `.reference-trace` only renders when `d` is non-empty (`{d && ()}` + // in ReferenceOverlay.tsx), and `d` is empty for a null anchor OR for jsdom's + // default all-zero getBoundingClientRect (toBoxes drops zero-area rects) — + // same reason the Task 8 lift tests above stub a real rect. A real host with + // a stubbed rect is needed here so the trace svg actually mounts. + function makeReferenceWithHost(): PendingReference { + const host = document.createElement('div'); + host.textContent = 'the referenced message'; + document.body.appendChild(host); + const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect; + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect); + return { kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null } }; + } + + it('stamps data-reduced="true" on the trace svg and the lift when reducedEffects is on', () => { + localStorage.setItem(REDUCED_EFFECTS_KEY, '1'); // ThemeProvider reads this synchronously on mount (theme-context.tsx:139) + renderOverlayWithTheme(makeReferenceWithHost()); + act(() => {}); + + const trace = document.querySelector('.reference-trace'); + const lift = document.querySelector('.reference-lift'); + expect(trace).not.toBeNull(); + expect(lift).not.toBeNull(); + expect(trace?.getAttribute('data-reduced')).toBe('true'); + expect(lift?.getAttribute('data-reduced')).toBe('true'); + }); + + it('leaves data-reduced entirely absent (not just falsy) when reducedEffects is off', () => { + // No localStorage write — ThemeProvider's default is reducedEffects: false. + renderOverlayWithTheme(makeReferenceWithHost()); + act(() => {}); + + const trace = document.querySelector('.reference-trace'); + const lift = document.querySelector('.reference-lift'); + expect(trace).not.toBeNull(); + expect(lift).not.toBeNull(); + // hasAttribute, not a falsy getAttribute check: React only omits the DOM + // attribute entirely when the prop value is `undefined`, and globals.css's + // `[data-reduced="true"]` attribute selector cares about presence, not + // truthiness — `data-reduced="false"` would still (wrongly) not match + // this selector but WOULD show up in the DOM, which is a different bug + // than what this test is pinning. + expect(trace?.hasAttribute('data-reduced')).toBe(false); + expect(lift?.hasAttribute('data-reduced')).toBe(false); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 4300fd31a..52c10f202 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { Scrim, REFERENCE_COMPOSER_Z } from '../overlays/Overlay'; import { CloseButton } from '../ui/CloseButton'; import { useReference } from '../../state/reference-context'; +import { useTheme } from '../../state/theme-context'; import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close'; import { useReferenceGeometry } from './use-reference-geometry'; import { shiftPath } from './reference-geometry'; @@ -19,6 +20,12 @@ import { shiftPath } from './reference-geometry'; */ export function ReferenceOverlay() { const { reference, clearReference } = useReference(); + // Task 9: reduced-effects fallback — collapses the trace/pulse/glow/travel + // motion to a static outline. Read here (not derived in globals.css alone) + // because there's no data-reduced-effects attribute on to key a CSS + // selector off of (theme-engine.ts only zeroes blur vars for this setting), + // so the component stamps its own data-reduced flag instead. + const { reducedEffects } = useTheme(); const depth = useEscStackDepth(); const depthAtOpen = useRef(null); // Task 7: the traced outline around the referenced content. Task 8 also @@ -226,7 +233,7 @@ export function ReferenceOverlay() { works regardless of the actual traced perimeter. Empty when the source is gone (host disconnected) — nothing renders in that case. */} {d && ( -
+ (see the positioning effect above). data-reduced (Task 9) disables + the travel transition and drops the lift shadow back to standard. */} +
{/* Cancel affordance pinned to the lifted card itself, not the viewport corner, once there IS a card to pin it to. */} diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index a32d789ed..59f476377 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -962,6 +962,46 @@ body[data-reference-held] .bottom-float { overflow: visible; } +/* Reduced effects (Task 9): outline only. Kills the trace animation, the + breathing pulse, the glow drop-shadow, and the travel easing — leaving a + static accent border and the standard overlay shadow. Every new visual + effect in this app gets one of these branches; no exceptions. + + Keyed off data-reduced stamped by ReferenceOverlay.tsx, NOT an html + attribute: verified 2026-07-26 that theme-engine.ts applies reducedEffects + by zeroing the blur vars and never writes a data-reduced-effects attribute + to , so an attribute selector there would be dead CSS. + + Corrected from the brief's literal selectors, which predate the Task 8 + markup split: the brief targeted `.reference-lift[data-reduced="true"] > + *`, but `.reference-lift`'s only element children today are + `.reference-lift-card` (the clone holder) and, for travelling references, + the cancel-button wrapper (`-top-3 -right-3`) — a bare `> *` would hand + that wrapper the same box-shadow treatment as the card, same class of bug + the non-reduced `.reference-lift > .reference-lift-card` rule above was + already scoped to avoid. Scoped to `.reference-lift-card` here too. */ +.reference-trace[data-reduced="true"] path.outline { + animation: none; + stroke-dashoffset: 0; + stroke-width: 2; + filter: none; +} +.reference-lift[data-reduced="true"] { transition: none; } +/* Only the travelling (chat) case gets the amplified `--ref-lift-shadow` in + the first place (the non-travelling/artifact rule just above sets + box-shadow: none unconditionally) — so this drop-back-to-standard rule is + scoped to `[data-travels="true"]` too, otherwise it would tie with that + rule's specificity and (by source order) incorrectly paint a shadow onto + the clipped artifact clone when reduced effects is on. */ +.reference-lift[data-reduced="true"][data-travels="true"] > .reference-lift-card { + box-shadow: 0 8px 32px rgba(0, 0, 0, var(--shadow-strength, 0.15)); +} + +@media (prefers-reduced-motion: reduce) { + .reference-trace path.outline { animation: none; stroke-dashoffset: 0; } + .reference-lift { transition: none; } +} + /* Marketplace rail cards — tighter shadow scoped to the rail scroll container. The default 0 8px 32px shadow needs ~40px of bottom clearance to render uncut inside overflow-x-auto's clip box; that From 35283396ee92c3d603a9ef3caf604153af091892 Mon Sep 17 00:00:00 2001 From: Destin Date: Sun, 26 Jul 2026 23:43:37 -0700 Subject: [PATCH 18/27] fix(reference): three cross-task defects found by the final branch review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues A/B — a detached source positioned the card wrongly. Both the FLIP travel effect and the artifact clip effect called getBoundingClientRect() on anchor.host unconditionally. When the host is gone (session switched and back, or the file tab closed) that returns an all-zero rect, so the chat card landed near the top-left corner and the artifact clone was pinned at (0,0) fully unclipped. Both now detect !host.isConnected and render the non-anchored centred card the spec's section 7 already specified, with no animation from a meaningless origin. Issue C — "right-click again to replace the held reference" (spec section 7) was dead for chat references. The window-wide scrim intercepts the click, so buildContextMenu bailed at its .chat-scroll ancestry gate. Artifact references only worked by accident, because cloneNode copies data-artifact-viewer onto the clone and that branch is checked before the .chat-scroll gate. ContextMenuHost now resolves the true element under the pointer via elementsFromPoint when the raw target is the scrim, so replacement works uniformly. Left-click on the scrim still cancels; the transcript stays dimmed. Also: object-wrapper workaround for the TS 5.9.3 never-narrowing quirk in the new test, and the elementsFromPoint stub is now restored after each test rather than left on the shared document. Both detached-source tests verified RED against the unfixed overlay. Co-Authored-By: Claude Opus 5 (1M context) --- .../context-menu/ContextMenuHost.test.tsx | 190 ++++++++++++++++++ .../context-menu/ContextMenuHost.tsx | 34 +++- .../reference/ReferenceOverlay.test.tsx | 97 +++++++++ .../components/reference/ReferenceOverlay.tsx | 50 +++++ desktop/src/renderer/styles/globals.css | 19 ++ 5 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx diff --git a/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx b/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx new file mode 100644 index 000000000..4a1c4d758 --- /dev/null +++ b/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +// Fix: pin jsdom explicitly (see ReferenceOverlay.test.tsx / use-esc-close.test.tsx +// for why) — this file lives under src/**/*.test.tsx, outside vitest.config.ts's +// tests/**/*.tsx auto-jsdom glob. +// +// Issue C (final review): "second 'Ask about this' while one is held -> +// replaces it" (spec §7) was dead for chat references. `.reference-scrim` is a +// window-wide `pointer-events: auto` layer that sits ABOVE `.chat-scroll` +// while a reference is held, so a right-click on a dimmed chat message hits +// the scrim first — and buildContextMenu's `.chat-scroll` ancestry gate then +// bails, because the scrim itself is portaled OUTSIDE `.chat-scroll` +// (ReferenceOverlay.tsx portals straight to document.body). The fix teaches +// ContextMenuHost's contextmenu handler to resolve the TRUE element under the +// pointer via `document.elementsFromPoint` whenever the raw event target is +// part of the reference overlay's chrome. +// +// jsdom does not implement `elementsFromPoint` at all (verified: it's simply +// undefined on `document`) — these tests STUB it explicitly rather than +// asserting anything about real hit-testing/paint order, which only a real +// browser can confirm. That is called out again at each stub site. +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; +import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; +import { ContextMenuHost } from './ContextMenuHost'; + +// jsdom does not implement elementsFromPoint, so the tests below install it. +// Capture whatever was there (normally nothing) and put it back after each +// test — leaving a stub on the shared `document` would change how any later +// test in the same environment feature-detects it. +const originalEFP = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint'); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + if (originalEFP) Object.defineProperty(document, 'elementsFromPoint', originalEFP); + else delete (document as { elementsFromPoint?: unknown }).elementsFromPoint; + vi.unstubAllGlobals(); +}); + +// Test-only bridge so assertions can read the CURRENT held reference without +// reaching into ContextMenuHost's private state — mirrors ReferenceOverlay. +// test.tsx's SetsReference idiom, just reading instead of writing. +function ReadsReference({ onValue }: { onValue: (r: PendingReference | null) => void }) { + const { reference } = useReference(); + onValue(reference); + return null; +} + +function renderHost() { + let latest: PendingReference | null = null; + const utils = render( + + + { latest = r; }} /> + , + ); + return { ...utils, getReference: () => latest }; +} + +// Builds the DOM shape a held chat reference dims: a `.reference-scrim` +// portaled straight to document.body (exactly how ReferenceOverlay.tsx +// portals it — a SIBLING of the chat tree, not an ancestor of it), and a +// separate `.chat-scroll > .user-bubble` subtree underneath it in paint +// order, standing in for the real transcript still visible (dimmed) through +// the scrim. +function buildDimmedChatDom() { + const scrim = document.createElement('div'); + scrim.className = 'reference-scrim'; + document.body.appendChild(scrim); + + const chatScroll = document.createElement('div'); + chatScroll.className = 'chat-scroll'; + const bubble = document.createElement('div'); + bubble.className = 'user-bubble'; + bubble.textContent = 'the dimmed message'; + chatScroll.appendChild(bubble); + document.body.appendChild(chatScroll); + + return { scrim, chatScroll, bubble }; +} + +describe('ContextMenuHost: right-click through the reference scrim (Issue C)', () => { + it('resolves past the scrim to the real dimmed message and opens an actionable menu', () => { + const { scrim, bubble } = buildDimmedChatDom(); + + // STUB, not a real browser hit-test: jsdom has no elementsFromPoint at + // all. This asserts the MECHANISM (ContextMenuHost consults it and picks + // the first non-overlay element), not real paint-order coordinates — a + // dev-instance check is still required to confirm actual hit-testing at + // a real (x, y). + document.elementsFromPoint = vi.fn().mockReturnValue([scrim, bubble, document.body, document.documentElement]); + + renderHost(); + + // The contextmenu event's native target is the SCRIM (what a real + // browser would hit-test first, since it's pointer-events:auto and + // covers the whole window) — not the bubble underneath it. + fireEvent.contextMenu(scrim, { clientX: 40, clientY: 60 }); + + expect(document.elementsFromPoint).toHaveBeenCalledWith(40, 60); + // Before the fix this menu never opens: buildContextMenu(scrim, ...) + // bails at the `.chat-scroll` gate because the scrim is portaled outside + // `.chat-scroll` entirely. + // getByRole throws if not found — this project has no jest-dom matchers + // registered (see other test files: they assert with .not.toBeNull(), + // not .toBeInTheDocument()), so the presence check IS the throw-or-not. + expect(screen.getByRole('menuitem', { name: 'Ask about this' })).not.toBeNull(); + }); + + it('running "Ask about this" resolved through the scrim replaces the held reference', () => { + const { scrim, bubble } = buildDimmedChatDom(); + document.elementsFromPoint = vi.fn().mockReturnValue([scrim, bubble, document.body, document.documentElement]); + + const initial: PendingReference = { + kind: 'chat-text', + label: 'the FIRST held reference', + promptText: 'x', + anchor: null, + }; + + // Object wrapper, not a bare `let`: TS 5.9.3 narrows a `let` that is only + // ever reassigned inside a closure to `never` at later property reads + // (TS2339). Same workaround the context-menu tests already use. + const seen: { current: PendingReference | null } = { current: null }; + render( + + + { seen.current = r; }} /> + + , + ); + expect(seen.current).toBe(initial); + + fireEvent.contextMenu(scrim, { clientX: 40, clientY: 60 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'Ask about this' })); + + // setReference always overwrites (reference-context.tsx) — the state + // layer already supported replacement; what was broken is that the UI + // could never REACH it for chat. `current` is now a DIFFERENT object + // than `initial`, built from the bubble resolved through the scrim. + const replaced = seen.current; + expect(replaced).not.toBeNull(); + expect(replaced).not.toBe(initial); + expect(replaced?.label).toContain('the dimmed message'); + }); + + it('does not touch elementsFromPoint at all when no reference is held (scoped fast path)', () => { + // Deliberately NO `.reference-scrim` in the DOM and NO stub for + // elementsFromPoint — jsdom doesn't implement it, so if the fix called it + // unconditionally (instead of gating on "target is under the scrim"), + // this test would throw a TypeError and fail. Passing proves the "no + // reference held -> behave exactly as today" requirement. + const chatScroll = document.createElement('div'); + chatScroll.className = 'chat-scroll'; + const bubble = document.createElement('div'); + bubble.className = 'user-bubble'; + bubble.textContent = 'a normal message, nothing held'; + chatScroll.appendChild(bubble); + document.body.appendChild(chatScroll); + + renderHost(); + + fireEvent.contextMenu(bubble, { clientX: 10, clientY: 10 }); + + // getByRole throws if not found — this project has no jest-dom matchers + // registered (see other test files: they assert with .not.toBeNull(), + // not .toBeInTheDocument()), so the presence check IS the throw-or-not. + expect(screen.getByRole('menuitem', { name: 'Ask about this' })).not.toBeNull(); + }); + + it('a right-click entirely outside any overlay chrome or actionable surface still opens nothing (regression guard)', () => { + buildDimmedChatDom(); // scrim present, but we click a target OUTSIDE it + const plainDiv = document.createElement('div'); + document.body.appendChild(plainDiv); + + renderHost(); + fireEvent.contextMenu(plainDiv, { clientX: 5, clientY: 5 }); + + expect(screen.queryByRole('menu')).toBeNull(); + }); +}); + +// Sibling bridge for the "replaces" test above — sets its value exactly once +// on mount, same shape as ReferenceOverlay.test.tsx's SetsReference. +function SetsInitial({ value }: { value: PendingReference }) { + const { setReference } = useReference(); + React.useEffect(() => { setReference(value); }, []); + return null; +} diff --git a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx index 2c00ce60b..faffc9aee 100644 --- a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx +++ b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx @@ -11,6 +11,38 @@ import { useReference } from '../../state/reference-context'; type MenuState = { x: number; y: number; entries: MenuEntry[] }; +// Issue C (final review): resolve the TRUE element under the pointer when the +// reference overlay's scrim (or any of its chrome — the trace svg, the +// lifted clone card, the cancel button) sits on top. `.reference-scrim` is a +// window-wide `pointer-events: auto` layer (ReferenceOverlay.tsx) rendered +// while a reference is held, so a plain `e.target` lookup during a +// right-click over a dimmed chat message always resolves to the SCRIM, not +// the message — and buildContextMenu's `.chat-scroll` ancestry gate then +// bails, because the scrim lives outside `.chat-scroll`. That silently killed +// spec §7's "second 'Ask about this' while one is held → replaces it" for +// chat. (Artifact references happened to keep working, but by accident: the +// lifted clone is pinned exactly over the source and cloneNode(true) copies +// the `data-artifact-viewer`/`data-doc-path` attributes onto it, so a +// right-click that lands on the CLONE resolves via that attribute check +// before build-menu.ts's `.chat-scroll` gate is ever reached. That's a +// coincidence of the artifact clone's markup, not something chat can lean +// on.) `elementsFromPoint` walks every element painted at this point, +// TOPMOST first, honoring real stacking order — so this finds whatever is +// genuinely under the cursor (the dimmed message, or the real artifact +// container beneath a clipped/pinned clone) instead of trusting `e.target`. +function resolveContextMenuTarget(e: MouseEvent): HTMLElement | null { + const raw = e.target as HTMLElement | null; + if (!raw) return null; + // Fast path: no reference held (`.reference-scrim` isn't in the DOM at + // all — ReferenceOverlay renders null) or the click landed somewhere + // outside the overlay's chrome entirely. Leaves today's behavior + // untouched byte-for-byte in the common case. + if (!raw.closest('.reference-scrim')) return raw; + const stack = document.elementsFromPoint(e.clientX, e.clientY); + const real = stack.find((el) => !el.closest('.reference-scrim')); + return (real as HTMLElement) ?? null; +} + export function ContextMenuHost() { const [menu, setMenu] = useState(null); // "Ask about this" now hands the menu a PendingReference instead of @@ -19,7 +51,7 @@ export function ContextMenuHost() { useEffect(() => { const onContextMenu = (e: MouseEvent) => { - const target = e.target as HTMLElement | null; + const target = resolveContextMenuTarget(e); if (!target) return; const entries = buildContextMenu(target, setReference); if (!entries) return; // not our surface — leave the default behavior alone diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index 784425904..edcba00b0 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -618,3 +618,100 @@ describe('reduced effects (Task 9)', () => { expect(lift?.hasAttribute('data-reduced')).toBe(false); }); }); + +// Issues A/B (final review): detached source. Reachable two ways — a session +// switch away-and-back restores a PARKED reference (reference-context.tsx) +// whose original message DOM was replaced meanwhile by ChatView, or an +// artifact reference's file tab is switched/closed while the reference stays +// held. `reference.anchor.host` is a real Element object in both cases (never +// null) but `.isConnected` is false. Before the fix, both positioning +// effects called `src.getBoundingClientRect()` unconditionally, which +// returns an all-zero rect for a disconnected element — landing the card +// pinned in the viewport's top-left corner (chat: zero-width FLIP source; +// artifact: pinned at (0,0) with no clip, showing the whole unclipped file). +// Spec §7 requires a non-anchored, CENTRED card instead. These tests fail +// against the pre-fix code because the pre-fix left/top/transform come out +// as the zero-rect values ('0px' / 'translate(0, 0)'-derived), not '50%' / +// 'translate(-50%, -50%)'. +describe('detached source (Issues A/B: final review)', () => { + // Round-trips the host through the document rather than never attaching it + // at all, matching the REAL scenario the bug report describes — a host + // that WAS in the document and got torn out from under a parked reference + // — not a host that was never attached in the first place. + function makeDetachedHost(text: string): HTMLElement { + const host = document.createElement('div'); + host.textContent = text; + document.body.appendChild(host); + document.body.removeChild(host); + return host; + } + + it('chat reference: renders a centred, non-animated card instead of a zero-rect corner pin', () => { + const host = makeDetachedHost('a message that no longer has a DOM home'); + expect(host.isConnected).toBe(false); + + renderOverlay({ + kind: 'chat-text', + label: 'x', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift).not.toBeNull(); + expect(lift.getAttribute('data-detached')).toBe('true'); + expect(lift.style.left).toBe('50%'); + expect(lift.style.top).toBe('50%'); + expect(lift.style.transform).toBe('translate(-50%, -50%)'); + expect(lift.style.clipPath).toBe('none'); + + // The clone itself is still valid — captured once at reference-creation + // time, independent of the source's later connectivity — so there is + // still content to show; only the positioning inputs were gone. + const clone = document.querySelector('.reference-lift-card')?.firstElementChild; + expect(clone).not.toBeNull(); + expect(clone?.textContent).toBe('a message that no longer has a DOM home'); + }); + + it('artifact reference: renders the same centred, unclipped card, not pinned at (0,0)', () => { + const host = makeDetachedHost('const x = 1;'); + expect(host.isConnected).toBe(false); + + renderOverlay({ + kind: 'artifact', + label: 'lines 1-1 of x.ts', + promptText: 'x', + anchor: { host, range: null }, + }); + act(() => {}); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift).not.toBeNull(); + expect(lift.hasAttribute('data-travels')).toBe(false); // sanity: still an artifact kind + expect(lift.getAttribute('data-detached')).toBe('true'); + expect(lift.style.left).toBe('50%'); + expect(lift.style.top).toBe('50%'); + expect(lift.style.transform).toBe('translate(-50%, -50%)'); + // No clip: there is no live selection position left to clip against. + expect(lift.style.clipPath).toBe('none'); + }); + + it('an attached reference never gets data-detached (regression guard)', () => { + const host = document.createElement('div'); + host.textContent = 'still attached'; + document.body.appendChild(host); + const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect; + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect); + + renderOverlay({ kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null } }); + act(() => {}); + + const lift = document.querySelector('.reference-lift') as HTMLElement; + expect(lift.hasAttribute('data-detached')).toBe(false); + // Sanity: it took the normal attached FLIP path, not the centred one. + expect(lift.style.left).toBe('10px'); + + document.body.removeChild(host); + }); +}); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 52c10f202..1d657bcf2 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -101,6 +101,37 @@ export function ReferenceOverlay() { const src = reference.anchor.host; if (!src) return; + // Issue A (final review): detached source. Reachable when a session + // switch away-and-back restores a PARKED reference (reference-context.tsx) + // whose original message DOM was replaced meanwhile by ChatView — the + // reference object survives, but `src` is no longer in the document. + // getBoundingClientRect() on a disconnected element returns an all-zero + // rect, which used to compute a bogus FLIP "First" position and land the + // card pinned in the viewport's top-left corner. Spec §7 wants a + // non-anchored, CENTRED card instead. The clone itself is still valid — + // captured once at reference-creation time in the effect above, + // independent of what happens to `src` afterward — so there is still + // content to show; only these positioning inputs are gone. + // + // Deliberately NO FLIP/RAF animation here: a "travel" implies a real + // start position to fly from, and a zero rect isn't one — animating from + // it would just be a meaningless slide from the corner, not a motion that + // means anything. The card simply appears centred. `data-detached` lets + // globals.css force `transition: none` too, so this doesn't depend on + // timing to avoid a stray CSS-transition slide from whatever inline + // transform this reused node happened to carry from its last attached + // measurement. + if (!src.isConnected) { + node.setAttribute('data-detached', 'true'); + node.style.left = '50%'; + node.style.top = '50%'; + node.style.width = 'min(90vw, 640px)'; + node.style.transform = 'translate(-50%, -50%)'; + node.style.clipPath = 'none'; + return; + } + node.removeAttribute('data-detached'); + const s = src.getBoundingClientRect(); node.style.left = `${s.left}px`; node.style.top = `${s.top}px`; @@ -144,6 +175,25 @@ export function ReferenceOverlay() { const src = reference.anchor.host; if (!src) return; + // Issue B (final review): same detached-source handling as the travel + // effect above, for the artifact (non-travelling) branch — reachable + // when the file tab holding the referenced selection is switched or + // closed while the reference stays held. No clip either: a stale + // selection rect no longer corresponds to anything on screen, so + // clipping to it would just hide the (still-valid) clone behind an + // arbitrary mask. See the WHY comment on the travel effect for the full + // reasoning (no FLIP animation, `data-detached` drives the CSS side). + if (!src.isConnected) { + node.setAttribute('data-detached', 'true'); + node.style.left = '50%'; + node.style.top = '50%'; + node.style.width = 'min(90vw, 640px)'; + node.style.transform = 'translate(-50%, -50%)'; + node.style.clipPath = 'none'; + return; + } + node.removeAttribute('data-detached'); + const s = src.getBoundingClientRect(); node.style.left = `${s.left}px`; node.style.top = `${s.top}px`; diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index 59f476377..4c5cedd68 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -962,6 +962,25 @@ body[data-reference-held] .bottom-float { overflow: visible; } +/* Issue A/B (final review): detached source — the held reference survived a + session switch or a closed file tab, but the DOM node it pointed at + didn't. See the WHY comment on the positioning effects in + ReferenceOverlay.tsx: there is no meaningful FLIP "First" position for a + disconnected element, so this state is deliberately static, not animated. + `transition: none` stops any stray slide from whatever inline transform + this REUSED node happened to carry from its last attached measurement. + The card-level override comes after (and therefore beats, same + specificity) the artifact `:not([data-travels="true"])` rule above — + without it, a detached ARTIFACT reference's clone (which can be an entire + file's worth of content, since there's no live selection left to clip + against) would render with `overflow: visible` and no height cap. */ +.reference-lift[data-detached="true"] { transition: none; } +.reference-lift[data-detached="true"] > .reference-lift-card { + max-height: 70vh; + overflow-y: auto; + box-shadow: var(--ref-lift-shadow); +} + /* Reduced effects (Task 9): outline only. Kills the trace animation, the breathing pulse, the glow drop-shadow, and the travel easing — leaving a static accent border and the standard overlay shadow. Every new visual From c727292b758f7446e720f42652d09ae80e698b95 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 28 Jul 2026 01:04:24 -0700 Subject: [PATCH 19/27] fix(reference): highlight hugs the travelling card; clone fills its wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects from Destin's dev-instance review. 1. "Black box around where the message bubble was originally." The trace SVG draws `d`, measured from anchor.host at its ORIGINAL position — but a chat reference's card flies to the viewport centre, so the outline stayed behind and boxed the empty space the bubble vacated. On a dark-accent theme that reads as a hard black rectangle. The source-anchored trace is now rendered only for NON-travelling (artifact) references, where nothing moves and the outline is exactly right. The travelling card carries its own ring + glow in CSS, so the highlight hugs the card and travels with it by construction. 2. "Weird extra clear space to the right of the centered message bubble." Chat bubbles carry `max-w-[85%]`. The lift wrapper is sized to the bubble's own measured rect, so that percentage resolved a SECOND time against it, leaving the card 15% narrower than its wrapper — dead space on the right, with the cancel button pinned out in it. The clone now fills the wrapper. 3. The ring sits on the clone rather than the holder, so it follows the bubble's own border-radius instead of boxing a rounded card in a square. Reduced-effects keeps a plain ring and drops the glow + amplified shadow. Task 9's data-reduced tests now cover both kinds, since the trace only exists for one of them. New regression test verified RED against the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../reference/ReferenceOverlay.test.tsx | 72 ++++++++++++++++--- .../components/reference/ReferenceOverlay.tsx | 10 ++- desktop/src/renderer/styles/globals.css | 35 +++++++-- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx index edcba00b0..1acffb0fa 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx @@ -533,6 +533,36 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix) // this can NOT prove — computed styles, whether the animation/glow/transition // actually stop, whether the lift shadow visually drops back — needs a real // dev-instance check (see the task report). +describe('dev-review fix: the highlight must not be left behind at the source', () => { + function withHost(kind: PendingReference['kind']): PendingReference { + const host = document.createElement('div'); + host.textContent = 'referenced content'; + document.body.appendChild(host); + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue( + { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect, + ); + return { kind, label: 'x', promptText: 'x', anchor: { host, range: null } }; + } + + it('a TRAVELLING (chat) reference renders no source-anchored trace', () => { + // The card flies to the viewport centre, so a trace measured from the + // source would outline the empty space the bubble left behind — the + // "black box around where the message bubble was originally" Destin hit + // in dev review. The travelling card carries its ring/glow in CSS instead. + renderOverlay(withHost('chat-text')); + act(() => {}); + expect(document.querySelector('.reference-lift')).not.toBeNull(); + expect(document.querySelector('.reference-trace')).toBeNull(); + }); + + it('a NON-travelling (artifact) reference still renders the in-place trace', () => { + // Nothing moves here, so the source-anchored outline is exactly right. + renderOverlay(withHost('artifact')); + act(() => {}); + expect(document.querySelector('.reference-trace')).not.toBeNull(); + }); +}); + describe('reduced effects (Task 9)', () => { const REDUCED_EFFECTS_KEY = 'youcoded-reduced-effects'; @@ -586,28 +616,49 @@ describe('reduced effects (Task 9)', () => { return { kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null } }; } + // The source-anchored trace svg only renders for NON-travelling (artifact) + // references now — a travelling chat card carries its own ring/glow in CSS + // so nothing is left behind at the source (dev-review fix). So the trace + // half of these assertions needs an artifact reference. + function makeArtifactReferenceWithHost(): PendingReference { + const host = document.createElement('div'); + host.textContent = 'the referenced lines'; + document.body.appendChild(host); + const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect; + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect); + return { kind: 'artifact', label: 'x', promptText: 'x', anchor: { host, range: null } }; + } + it('stamps data-reduced="true" on the trace svg and the lift when reducedEffects is on', () => { localStorage.setItem(REDUCED_EFFECTS_KEY, '1'); // ThemeProvider reads this synchronously on mount (theme-context.tsx:139) - renderOverlayWithTheme(makeReferenceWithHost()); + const { unmount } = renderOverlayWithTheme(makeReferenceWithHost()); act(() => {}); - const trace = document.querySelector('.reference-trace'); - const lift = document.querySelector('.reference-lift'); - expect(trace).not.toBeNull(); - expect(lift).not.toBeNull(); - expect(trace?.getAttribute('data-reduced')).toBe('true'); - expect(lift?.getAttribute('data-reduced')).toBe('true'); + // Travelling (chat): no source-anchored trace, but the lift is stamped. + expect(document.querySelector('.reference-trace')).toBeNull(); + expect(document.querySelector('.reference-lift')?.getAttribute('data-reduced')).toBe('true'); + unmount(); + + // Non-travelling (artifact): the trace renders and is stamped too. + renderOverlayWithTheme(makeArtifactReferenceWithHost()); + act(() => {}); + expect(document.querySelector('.reference-trace')?.getAttribute('data-reduced')).toBe('true'); }); it('leaves data-reduced entirely absent (not just falsy) when reducedEffects is off', () => { // No localStorage write — ThemeProvider's default is reducedEffects: false. - renderOverlayWithTheme(makeReferenceWithHost()); + const { unmount } = renderOverlayWithTheme(makeReferenceWithHost()); act(() => {}); - const trace = document.querySelector('.reference-trace'); const lift = document.querySelector('.reference-lift'); - expect(trace).not.toBeNull(); expect(lift).not.toBeNull(); + expect(lift?.hasAttribute('data-reduced')).toBe(false); + unmount(); + + renderOverlayWithTheme(makeArtifactReferenceWithHost()); + act(() => {}); + const trace = document.querySelector('.reference-trace'); + expect(trace).not.toBeNull(); // hasAttribute, not a falsy getAttribute check: React only omits the DOM // attribute entirely when the prop value is `undefined`, and globals.css's // `[data-reduced="true"]` attribute selector cares about presence, not @@ -615,7 +666,6 @@ describe('reduced effects (Task 9)', () => { // this selector but WOULD show up in the DOM, which is a different bug // than what this test is pinning. expect(trace?.hasAttribute('data-reduced')).toBe(false); - expect(lift?.hasAttribute('data-reduced')).toBe(false); }); }); diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 1d657bcf2..68ae55478 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -282,7 +282,15 @@ export function ReferenceOverlay() { fixed 100-unit stroke-dasharray/breathe animation in globals.css works regardless of the actual traced perimeter. Empty when the source is gone (host disconnected) — nothing renders in that case. */} - {d && ( + {/* Only the NON-travelling (artifact) case gets the source-anchored trace. + `d` is measured from anchor.host where it actually sits, so for a + travelling chat reference — whose card flies to the viewport centre — + it would paint a hard outlined box around the EMPTY space the bubble + left behind (Destin, dev review: "the black box visible around where + the message bubble was originally"). The travelling card carries its + own ring + glow in CSS instead, so the highlight hugs the card and + travels with it by construction. */} + {d && !travels && (
12:55 AM
'; + document.body.appendChild(el); + + const ref = buildChatReference(el, el)!; + expect(ref.promptText).toContain('"Done! Created a test file."'); + expect(ref.promptText).not.toContain('12:55'); + }); + + it('leaves the live DOM untouched while stripping chrome', () => { + const el = document.createElement('div'); + el.className = 'assistant-bubble'; + el.innerHTML = 'body
12:55 AM
'; + document.body.appendChild(el); + const before = el.outerHTML; + buildChatReference(el, el); + expect(el.outerHTML).toBe(before); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts index 5c1e8011d..3450a49f6 100644 --- a/desktop/src/renderer/components/context-menu/build-reference.ts +++ b/desktop/src/renderer/components/context-menu/build-reference.ts @@ -89,11 +89,27 @@ function baseName(p: string): string { return p.replace(/\\/g, '/').split('/').pop() || p; } +/** + * The quotable text of a bubble, EXCLUDING its chrome. + * + * `.bubble-timestamp` renders INSIDE the bubble div (UserMessage.tsx:75, + * AssistantTurnBubble.tsx:437), so a plain `textContent` sweeps it into the + * quote — Destin's dev review caught a scaffold reading + * `...ready to use or delete as needed.12:55 AM"`. Clone the node, strip the + * chrome, then read. Cloning matters: the reference path must never mutate + * the live DOM (an earlier design did, and crashed React's reconciler). + */ +function elementQuote(el: Element): string { + const copy = el.cloneNode(true) as Element; + copy.querySelectorAll('.bubble-timestamp').forEach((n) => n.remove()); + return copy.textContent?.trim() ?? ''; +} + export function buildChatReference(bubble: Element | null, target: HTMLElement): PendingReference | null { // Fix vs. the original textMenu(): fall back to TARGET's text, not just bubble's, // when there's no bubble ancestor — otherwise a floating (non-bubble) text node // with no live selection always produced an empty quote and a silent null return. - const quote = (selectionText().trim() || (bubble ?? target).textContent?.trim()) ?? ''; + const quote = (selectionText().trim() || elementQuote(bubble ?? target)) ?? ''; if (!quote) return null; // "you said" reads right for an assistant message; flip it for the user's own From 11feb38fd776856a7ff3e65fb16d3c2bb5aac027 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 28 Jul 2026 01:27:57 -0700 Subject: [PATCH 21/27] fix(reference): dedup on true sent text, render scaffold as inline reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem 1: InputBar dispatched the optimistic USER_PROMPT/QUEUED_MESSAGE_ADDED with the user's raw draft while the actual sent text carried the reference scaffold prepended. chat-reducer's TRANSCRIPT_USER_MESSAGE dedup matches on exact content, so the mismatch meant no pending entry was ever found and the transcript event appended a second bubble. Fix: dispatch outgoing.content (the exact string sent) at all three call sites instead of the draft-only bubbleMessage. Problem 2: since the timeline entry now legitimately holds the scaffold, UserMessage rendered it as raw boilerplate text. Added context-menu/reference-prompt.ts as the single source of truth for the scaffold format (lead-ins, follow-up marker, buildScaffold/ buildArtifactScaffold, and a whitespace-tolerant parseReferencePrompt — it has to tolerate InputBar's newline-flattening sanitize, not just the builder's own multi-line output). build-reference.ts now calls the shared builder instead of holding its own copy. UserMessage renders a quoted reply strip (collapsed past ~3 lines/~240 chars, via a real + )} +
+ ); +} + +function baseName(p: string): string { + return p.replace(/\\/g, '/').split('/').pop() || p; +} + +/** Compact one-line summary for an artifact reference — no collapsing needed, + * it's already a short descriptor ("lines 12-14 of chat-reducer.ts"), not a + * quoted body. */ +function artifactSummary(descriptor: string, path: string): string { + return descriptor.startsWith('line') ? `${descriptor} of ${baseName(path)}` : `${descriptor} — ${baseName(path)}`; +} + +function ReferenceReplyBody({ parsed, sessionId }: { parsed: ParsedReference; sessionId: string }) { + const followUp = renderMessageBody(parsed.followUp, sessionId, 'f'); + if (parsed.kind === 'artifact') { + return ( + <> +
+ Referencing {artifactSummary(parsed.descriptor, parsed.path)} +
+ {followUp} + + ); + } + return ( + <> + + {followUp} + + ); +} + export default React.memo(function UserMessage({ message, sessionId, showTimestamps }: Props) { const content = message.content; @@ -44,33 +148,29 @@ export default React.memo(function UserMessage({ message, sessionId, showTimesta if (i < attachments.length - 1 || text.length > 0) attachmentPills.push(' '); } + // Ask Claude About This (spec 2026-07-26): the timeline entry now stores the + // TRUE sent text — scaffold included — so TRANSCRIPT_USER_MESSAGE's + // content-equality dedup can actually match it (see InputBar.tsx's send() + // fix). That means a referenced message's `content` is the raw scaffold + // string, not just the user's words — recover the pieces here and render + // an inline reply block instead of dumping the scaffold as plain text. + const parsed = parseReferencePrompt(text); + // Detect filepaths in the (remaining) typed text and render each as a // clickable pill that opens in the artifact viewer, same as assistant // messages. Non-path spans keep the flowing-keyword + URL-link treatment. // NOTE: this covers the LIVE bubble; a reloaded-from-transcript message // loses attachment paths (the transcript stores images as blocks, not // paths), so pills there fall back to plain text. - const matches = detectFilepaths(text); - - let body: React.ReactNode[]; - if (matches.length === 0) { - body = renderTextRun(text, 't'); - } else { - body = []; - let cursor = 0; - matches.forEach((m, mi) => { - if (m.start > cursor) body.push(...renderTextRun(text.slice(cursor, m.start), `t${mi}`)); - body.push(); - cursor = m.end; - }); - if (cursor < text.length) body.push(...renderTextRun(text.slice(cursor), 'tend')); - } - body = [...attachmentPills, ...body]; + const body: React.ReactNode[] = parsed + ? [] + : renderMessageBody(text, sessionId, 't'); + const rendered = [...attachmentPills, ...body]; return (
- {body} + {rendered} {showTimestamps && (
{formatBubbleTime(message.timestamp)} diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts index 3450a49f6..f0d4b3e78 100644 --- a/desktop/src/renderer/components/context-menu/build-reference.ts +++ b/desktop/src/renderer/components/context-menu/build-reference.ts @@ -1,5 +1,13 @@ import { editorViewFor } from '../artifact-views/cm/editor-registry'; import type { PendingReference } from '../../state/reference-context'; +import { + LEAD_ASSISTANT, + LEAD_USER, + LEAD_NEUTRAL, + LEAD_CODE, + buildScaffold, + buildArtifactScaffold, +} from './reference-prompt'; // describeArtifactSelection MOVES here from build-menu.ts:205 (with its full // comment block) — see Task 2 Step 1. Module-private on purpose: exporting it @@ -76,11 +84,6 @@ export function truncateLabel(text: string, max = 42): string { return flat.length <= max ? flat : flat.slice(0, max) + '…'; } -function scaffold(lead: string, body: string, fenced: boolean): string { - const quoted = fenced ? '```\n' + body + '\n```' : `"${body}"`; - return `${lead}\n${quoted}\n\nThe user has a follow-up: `; -} - function selectionText(): string { return window.getSelection()?.toString() ?? ''; } @@ -115,10 +118,10 @@ export function buildChatReference(bubble: Element | null, target: HTMLElement): // "you said" reads right for an assistant message; flip it for the user's own // bubble, and stay neutral if we can't tell. (Moved verbatim from build-menu.ts.) const lead = bubble?.classList.contains('assistant-bubble') - ? 'In an earlier message, you said:' + ? LEAD_ASSISTANT : bubble?.classList.contains('user-bubble') - ? 'Earlier I wrote:' - : 'Regarding this:'; + ? LEAD_USER + : LEAD_NEUTRAL; const host = (bubble ?? target) as Element; const range = selectionText().trim() ? captureRange() : null; @@ -126,7 +129,7 @@ export function buildChatReference(bubble: Element | null, target: HTMLElement): return { kind: 'chat-text', label: `"${truncateLabel(quote)}"`, - promptText: scaffold(lead, quote, false), + promptText: buildScaffold(lead, quote, false), anchor: { host, range }, }; } @@ -136,7 +139,7 @@ export function buildCodeReference(pre: HTMLElement): PendingReference { return { kind: 'chat-code', label: truncateLabel(code), - promptText: scaffold('Earlier, you shared this code:', code, true), + promptText: buildScaffold(LEAD_CODE, code, true), anchor: { host: pre, range: null }, }; } @@ -157,7 +160,7 @@ export function buildArtifactReference(container: HTMLElement): PendingReference // `ref` is either "line 2" / "lines 2-4" or a quoted excerpt; only the // line form reads well with "of ". label: ref.startsWith('line') ? `${ref} of ${baseName(path)}` : truncateLabel(ref), - promptText: `The user is referencing ${ref} from "${path}". Respond to the following prompt accordingly:\n\n`, + promptText: buildArtifactScaffold(ref, path), anchor: { host: container, range: captureRange() }, }; } diff --git a/desktop/src/renderer/components/context-menu/reference-prompt.test.ts b/desktop/src/renderer/components/context-menu/reference-prompt.test.ts new file mode 100644 index 000000000..562c12458 --- /dev/null +++ b/desktop/src/renderer/components/context-menu/reference-prompt.test.ts @@ -0,0 +1,159 @@ +// Round-trip pin for the reference scaffold module (spec 2026-07-26 inline +// reply). build-reference.ts's own tests already pin the exact promptText +// strings for each shape (moved verbatim from v1) — this file's job is the +// OTHER direction: given a dispatched message's content, can the parser +// recover the pieces the builder put in? +import { describe, it, expect } from 'vitest'; +import { + LEAD_ASSISTANT, + LEAD_USER, + LEAD_NEUTRAL, + LEAD_CODE, + FOLLOW_UP_MARKER, + buildScaffold, + buildArtifactScaffold, + parseReferencePrompt, +} from './reference-prompt'; + +// Mirrors InputBar's buildOutgoingMessage sanitize step (outgoing-message.ts): +// composeOutgoing() prepends promptText to the draft, and THAT combined +// string — not promptText alone — is what gets flattened before it ever +// becomes a dispatched message's content. Reproduced here (rather than +// imported) so this stays a pure test of reference-prompt.ts's own contract: +// it must survive whatever InputBar does to it, not just its own output. +function flatten(rawText: string): string { + return rawText.replace(/[\r\n]+/g, ' ').trim(); +} + +describe('buildScaffold / parseReferencePrompt round-trip (chat-text)', () => { + it('round-trips the assistant lead-in', () => { + const promptText = buildScaffold(LEAD_ASSISTANT, 'the reducer preserves Map refs', false); + const content = promptText + "what's in it?"; + const parsed = parseReferencePrompt(content); + expect(parsed).toEqual({ + kind: 'chat-text', + lead: LEAD_ASSISTANT, + quote: 'the reducer preserves Map refs', + fenced: false, + followUp: "what's in it?", + }); + }); + + it('round-trips the user lead-in', () => { + const promptText = buildScaffold(LEAD_USER, 'why does memo work', false); + const parsed = parseReferencePrompt(promptText + 'good question actually'); + expect(parsed?.kind).toBe('chat-text'); + if (parsed?.kind === 'chat-text') { + expect(parsed.lead).toBe(LEAD_USER); + expect(parsed.quote).toBe('why does memo work'); + expect(parsed.followUp).toBe('good question actually'); + } + }); + + it('round-trips the neutral lead-in', () => { + const promptText = buildScaffold(LEAD_NEUTRAL, 'floating text', false); + const parsed = parseReferencePrompt(promptText + 'ok'); + expect(parsed?.kind).toBe('chat-text'); + if (parsed?.kind === 'chat-text') expect(parsed.lead).toBe(LEAD_NEUTRAL); + }); +}); + +describe('buildScaffold / parseReferencePrompt round-trip (chat-code)', () => { + it('round-trips a fenced code reference', () => { + const promptText = buildScaffold(LEAD_CODE, 'const x = 1;', true); + const parsed = parseReferencePrompt(promptText + 'what does x do?'); + expect(parsed).toEqual({ + kind: 'chat-code', + lead: LEAD_CODE, + quote: 'const x = 1;', + fenced: true, + followUp: 'what does x do?', + }); + }); +}); + +describe('buildArtifactScaffold / parseReferencePrompt round-trip (artifact)', () => { + it('round-trips a line-number descriptor', () => { + const promptText = buildArtifactScaffold('line 2', 'docs/notes.txt'); + const parsed = parseReferencePrompt(promptText + 'what happens here?'); + expect(parsed).toEqual({ + kind: 'artifact', + descriptor: 'line 2', + path: 'docs/notes.txt', + followUp: 'what happens here?', + }); + }); + + it('round-trips a quoted-excerpt descriptor (no line number found)', () => { + const promptText = buildArtifactScaffold('"some excerpt"', 'src/foo.ts'); + const parsed = parseReferencePrompt(promptText + 'explain'); + expect(parsed).toEqual({ + kind: 'artifact', + descriptor: '"some excerpt"', + path: 'src/foo.ts', + followUp: 'explain', + }); + }); +}); + +describe('parseReferencePrompt on a non-scaffold message', () => { + it('returns null for an ordinary typed message', () => { + expect(parseReferencePrompt('what is the plan for today?')).toBeNull(); + }); + + it('returns null for a message that merely mentions the marker text', () => { + expect(parseReferencePrompt(`I saw "${FOLLOW_UP_MARKER}" in the code somewhere`)).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(parseReferencePrompt('')).toBeNull(); + }); +}); + +// The integration case that actually matters: after InputBar's +// buildOutgoingMessage sanitize collapses every '\n'/'\n\n' run in the +// scaffold to a single space (see this module's header comment), the parser +// must still recognize the shape from what's ACTUALLY dispatched — not the +// idealized multi-line string buildScaffold() returns in isolation. +describe('parseReferencePrompt against InputBar-flattened content', () => { + it('recovers a chat-text reference after newline flattening', () => { + const promptText = buildScaffold(LEAD_ASSISTANT, 'Done! Created a test file at test-temp.txt.', false); + const flattened = flatten(promptText + "what's in it?"); + expect(flattened).not.toContain('\n'); // sanity: flattening actually happened + const parsed = parseReferencePrompt(flattened); + expect(parsed).toEqual({ + kind: 'chat-text', + lead: LEAD_ASSISTANT, + quote: 'Done! Created a test file at test-temp.txt.', + fenced: false, + followUp: "what's in it?", + }); + }); + + it('recovers a chat-code reference after newline flattening (multi-line code collapses to one line)', () => { + const promptText = buildScaffold(LEAD_CODE, 'const x = 1;\nconst y = 2;', true); + const flattened = flatten(promptText + 'explain'); + const parsed = parseReferencePrompt(flattened); + expect(parsed?.kind).toBe('chat-code'); + if (parsed?.kind === 'chat-code') { + // The original two lines are now one — that information is genuinely + // gone by the time this string reaches the parser (InputBar's sanitize + // did that, not us). The parser's job is just to recover exactly what + // survived, not to un-flatten it. + expect(parsed.quote).toBe('const x = 1; const y = 2;'); + expect(parsed.followUp).toBe('explain'); + } + }); + + it('recovers an artifact reference after flattening, with an empty follow-up', () => { + const promptText = buildArtifactScaffold('lines 12-14', 'chat-reducer.ts'); + const flattened = flatten(promptText); // no draft typed — the trim() edge case + const parsed = parseReferencePrompt(flattened); + expect(parsed).toEqual({ + kind: 'artifact', + descriptor: 'lines 12-14', + path: 'chat-reducer.ts', + followUp: '', + }); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/reference-prompt.ts b/desktop/src/renderer/components/context-menu/reference-prompt.ts new file mode 100644 index 000000000..0631b57d0 --- /dev/null +++ b/desktop/src/renderer/components/context-menu/reference-prompt.ts @@ -0,0 +1,141 @@ +// Single source of truth for the "Ask Claude about this" prompt scaffold +// (spec 2026-07-26). build-reference.ts is the only BUILDER (turns a chat +// selection / artifact selection into promptText); UserMessage.tsx is the +// only PARSER (turns a dispatched message's content back into the pieces it +// renders as an inline reply). Both used to hold their own copy of the lead +// strings and separators — the real risk here isn't either one being wrong +// in isolation, it's the two silently drifting apart. Putting both directions +// through the same constants closes that gap. +// +// WHY the parser can't assume literal '\n': the scaffold's promptText (built +// with real '\n'/'\n\n') gets prepended to the user's draft in InputBar's +// composeOutgoing(), and the COMBINED string is then run through +// buildOutgoingMessage()'s sanitize step — `rawText.replace(/[\r\n]+/g, ' ')` +// — before it ever becomes the dispatched message content (see +// outgoing-message.ts). That collapses every run of newlines, INCLUDING the +// ones inside the scaffold itself, to a single space. So the string that +// actually lands in a timeline entry's `content` is single-line, not the +// multi-line text buildScaffold() returns. The parser matches on `\s+` +// (falls back to `\s*` only where the gap can legally be empty) specifically +// so it recognizes BOTH the raw builder output (used directly in the +// round-trip unit test below) and the flattened runtime string (what +// UserMessage actually receives) with one code path. + +/** Lead-in above a quoted chat message. Neutral when we can't tell whose bubble it was. */ +export const LEAD_ASSISTANT = 'In an earlier message, you said:'; +export const LEAD_USER = 'Earlier I wrote:'; +export const LEAD_NEUTRAL = 'Regarding this:'; +/** Lead-in above a fenced code reference — always this one string, never the three above. */ +export const LEAD_CODE = 'Earlier, you shared this code:'; + +/** Marks the boundary between the quoted reference and the user's own words. + * + * Known miss: if the user sends a chat-text/chat-code reference with a + * completely EMPTY follow-up, buildOutgoingMessage's trailing `.trim()` + * strips this marker's own trailing space (nothing follows it, so it's the + * last char of the string) — the marker then no longer occurs literally in + * the dispatched content, parseChat's indexOf lookup misses, and the message + * renders as plain text instead of the reply block. Rare in practice (it + * requires holding a reference and hitting Send with zero typed follow-up) + * and not worth widening the marker match for one all-punctuation edge case. */ +export const FOLLOW_UP_MARKER = 'The user has a follow-up: '; + +const ARTIFACT_PREFIX = 'The user is referencing '; +const ARTIFACT_MIDDLE = ' from "'; +// Everything up to (not including) the '\n\n' + draft that composeOutgoing appends. +const ARTIFACT_SUFFIX_FIXED = '". Respond to the following prompt accordingly:'; + +/** Builds the lead+quote scaffold for a chat-text or chat-code reference. */ +export function buildScaffold(lead: string, body: string, fenced: boolean): string { + const quoted = fenced ? '```\n' + body + '\n```' : `"${body}"`; + return `${lead}\n${quoted}\n\n${FOLLOW_UP_MARKER}`; +} + +/** Builds the scaffold for an artifact (file/line) reference — no quoted body. */ +export function buildArtifactScaffold(descriptor: string, path: string): string { + return `${ARTIFACT_PREFIX}${descriptor}${ARTIFACT_MIDDLE}${path}${ARTIFACT_SUFFIX_FIXED}\n\n`; +} + +export type ParsedReference = + | { kind: 'chat-text' | 'chat-code'; lead: string; quote: string; fenced: boolean; followUp: string } + | { kind: 'artifact'; descriptor: string; path: string; followUp: string }; + +const CHAT_LEADS: ReadonlyArray<{ lead: string; fenced: boolean }> = [ + { lead: LEAD_ASSISTANT, fenced: false }, + { lead: LEAD_USER, fenced: false }, + { lead: LEAD_NEUTRAL, fenced: false }, + { lead: LEAD_CODE, fenced: true }, +]; + +// Strips a fixed prefix/suffix pair, trimming exactly the whitespace that +// separates them from the body (the flattened-or-not newline). Returns null +// when the shape doesn't match — the caller falls through to "not a scaffold". +function unwrapFence(s: string): string | null { + if (!s.startsWith('```') || !s.endsWith('```') || s.length < 6) return null; + return s.slice(3, -3).replace(/^\s+/, '').replace(/\s+$/, ''); +} + +function unwrapQuotes(s: string): string | null { + if (s.length < 2 || s[0] !== '"' || s[s.length - 1] !== '"') return null; + return s.slice(1, -1); +} + +function parseChat(content: string): ParsedReference | null { + for (const { lead, fenced } of CHAT_LEADS) { + if (!content.startsWith(lead)) continue; + // Separator after the lead always has at least one char (a real '\n' in + // the builder's own output, one collapsed space at runtime) — it's never + // at the very end of the string, so trim() in buildOutgoingMessage can't + // have eaten it away. Safe to require \s+. + const wsAfterLead = content.slice(lead.length).match(/^\s+/); + if (!wsAfterLead) continue; + const afterLead = content.slice(lead.length + wsAfterLead[0].length); + + const markerIdx = afterLead.indexOf(FOLLOW_UP_MARKER); + if (markerIdx === -1) continue; + + // Walk back over the whitespace run between the quote and the marker + // (the original '\n\n', or one collapsed space) to find where the + // quoted text actually ends. + let quotedEnd = markerIdx; + while (quotedEnd > 0 && /\s/.test(afterLead[quotedEnd - 1])) quotedEnd--; + const quoted = afterLead.slice(0, quotedEnd); + const followUp = afterLead.slice(markerIdx + FOLLOW_UP_MARKER.length); + + const quote = fenced ? unwrapFence(quoted) : unwrapQuotes(quoted); + if (quote === null) continue; + + return { kind: fenced ? 'chat-code' : 'chat-text', lead, quote, fenced, followUp }; + } + return null; +} + +function parseArtifact(content: string): ParsedReference | null { + if (!content.startsWith(ARTIFACT_PREFIX)) return null; + // lastIndexOf, not indexOf: the descriptor can itself be a quoted excerpt + // (describeArtifactSelection falls back to `"${sel}"` when it can't cite a + // line number), which could coincidentally contain this exact suffix text. + // Anchoring from the end is the more common case to get right. + const suffixIdx = content.lastIndexOf(ARTIFACT_SUFFIX_FIXED); + if (suffixIdx === -1) return null; + const middleIdx = content.indexOf(ARTIFACT_MIDDLE, ARTIFACT_PREFIX.length); + if (middleIdx === -1 || middleIdx >= suffixIdx) return null; + + const descriptor = content.slice(ARTIFACT_PREFIX.length, middleIdx); + const path = content.slice(middleIdx + ARTIFACT_MIDDLE.length, suffixIdx); + // \s* (not \s+): if the user sent the reference with an EMPTY follow-up, + // buildOutgoingMessage's trailing .trim() removes the '\n\n'/' ' gap + // entirely, leaving nothing after the suffix. A required \s+ would reject + // that (rare but legitimate) case. + const afterSuffix = content.slice(suffixIdx + ARTIFACT_SUFFIX_FIXED.length); + const ws = afterSuffix.match(/^\s*/)![0]; + const followUp = afterSuffix.slice(ws.length); + + return { kind: 'artifact', descriptor, path, followUp }; +} + +/** Returns the parsed pieces of a reference scaffold, or null if `content` + * isn't one (an ordinary typed message, for example). */ +export function parseReferencePrompt(content: string): ParsedReference | null { + return parseChat(content) ?? parseArtifact(content); +} From 9c625d9ddfb053c53eb012c371ecd62d72a27887 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 28 Jul 2026 01:43:24 -0700 Subject: [PATCH 22/27] fix(reference): unclip the card ring; make the cancel button a solid chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dev-review defects. 1. Weird corner artifacts around the lifted card. box-shadow paints OUTSIDE the border box and is clipped by an ANCESTOR's overflow — .reference-lift-card carried max-height/overflow-y, which sliced the clone's ring off everywhere except where it bled past the corners. The scroll clamp moves onto the clone itself; an element's own overflow never clips its own shadow. 2. The cancel X was the default ghost Button — transparent, so it disappeared against a wallpaper theme. Now a solid circular chip on bg-panel/border-edge (theme tokens, no literals). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/reference/ReferenceOverlay.tsx | 18 ++++++++++++++++-- desktop/src/renderer/styles/globals.css | 16 +++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx index 68ae55478..ed1ffbd46 100644 --- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx +++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx @@ -320,7 +320,14 @@ export function ReferenceOverlay() { // "worked" was really landing on the scrim's own onClick behind it. // Same idiom as Toast.tsx's action slot.
- +
)}
@@ -335,7 +342,14 @@ export function ReferenceOverlay() { // the travelling-case wrapper above rather than relying on a CSS // ancestry detail a future refactor could silently change.
- +
)} , diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index 5706f13a0..b0ca0a94a 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -948,9 +948,6 @@ body[data-reference-held] .bottom-float { button wrapper the same box-shadow/70vh-scroll treatment as the cloned card. Scope to .reference-lift-card, the clone holder only. */ .reference-lift > .reference-lift-card { - /* A message longer than the viewport must not overflow off-screen. */ - max-height: 70vh; - overflow-y: auto; pointer-events: auto; } @@ -965,6 +962,14 @@ body[data-reference-held] .bottom-float { .reference-lift > .reference-lift-card > * { max-width: 100%; width: 100%; + /* A message longer than the viewport must not overflow off-screen. + This lives on the CLONE, not the holder: box-shadow paints OUTSIDE the + border box and is clipped by an ANCESTOR's overflow, so holder-level + `overflow-y: auto` sliced the clone's ring off everywhere except where it + bled past the corners — the "weird corners" Destin saw in dev review. + An element's own overflow never clips its own shadow. */ + max-height: 70vh; + overflow-y: auto; } /* Ring + glow on the CLONE, not the holder: the clone keeps the bubble's own @@ -979,7 +984,8 @@ body[data-reference-held] .bottom-float { } /* Non-travelling (artifact) clone: pinned over the original and clipped to the selection, so only the referenced lines stay bright above the dim. */ -.reference-lift:not([data-travels="true"]) > .reference-lift-card { +.reference-lift:not([data-travels="true"]) > .reference-lift-card, +.reference-lift:not([data-travels="true"]) > .reference-lift-card > * { box-shadow: none; max-height: none; overflow: visible; @@ -998,7 +1004,7 @@ body[data-reference-held] .bottom-float { file's worth of content, since there's no live selection left to clip against) would render with `overflow: visible` and no height cap. */ .reference-lift[data-detached="true"] { transition: none; } -.reference-lift[data-detached="true"] > .reference-lift-card { +.reference-lift[data-detached="true"] > .reference-lift-card > * { max-height: 70vh; overflow-y: auto; } From 9561007e2830751f75161df215514d7918afc0b9 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 28 Jul 2026 01:53:45 -0700 Subject: [PATCH 23/27] feat(reference): reply renders as a collapsed pill that expands to a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destin picked options B+D from the dev-review mockup. They compose into one control rather than two: D's pill IS the collapsed state, B's labelled panel IS the expanded one. Collapsed by default and unconditionally — not length-gated — so the bubble stays roughly the size of what the user actually typed and the reference never competes with their own words. Clicking the pill reveals the full quote in a tinted panel headed 'Claude said', with a Hide control to collapse again. An artifact reference renders as the same pill shape but static: it is already a short descriptor ('lines 12-14 of chat-reducer.ts'), not a quoted body, so there is nothing to expand. Removes the now-dead JS clamp helpers (isLongQuote/clampQuote/COLLAPSE_*) — the pill ellipsises in CSS, so nothing truncates in JS any more. Co-Authored-By: Claude Opus 5 (1M context) --- .../renderer/components/UserMessage.test.tsx | 52 +++++---- .../src/renderer/components/UserMessage.tsx | 107 ++++++++++-------- desktop/src/renderer/styles/globals.css | 21 ++++ 3 files changed, 112 insertions(+), 68 deletions(-) diff --git a/desktop/src/renderer/components/UserMessage.test.tsx b/desktop/src/renderer/components/UserMessage.test.tsx index 82b45b443..35910700a 100644 --- a/desktop/src/renderer/components/UserMessage.test.tsx +++ b/desktop/src/renderer/components/UserMessage.test.tsx @@ -5,7 +5,7 @@ import '@testing-library/jest-dom/vitest'; import React from 'react'; import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import UserMessage from './UserMessage'; import { buildScaffold, buildArtifactScaffold, LEAD_ASSISTANT, LEAD_CODE } from './context-menu/reference-prompt'; import type { ChatMessage } from '../../shared/types'; @@ -56,51 +56,57 @@ describe('UserMessage — chat-text reference renders as an inline reply', () => }); describe('UserMessage — chat-code reference renders monospaced', () => { - it('renders the fenced code as the quote', () => { + it('renders the fenced code monospaced once expanded', () => { const content = buildScaffold(LEAD_CODE, 'const x = 1;', true) + 'what does x do?'; const { container } = render(); + // Collapsed by default (pill); the quote text is present in the pill but the + // monospace treatment belongs to the expanded panel. + fireEvent.click(screen.getByRole('button', { expanded: false })); expect(screen.getByText('const x = 1;')).toBeInTheDocument(); expect(container.querySelector('.font-mono')).not.toBeNull(); }); }); describe('UserMessage — artifact reference renders a compact descriptor line', () => { - it('renders "Referencing of " with no collapsing', () => { + it('renders the descriptor as a static pill with no toggle', () => { const content = buildArtifactScaffold('lines 12-14', 'src/state/chat-reducer.ts') + 'what happens here?'; render(); - expect(screen.getByText(/Referencing lines 12-14 of chat-reducer\.ts/)).toBeInTheDocument(); + // Renders as the same pill shape as a quote, but static — an artifact + // reference is already a short descriptor, so it never expands. + expect(screen.getByText(/lines 12-14 of chat-reducer\.ts/)).toBeInTheDocument(); expect(screen.getByText('what happens here?')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /show more/i })).toBeNull(); + expect(screen.queryByRole('button', { expanded: false })).toBeNull(); }); }); -describe('UserMessage — long quote collapses by default with a working toggle', () => { - // Over the 240-char threshold (UserMessage.tsx COLLAPSE_CHAR_THRESHOLD). +describe('UserMessage — reference collapses to a pill and expands to a panel', () => { const longQuote = 'x'.repeat(300); const content = buildScaffold(LEAD_ASSISTANT, longQuote, false) + 'ok'; - it('clamps the quote and shows a Button-based toggle, default collapsed', () => { + it('is collapsed to a pill by default, so the bubble stays the size of what was typed', () => { render(); - const toggle = screen.getByRole('button', { name: /show more/i }); - expect(toggle).toBeInTheDocument(); - // The full 300-char quote should NOT be present verbatim while collapsed. - expect(screen.queryByText(longQuote)).toBeNull(); + const pill = screen.getByRole('button', { expanded: false }); + expect(pill).toBeInTheDocument(); + expect(pill.tagName).toBe('BUTTON'); // routes through the Button primitive + // The panel's label only exists once expanded. + expect(screen.queryByText(/claude said/i)).toBeNull(); }); - it('expands to the full quote on click, then collapses again', () => { + it('expands into the labelled panel, then collapses again', () => { render(); - const toggle = screen.getByRole('button', { name: /show more/i }); - fireEvent.click(toggle); + fireEvent.click(screen.getByRole('button', { expanded: false })); + + expect(screen.getByText(/claude said/i)).toBeInTheDocument(); expect(screen.getByText(longQuote)).toBeInTheDocument(); - const collapseToggle = screen.getByRole('button', { name: /show less/i }); - fireEvent.click(collapseToggle); - expect(screen.queryByText(longQuote)).toBeNull(); - expect(screen.getByRole('button', { name: /show more/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /hide/i })); + expect(screen.queryByText(/claude said/i)).toBeNull(); + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument(); }); - it('the toggle is a real + ); + } -/** The quoted-reference strip inside a user bubble. Collapsed by default when - * long — the toggle is a real - )} +
+
+ {quote} +
); } @@ -113,8 +124,14 @@ function ReferenceReplyBody({ parsed, sessionId }: { parsed: ParsedReference; se if (parsed.kind === 'artifact') { return ( <> -
- Referencing {artifactSummary(parsed.descriptor, parsed.path)} + {/* An artifact reference is already a short descriptor + ("lines 12-14 of chat-reducer.ts"), not a quoted body — it renders + as the same pill shape but never needs to expand. */} +
+ + + {artifactSummary(parsed.descriptor, parsed.path)} +
{followUp} diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index b0ca0a94a..d63524cd7 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -2224,3 +2224,24 @@ html[data-platform="electron"][data-view-mode="terminal"] .chrome-glass { .artifact-content-pane * { overflow-anchor: none; } + +/* ═══════════════════════════════════════════════════════════════════════════ + Reference reply inside a user bubble (dev review: Destin picked B+D). + The pill is the COLLAPSED state, the panel the EXPANDED one — one control, + two states. Everything tints from --on-accent so it re-themes with the + bubble it sits inside and never needs a literal colour. + ═══════════════════════════════════════════════════════════════════════════ */ +.reference-pill { + background: color-mix(in oklab, var(--on-accent) 14%, transparent); + /* The pill must ellipsise, not wrap: its whole point is that the bubble + stays about the size of what the user actually typed. */ + min-width: 0; +} +.reference-pill:hover:not(.reference-pill--static) { + background: color-mix(in oklab, var(--on-accent) 22%, transparent); +} +.reference-pill--static { cursor: default; } + +.reference-panel { + background: color-mix(in oklab, var(--on-accent) 12%, transparent); +} From 2a89452d893d9c97ea5bdc0b39d1f270de5ec5f9 Mon Sep 17 00:00:00 2001 From: Destin Date: Tue, 28 Jul 2026 11:58:59 -0700 Subject: [PATCH 24/27] fix(reference): hide moved source, highlight the selection, drop the traced outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev-instance review flagged four defects in the held reference feature: source and travelling clone both visible at once, no indication of which part of a message was selected, a "weird black box" around artifact selections, and an "uneven and janky" traced outline. - Hide the source (visibility:hidden, not display:none) for the duration a CHAT reference is held, so the clone reads as the bubble having moved rather than a second copy. Restored to its exact prior inline state (including dropping a leftover empty style attribute) on clear. - Capture the selection's character offsets at build time (computeSelectionOffsets) and re-apply them as a inside the detached clone (applyHighlightMark), for both chat and artifact references. - Delete the traced SVG outline entirely for both kinds. The artifact clip-path (and its underlying geometry hook) stays — it never rendered through the SVG, just shared its path data — along with the dead CSS (keyframes, --ref-wash, reduced-motion branch) that only the SVG used. Co-Authored-By: Claude Opus 5 (1M context) --- .../context-menu/build-reference.test.ts | 57 +++++ .../context-menu/build-reference.ts | 50 +++- .../reference/ReferenceOverlay.test.tsx | 219 +++++++++++++----- .../components/reference/ReferenceOverlay.tsx | 137 ++++++++--- .../reference/apply-highlight.test.ts | 127 ++++++++++ .../components/reference/apply-highlight.ts | 56 +++++ .../reference/reference-geometry.ts | 10 +- .../reference/use-reference-geometry.test.ts | 2 +- .../src/renderer/state/reference-context.tsx | 14 ++ desktop/src/renderer/styles/globals.css | 49 ++-- 10 files changed, 592 insertions(+), 129 deletions(-) create mode 100644 desktop/src/renderer/components/reference/apply-highlight.test.ts create mode 100644 desktop/src/renderer/components/reference/apply-highlight.ts diff --git a/desktop/src/renderer/components/context-menu/build-reference.test.ts b/desktop/src/renderer/components/context-menu/build-reference.test.ts index eaf221d36..6339f7783 100644 --- a/desktop/src/renderer/components/context-menu/build-reference.test.ts +++ b/desktop/src/renderer/components/context-menu/build-reference.test.ts @@ -99,6 +99,63 @@ describe('buildChatReference', () => { }); }); +// Dev-review fix B: "it doesn't show that I was asking about a specific +// selection" — the anchor now carries character offsets, relative to the +// host's TEXT, of exactly what was selected. These round-trip tests fail +// against the pre-fix code because `anchor.selection` doesn't exist there at +// all (TypeScript would reject `ref.anchor?.selection`, and at runtime the +// property reads `undefined`). +describe('selection offset capture (dev-review fix B)', () => { + it('round-trips a plain substring selection to matching offsets', () => { + const el = mountBubble('assistant-bubble', 'alpha bravo charlie'); + selectWithin(el, 6, 11); // "bravo" + const ref = buildChatReference(el, el)!; + expect(ref.anchor?.selection).toEqual({ start: 6, end: 11 }); + // The offsets must map back onto the SAME text they were captured from. + expect(el.textContent!.slice(6, 11)).toBe('bravo'); + }); + + it('is null when there is no live selection (whole-element reference)', () => { + const el = mountBubble('assistant-bubble', 'no selection here'); + const ref = buildChatReference(el, el)!; + expect(ref.anchor?.range).toBeNull(); + expect(ref.anchor?.selection).toBeNull(); + }); + + it('walks PAST a later chrome text node without corrupting the offsets (host text includes chrome)', () => { + // Mirrors a real bubble: content text node, then a chrome element + // (.bubble-timestamp) with its OWN, separate text node — both under the + // same host. Offsets are deliberately computed against the host's FULL + // text-node walk, chrome included (see computeSelectionOffsets's WHY + // comment): ReferenceOverlay repeats this exact walk over the + // cloneNode(true) copy later, and cloneNode preserves the chrome too. + const el = document.createElement('div'); + el.className = 'assistant-bubble'; + el.innerHTML = 'alpha bravo charlie
12:55 AM
'; + document.body.appendChild(el); + + selectWithin(el, 6, 11); // selects within el.firstChild -> "bravo" + const ref = buildChatReference(el, el)!; + expect(ref.anchor?.selection).toEqual({ start: 6, end: 11 }); + }); + + it('artifact references also capture selection offsets, relative to the container', () => { + const container = document.createElement('div'); + container.setAttribute('data-artifact-viewer', 'true'); + container.setAttribute('data-doc-path', 'docs/notes.txt'); + container.setAttribute('data-artifact-source', 'raw'); + const pre = document.createElement('pre'); + pre.textContent = 'alpha\nbravo\ncharlie'; + container.appendChild(pre); + document.body.appendChild(container); + + selectWithin(pre, 6, 11); // "bravo" + const ref = buildArtifactReference(container)!; + expect(ref.anchor?.selection).toEqual({ start: 6, end: 11 }); + expect(container.textContent!.slice(6, 11)).toBe('bravo'); + }); +}); + describe('buildCodeReference', () => { it('fences the code block and strips trailing newlines', () => { const pre = document.createElement('pre'); diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts index f0d4b3e78..9396badad 100644 --- a/desktop/src/renderer/components/context-menu/build-reference.ts +++ b/desktop/src/renderer/components/context-menu/build-reference.ts @@ -78,6 +78,44 @@ function captureRange(): Range | null { return sel.getRangeAt(0).cloneRange(); } +/** + * Character offsets of `range` relative to `host`'s TEXT — walks host's text + * nodes in document order (TreeWalker), accumulating lengths until the + * range's start/end containers+offsets are reached (dev-review fix B: show + * which part of the message was actually selected inside the moved clone). + * + * Computed against the LIVE host, not a stripped-chrome copy (contrast + * `elementQuote`, which strips `.bubble-timestamp` before reading text) — + * ReferenceOverlay later re-walks the CLONE (`host.cloneNode(true)`) with + * these same offsets, and `cloneNode` preserves host's exact node order and + * text lengths, chrome included. Offsets computed against a stripped copy + * would silently drift once the clone's un-stripped chrome text nodes are + * counted differently. + * + * Only resolves the simple, common case where the Range's start/end + * containers ARE text nodes (true for a plain click-drag selection, which is + * what `selectionText()`/`captureRange()` already require to be non-empty). + * Returns null — never throws — when a container isn't found among host's + * text nodes, so the caller can skip the highlight instead of crashing the + * reference capture. + */ +function computeSelectionOffsets(host: Element, range: Range): { start: number; end: number } | null { + const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT); + let offset = 0; + let start: number | null = null; + let end: number | null = null; + let node: Node | null; + while ((node = walker.nextNode())) { + const len = (node as Text).data.length; + if (start === null && node === range.startContainer) start = offset + range.startOffset; + if (end === null && node === range.endContainer) end = offset + range.endOffset; + offset += len; + if (start !== null && end !== null) break; + } + if (start === null || end === null || end <= start) return null; + return { start, end }; +} + /** One-line, bounded placeholder copy. Newlines collapse so it can't wrap. */ export function truncateLabel(text: string, max = 42): string { const flat = text.replace(/\s+/g, ' ').trim(); @@ -125,12 +163,13 @@ export function buildChatReference(bubble: Element | null, target: HTMLElement): const host = (bubble ?? target) as Element; const range = selectionText().trim() ? captureRange() : null; + const selection = range ? computeSelectionOffsets(host, range) : null; return { kind: 'chat-text', label: `"${truncateLabel(quote)}"`, promptText: buildScaffold(lead, quote, false), - anchor: { host, range }, + anchor: { host, range, selection }, }; } @@ -140,7 +179,10 @@ export function buildCodeReference(pre: HTMLElement): PendingReference { kind: 'chat-code', label: truncateLabel(code), promptText: buildScaffold(LEAD_CODE, code, true), - anchor: { host: pre, range: null }, + // No live-selection concept here: buildCodeReference always quotes the + // WHOLE
 block (never a partial selection), so there's nothing to
+    // highlight inside the clone.
+    anchor: { host: pre, range: null, selection: null },
   };
 }
 
@@ -154,6 +196,8 @@ export function buildArtifactReference(container: HTMLElement): PendingReference
   if (!sel || !path) return null;
 
   const ref = describeArtifactSelection(sel, container);
+  const range = captureRange();
+  const selection = range ? computeSelectionOffsets(container, range) : null;
 
   return {
     kind: 'artifact',
@@ -161,6 +205,6 @@ export function buildArtifactReference(container: HTMLElement): PendingReference
     // line form reads well with "of ".
     label: ref.startsWith('line') ? `${ref} of ${baseName(path)}` : truncateLabel(ref),
     promptText: buildArtifactScaffold(ref, path),
-    anchor: { host: container, range: captureRange() },
+    anchor: { host: container, range, selection },
   };
 }
diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
index 1acffb0fa..6f75a6813 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
@@ -279,15 +279,15 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
     return host;
   }
 
-  it('a chat reference clones the source via cloneNode, travels, and leaves the source unmutated', () => {
+  it('a chat reference clones the source via cloneNode, travels, hides the source, and restores it byte-identical on clear', () => {
     const host = makeHost('the referenced message');
     const originalOuterHTML = host.outerHTML;
 
-    renderOverlay({
+    const { rerender } = renderOverlay({
       kind: 'chat-text',
       label: 'x',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
 
@@ -300,20 +300,44 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
     expect(clone).not.toBeNull();
     // A DIFFERENT node than the source — proves this is cloneNode output,
     // not a re-parent/move of the original (which would detach it from the
-    // transcript) and not a live reference to it.
+    // transcript) and not a live reference to it. Captured from the ORIGINAL
+    // markup, before the source-hide effect below adds an inline style — the
+    // clone must reflect the bubble as it looked at capture time, not
+    // (accidentally) invisible itself.
     expect(clone).not.toBe(host);
     expect(clone?.outerHTML).toBe(originalOuterHTML);
     expect(clone?.getAttribute('data-test-marker')).toBe('source');
 
-    // No DOM mutation in the reference path — the invariant this whole
-    // feature is built around (see reference-context.tsx's WHY comment on
-    // the withdrawn Range.surroundContents() design, which crashed the
-    // renderer). The source keeps its exact original markup and stays
-    // attached exactly where it always was.
-    expect(host.outerHTML).toBe(originalOuterHTML);
+    // Dev-review fix 1 ("I don't like that I can see both the original
+    // message and the centered message"): the SOURCE gets hidden — not
+    // removed, not mutated structurally — for as long as its (travelling)
+    // reference is held. visibility:hidden, not display:none, so the
+    // transcript layout doesn't reflow under the dim.
+    expect(host.style.visibility).toBe('hidden');
+    // No STRUCTURAL DOM mutation in the reference path — the invariant this
+    // whole feature is built around (see reference-context.tsx's WHY comment
+    // on the withdrawn Range.surroundContents() design, which split text
+    // nodes and crashed the renderer). One reversible inline style is a
+    // different class of change: it adds no nodes/classes/attributes, and
+    // gets undone exactly below.
     expect(host.isConnected).toBe(true);
     expect(host.parentElement).toBe(document.body);
 
+    // Clearing the reference must restore the EXACT prior inline style — a
+    // reference that gets cleared must never leave a permanently invisible
+    // message behind in the transcript.
+    rerender(
+      
+        
+          
+          
+        
+      ,
+    );
+    act(() => {});
+    expect(host.outerHTML).toBe(originalOuterHTML);
+    expect(host.style.visibility).toBe('');
+
     document.body.removeChild(host);
   });
 
@@ -329,7 +353,7 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
       kind: 'artifact',
       label: 'lines 1-1 of x.ts',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
 
@@ -365,7 +389,7 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
       kind: 'chat-text',
       label: 'x',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
     expect(document.querySelector('.reference-lift-card')?.firstElementChild).not.toBeNull();
@@ -388,6 +412,90 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
 
     document.body.removeChild(host);
   });
+
+  // Dev-review fix B, wired end-to-end: apply-highlight.test.ts pins the
+  // wrapping mechanics in isolation; this proves ReferenceOverlay actually
+  // CALLS applyHighlightMark on the clone with the anchor's captured
+  // offsets, for both kinds — a wiring bug (wrong offsets passed, or never
+  // called at all) wouldn't show up in the isolated unit tests alone.
+  it('applies the reference-mark highlight inside the clone at the anchor\'s captured offsets', () => {
+    const host = makeHost('alpha bravo charlie'); // "bravo" = offsets 6-11
+
+    renderOverlay({
+      kind: 'chat-text',
+      label: 'x',
+      promptText: 'x',
+      anchor: { host, range: null, selection: { start: 6, end: 11 } },
+    });
+    act(() => {});
+
+    const clone = document.querySelector('.reference-lift-card')?.firstElementChild;
+    expect(clone).not.toBeNull();
+    const mark = clone?.querySelector('mark.reference-mark');
+    expect(mark).not.toBeNull();
+    expect(mark?.textContent).toBe('bravo');
+    // The rest of the clone's text must still be there, untouched.
+    expect(clone?.textContent).toBe('alpha bravo charlie');
+
+    document.body.removeChild(host);
+  });
+
+  it('a selection spanning multiple text nodes in the source highlights each covered run in the clone', () => {
+    const host = document.createElement('div');
+    host.setAttribute('data-test-marker', 'source');
+    const spanA = document.createElement('span');
+    spanA.textContent = 'alpha ';
+    const spanB = document.createElement('span');
+    spanB.textContent = 'bravo';
+    host.appendChild(spanA);
+    host.appendChild(spanB);
+    document.body.appendChild(host);
+
+    // Full text "alpha bravo" (11 chars); offsets 3-8 ("ha br") straddle the
+    // spanA/spanB boundary at offset 6 — same fixture shape as
+    // apply-highlight.test.ts's multi-node case, exercised through the real
+    // component this time.
+    renderOverlay({
+      kind: 'chat-text',
+      label: 'x',
+      promptText: 'x',
+      anchor: { host, range: null, selection: { start: 3, end: 8 } },
+    });
+    act(() => {});
+
+    const clone = document.querySelector('.reference-lift-card')?.firstElementChild;
+    expect(clone).not.toBeNull();
+    const marks = clone?.querySelectorAll('mark.reference-mark');
+    expect(marks).toHaveLength(2);
+    expect(Array.from(marks ?? []).map((m) => m.textContent)).toEqual(['ha ', 'br']);
+    expect(clone?.textContent).toBe('alpha bravo');
+
+    document.body.removeChild(host);
+  });
+
+  it('skips the highlight (renders the clone unmarked, no throw) when the offsets cannot be resolved', () => {
+    const host = makeHost('short');
+
+    // Offsets that don't correspond to anything in a 5-character host —
+    // applyHighlightMark's runs list comes back empty, and ReferenceOverlay
+    // must not let that take down the whole reference.
+    expect(() => {
+      renderOverlay({
+        kind: 'chat-text',
+        label: 'x',
+        promptText: 'x',
+        anchor: { host, range: null, selection: { start: 100, end: 200 } },
+      });
+      act(() => {});
+    }).not.toThrow();
+
+    const clone = document.querySelector('.reference-lift-card')?.firstElementChild;
+    expect(clone).not.toBeNull();
+    expect(clone?.querySelector('mark.reference-mark')).toBeNull();
+    expect(clone?.textContent).toBe('short');
+
+    document.body.removeChild(host);
+  });
 });
 
 // Task 8 defect fix: the FLIP positioning effect used to depend on `d`
@@ -433,7 +541,7 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix)
       kind: 'chat-text',
       label: 'x',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
     // Let the RAF-scheduled "Last" transform apply (real timers — see the
@@ -488,7 +596,7 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix)
       kind: 'artifact',
       label: 'lines 1-1 of x.ts',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
 
@@ -522,18 +630,14 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix)
   });
 });
 
-// Task 9: reduced-effects fallback. globals.css keys the whole branch off a
-// `data-reduced` attribute stamped by THIS component (there's no
-// data-reduced-effects attribute on  to select on — theme-engine.ts
-// only zeroes blur vars for this setting, verified in the task brief), so the
-// only honestly-checkable-in-jsdom claim is the mechanism: does the attribute
-// land on `.reference-trace` and `.reference-lift` when reducedEffects is
-// true, and is it ABSENT (not merely falsy — globals.css's `[data-reduced=
-// "true"]` selector needs the attribute gone entirely) when it's false. What
-// this can NOT prove — computed styles, whether the animation/glow/transition
-// actually stop, whether the lift shadow visually drops back — needs a real
-// dev-instance check (see the task report).
-describe('dev-review fix: the highlight must not be left behind at the source', () => {
+// Dev-review fix C/D: "the artifact panel still puts a weird black box around
+// the selection" / "the traced outline is uneven and janky". The `.reference-
+// trace` SVG (wash fill + animated stroke outline) is deleted entirely — for
+// BOTH kinds, not just the travelling one it was already skipped for. The
+// artifact clip-path (still driven by the same `d` geometry) is what actually
+// keeps the selection bright; it was never rendered THROUGH this SVG, so
+// removing the SVG doesn't touch the clip.
+describe('dev-review fix C/D: no traced SVG outline for either kind', () => {
   function withHost(kind: PendingReference['kind']): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'referenced content';
@@ -541,7 +645,7 @@ describe('dev-review fix: the highlight must not be left behind at the source',
     vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
       { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect,
     );
-    return { kind, label: 'x', promptText: 'x', anchor: { host, range: null } };
+    return { kind, label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
   }
 
   it('a TRAVELLING (chat) reference renders no source-anchored trace', () => {
@@ -555,11 +659,17 @@ describe('dev-review fix: the highlight must not be left behind at the source',
     expect(document.querySelector('.reference-trace')).toBeNull();
   });
 
-  it('a NON-travelling (artifact) reference still renders the in-place trace', () => {
-    // Nothing moves here, so the source-anchored outline is exactly right.
+  it('a NON-travelling (artifact) reference ALSO renders no traced outline (the "weird black box" fix)', () => {
+    // Previously this WAS the one case that rendered `.reference-trace` — the
+    // exact "weird black box" / "uneven and janky" outline dev review flagged.
+    // It's gone now; the clip-path (asserted elsewhere) does the real work.
     renderOverlay(withHost('artifact'));
     act(() => {});
-    expect(document.querySelector('.reference-trace')).not.toBeNull();
+    expect(document.querySelector('.reference-trace')).toBeNull();
+    // Sanity: the lift itself still renders and still isn't the travelling case.
+    const lift = document.querySelector('.reference-lift');
+    expect(lift).not.toBeNull();
+    expect(lift?.hasAttribute('data-travels')).toBe(false);
   });
 });
 
@@ -602,70 +712,63 @@ describe('reduced effects (Task 9)', () => {
     );
   }
 
-  // `.reference-trace` only renders when `d` is non-empty (`{d && ()}`
-  // in ReferenceOverlay.tsx), and `d` is empty for a null anchor OR for jsdom's
-  // default all-zero getBoundingClientRect (toBoxes drops zero-area rects) —
-  // same reason the Task 8 lift tests above stub a real rect. A real host with
-  // a stubbed rect is needed here so the trace svg actually mounts.
+  // A real host with a stubbed rect (jsdom's own getBoundingClientRect is
+  // all-zero — no layout engine) so the lift's positioning effects have
+  // something non-degenerate to work with.
   function makeReferenceWithHost(): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'the referenced message';
     document.body.appendChild(host);
     const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
     vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
-    return { kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null } };
+    return { kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
   }
 
-  // The source-anchored trace svg only renders for NON-travelling (artifact)
-  // references now — a travelling chat card carries its own ring/glow in CSS
-  // so nothing is left behind at the source (dev-review fix). So the trace
-  // half of these assertions needs an artifact reference.
   function makeArtifactReferenceWithHost(): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'the referenced lines';
     document.body.appendChild(host);
     const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
     vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
-    return { kind: 'artifact', label: 'x', promptText: 'x', anchor: { host, range: null } };
+    return { kind: 'artifact', label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
   }
 
-  it('stamps data-reduced="true" on the trace svg and the lift when reducedEffects is on', () => {
+  // Dev-review fix C/D deleted `.reference-trace` for both kinds, so its
+  // data-reduced stamping is no longer a thing to assert — only
+  // `.reference-lift`'s remains (it still drives the reduced ring/shadow).
+  it('stamps data-reduced="true" on the lift when reducedEffects is on, for both kinds', () => {
     localStorage.setItem(REDUCED_EFFECTS_KEY, '1'); // ThemeProvider reads this synchronously on mount (theme-context.tsx:139)
     const { unmount } = renderOverlayWithTheme(makeReferenceWithHost());
     act(() => {});
-
-    // Travelling (chat): no source-anchored trace, but the lift is stamped.
     expect(document.querySelector('.reference-trace')).toBeNull();
     expect(document.querySelector('.reference-lift')?.getAttribute('data-reduced')).toBe('true');
     unmount();
 
-    // Non-travelling (artifact): the trace renders and is stamped too.
     renderOverlayWithTheme(makeArtifactReferenceWithHost());
     act(() => {});
-    expect(document.querySelector('.reference-trace')?.getAttribute('data-reduced')).toBe('true');
+    expect(document.querySelector('.reference-trace')).toBeNull();
+    expect(document.querySelector('.reference-lift')?.getAttribute('data-reduced')).toBe('true');
   });
 
-  it('leaves data-reduced entirely absent (not just falsy) when reducedEffects is off', () => {
+  it('leaves data-reduced entirely absent (not just falsy) on the lift when reducedEffects is off, for both kinds', () => {
     // No localStorage write — ThemeProvider's default is reducedEffects: false.
     const { unmount } = renderOverlayWithTheme(makeReferenceWithHost());
     act(() => {});
 
     const lift = document.querySelector('.reference-lift');
     expect(lift).not.toBeNull();
-    expect(lift?.hasAttribute('data-reduced')).toBe(false);
-    unmount();
-
-    renderOverlayWithTheme(makeArtifactReferenceWithHost());
-    act(() => {});
-    const trace = document.querySelector('.reference-trace');
-    expect(trace).not.toBeNull();
     // hasAttribute, not a falsy getAttribute check: React only omits the DOM
     // attribute entirely when the prop value is `undefined`, and globals.css's
     // `[data-reduced="true"]` attribute selector cares about presence, not
     // truthiness — `data-reduced="false"` would still (wrongly) not match
     // this selector but WOULD show up in the DOM, which is a different bug
     // than what this test is pinning.
-    expect(trace?.hasAttribute('data-reduced')).toBe(false);
+    expect(lift?.hasAttribute('data-reduced')).toBe(false);
+    unmount();
+
+    renderOverlayWithTheme(makeArtifactReferenceWithHost());
+    act(() => {});
+    expect(document.querySelector('.reference-lift')?.hasAttribute('data-reduced')).toBe(false);
   });
 });
 
@@ -704,7 +807,7 @@ describe('detached source (Issues A/B: final review)', () => {
       kind: 'chat-text',
       label: 'x',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
 
@@ -732,7 +835,7 @@ describe('detached source (Issues A/B: final review)', () => {
       kind: 'artifact',
       label: 'lines 1-1 of x.ts',
       promptText: 'x',
-      anchor: { host, range: null },
+      anchor: { host, range: null, selection: null },
     });
     act(() => {});
 
@@ -754,7 +857,7 @@ describe('detached source (Issues A/B: final review)', () => {
     const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
     vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
 
-    renderOverlay({ kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null } });
+    renderOverlay({ kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } });
     act(() => {});
 
     const lift = document.querySelector('.reference-lift') as HTMLElement;
diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
index ed1ffbd46..13a000447 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
@@ -7,12 +7,15 @@ import { useTheme } from '../../state/theme-context';
 import { useEscClose, useEscStackDepth } from '../../hooks/use-esc-close';
 import { useReferenceGeometry } from './use-reference-geometry';
 import { shiftPath } from './reference-geometry';
+import { applyHighlightMark } from './apply-highlight';
 
 /**
  * The held "Ask Claude about this" reference (spec 2026-07-26).
  *
- * One app-wide instance. Owns the window-wide dim; Tasks 7 and 8 add the traced
- * outline and the lifted clone on top of this shell.
+ * One app-wide instance. Owns the window-wide dim; Task 8 adds the lifted
+ * clone on top of this shell. (Task 7's traced SVG outline was removed in the
+ * dev-review pass — see the WHY comment below `useReferenceGeometry` — but
+ * its underlying geometry hook lives on, feeding the artifact clip-path.)
  *
  * Window-wide, not pane-scoped — Destin's 10B call: "dim should apply to the
  * whole window so it's obvious what is being highlighted / what the user is
@@ -28,11 +31,15 @@ export function ReferenceOverlay() {
   const { reducedEffects } = useTheme();
   const depth = useEscStackDepth();
   const depthAtOpen = useRef(null);
-  // Task 7: the traced outline around the referenced content. Task 8 also
-  // reuses `d` directly for the artifact clip-path (see the lift effect
-  // below) — the hook used to return raw `rects` too, for a redraw approach
-  // that was dropped in favor of clipping the clone, so that field was
-  // deleted as dead code.
+  // Task 7 originally drew `d` as a visible traced SVG outline around the
+  // referenced content. Dev review flagged it as "the weird black box" /
+  // "uneven and janky" — that rendering is gone (see the JSX below), but the
+  // hook and `d` itself stay: Task 8 still needs `d` for the artifact
+  // clip-path (see the lift effect below), which is what actually keeps only
+  // the referenced lines bright above the dim. (The hook used to return raw
+  // `rects` too, for a redraw approach dropped in favor of clipping the
+  // clone; that field was deleted as dead code back then, unrelated to this
+  // pass.)
   const { d } = useReferenceGeometry(reference?.anchor ?? null);
 
   // Task 8: chat references lift a clone to the viewport centre; artifact
@@ -73,10 +80,78 @@ export function ReferenceOverlay() {
     if (!src) return;
     const copy = src.cloneNode(true) as HTMLElement;
     // (nothing to strip — the anchor never wrote attributes onto the source)
+
+    // Dev-review fix B: show WHICH PART was selected, inside the clone.
+    // `selection` is a pair of character offsets computed against the LIVE
+    // host (build-reference.ts's computeSelectionOffsets) — they map onto
+    // `copy` unchanged because cloneNode(true) preserves host's exact
+    // text-node order and lengths. Applied to the CLONE, never the source:
+    // mutating a detached clone is safe (see apply-highlight.ts's WHY
+    // comment); mutating the live source is exactly the class of change that
+    // crashed React's reconciler under the withdrawn surroundContents()
+    // design. Best-effort — if the offsets don't resolve (e.g. a stale
+    // selection that no longer maps onto the current DOM shape),
+    // applyHighlightMark silently no-ops rather than throwing, so a bad
+    // offset degrades to "no highlight" instead of a broken reference.
+    const sel = reference.anchor.selection;
+    if (sel) {
+      try {
+        applyHighlightMark(copy, sel.start, sel.end);
+      } catch {
+        // Skip the highlight rather than losing the whole clone over it.
+      }
+    }
+
     holder.replaceChildren(copy);
     return () => holder.replaceChildren();
   }, [reference]);
 
+  // Dev-review fix 1: "I don't like that I can see both the original message
+  // and the centered message. It should be the same bubble that appears to
+  // move." Hide the SOURCE bubble for the duration a CHAT (travelling)
+  // reference is held, so the clone reads as the original having moved
+  // rather than a second copy appearing alongside it. Scoped to `travels`
+  // only — the artifact (pinned/clipped) case deliberately still shows the
+  // dimmed original underneath its clipped-bright clone; hiding it there
+  // would blank out everything outside the selection instead of dimming it.
+  //
+  // visibility: hidden, NOT display: none — the transcript's layout box must
+  // stay in place. display: none would collapse the row's height and reflow
+  // every message below it while the dim is up, which is its own visible
+  // glitch (and would also break the FLIP "First" measurement, which reads
+  // the source's live rect).
+  //
+  // Inline style only, restored to its EXACT prior value (not hardcoded back
+  // to ''): some other feature could already have an inline `visibility` on
+  // this element for an unrelated reason, and clobbering that on clear would
+  // leave it wrong. This is NOT the same class of change as the withdrawn
+  // Range.surroundContents() design (see reference-context.tsx's WHY
+  // comment) — that SPLIT TEXT NODES, a structural DOM mutation React's
+  // fiber tree loses track of. Toggling one existing inline style property
+  // adds/removes no nodes, classes, or attributes, and React never wrote
+  // this property itself, so there is nothing for the next reconcile to
+  // disagree with.
+  useEffect(() => {
+    if (!reference?.anchor || !travels) return;
+    const src = reference.anchor.host as HTMLElement;
+    if (!src || !src.style) return;
+    const prevVisibility = src.style.visibility;
+    // Remember whether `style` existed at all BEFORE we touch it: setting a
+    // longhand property back to '' correctly drops it from cssText (verified
+    // against jsdom — `style.length` falls back to 0 when it was the only
+    // property), but browsers never auto-remove the now-empty `style=""`
+    // ATTRIBUTE itself, only an explicit removeAttribute does. Restoring
+    // "exactly" means the source ends up with NO style attribute at all if it
+    // never had one — not a harmless-looking but still-different `style=""`
+    // husk left behind in the live transcript's markup.
+    const hadStyleAttr = src.hasAttribute('style');
+    src.style.visibility = 'hidden';
+    return () => {
+      src.style.visibility = prevVisibility;
+      if (!hadStyleAttr && src.style.length === 0) src.removeAttribute('style');
+    };
+  }, [reference, travels]);
+
   // FLIP: place the clone exactly over the real element (First), then
   // transform it to the viewport centre (Last) — chat references only.
   // Scroll-to-centre is NOT an option: the most likely right-click target is
@@ -201,17 +276,15 @@ export function ReferenceOverlay() {
     node.style.transform = 'translate(0, 0)';
 
     // `d` is built in VIEWPORT coordinates (use-reference-geometry.ts's
-    // `origin = {left:0,top:0}`), which lines up for free with the trace
-    // SVG (`.reference-trace` is `position:fixed; inset:0`, so ITS border
-    // box origin IS the viewport origin). It does NOT line up for free
-    // here: `clip-path: path()` resolves its coordinates against the
-    // clipped element's OWN border box — this node's box starts at
-    // (s.left, s.top), not (0,0), because it's pinned over the source.
-    // Verified against the CSS Shapes spec (path() uses the same
-    // reference-box rule polygon()/circle() use for percentages), not
-    // assumed. Without shiftPath the clip silently lands offset by the
-    // source's own position — correct only for a source pinned at the
-    // viewport origin, which is not the general case.
+    // `origin = {left:0,top:0}`). `clip-path: path()` does NOT resolve its
+    // coordinates against the viewport — it resolves against the clipped
+    // element's OWN border box, and this node's box starts at (s.left,
+    // s.top), not (0,0), because it's pinned over the source. Verified
+    // against the CSS Shapes spec (path() uses the same reference-box rule
+    // polygon()/circle() use for percentages), not assumed. Without
+    // shiftPath the clip silently lands offset by the source's own
+    // position — correct only for a source pinned at the viewport origin,
+    // which is not the general case.
     node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none';
   }, [reference, travels, d]);
 
@@ -277,25 +350,15 @@ export function ReferenceOverlay() {
 
   return createPortal(
     
-      {/* Traced outline around the referenced selection/element (Task 7).
-          pathLength={100} normalizes both paths' length to 100 units so the
-          fixed 100-unit stroke-dasharray/breathe animation in globals.css
-          works regardless of the actual traced perimeter. Empty when the
-          source is gone (host disconnected) — nothing renders in that case. */}
-      {/* Only the NON-travelling (artifact) case gets the source-anchored trace.
-          `d` is measured from anchor.host where it actually sits, so for a
-          travelling chat reference — whose card flies to the viewport centre —
-          it would paint a hard outlined box around the EMPTY space the bubble
-          left behind (Destin, dev review: "the black box visible around where
-          the message bubble was originally"). The travelling card carries its
-          own ring + glow in CSS instead, so the highlight hugs the card and
-          travels with it by construction. */}
-      {d && !travels && (
-        
-      )}
+      {/* Dev-review fix C/D: the traced SVG outline (formerly rendered here for
+          the non-travelling/artifact case) is GONE — it was both "the weird
+          black box" around the selection and "uneven and janky" (Destin, dev
+          review). `d` and `useReferenceGeometry` stay: the artifact clone
+          below still needs `d` for its clip-path (that's what keeps only the
+          referenced lines bright above the dim), it's just no longer also
+          drawn as a visible stroke. The travelling clone already has its own
+          ring + glow in CSS, and now the selection highlight from fix B too —
+          between the two, nothing is left needing a traced outline. */}
       {/* Task 8: the clone. Chat kinds travel to centre (`data-travels`
           drives the CSS transition + the non-clipping shadow/scroll rules);
           artifact kinds stay pinned over the source and get clipped instead
diff --git a/desktop/src/renderer/components/reference/apply-highlight.test.ts b/desktop/src/renderer/components/reference/apply-highlight.test.ts
new file mode 100644
index 000000000..f003c59cb
--- /dev/null
+++ b/desktop/src/renderer/components/reference/apply-highlight.test.ts
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+// Fix: pin jsdom explicitly (see use-esc-close.test.tsx for why) — this file
+// lives under src/**/*.test.ts, outside vitest.config.ts's tests/**/*.tsx
+// auto-jsdom glob.
+//
+// Dev-review fix B: "it doesn't show that I was asking about a specific
+// selection" — this is the half of the fix that mutates the (detached)
+// CLONE to visually mark the referenced span. These tests fail against the
+// pre-fix code because applyHighlightMark doesn't exist there at all.
+import { describe, it, expect } from 'vitest';
+import { applyHighlightMark } from './apply-highlight';
+
+describe('applyHighlightMark', () => {
+  it('wraps a substring within a single text node in ', () => {
+    const root = document.createElement('div');
+    root.textContent = 'alpha bravo charlie';
+    document.body.appendChild(root);
+
+    applyHighlightMark(root, 6, 11); // "bravo"
+
+    const mark = root.querySelector('mark.reference-mark');
+    expect(mark).not.toBeNull();
+    expect(mark?.textContent).toBe('bravo');
+    // The surrounding text must survive unmutated — only the covered run is
+    // wrapped, not swallowed or duplicated.
+    expect(root.textContent).toBe('alpha bravo charlie');
+
+    document.body.removeChild(root);
+  });
+
+  it('wraps only the highlighted run, leaving prefix/suffix as plain text siblings', () => {
+    const root = document.createElement('div');
+    root.textContent = 'alpha bravo charlie';
+    document.body.appendChild(root);
+
+    applyHighlightMark(root, 6, 11);
+
+    // Expect three top-level children of root: text "alpha ", bravo, text " charlie".
+    const children = Array.from(root.childNodes);
+    expect(children.map((n) => n.textContent)).toEqual(['alpha ', 'bravo', ' charlie']);
+    expect((children[1] as Element).tagName).toBe('MARK');
+    expect((children[1] as Element).className).toBe('reference-mark');
+
+    document.body.removeChild(root);
+  });
+
+  it('handles a span crossing multiple text nodes — wraps each covered run separately', () => {
+    // Two sibling  elements, each with its own text node, standing in
+    // for e.g. two adjacent syntax-highlighted tokens in a code clone.
+    const root = document.createElement('div');
+    const spanA = document.createElement('span');
+    spanA.textContent = 'alpha ';
+    const spanB = document.createElement('span');
+    spanB.textContent = 'bravo';
+    root.appendChild(spanA);
+    root.appendChild(spanB);
+    document.body.appendChild(root);
+
+    // Full text is "alpha bravo" (11 chars). Highlight "ha br" (offsets 3-8),
+    // which straddles the boundary between spanA ("alpha ", 0-6) and spanB
+    // ("bravo", 6-11).
+    applyHighlightMark(root, 3, 8);
+
+    const marks = root.querySelectorAll('mark.reference-mark');
+    expect(marks).toHaveLength(2); // one run per covered text node — not one mark spanning across elements
+    expect(Array.from(marks).map((m) => m.textContent)).toEqual(['ha ', 'br']);
+    // Nothing lost or duplicated across the whole subtree.
+    expect(root.textContent).toBe('alpha bravo');
+
+    document.body.removeChild(root);
+  });
+
+  it('highlights the ENTIRE text node when the span exactly covers it (no split needed)', () => {
+    const root = document.createElement('div');
+    root.textContent = 'bravo';
+    document.body.appendChild(root);
+
+    applyHighlightMark(root, 0, 5);
+
+    expect(root.childNodes).toHaveLength(1);
+    const only = root.firstChild as Element;
+    expect(only.tagName).toBe('MARK');
+    expect(only.textContent).toBe('bravo');
+
+    document.body.removeChild(root);
+  });
+
+  it('is a no-op (no throw, no DOM change) when the span is empty or inverted', () => {
+    const root = document.createElement('div');
+    root.textContent = 'alpha bravo';
+    document.body.appendChild(root);
+    const before = root.outerHTML;
+
+    applyHighlightMark(root, 5, 5); // empty
+    applyHighlightMark(root, 8, 3); // inverted
+    expect(root.outerHTML).toBe(before);
+
+    document.body.removeChild(root);
+  });
+
+  it('is a no-op (no throw, no DOM change) when the offsets fall entirely outside the text', () => {
+    const root = document.createElement('div');
+    root.textContent = 'short';
+    document.body.appendChild(root);
+    const before = root.outerHTML;
+
+    applyHighlightMark(root, 100, 200); // "offsets can't be resolved" case from the task brief
+    expect(root.outerHTML).toBe(before);
+
+    document.body.removeChild(root);
+  });
+
+  it('clamps a span that starts inside the text but runs past its end', () => {
+    const root = document.createElement('div');
+    root.textContent = 'bravo';
+    document.body.appendChild(root);
+
+    applyHighlightMark(root, 2, 999); // "avo" is all that exists past offset 2
+
+    const mark = root.querySelector('mark.reference-mark');
+    expect(mark).not.toBeNull();
+    expect(mark?.textContent).toBe('avo');
+    expect(root.textContent).toBe('bravo');
+
+    document.body.removeChild(root);
+  });
+});
diff --git a/desktop/src/renderer/components/reference/apply-highlight.ts b/desktop/src/renderer/components/reference/apply-highlight.ts
new file mode 100644
index 000000000..24403a469
--- /dev/null
+++ b/desktop/src/renderer/components/reference/apply-highlight.ts
@@ -0,0 +1,56 @@
+/**
+ * Applies the `.reference-mark` highlight to a DETACHED clone, at the
+ * character-offset span `build-reference.ts` computed against the live
+ * source (dev-review fix B: "it doesn't show that I was asking about a
+ * specific selection").
+ *
+ * Mutating `root` here is SAFE and a different class of change than the
+ * DOM-mutation the reference path otherwise forbids: `root` is a
+ * `cloneNode(true)` copy, detached from the document and never seen by
+ * React, so splitting its text nodes touches nothing React's reconciler
+ * tracks. Contrast the withdrawn `Range.surroundContents()` design, which
+ * split text nodes INSIDE the live, React-managed source and crashed the
+ * next reconcile with `NotFoundError: removeChild` — see the WHY comment on
+ * `captureRange` in build-reference.ts.
+ */
+export function applyHighlightMark(root: Element, start: number, end: number): void {
+  if (!(end > start)) return; // empty/inverted span — nothing to highlight
+
+  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+  // Collect the covered runs FIRST, then mutate — mutating mid-walk (splitText
+  // creates a new sibling Text node) would confuse the TreeWalker's notion of
+  // "next node" and risks visiting a just-created fragment a second time.
+  const runs: { node: Text; from: number; to: number }[] = [];
+  let offset = 0;
+  let node: Node | null;
+  while ((node = walker.nextNode())) {
+    const text = node as Text;
+    const len = text.data.length;
+    const nodeStart = offset;
+    const nodeEnd = offset + len;
+    const from = Math.max(start, nodeStart);
+    const to = Math.min(end, nodeEnd);
+    if (from < to) runs.push({ node: text, from: from - nodeStart, to: to - nodeStart });
+    offset += len;
+  }
+  if (runs.length === 0) return; // offsets didn't land on anything real — skip, don't throw
+
+  // Wrap each run independently — a span crossing multiple text nodes (e.g. a
+  // selection that straddles two  tokens from syntax highlighting)
+  // gets one  per covered node, not one mark spanning across element
+  // boundaries (which isn't representable as a single DOM wrap anyway).
+  for (const run of runs) {
+    let target = run.node;
+    // Peel off the UNcovered prefix into its own text node, so `target` starts
+    // exactly at the covered run.
+    if (run.from > 0) target = target.splitText(run.from);
+    const runLength = run.to - run.from;
+    // Peel off the uncovered suffix, if any remains after the split above.
+    if (runLength < target.data.length) target.splitText(runLength);
+
+    const mark = document.createElement('mark');
+    mark.className = 'reference-mark';
+    target.parentNode?.replaceChild(mark, target);
+    mark.appendChild(target);
+  }
+}
diff --git a/desktop/src/renderer/components/reference/reference-geometry.ts b/desktop/src/renderer/components/reference/reference-geometry.ts
index c28f0f9cb..ef46e2465 100644
--- a/desktop/src/renderer/components/reference/reference-geometry.ts
+++ b/desktop/src/renderer/components/reference/reference-geometry.ts
@@ -46,10 +46,12 @@ export function buildUnionPath(boxes: Box[]): string {
  * Shifts every coordinate pair in a `buildUnionPath` string by (dx, dy).
  *
  * Task 8's artifact-reference clip-path needs this: `d` is built in VIEWPORT
- * coordinates (use-reference-geometry.ts's `origin = {left:0,top:0}`), which
- * is exactly right for the trace SVG (`.reference-trace` is `position:fixed;
- * inset:0`, so its own border box origin IS the viewport origin). But CSS
- * `clip-path: path(...)` resolves its coordinates against the top-left of the
+ * coordinates (use-reference-geometry.ts's `origin = {left:0,top:0}` — a
+ * carry-over from when it also fed a `position:fixed; inset:0` traced SVG,
+ * `.reference-trace`, since removed in a dev-review pass; the viewport-origin
+ * choice stayed because it's still the natural coordinate space for a
+ * `getBoundingClientRect()`-derived path). CSS `clip-path: path(...)`
+ * resolves its coordinates against the top-left of the
  * CLIPPED ELEMENT'S OWN reference box (border-box by default) — the same rule
  * `polygon()`/`circle()` use for percentages — NOT the viewport. The lifted
  * clone's box is pinned at the source's rect, not at (0,0), so its path must
diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
index ad4d2e065..4398a4206 100644
--- a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
+++ b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
@@ -52,7 +52,7 @@ afterEach(() => {
 });
 
 function makeAnchor(host: Element, range: Range | null): ReferenceAnchor {
-  return { host, range };
+  return { host, range, selection: null };
 }
 
 // jsdom's Range has no getClientRects at all (not even a no-op) — vi.spyOn
diff --git a/desktop/src/renderer/state/reference-context.tsx b/desktop/src/renderer/state/reference-context.tsx
index 8fc465962..bcce6edf9 100644
--- a/desktop/src/renderer/state/reference-context.tsx
+++ b/desktop/src/renderer/state/reference-context.tsx
@@ -12,6 +12,20 @@ export type ReferenceAnchor = {
   host: Element;
   /** Live Range over the selection, or null for a whole-element reference. */
   range: Range | null;
+  /**
+   * Character offsets of the selection relative to `host`'s TEXT (walked via
+   * TreeWalker, same node order `host.cloneNode(true)` preserves) — null when
+   * there was no live selection to capture, or the offsets couldn't be
+   * resolved. Dev-review fix: the centred/pinned clone used to show the whole
+   * message with no indication of which part was actually selected ("it
+   * doesn't show that I was asking about a specific selection"). Offsets,
+   * not the Range itself, are what ReferenceOverlay re-applies to the CLONE —
+   * the clone is a structurally-identical copy (same text-node order/
+   * lengths), so an offset pair computed against the live host maps onto it
+   * without needing the original Range object (which points at nodes that
+   * belong to the source, not the clone).
+   */
+  selection: { start: number; end: number } | null;
 };
 
 export type PendingReference = {
diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css
index d63524cd7..eb4fcb1c2 100644
--- a/desktop/src/renderer/styles/globals.css
+++ b/desktop/src/renderer/styles/globals.css
@@ -910,28 +910,31 @@ body[data-reference-held] .bottom-float {
    ═══════════════════════════════════════════════════════════════════════════ */
 .reference-scrim {
   --ref-stroke: var(--accent);
-  --ref-wash: color-mix(in oklab, var(--accent) 12%, transparent);
   --ref-glow: color-mix(in oklab, var(--accent) 45%, transparent);
   /* 2.5x the standard overlay shadow: reads as lifted on light themes
      (0.2 -> 0.5) without going murky on dark ones (0.1 -> 0.25). */
   --ref-lift-shadow: 0 24px 64px rgba(0, 0, 0, calc(var(--shadow-strength, 0.15) * 2.5));
 }
 
-.reference-trace { position: fixed; inset: 0; pointer-events: none; overflow: visible; }
-.reference-trace path.wash { fill: var(--ref-wash); stroke: none; }
-.reference-trace path.outline {
-  fill: none;
-  stroke: var(--ref-stroke);
-  stroke-width: 1.5;
-  stroke-linejoin: round;
-  filter: drop-shadow(0 0 5px var(--ref-glow));
-  stroke-dasharray: 100;
-  stroke-dashoffset: 100;
-  animation: reference-trace-in 620ms 120ms cubic-bezier(.4, 0, .2, 1) forwards,
-             reference-breathe 2800ms 740ms ease-in-out infinite;
+/* Dev-review fix C/D removed the traced SVG outline entirely (`.reference-
+   trace`, its wash/outline paths, and the `reference-trace-in`/
+   `reference-breathe` keyframes that drove it) — it was both "the weird
+   black box" around an artifact selection and "uneven and janky" as an
+   outline. `--ref-wash` (the fill it used) went with it; `--ref-stroke` and
+   `--ref-glow` survive below, reused by the travelling clone's own ring.
+   The artifact clip-path (ReferenceOverlay.tsx) still does the real work of
+   keeping only the referenced lines bright above the dim — it never rendered
+   through this SVG, just shared its `d` geometry. */
+
+/* Dev-review fix B: the highlighted span inside a reference clone (chat and
+   artifact both — see apply-highlight.ts). Theme tokens only: a literal
+   colour here would break every community theme, same rule as the rest of
+   this block. */
+.reference-mark {
+  background: color-mix(in oklab, var(--accent) 28%, transparent);
+  color: inherit;
+  border-radius: var(--radius-sm);
 }
-@keyframes reference-trace-in { to { stroke-dashoffset: 0; } }
-@keyframes reference-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
 
 /* The lifted clone. Chat references travel to centre; artifact references do
    NOT (their selection is already where the user is looking) — spec 2.2. */
@@ -1009,10 +1012,11 @@ body[data-reference-held] .bottom-float {
   overflow-y: auto;
 }
 
-/* Reduced effects (Task 9): outline only. Kills the trace animation, the
-   breathing pulse, the glow drop-shadow, and the travel easing — leaving a
-   static accent border and the standard overlay shadow. Every new visual
-   effect in this app gets one of these branches; no exceptions.
+/* Reduced effects (Task 9): drops the travel easing and the amplified glow —
+   leaving a static accent ring and the standard overlay shadow. Every new
+   visual effect in this app gets one of these branches; no exceptions.
+   (The trace-animation/breathing-pulse half of this comment's original scope
+   was removed along with `.reference-trace` itself — dev-review fix C/D.)
 
    Keyed off data-reduced stamped by ReferenceOverlay.tsx, NOT an html
    attribute: verified 2026-07-26 that theme-engine.ts applies reducedEffects
@@ -1027,12 +1031,6 @@ body[data-reference-held] .bottom-float {
    that wrapper the same box-shadow treatment as the card, same class of bug
    the non-reduced `.reference-lift > .reference-lift-card` rule above was
    already scoped to avoid. Scoped to `.reference-lift-card` here too. */
-.reference-trace[data-reduced="true"] path.outline {
-  animation: none;
-  stroke-dashoffset: 0;
-  stroke-width: 2;
-  filter: none;
-}
 .reference-lift[data-reduced="true"] { transition: none; }
 /* Only the travelling (chat) case gets the amplified `--ref-lift-shadow` in
    the first place (the non-travelling/artifact rule just above sets
@@ -1050,7 +1048,6 @@ body[data-reference-held] .bottom-float {
 }
 
 @media (prefers-reduced-motion: reduce) {
-  .reference-trace path.outline { animation: none; stroke-dashoffset: 0; }
   .reference-lift { transition: none; }
 }
 

From fdb2c3a23ebe3fe976b8dc35673ba97d89cc7b53 Mon Sep 17 00:00:00 2001
From: Destin 
Date: Tue, 28 Jul 2026 12:38:48 -0700
Subject: [PATCH 25/27] fix(reference): animate the reference card back on
 cancel, fix invisible artifact selections
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Dev-review follow-up on the held-reference feature:

- Cancelling (Esc, scrim click, x button) now flies the clone back to the
  source's current rect before actually clearing the reference, mirroring
  the entry FLIP's timing/easing. The source stays hidden until the flight
  lands, so the "two copies visible" bug this feature already fixed once
  can't come back on the way out. Sending is untouched — InputBar's send()
  still calls clearReference() directly, so it clears immediately with no
  animation; the call SITE is what distinguishes cancel from send, not a
  flag. Detached sources, artifact references (which never travelled),
  reducedEffects, and prefers-reduced-motion all skip straight to an
  immediate clear.
- Artifact selections now get a visible ring (theme-token box-shadow), not
  just a translucent background tint layered on a clip-path region that was
  already the bright part of the screen. Investigated first: the offsets and
  the mark were already reaching the artifact clone correctly (the offset
  capture and the clip-path share the same captured Range) — the gap was
  purely that the mark had no edge of its own to read as "selected" once it
  sat inside an already-undimmed area.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../reference/ReferenceOverlay.test.tsx       | 329 ++++++++++++++++++
 .../components/reference/ReferenceOverlay.tsx | 145 +++++++-
 desktop/src/renderer/styles/globals.css       |  38 +-
 3 files changed, 505 insertions(+), 7 deletions(-)

diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
index 6f75a6813..ecdb58f1f 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
@@ -868,3 +868,332 @@ describe('detached source (Issues A/B: final review)', () => {
     document.body.removeChild(host);
   });
 });
+
+// Dev-review follow-up: "there is an animation to center them, but I want an
+// animation to move it back into place when I click out / exit the ask
+// mode." jsdom has no real layout or CSS transition engine, so nothing here
+// proves the card visually glides back — that's a real-browser check (see
+// the task report). What IS provable: (1) a user cancel keeps the reference
+// held (and the source hidden) through a delay instead of clearing
+// synchronously, (2) the clone's `transform` gets re-targeted away from its
+// entry value, (3) the reference genuinely clears — and the source
+// un-hides — only after that delay, (4) sending (a direct clearReference()
+// call, same as InputBar's send()) is untouched: still immediate, (5) a
+// detached source and reducedEffects/prefers-reduced-motion all skip
+// straight to an immediate clear, and (6) unmounting mid-flight leaves
+// nothing stranded.
+describe('exit animation (dev-review follow-up: return trip on cancel)', () => {
+  const REF_RECT = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
+
+  function makeHost(text: string): HTMLElement {
+    const host = document.createElement('div');
+    host.setAttribute('data-test-marker', 'source');
+    host.textContent = text;
+    document.body.appendChild(host);
+    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(REF_RECT);
+    return host;
+  }
+
+  async function settleEntry() {
+    // Let the entry FLIP's RAF land, so there's a real "painted" transform
+    // value to leave from — same idiom as the scroll-restart tests above.
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    });
+  }
+
+  async function settlePastReturnWindow() {
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 500));
+    });
+  }
+
+  // Bridges InputBar's real send() call site: send() calls the context's
+  // clearReference() DIRECTLY, never through ReferenceOverlay's cancel UI —
+  // this button reproduces exactly that call, without depending on
+  // InputBar's own composer machinery.
+  function ClearsReferenceDirectly() {
+    const { clearReference } = useReference();
+    return ;
+  }
+
+  it('cancelling via the scrim keeps the reference held and the source hidden through a delay, re-targets transform, then actually clears', async () => {
+    const host = makeHost('the referenced message');
+
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+    await settleEntry();
+
+    const lift = document.querySelector('.reference-lift') as HTMLElement;
+    const transformAfterEntry = lift.style.transform;
+    // Sanity: it actually travelled in, so a later "changed again" check
+    // isn't trivially true from an untouched initial value.
+    expect(transformAfterEntry).not.toBe('translate(0, 0)');
+    expect(host.style.visibility).toBe('hidden');
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {});
+
+    // The whole point of the fix: cancelling must NOT clear synchronously —
+    // before this fix clearReference() ran straight from the click and the
+    // scrim (and the clone) vanished on this same synchronous assertion.
+    expect(document.querySelector('.reference-scrim')).not.toBeNull();
+    expect(lift.getAttribute('data-exiting')).toBe('true');
+    expect(lift.style.transform).not.toBe(transformAfterEntry);
+    // Source must stay hidden through the whole flight — showing it back
+    // early is exactly the "two copies visible" duplication bug this
+    // feature already fixed once on the way IN (see ReferenceOverlay.tsx's
+    // source-hide effect WHY comment).
+    expect(host.style.visibility).toBe('hidden');
+
+    await settlePastReturnWindow();
+
+    // NOW it actually clears, and the source pops back — not before.
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+    expect(document.body.hasAttribute('data-reference-held')).toBe(false);
+
+    document.body.removeChild(host);
+  });
+
+  it('cancelling via the × button plays the same deferred-clear return trip', async () => {
+    const host = makeHost('the referenced message');
+
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+    await settleEntry();
+
+    fireEvent.click(document.querySelector('[aria-label="Cancel reference"]') as Element);
+    act(() => {});
+    expect(document.querySelector('.reference-scrim')).not.toBeNull();
+    expect(host.style.visibility).toBe('hidden');
+
+    await settlePastReturnWindow();
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+
+    document.body.removeChild(host);
+  });
+
+  it('cancelling via Esc plays the same deferred-clear return trip', async () => {
+    const host = makeHost('the referenced message');
+
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+    await settleEntry();
+
+    fireEvent.keyDown(window, { key: 'Escape' });
+    act(() => {});
+    expect(document.querySelector('.reference-scrim')).not.toBeNull();
+    expect(host.style.visibility).toBe('hidden');
+
+    await settlePastReturnWindow();
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+
+    document.body.removeChild(host);
+  });
+
+  it('sending (InputBar\'s real call site: a direct clearReference()) clears immediately — no held delay, no exit animation', async () => {
+    const host = makeHost('the referenced message');
+
+    render(
+      
+        
+          
+          
+          
+        
+      ,
+    );
+    act(() => {});
+    await settleEntry();
+    expect(host.style.visibility).toBe('hidden'); // sanity: reference is actually held
+
+    fireEvent.click(screen.getByText('send'));
+    act(() => {}); // NO extra 460ms wait — this is the whole point of the test
+
+    // Cleared on the SAME tick as the click — no "still held" window like
+    // the cancel tests above get, and the source is restored right away
+    // rather than staying hidden through a delay.
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+
+    document.body.removeChild(host);
+  });
+
+  it('a detached source clears immediately on cancel — nothing to fly back to', async () => {
+    const host = document.createElement('div');
+    host.textContent = 'no longer in the document';
+    document.body.appendChild(host);
+    document.body.removeChild(host); // detach, matching the Issues A/B fixture above
+    expect(host.isConnected).toBe(false);
+
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {}); // no extra wait
+
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+  });
+
+  it('an artifact reference clears immediately on cancel — it never travelled, so there is no return trip', async () => {
+    const host = makeHost('const x = 1;');
+
+    renderOverlay({
+      kind: 'artifact', label: 'lines 1-1 of x.ts', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {}); // no extra wait
+
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+
+    document.body.removeChild(host);
+  });
+
+  it('unmounting mid-animation leaves no stranded body attribute, custom property, or hidden source, and the later timer fires harmlessly', async () => {
+    const host = makeHost('the referenced message');
+
+    const { unmount } = renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+    await settleEntry();
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {});
+    // Mid-flight: still held, source still hidden.
+    expect(document.body.getAttribute('data-reference-held')).toBe('true');
+    expect(host.style.visibility).toBe('hidden');
+
+    // Unmount the WHOLE tree before the return window elapses — this is the
+    // "component unmounts mid-animation" case the brief calls out.
+    expect(() => unmount()).not.toThrow();
+
+    // React's own effect cleanups (source-hide, body attribute) run on
+    // unmount regardless of the exit timer's state — nothing left stranded.
+    expect(document.body.hasAttribute('data-reference-held')).toBe(false);
+    expect(document.body.style.getPropertyValue('--reference-composer-z')).toBe('');
+    expect(host.style.visibility).toBe('');
+
+    // The pending exit timer would otherwise fire AFTER the tree is gone —
+    // must not throw when it does (this also proves the cleanup effect
+    // actually cancelled it, since a stray clearReference() call against a
+    // torn-down provider is the failure mode being guarded against).
+    await expect(settlePastReturnWindow()).resolves.not.toThrow();
+
+    document.body.removeChild(host);
+  });
+
+  it('reducedEffects skips the animation and clears immediately on cancel', async () => {
+    const REDUCED_EFFECTS_KEY = 'youcoded-reduced-effects';
+    function makeLocalStorageMock() {
+      let store: Record = {};
+      return {
+        getItem: (key: string) => store[key] ?? null,
+        setItem: (key: string, value: string) => { store[key] = String(value); },
+        removeItem: (key: string) => { delete store[key]; },
+        clear: () => { store = {}; },
+        get length() { return Object.keys(store).length; },
+        key: (n: number) => Object.keys(store)[n] ?? null,
+      };
+    }
+    const lsMock = makeLocalStorageMock();
+    vi.stubGlobal('localStorage', lsMock);
+    localStorage.setItem(REDUCED_EFFECTS_KEY, '1'); // ThemeProvider reads this synchronously on mount
+
+    const host = makeHost('the referenced message');
+    render(
+      
+        
+          
+            
+            
+          
+        
+      ,
+    );
+    act(() => {});
+    await settleEntry();
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {}); // no extra wait — reducedEffects must clear right away
+
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+
+    vi.unstubAllGlobals();
+    document.body.removeChild(host);
+  });
+
+  it('prefers-reduced-motion skips the animation and clears immediately on cancel', async () => {
+    // jsdom has no matchMedia at all (see ProjectHero.test.tsx) — stub it to
+    // report the reduce query as matched.
+    (window as any).matchMedia = (query: string) => ({
+      matches: query.includes('prefers-reduced-motion'),
+      media: query,
+      addEventListener: () => {},
+      removeEventListener: () => {},
+    });
+
+    const host = makeHost('the referenced message');
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: null },
+    });
+    act(() => {});
+    await settleEntry();
+
+    fireEvent.click(document.querySelector('.reference-scrim') as Element);
+    act(() => {}); // no extra wait
+
+    expect(document.querySelector('.reference-scrim')).toBeNull();
+    expect(host.style.visibility).toBe('');
+
+    delete (window as any).matchMedia;
+    document.body.removeChild(host);
+  });
+});
+
+// Dev-review follow-up round 2: "my highlighted artifact viewer selections
+// aren't focused/selected at all." Investigation confirmed the mark WAS
+// reaching the artifact clone (applyHighlightMark isn't kind-gated, and
+// build-reference.test.ts already pins that artifact selections capture
+// `anchor.selection` too) — the gap was purely visual: a translucent
+// background on top of a clip-path region that's ALREADY the bright part of
+// the screen barely reads as anything. This test pins the CSS fix (an inset
+// ring giving the mark its own edge) the same way the composer-lift test
+// above pins its rule — a source-text assertion, since jsdom can't render
+// color-mix() or prove a ring is visually distinguishable.
+describe('reference-mark visibility (dev-review follow-up round 2)', () => {
+  it('globals.css gives .reference-mark its own ring, not just a background tint', () => {
+    const css = readFileSync(join(__dirname, '..', '..', 'styles', 'globals.css'), 'utf8');
+    const rule = css.match(/\.reference-mark\s*\{[^}]*\}/)?.[0] ?? '';
+    expect(rule).not.toBe('');
+    // Theme tokens only — a literal colour would break community themes.
+    expect(rule).toMatch(/box-shadow:\s*inset[^;]*var\(--accent\)/);
+  });
+});
diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
index 13a000447..8abe04a31 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
 import { createPortal } from 'react-dom';
 import { Scrim, REFERENCE_COMPOSER_Z } from '../overlays/Overlay';
 import { CloseButton } from '../ui/CloseButton';
@@ -9,6 +9,14 @@ import { useReferenceGeometry } from './use-reference-geometry';
 import { shiftPath } from './reference-geometry';
 import { applyHighlightMark } from './apply-highlight';
 
+// Mirrors the duration in globals.css's `.reference-lift { transition:
+// transform 460ms ... }` — not read from the stylesheet (no clean way to
+// introspect a computed transition-duration before the transition itself has
+// started), just kept numerically in sync by hand. If that rule's duration
+// ever changes, this constant must change with it. Module-level: it's a
+// static value, not per-render state.
+const RETURN_DURATION_MS = 460;
+
 /**
  * The held "Ask Claude about this" reference (spec 2026-07-26).
  *
@@ -288,8 +296,125 @@ export function ReferenceOverlay() {
     node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none';
   }, [reference, travels, d]);
 
-  // Esc cancels. LIFO, so if a drawer opened on top, Esc closes that first.
-  useEscClose(!!reference, clearReference);
+  // Dev-review follow-up: "there is an animation to center them, but I want
+  // an animation to move it back into place when I click out / exit the ask
+  // mode." Esc, the scrim click, and the × button all route through THIS
+  // function now instead of calling `clearReference()` straight from context
+  // — see the WHY block below for why that indirection is the whole trick.
+  //
+  // Sending a message must NOT play this animation (the reference is
+  // consumed, not cancelled — spec says it "clears immediately"). InputBar's
+  // send() still calls the context's `clearReference()` DIRECTLY (see
+  // reference-context.tsx), never this function, so the distinction is
+  // structural: whichever code path calls beginExit() is by definition a
+  // user cancel, and whichever calls context.clearReference() directly is
+  // by definition an immediate consume/discard. No flag, no "was this a
+  // send" boolean to keep in sync — the CALL SITE is the signal.
+  //
+  // Mechanism: this does NOT null the context's `reference` up front. It
+  // only re-targets the clone's `transform` (the existing CSS transition on
+  // `.reference-lift` — see globals.css — animates between whatever value
+  // was last painted and this new one, no JS interpolation needed) and
+  // defers the REAL `clearReference()` call until after the transition
+  // would have finished. Because `reference` stays truthy for the whole
+  // return trip, every other effect in this component that keys off it —
+  // the source-visibility hide, the body `data-reference-held` attribute —
+  // keeps doing exactly what it already does for a held reference, with no
+  // extra plumbing: the source only pops back into view (and the body
+  // attribute only clears) at the SAME moment `reference` finally goes
+  // null, which is the end of the flight, not the start. That's what
+  // prevents the "two copies visible" bug this feature already fixed once
+  // (see the source-hide effect's WHY comment above) from coming back on
+  // the way OUT.
+  const exitingRef = useRef(false);
+  const exitTimerRef = useRef | null>(null);
+
+  const beginExit = useCallback(() => {
+    if (!reference) return; // nothing held — nothing to cancel
+    if (exitingRef.current) return; // already animating out; a second Esc/click mid-flight is a no-op, not a restart
+
+    const node = liftRef.current;
+    const src = reference.anchor?.host as HTMLElement | undefined;
+    const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
+
+    // Clear immediately, no animation, when there is nothing meaningful to
+    // animate:
+    //   - !travels (artifact): the clone never travelled anywhere, so there
+    //     is no return trip to make — same reasoning as the entry effect,
+    //     which never runs the FLIP choreography for this kind either.
+    //   - reducedEffects / prefers-reduced-motion: motion is opted out.
+    //   - no anchor, no node, or a detached source: no live rect to fly
+    //     BACK TO. Animating to a stale/zero rect would be exactly the
+    //     "meaningless slide from the corner" the entry effect's own
+    //     detached-source branch already refuses to do (see its WHY
+    //     comment) — same call, applied to the exit direction.
+    if (!travels || reducedEffects || prefersReducedMotion || !node || !src || !src.isConnected) {
+      clearReference();
+      return;
+    }
+
+    exitingRef.current = true;
+    // Diagnostic/test hook, not load-bearing for any CSS rule today — lets a
+    // test (or a future style) distinguish "flying in" from "flying out"
+    // without re-deriving it from transform math.
+    node.setAttribute('data-exiting', 'true');
+
+    // Reverse FLIP. `node.style.left`/`top` are still the ENTRY "First"
+    // position (the source's rect at the moment the reference was taken —
+    // nothing has touched them since), so express the CURRENT source rect
+    // as a delta from that SAME anchor and hand it to `transform`. No RAF
+    // needed here, unlike the entry effect: the node is already painted at
+    // its centred position (it's been sitting there since entry), so simply
+    // assigning a NEW `transform` value lets the existing CSS transition
+    // animate FROM that already-painted value TO this one. The entry effect
+    // needs the RAF trick only because it sets left/top and
+    // transform:translate(0,0) in the same synchronous pass, before
+    // anything has painted — a same-tick change has no visible "before"
+    // state to transition from.
+    const originalLeft = parseFloat(node.style.left) || 0;
+    const originalTop = parseFloat(node.style.top) || 0;
+    const s = src.getBoundingClientRect();
+    // Snap width to the source's CURRENT size (not transitioned, same as the
+    // entry effect's one-time width set) — the brief asks for "the source
+    // element's current rect", and the source's width may have reflowed
+    // (e.g. a window resize) while the reference was held.
+    node.style.width = `${s.width}px`;
+    node.style.transform = `translate(${s.left - originalLeft}px, ${s.top - originalTop}px)`;
+
+    exitTimerRef.current = setTimeout(() => {
+      exitingRef.current = false;
+      exitTimerRef.current = null;
+      // The REAL clear. Only now — after the flight has had time to finish —
+      // does `reference` actually go null, which is what lets the source and
+      // the body attribute pop back at the right moment (see the block
+      // comment above).
+      clearReference();
+    }, RETURN_DURATION_MS);
+  }, [reference, travels, reducedEffects, clearReference]);
+
+  // Safety net for two cases at once: (1) the component unmounts mid-flight
+  // (a pending timer must not fire clearReference() against a dead
+  // component's stale closure), and (2) `reference` changes out from under
+  // an in-flight exit for some OTHER reason (e.g. a brand new reference gets
+  // set before the old one's return trip finished) — the stale timer must
+  // not later clear the NEW reference. Keyed on `reference` alone: this
+  // cleanup runs on every identity change of `reference`, including the
+  // exit timer's own eventual `clearReference()` call, at which point
+  // `exitTimerRef.current` is already null and this is a harmless no-op.
+  useEffect(() => {
+    return () => {
+      if (exitTimerRef.current) {
+        clearTimeout(exitTimerRef.current);
+        exitTimerRef.current = null;
+      }
+      exitingRef.current = false;
+    };
+  }, [reference]);
+
+  // Esc cancels — via beginExit, not a direct clearReference, so Esc plays
+  // the return animation like the scrim click and × button do. LIFO, so if a
+  // drawer opened on top, Esc closes that first (unrelated to this feature).
+  useEscClose(!!reference, beginExit);
 
   useEffect(() => {
     if (!reference) { depthAtOpen.current = null; return; }
@@ -326,6 +451,14 @@ export function ReferenceOverlay() {
     // case). So: any contention for the L2 band — sequential OR same-commit —
     // makes the reference yield. Pinned by
     // ReferenceOverlay.test.tsx's "same-commit L2 contention" test.
+    //
+    // Deliberately calls clearReference() DIRECTLY, not beginExit(): this
+    // fires because something else just claimed the L2 band and is about to
+    // paint over this scrim, not because the user asked to cancel. Whatever
+    // opened on top would visually cover a return flight anyway, and
+    // yielding immediately keeps this already-accepted-as-lossy edge case
+    // (see the Finding 3 comment above) simple rather than layering an
+    // animation underneath a state where "cancel" wasn't really the intent.
     if (depth > depthAtOpen.current) clearReference();
   }, [reference, depth, clearReference]);
 
@@ -349,7 +482,7 @@ export function ReferenceOverlay() {
   if (!reference) return null;
 
   return createPortal(
-    
+    
       {/* Dev-review fix C/D: the traced SVG outline (formerly rendered here for
           the non-travelling/artifact case) is GONE — it was both "the weird
           black box" around the selection and "uneven and janky" (Destin, dev
@@ -385,7 +518,7 @@ export function ReferenceOverlay() {
           
computeSelectionOffsets, same call used by the chat path; pinned by + build-reference.test.ts's "artifact references also capture selection + offsets" case), and ReferenceOverlay.tsx's mark-application effect isn't + kind-gated, so applyHighlightMark DOES reach the artifact clone. clip-path + isn't cropping it away either — the mark's offsets and the clip's `d` are + both derived from the SAME captured Range, so they land in the same place. + The actual gap: for an ARTIFACT reference the clip-path ALREADY undims + almost exactly the marked span (that's the whole mechanism keeping only + the referenced lines bright — see the clip effect's WHY comment in + ReferenceOverlay.tsx), so a translucent background tint on top of a region + that's already "the bright part" reads as barely-there — there's nothing + BUT the dim/bright boundary distinguishing it, and that boundary has no + edge of its own. The travelling (chat) case never had this problem: its + clone is undimmed everywhere, so the mark was the ONLY signal — but a flat + tint is just as weak a signal there too, it's only less noticeable because + the ring+glow on the whole travelling card (`--ref-stroke`/`--ref-glow` + above) was already doing that job for it. Fix: give the mark its OWN edge + — an inset ring, independent of whatever's dimmed around it — so it reads + as "this text is selected" on its own terms, not by contrast alone. Not a + traced outline around the union bounding box (explicitly rejected as "a + weird black box" / "uneven and janky" — see the dev-review fix C/D block + below): this rides on the SAME per-run `` elements apply-highlight.ts + already produces, one per covered text run (naturally one per source line + for CM6 — each `.cm-line` is its own text node), so multi-line selections + get several small rings that hug each line's own text rather than one + rectangle spanning the whole selection. box-decoration-break: clone makes + a SINGLE mark that itself wraps across visual lines (a raw
+   multi-line selection is one continuous text node — MarkdownView.tsx's
+   non-CM6 path) render the ring on every line fragment too, instead of the
+   default 'slice' behavior leaving the wrap points looking open-ended. */
 .reference-mark {
-  background: color-mix(in oklab, var(--accent) 28%, transparent);
+  background: color-mix(in oklab, var(--accent) 30%, transparent);
   color: inherit;
   border-radius: var(--radius-sm);
+  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 55%, transparent);
+  box-decoration-break: clone;
+  -webkit-box-decoration-break: clone;
 }
 
 /* The lifted clone. Chat references travel to centre; artifact references do

From 949532b75c7f597758cb3df74b5a790ea169f09e Mon Sep 17 00:00:00 2001
From: Destin 
Date: Tue, 28 Jul 2026 13:03:57 -0700
Subject: [PATCH 26/27] fix(reference): restore the traced selection outline,
 anchored to the clone
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Commit 2a89452d deleted the outline entirely after dev review flagged it as
"uneven and janky" with a "weird black box" around artifact selections. Two
different bugs were tangled together there and deletion papered over both:

- Geometry: the union path was raw, unsnapped, unmerged client rects joined
  with hard 90 degree steps. reference-geometry.ts now snaps every edge to a
  whole pixel, merges near-identical adjacent line-box edges (2px tolerance),
  drops near-zero boxes, and rounds every corner (quadratic, clamped per
  vertex to half the shorter adjacent edge) via a new buildRoundedOutlinePath.
- Anchor: the outline used to measure the SOURCE's position, so a travelling
  chat reference painted a box around the empty space the bubble left behind
  — worked around at the time by just never rendering it for that case.
  useReferenceGeometry now measures the `.reference-mark` elements inside the
  CLONE instead, which is wherever the highlight actually is for both a
  travelling and a pinned clone, by construction. A transitionrun/end-driven
  rAF loop keeps it tracking during the FLIP travel without ever touching
  transform/left/top itself, so it can't restart the travel the way sharing
  an effect with `d` used to.

.reference-mark's inset ring (added as a stand-in while there was no outline)
is now a literal duplicate of the restored outline and is removed; the
background tint stays as a distinct signal. The travelling card's own
ring+glow is kept — it answers a different question ("this is the referenced
message" vs. the outline's "this is the selected span within it").

Full reasoning, tolerances, and verification output in
.superpowers/sdd/outline-restore-report.md (gitignored, local only).

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../reference/ReferenceOverlay.test.tsx       | 235 ++++++++---
 .../components/reference/ReferenceOverlay.tsx | 105 +++--
 .../reference/reference-geometry.test.ts      | 101 ++++-
 .../reference/reference-geometry.ts           | 210 ++++++++--
 .../reference/use-reference-geometry.test.ts  | 381 +++++++++---------
 .../reference/use-reference-geometry.ts       | 189 ++++++---
 desktop/src/renderer/styles/globals.css       | 112 ++---
 7 files changed, 926 insertions(+), 407 deletions(-)

diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
index ecdb58f1f..491dd4a84 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
@@ -12,19 +12,26 @@ import { ReferenceProvider, useReference, type PendingReference } from '../../st
 import { ThemeProvider } from '../../state/theme-context';
 import { ReferenceOverlay } from './ReferenceOverlay';
 import { REFERENCE_COMPOSER_Z } from '../overlays/Overlay';
-import { toBoxes, buildUnionPath, shiftPath } from './reference-geometry';
-
-// jsdom doesn't implement ResizeObserver (same stub as
-// use-reference-geometry.test.ts). Only the Task 8 lift tests below drive a
-// real (non-null) anchor through useReferenceGeometry, which is what
-// actually constructs one — but defining it once at module scope is simpler
-// than duplicating the stub per-test for just those cases.
-class NoopResizeObserver {
-  observe() {}
-  disconnect() {}
-  unobserve() {}
+import { toBoxes, mergeAdjacentBoxes, buildRoundedOutlinePath, shiftPath } from './reference-geometry';
+
+// jsdom implements Element.getClientRects() but always returns an empty list
+// (no layout engine) — the traced-outline hook (useReferenceGeometry) reads
+// it directly off every `.reference-mark` element, and those elements are
+// created INSIDE ReferenceOverlay's own clone-population effect, so a test
+// can't get a handle on them before render the way it could stub a `host`
+// element's getBoundingClientRect ahead of time. Stubbing
+// Element.prototype.getClientRects globally (keyed on the `.reference-mark`
+// class) is the only way to hand every future mark a real rect regardless of
+// when it's created. Prototype-level — MUST be restored after each test that
+// uses it (vi.restoreAllMocks() in that describe block's own afterEach), or
+// it leaks into unrelated tests elsewhere in this file.
+function stubMarkRects(rectOrGetter: DOMRect | (() => DOMRect)) {
+  const getRect = typeof rectOrGetter === 'function' ? (rectOrGetter as () => DOMRect) : () => rectOrGetter;
+  return vi.spyOn(Element.prototype, 'getClientRects').mockImplementation(function (this: Element) {
+    if (this.classList.contains('reference-mark')) return [getRect()] as unknown as DOMRectList;
+    return [] as unknown as DOMRectList;
+  });
 }
-(global as any).ResizeObserver = NoopResizeObserver;
 
 // ReferenceOverlay portals straight to document.body (window-wide, not scoped
 // to RTL's per-test container), so an un-cleaned-up previous test's scrim
@@ -348,12 +355,19 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
     // shift, same idiom as use-reference-geometry.test.ts.
     const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
     vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
+    // The clip-path geometry now comes from the `.reference-mark` elements
+    // INSIDE THE CLONE (restoration fix — see use-reference-geometry.ts), not
+    // the source, so there must be a selection for a mark to exist at all —
+    // stub every `.reference-mark`'s rect to the clone's own on-screen
+    // position (which, for a pinned/non-travelling artifact clone, coincides
+    // with the source's rect).
+    const marksSpy = stubMarkRects(rect);
 
     renderOverlay({
       kind: 'artifact',
       label: 'lines 1-1 of x.ts',
       promptText: 'x',
-      anchor: { host, range: null, selection: null },
+      anchor: { host, range: null, selection: { start: 0, end: 'const x = 1;'.length } },
     });
     act(() => {});
 
@@ -369,10 +383,11 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
     // CLIPPED ELEMENT's own border box (confirmed against the CSS Shapes
     // spec), and this element's border box starts at (rect.left, rect.top),
     // not (0, 0) — so the raw viewport-relative `d` would clip the wrong
-    // region if used unshifted. This is exactly the "reuses buildUnionPath
-    // unchanged" pipeline the geometry hook already runs, recomputed here
-    // and compared against what the effect actually wrote.
-    const expectedD = buildUnionPath(toBoxes([rect], { left: 0, top: 0 } as DOMRect));
+    // region if used unshifted. This recomputes the SAME pipeline
+    // useReferenceGeometry runs (toBoxes -> mergeAdjacentBoxes ->
+    // buildRoundedOutlinePath over the mark rects) and compares against what
+    // the effect actually wrote.
+    const expectedD = buildRoundedOutlinePath(mergeAdjacentBoxes(toBoxes([rect], { left: 0, top: 0 } as DOMRect)));
     const expectedClip = `path('${shiftPath(expectedD, -rect.left, -rect.top)}')`;
     expect(lift.style.clipPath).toBe(expectedClip);
     // Sanity check that the shift is load-bearing: the unshifted path is a
@@ -380,6 +395,8 @@ describe('lift (Task 8: FLIP travel + artifact clip)', () => {
     // fail the assertion above rather than accidentally still pass.
     expect(expectedD).not.toBe(shiftPath(expectedD, -rect.left, -rect.top));
 
+    marksSpy.mockRestore();
+
     document.body.removeChild(host);
   });
 
@@ -588,15 +605,18 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix)
 
   it('an artifact (non-travelling) reference DOES update its clip-path when `d` changes (i.e. on scroll)', async () => {
     const host = makeHost('const x = 1;');
-    const rectCtl = stubMovingRect(host, {
-      left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50,
-    } as DOMRect);
+    const initialRect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
+    const rectCtl = stubMovingRect(host, initialRect);
+    // The mark's rect tracks the SAME position as the host — the pinned
+    // artifact clone sits over the source, so its marks move with it.
+    let markRect = initialRect;
+    const marksSpy = stubMarkRects(() => markRect);
 
     renderOverlay({
       kind: 'artifact',
       label: 'lines 1-1 of x.ts',
       promptText: 'x',
-      anchor: { host, range: null, selection: null },
+      anchor: { host, range: null, selection: { start: 0, end: 'const x = 1;'.length } },
     });
     act(() => {});
 
@@ -615,29 +635,29 @@ describe('lift: scroll must not restart the travel animation (task-8 defect fix)
     // actually re-measured rather than coincidentally matching.
     const movedRect = { left: 400, top: 300, right: 540, bottom: 360, width: 140, height: 60 } as DOMRect;
     rectCtl.move(movedRect);
+    markRect = movedRect;
     act(() => { fireEvent.scroll(window); });
 
     // Unlike the travelling case above, the artifact clip MUST track the
     // new rect — the selection moved with the page, so a stale clip would
     // reveal the wrong lines. Recompute independently (same idiom as the
     // existing artifact test above) rather than just asserting "changed".
-    const expectedD = buildUnionPath(toBoxes([movedRect], { left: 0, top: 0 } as DOMRect));
+    const expectedD = buildRoundedOutlinePath(mergeAdjacentBoxes(toBoxes([movedRect], { left: 0, top: 0 } as DOMRect)));
     const expectedClip = `path('${shiftPath(expectedD, -movedRect.left, -movedRect.top)}')`;
     expect(lift.style.clipPath).toBe(expectedClip);
+    marksSpy.mockRestore();
     expect(lift.style.clipPath).not.toBe(firstClip);
 
     document.body.removeChild(host);
   });
 });
 
-// Dev-review fix C/D: "the artifact panel still puts a weird black box around
-// the selection" / "the traced outline is uneven and janky". The `.reference-
-// trace` SVG (wash fill + animated stroke outline) is deleted entirely — for
-// BOTH kinds, not just the travelling one it was already skipped for. The
-// artifact clip-path (still driven by the same `d` geometry) is what actually
-// keeps the selection bright; it was never rendered THROUGH this SVG, so
-// removing the SVG doesn't touch the clip.
-describe('dev-review fix C/D: no traced SVG outline for either kind', () => {
+// Restored 2026-07-28: the `.reference-trace` SVG (wash fill + animated
+// stroke outline), deleted by a dev-review pass as "the weird black box" /
+// "uneven and janky", is back — anchored to the `.reference-mark` elements
+// INSIDE THE CLONE this time (see use-reference-geometry.ts's WHY comment),
+// for BOTH kinds, not just the artifact one it was previously limited to.
+describe('traced outline (Task 7, restored 2026-07-28)', () => {
   function withHost(kind: PendingReference['kind']): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'referenced content';
@@ -648,29 +668,87 @@ describe('dev-review fix C/D: no traced SVG outline for either kind', () => {
     return { kind, label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
   }
 
-  it('a TRAVELLING (chat) reference renders no source-anchored trace', () => {
-    // The card flies to the viewport centre, so a trace measured from the
-    // source would outline the empty space the bubble left behind — the
-    // "black box around where the message bubble was originally" Destin hit
-    // in dev review. The travelling card carries its ring/glow in CSS instead.
+  it('renders no outline for a whole-message CHAT reference (no partial selection — nothing to trace)', () => {
+    // A whole-message reference has no `.reference-mark` at all (applyHighlightMark
+    // is never called without a selection), so there is nothing for the
+    // marks-based geometry to measure — the travelling card's own ring
+    // already signals "this is the reference" for this case.
     renderOverlay(withHost('chat-text'));
     act(() => {});
     expect(document.querySelector('.reference-lift')).not.toBeNull();
     expect(document.querySelector('.reference-trace')).toBeNull();
   });
 
-  it('a NON-travelling (artifact) reference ALSO renders no traced outline (the "weird black box" fix)', () => {
-    // Previously this WAS the one case that rendered `.reference-trace` — the
-    // exact "weird black box" / "uneven and janky" outline dev review flagged.
-    // It's gone now; the clip-path (asserted elsewhere) does the real work.
+  it('renders no outline for a whole-file ARTIFACT reference either', () => {
     renderOverlay(withHost('artifact'));
     act(() => {});
     expect(document.querySelector('.reference-trace')).toBeNull();
-    // Sanity: the lift itself still renders and still isn't the travelling case.
     const lift = document.querySelector('.reference-lift');
     expect(lift).not.toBeNull();
     expect(lift?.hasAttribute('data-travels')).toBe(false);
   });
+
+  it('a CHAT (travelling) reference with a partial selection traces the CLONE\'s marks, not the source\'s (very different) position', () => {
+    const host = document.createElement('div');
+    host.textContent = 'referenced content';
+    document.body.appendChild(host);
+    // Source sits at a position FAR from where the clone's mark will be
+    // measured — this is the exact case the original (deleted) version got
+    // wrong: it measured the source, which for a travelling clone is the
+    // empty space the bubble flew away from, not where the highlight is.
+    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
+      { left: 500, top: 600, right: 600, bottom: 650, width: 100, height: 50 } as DOMRect,
+    );
+    const markRect = { left: 30, top: 40, right: 130, bottom: 60, width: 100, height: 20 } as DOMRect;
+    const marksSpy = stubMarkRects(markRect);
+
+    renderOverlay({
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: { start: 0, end: 'referenced content'.length } },
+    });
+    act(() => {});
+
+    const trace = document.querySelector('.reference-trace');
+    expect(trace).not.toBeNull();
+    const outline = trace?.querySelector('path.outline');
+    const wash = trace?.querySelector('path.wash');
+    expect(outline).not.toBeNull();
+    // Both paths share the SAME `d` — one measurement, two consumers.
+    expect(outline?.getAttribute('d')).toBe(wash?.getAttribute('d'));
+
+    const expectedD = buildRoundedOutlinePath(mergeAdjacentBoxes(toBoxes([markRect], { left: 0, top: 0 } as DOMRect)));
+    expect(outline?.getAttribute('d')).toBe(expectedD);
+
+    const sourceBasedD = buildRoundedOutlinePath(
+      mergeAdjacentBoxes(toBoxes([{ left: 500, top: 600, right: 600, bottom: 650, width: 100, height: 50 } as DOMRect], { left: 0, top: 0 } as DOMRect)),
+    );
+    // The regression this restoration fixes: the outline must NOT trace the
+    // source's rect.
+    expect(outline?.getAttribute('d')).not.toBe(sourceBasedD);
+
+    marksSpy.mockRestore();
+  });
+
+  it('an ARTIFACT (non-travelling) reference with a partial selection also gets the traced outline', () => {
+    const host = document.createElement('div');
+    host.textContent = 'const x = 1;';
+    document.body.appendChild(host);
+    const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
+    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
+    const marksSpy = stubMarkRects(rect);
+
+    renderOverlay({
+      kind: 'artifact', label: 'lines 1-1 of x.ts', promptText: 'x',
+      anchor: { host, range: null, selection: { start: 0, end: 'const x = 1;'.length } },
+    });
+    act(() => {});
+
+    expect(document.querySelector('.reference-trace')).not.toBeNull();
+    const lift = document.querySelector('.reference-lift');
+    expect(lift?.hasAttribute('data-travels')).toBe(false);
+
+    marksSpy.mockRestore();
+  });
 });
 
 describe('reduced effects (Task 9)', () => {
@@ -714,44 +792,59 @@ describe('reduced effects (Task 9)', () => {
 
   // A real host with a stubbed rect (jsdom's own getBoundingClientRect is
   // all-zero — no layout engine) so the lift's positioning effects have
-  // something non-degenerate to work with.
+  // something non-degenerate to work with. Shared so tests can also stub
+  // `.reference-mark` rects (via stubMarkRects) to the SAME rect, giving the
+  // restored traced outline something non-empty to render.
+  const REF_RECT = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
+
   function makeReferenceWithHost(): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'the referenced message';
     document.body.appendChild(host);
-    const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
-    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
-    return { kind: 'chat-text', label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
+    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(REF_RECT);
+    return {
+      kind: 'chat-text', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: { start: 0, end: 'the referenced message'.length } },
+    };
   }
 
   function makeArtifactReferenceWithHost(): PendingReference {
     const host = document.createElement('div');
     host.textContent = 'the referenced lines';
     document.body.appendChild(host);
-    const rect = { left: 10, top: 20, right: 110, bottom: 70, width: 100, height: 50 } as DOMRect;
-    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(rect);
-    return { kind: 'artifact', label: 'x', promptText: 'x', anchor: { host, range: null, selection: null } };
+    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(REF_RECT);
+    return {
+      kind: 'artifact', label: 'x', promptText: 'x',
+      anchor: { host, range: null, selection: { start: 0, end: 'the referenced lines'.length } },
+    };
   }
 
-  // Dev-review fix C/D deleted `.reference-trace` for both kinds, so its
-  // data-reduced stamping is no longer a thing to assert — only
-  // `.reference-lift`'s remains (it still drives the reduced ring/shadow).
-  it('stamps data-reduced="true" on the lift when reducedEffects is on, for both kinds', () => {
+  // Restored 2026-07-28: the traced outline is back, so reducedEffects must
+  // strip ITS animation/glow too (static outline — see globals.css's
+  // `.reference-trace[data-reduced="true"]` rule), on top of the existing
+  // `.reference-lift` ring/shadow reduction.
+  it('stamps data-reduced="true" on both the lift AND the traced outline when reducedEffects is on, for both kinds', () => {
     localStorage.setItem(REDUCED_EFFECTS_KEY, '1'); // ThemeProvider reads this synchronously on mount (theme-context.tsx:139)
+    const marksSpy = stubMarkRects(REF_RECT);
+
     const { unmount } = renderOverlayWithTheme(makeReferenceWithHost());
     act(() => {});
-    expect(document.querySelector('.reference-trace')).toBeNull();
+    expect(document.querySelector('.reference-trace')).not.toBeNull();
+    expect(document.querySelector('.reference-trace')?.getAttribute('data-reduced')).toBe('true');
     expect(document.querySelector('.reference-lift')?.getAttribute('data-reduced')).toBe('true');
     unmount();
 
     renderOverlayWithTheme(makeArtifactReferenceWithHost());
     act(() => {});
-    expect(document.querySelector('.reference-trace')).toBeNull();
+    expect(document.querySelector('.reference-trace')?.getAttribute('data-reduced')).toBe('true');
     expect(document.querySelector('.reference-lift')?.getAttribute('data-reduced')).toBe('true');
+
+    marksSpy.mockRestore();
   });
 
-  it('leaves data-reduced entirely absent (not just falsy) on the lift when reducedEffects is off, for both kinds', () => {
+  it('leaves data-reduced entirely absent (not just falsy) on the lift and the traced outline when reducedEffects is off, for both kinds', () => {
     // No localStorage write — ThemeProvider's default is reducedEffects: false.
+    const marksSpy = stubMarkRects(REF_RECT);
     const { unmount } = renderOverlayWithTheme(makeReferenceWithHost());
     act(() => {});
 
@@ -764,11 +857,15 @@ describe('reduced effects (Task 9)', () => {
     // this selector but WOULD show up in the DOM, which is a different bug
     // than what this test is pinning.
     expect(lift?.hasAttribute('data-reduced')).toBe(false);
+    expect(document.querySelector('.reference-trace')?.hasAttribute('data-reduced')).toBe(false);
     unmount();
 
     renderOverlayWithTheme(makeArtifactReferenceWithHost());
     act(() => {});
     expect(document.querySelector('.reference-lift')?.hasAttribute('data-reduced')).toBe(false);
+    expect(document.querySelector('.reference-trace')?.hasAttribute('data-reduced')).toBe(false);
+
+    marksSpy.mockRestore();
   });
 });
 
@@ -1178,22 +1275,26 @@ describe('exit animation (dev-review follow-up: return trip on cancel)', () => {
   });
 });
 
-// Dev-review follow-up round 2: "my highlighted artifact viewer selections
-// aren't focused/selected at all." Investigation confirmed the mark WAS
-// reaching the artifact clone (applyHighlightMark isn't kind-gated, and
-// build-reference.test.ts already pins that artifact selections capture
-// `anchor.selection` too) — the gap was purely visual: a translucent
-// background on top of a clip-path region that's ALREADY the bright part of
-// the screen barely reads as anything. This test pins the CSS fix (an inset
-// ring giving the mark its own edge) the same way the composer-lift test
-// above pins its rule — a source-text assertion, since jsdom can't render
-// color-mix() or prove a ring is visually distinguishable.
-describe('reference-mark visibility (dev-review follow-up round 2)', () => {
-  it('globals.css gives .reference-mark its own ring, not just a background tint', () => {
+// Dev-review follow-up round 2 (superseded 2026-07-28): "my highlighted
+// artifact viewer selections aren't focused/selected at all" was originally
+// fixed by giving `.reference-mark` its own inset ring, since at the time
+// there was no traced outline at all — the ring was the ONLY boundary signal
+// a mark had. Now that the traced outline is restored (anchored to these same
+// mark elements — see use-reference-geometry.ts), that ring is a literal
+// duplicate of it: same union region, same accent colour, drawn twice. This
+// test pins the OUTCOME of reconciling the two — the ring is gone, the
+// background tint (a distinct signal: it marks the exact covered text,
+// character for character, vs. the outline's smoothed/merged approximation)
+// stays — the same source-text-assertion idiom as the composer-lift test
+// above, since jsdom can't render color-mix() or prove a ring is visually
+// distinguishable.
+describe('reference-mark visibility (outline restoration: ring reconciled away)', () => {
+  it('globals.css gives .reference-mark a background tint but no inset ring (redundant with the restored outline)', () => {
     const css = readFileSync(join(__dirname, '..', '..', 'styles', 'globals.css'), 'utf8');
     const rule = css.match(/\.reference-mark\s*\{[^}]*\}/)?.[0] ?? '';
     expect(rule).not.toBe('');
     // Theme tokens only — a literal colour would break community themes.
-    expect(rule).toMatch(/box-shadow:\s*inset[^;]*var\(--accent\)/);
+    expect(rule).toMatch(/background:[^;]*var\(--accent\)/);
+    expect(rule).not.toMatch(/box-shadow/);
   });
 });
diff --git a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
index 8abe04a31..c32d85bb1 100644
--- a/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
@@ -20,10 +20,10 @@ const RETURN_DURATION_MS = 460;
 /**
  * The held "Ask Claude about this" reference (spec 2026-07-26).
  *
- * One app-wide instance. Owns the window-wide dim; Task 8 adds the lifted
- * clone on top of this shell. (Task 7's traced SVG outline was removed in the
- * dev-review pass — see the WHY comment below `useReferenceGeometry` — but
- * its underlying geometry hook lives on, feeding the artifact clip-path.)
+ * One app-wide instance. Owns the window-wide dim, the lifted clone (Task 8),
+ * and the traced outline around the highlighted selection (Task 7, restored
+ * 2026-07-28 — see the WHY comment on `useReferenceGeometry` below for what
+ * changed the second time around).
  *
  * Window-wide, not pane-scoped — Destin's 10B call: "dim should apply to the
  * whole window so it's obvious what is being highlighted / what the user is
@@ -39,16 +39,6 @@ export function ReferenceOverlay() {
   const { reducedEffects } = useTheme();
   const depth = useEscStackDepth();
   const depthAtOpen = useRef(null);
-  // Task 7 originally drew `d` as a visible traced SVG outline around the
-  // referenced content. Dev review flagged it as "the weird black box" /
-  // "uneven and janky" — that rendering is gone (see the JSX below), but the
-  // hook and `d` itself stay: Task 8 still needs `d` for the artifact
-  // clip-path (see the lift effect below), which is what actually keeps only
-  // the referenced lines bright above the dim. (The hook used to return raw
-  // `rects` too, for a redraw approach dropped in favor of clipping the
-  // clone; that field was deleted as dead code back then, unrelated to this
-  // pass.)
-  const { d } = useReferenceGeometry(reference?.anchor ?? null);
 
   // Task 8: chat references lift a clone to the viewport centre; artifact
   // references stay put and get clipped to the selection instead (spec 2.2).
@@ -114,6 +104,22 @@ export function ReferenceOverlay() {
     return () => holder.replaceChildren();
   }, [reference]);
 
+  // Restored 2026-07-28 (see the file-header WHY comment): `d` traces the
+  // `.reference-mark` elements INSIDE THE CLONE (liftRef's subtree), not the
+  // original source — the fix for the travelling-card case pointing at empty
+  // space, by construction. One measurement feeds BOTH the SVG outline drawn
+  // in the JSX below AND (for the artifact, non-travelling case only) the
+  // clip-path effect further down, so they can never show two different
+  // shapes. Declared here — after the clone-population effect above, before
+  // the positioning effects below — purely so `d`/`remeasure` are in lexical
+  // scope for the clip effect's dependency array and body; the hook's OWN
+  // internal effects are independently correct regardless of call-site order
+  // (see its WHY comments), and the positioning effects below call
+  // `remeasure()` explicitly right after they set left/top/transform so the
+  // very first paint is accurate rather than racing this hook's own
+  // mount-time measurement against those styles being set.
+  const { d, remeasure } = useReferenceGeometry(liftRef, !!reference, reference);
+
   // Dev-review fix 1: "I don't like that I can see both the original message
   // and the centered message. It should be the same bubble that appears to
   // move." Hide the SOURCE bubble for the duration a CHAT (travelling)
@@ -211,6 +217,7 @@ export function ReferenceOverlay() {
       node.style.width = 'min(90vw, 640px)';
       node.style.transform = 'translate(-50%, -50%)';
       node.style.clipPath = 'none';
+      remeasure(); // outline still needs to trace the (centred) marks even without a live FLIP
       return;
     }
     node.removeAttribute('data-detached');
@@ -226,6 +233,14 @@ export function ReferenceOverlay() {
     // park/restore in reference-context.tsx swapping straight from one
     // held reference to another.
     node.style.clipPath = 'none';
+    // Fix: this hook's own mount-time measurement (useReferenceGeometry's
+    // internal effect) can run BEFORE this positioning effect in the same
+    // commit (hook-call order isn't guaranteed the other way), which would
+    // read the marks at whatever position the REUSED node last had — an
+    // explicit remeasure() here, right after left/top/transform are set,
+    // guarantees the "First" position's outline is correct regardless of
+    // that ordering.
+    remeasure();
 
     // Next frame so the browser paints the First position before transitioning.
     const raf = requestAnimationFrame(() => {
@@ -233,8 +248,22 @@ export function ReferenceOverlay() {
       const dx = (window.innerWidth - s.width) / 2 - s.left;
       const dy = (window.innerHeight - h) / 2 - s.top;
       node.style.transform = `translate(${dx}px, ${dy}px)`;
+      // The "Last" (centred) position. `transitionrun` (see
+      // useReferenceGeometry) picks up the continuous mid-flight tracking
+      // from here — this call just guarantees the FINAL settle is correct
+      // even in an environment where the transition doesn't fire (reduced
+      // motion, transition: none).
+      remeasure();
     });
     return () => cancelAnimationFrame(raf);
+    // remeasure is NOT listed as a dep: it's useReferenceGeometry's `measure`
+    // callback, memoized on `[containerRef, active]` — containerRef (liftRef)
+    // never changes identity and `active` (`!!reference`) only flips exactly
+    // when `reference` does, which is already this effect's own dependency.
+    // Listing it would add nothing; NOT listing it is what keeps this effect
+    // inert to the geometry hook's own re-renders, same reasoning as `d`
+    // being excluded below.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [reference, travels]);
 
   // Artifact clip: pin the clone over the source and clip it to the
@@ -252,6 +281,17 @@ export function ReferenceOverlay() {
   // desired behaviour (this mirrors what the pre-split effect already did
   // for the non-travelling branch; only the travelling branch's re-run-on-
   // scroll was the bug).
+  //
+  // Restoration note: `d` now comes from the marks INSIDE THIS CLONE, not
+  // the source anymore (see useReferenceGeometry's WHY comment) — but since
+  // the artifact clone is pinned exactly over the source, the marks land at
+  // the same screen position the source's own range used to measure, so the
+  // clip behaves identically for the common case. The one deliberate change:
+  // a reference with NO partial selection (no marks) now clips to nothing
+  // (`d` is `''`, so `clip-path: none`) instead of the old fallback of
+  // clipping to the whole host box — a no-op clip in practice (the pinned
+  // clone's own box already IS that rect), so `none` is simpler for the
+  // exact same visible result.
   useEffect(() => {
     const node = liftRef.current;
     if (!node || !reference?.anchor || travels) return;
@@ -273,6 +313,7 @@ export function ReferenceOverlay() {
       node.style.width = 'min(90vw, 640px)';
       node.style.transform = 'translate(-50%, -50%)';
       node.style.clipPath = 'none';
+      remeasure();
       return;
     }
     node.removeAttribute('data-detached');
@@ -294,6 +335,12 @@ export function ReferenceOverlay() {
     // position — correct only for a source pinned at the viewport origin,
     // which is not the general case.
     node.style.clipPath = d ? `path('${shiftPath(d, -s.left, -s.top)}')` : 'none';
+    // `d` may still be stale here (measured against wherever this REUSED
+    // node was positioned last, if useReferenceGeometry's own mount effect
+    // ran before this one in the same commit) — remeasure() now that
+    // left/top are correct triggers a fresh `d`, which re-runs THIS effect
+    // (it's in the deps below) with the accurate clip on the very next pass.
+    remeasure();
   }, [reference, travels, d]);
 
   // Dev-review follow-up: "there is an animation to center them, but I want
@@ -483,15 +530,27 @@ export function ReferenceOverlay() {
 
   return createPortal(
     
-      {/* Dev-review fix C/D: the traced SVG outline (formerly rendered here for
-          the non-travelling/artifact case) is GONE — it was both "the weird
-          black box" around the selection and "uneven and janky" (Destin, dev
-          review). `d` and `useReferenceGeometry` stay: the artifact clone
-          below still needs `d` for its clip-path (that's what keeps only the
-          referenced lines bright above the dim), it's just no longer also
-          drawn as a visible stroke. The travelling clone already has its own
-          ring + glow in CSS, and now the selection highlight from fix B too —
-          between the two, nothing is left needing a traced outline. */}
+      {/* Traced outline around the highlighted selection (Task 7, restored
+          2026-07-28). pathLength={100} normalizes both paths' length to 100
+          units so the fixed 100-unit stroke-dasharray/breathe animation in
+          globals.css works regardless of the actual traced perimeter. Renders
+          for BOTH kinds now — unlike the original (and the since-deleted)
+          version, which only ever traced the non-travelling/artifact case,
+          this one is anchored to the `.reference-mark` elements INSIDE THE
+          CLONE (see useReferenceGeometry's WHY comment), so for a travelling
+          chat reference it tracks the marks wherever the clone currently is
+          — never the empty space the source left behind, which was the
+          original bug. Empty (nothing renders) when there's no partial
+          selection to trace — a whole-message/whole-file reference already
+          has its own "this is the reference" signal (the travelling card's
+          ring, or simply being the one undimmed clone), so an outline around
+          the entire clone would just duplicate that, not clarify it. */}
+      {d && (
+        
+      )}
       {/* Task 8: the clone. Chat kinds travel to centre (`data-travels`
           drives the CSS transition + the non-clipping shadow/scroll rules);
           artifact kinds stay pinned over the source and get clipped instead
diff --git a/desktop/src/renderer/components/reference/reference-geometry.test.ts b/desktop/src/renderer/components/reference/reference-geometry.test.ts
index 0dd217ce5..0d3d3be93 100644
--- a/desktop/src/renderer/components/reference/reference-geometry.test.ts
+++ b/desktop/src/renderer/components/reference/reference-geometry.test.ts
@@ -3,7 +3,7 @@
 // the outline is the stepped union of those boxes — down the right edges,
 // back up the left. No DOM needed, so this runs in the default node env.
 import { describe, it, expect } from 'vitest';
-import { buildUnionPath, toBoxes, shiftPath, type Box } from './reference-geometry';
+import { buildUnionPath, buildRoundedOutlinePath, mergeAdjacentBoxes, toBoxes, shiftPath, type Box } from './reference-geometry';
 
 const box = (l: number, t: number, r: number, b: number): Box => ({ l, t, r, b });
 
@@ -53,6 +53,94 @@ describe('toBoxes', () => {
     const out = toBoxes([rect(120, 90, 40, 20), rect(120, 70, 40, 20)], host, 0);
     expect(out[0].t).toBeLessThan(out[1].t);
   });
+
+  // Restoration fix: real browsers hand back sub-pixel DOMRect values; the
+  // old (deleted) outline carried those through unrounded, which was a
+  // direct source of visible jitter. This is currently unproven — the
+  // pre-restoration `toBoxes` had no rounding step at all — and fails
+  // against it (0.5 offsets flow straight through to the output).
+  it('snaps every edge to a whole pixel', () => {
+    const out = toBoxes([rect(120.4, 70.6, 40.2, 20.3)], host, 0);
+    // left = 120.4 - 100 = 20.4 -> 20; top = 70.6 - 50 = 20.6 -> 21;
+    // right = 120.4+40.2 - 100 = 60.6 -> 61; bottom = 70.6+20.3 - 50 = 40.9 -> 41
+    expect(out).toEqual([box(20, 21, 61, 41)]);
+  });
+
+  it('drops near-zero (not just exactly-zero) rects — collapsed-range measurement noise', () => {
+    // width 0.4 is sub-pixel noise, not a real line box (real text line
+    // boxes are never sub-pixel tall/wide).
+    expect(toBoxes([rect(120, 70, 0.4, 20)], host, 0)).toHaveLength(0);
+  });
+});
+
+describe('mergeAdjacentBoxes', () => {
+  it('passes through arrays too short to merge unchanged', () => {
+    expect(mergeAdjacentBoxes([])).toEqual([]);
+    expect(mergeAdjacentBoxes([box(0, 0, 10, 10)])).toEqual([box(0, 0, 10, 10)]);
+  });
+
+  // This is currently unproven — the pre-restoration geometry had no merge
+  // pass at all, so two adjacent boxes 1px apart on both edges are returned
+  // untouched, not snapped to a shared edge — and fails against it.
+  it('snaps left/right edges within tolerance to a shared value on adjacent boxes', () => {
+    const boxes = [box(10, 0, 90, 20), box(11, 20, 89, 40)];
+    const out = mergeAdjacentBoxes(boxes, 2);
+    expect(out[0].l).toBe(out[1].l);
+    expect(out[0].r).toBe(out[1].r);
+    // Merged to the min left / max right — the union grows to cover both,
+    // never shrinks below either box's own extent.
+    expect(out[0].l).toBe(10);
+    expect(out[0].r).toBe(90);
+  });
+
+  it('leaves edges alone when the difference exceeds tolerance (a real content difference, not noise)', () => {
+    const boxes = [box(10, 0, 90, 20), box(30, 20, 90, 40)]; // 20px apart — a real mid-line start
+    const out = mergeAdjacentBoxes(boxes, 2);
+    expect(out[0].l).toBe(10);
+    expect(out[1].l).toBe(30);
+  });
+
+  it('does not mutate its input', () => {
+    const boxes = [box(10, 0, 90, 20), box(11, 20, 89, 40)];
+    const snapshot = boxes.map((b) => ({ ...b }));
+    mergeAdjacentBoxes(boxes, 2);
+    expect(boxes).toEqual(snapshot);
+  });
+});
+
+describe('buildRoundedOutlinePath', () => {
+  it('returns empty string for no boxes', () => {
+    expect(buildRoundedOutlinePath([])).toBe('');
+  });
+
+  // Hand-derived: box(0,0,4,20) has a 4px-wide top/bottom edge, so the
+  // default radius (6) must clamp to half that edge (2px) at every corner —
+  // this is the "clamped to half the shorter adjacent edge" requirement.
+  // This is currently unproven — buildRoundedOutlinePath does not exist yet
+  // on the pre-restoration code — and fails simply because the import
+  // doesn't resolve.
+  it('rounds a single narrow box, clamping the radius to half the short edge', () => {
+    const d = buildRoundedOutlinePath([box(0, 0, 4, 20)], 6);
+    expect(d).toBe('M 2 0 Q 4 0 4 2 L 4 18 Q 4 20 2 20 L 2 20 Q 0 20 0 18 L 0 2 Q 0 0 2 0 Z');
+    // Contains a curve command — the whole point of "rounded".
+    expect(d).toContain('Q');
+  });
+
+  // Hand-derived two-box stepped case (mirrors buildUnionPath's 3-line test
+  // in spirit): a wide top line, a narrower bottom line. Several corners here
+  // clamp to 5 (half of the 10px step) instead of the full default radius 6,
+  // proving the clamp is genuinely per-vertex, not a single global value.
+  it('rounds every corner of a multi-line stepped union, clamping per-vertex', () => {
+    const d = buildRoundedOutlinePath([box(0, 0, 20, 20), box(0, 20, 10, 40)], 6);
+    expect(d).toBe(
+      'M 14 0 Q 20 0 20 6 L 20 15 Q 20 20 15 20 L 15 20 Q 10 20 10 25 ' +
+      'L 10 35 Q 10 40 5 40 L 5 40 Q 0 40 0 35 L 0 26 Q 0 20 0 14 L 0 6 Q 0 0 6 0 Z',
+    );
+  });
+
+  it('closes the path', () => {
+    expect(buildRoundedOutlinePath([box(0, 0, 10, 10)]).endsWith('Z')).toBe(true);
+  });
 });
 
 // Task 8: clip-path correction. clip-path: path() resolves against the
@@ -79,4 +167,15 @@ describe('shiftPath', () => {
     const d = buildUnionPath([box(0, 0, 10, 10)]);
     expect(shiftPath(d, 0, 0)).toBe(d);
   });
+
+  // The rounded outline path (buildRoundedOutlinePath) emits Q commands —
+  // shiftPath must shift BOTH coordinate pairs a Q carries (control point,
+  // then endpoint), not just the first. This is currently unproven — the
+  // pre-restoration regex only matched `[ML]`, so a `Q` command would pass
+  // through completely unshifted — and fails against it.
+  it('shifts both coordinate pairs of a Q (quadratic curve) command', () => {
+    const d = buildRoundedOutlinePath([box(0, 0, 4, 20)], 6);
+    const shifted = shiftPath(d, 10, 5);
+    expect(shifted).toBe('M 12 5 Q 14 5 14 7 L 14 23 Q 14 25 12 25 L 12 25 Q 10 25 10 23 L 10 7 Q 10 5 12 5 Z');
+  });
 });
diff --git a/desktop/src/renderer/components/reference/reference-geometry.ts b/desktop/src/renderer/components/reference/reference-geometry.ts
index ef46e2465..d1d336dd9 100644
--- a/desktop/src/renderer/components/reference/reference-geometry.ts
+++ b/desktop/src/renderer/components/reference/reference-geometry.ts
@@ -1,5 +1,7 @@
 /**
- * Geometry for the traced selection outline (spec 2026-07-26 §5.6).
+ * Geometry for the traced selection outline (spec 2026-07-26 §5.6; restored
+ * 2026-07-28 after a dev-review pass deleted it as "uneven and janky" — see
+ * the WHY comments below for what actually changed this time around).
  *
  * Pure on purpose: this is the trickiest logic in the feature and the part most
  * likely to be silently wrong, so it must be testable without a DOM or a render.
@@ -9,25 +11,84 @@ export type Box = { l: number; r: number; t: number; b: number };
 
 /**
  * Host-relative, padded, sorted line boxes from raw client rects.
- * Zero-area rects are dropped — a collapsed range emits them and they would
- * add a degenerate spike to the outline.
+ *
+ * Snaps every edge to a whole pixel (Math.round). Restoration fix: real
+ * browsers hand back sub-pixel DOMRect values (fractional left/top/right/
+ * bottom from font hinting, device-pixel-ratio scaling, etc.) — carrying
+ * those through unrounded was a direct source of visible jitter in the old
+ * traced outline, especially while the reference card was mid-FLIP-travel
+ * and re-measuring every frame: two rects that are visually "the same line,
+ * still" but differ by a fraction of a pixel produced a wobbling edge
+ * instead of a stable one. Rounding once, at the DOMRect -> Box boundary
+ * (the one place raw measurements enter this pure pipeline), means every
+ * downstream consumer (merge, path-building) works with stable integers.
+ *
+ * Drops near-zero rects (< 0.5px wide or tall) — a collapsed range emits
+ * degenerate zero-area rects, and real text line boxes are never sub-pixel
+ * tall, so anything under 0.5px is measurement noise, not content.
  */
 export function toBoxes(rects: DOMRect[], host: DOMRect, pad = 2): Box[] {
   return rects
-    .filter((r) => r.width > 0 && r.height > 0)
+    .filter((r) => r.width > 0.5 && r.height > 0.5)
     .map((r) => ({
-      l: r.left - host.left - pad,
-      r: r.right - host.left + pad,
-      t: r.top - host.top - pad,
-      b: r.bottom - host.top + pad,
+      l: Math.round(r.left - host.left - pad),
+      r: Math.round(r.right - host.left + pad),
+      t: Math.round(r.top - host.top - pad),
+      b: Math.round(r.bottom - host.top + pad),
     }))
     .sort((a, b) => a.t - b.t);
 }
 
+/**
+ * Snaps consecutive line boxes' left/right edges to a shared value when
+ * they're within `tolerance` px of each other.
+ *
+ * Restoration fix: the old outline joined raw per-line rects with hard 90°
+ * steps, so two lines that are — to the eye — the same width (e.g. two full
+ * lines of a wrapped paragraph) but differ by a stray 1-2px of sub-pixel
+ * font-metric noise produced a visible micro "staircase" notch even after
+ * pixel-snapping alone. `tolerance = 2` mirrors the existing `pad` default
+ * above: real content differences (a selection starting mid-line, a shorter
+ * final line) are always at least one character's width apart — several
+ * pixels at minimum — so a 2px tolerance merges only the noise, never a
+ * genuine edge. `boxes` is assumed pre-sorted by `t` (toBoxes already does
+ * this), so "consecutive in the array" is "consecutive top-to-bottom" —
+ * exactly the adjacency the visible staircase comes from.
+ */
+export function mergeAdjacentBoxes(boxes: Box[], tolerance = 2): Box[] {
+  if (boxes.length < 2) return boxes;
+  // Copy rather than mutate the input — callers (tests, the geometry hook)
+  // may hold onto the original array.
+  const merged = boxes.map((b) => ({ ...b }));
+  for (let i = 1; i < merged.length; i++) {
+    const prev = merged[i - 1];
+    const cur = merged[i];
+    if (Math.abs(cur.l - prev.l) <= tolerance) {
+      const l = Math.min(prev.l, cur.l);
+      prev.l = l;
+      cur.l = l;
+    }
+    if (Math.abs(cur.r - prev.r) <= tolerance) {
+      const r = Math.max(prev.r, cur.r);
+      prev.r = r;
+      cur.r = r;
+    }
+  }
+  return merged;
+}
+
 /**
  * The stepped union outline: walk DOWN the right edges of every line box, then
  * back UP the left edges. For a selection that starts mid-line and ends
  * mid-line this produces the familiar notched shape rather than a bounding box.
+ *
+ * Kept exactly as it was (sharp corners, no snap/merge baked in) — it's still
+ * a valid, independently-tested pure primitive, and other code may reasonably
+ * want the raw union without the outline-specific smoothing pass. Production
+ * rendering (the traced SVG, the artifact clip-path) now goes through
+ * `buildRoundedOutlinePath` instead — see its WHY comment for the rounding
+ * approach and why sharp-cornered `buildUnionPath` output was the "uneven and
+ * janky" complaint that got this feature deleted the first time.
  */
 export function buildUnionPath(boxes: Box[]): string {
   if (boxes.length === 0) return '';
@@ -42,28 +103,127 @@ export function buildUnionPath(boxes: Box[]): string {
   return cmds.join(' ');
 }
 
+type Point = { x: number; y: number };
+
+/**
+ * Same traversal `buildUnionPath` uses (down the right edges, back up the
+ * left) but as raw {x,y} points instead of a command string, with
+ * consecutive duplicate points collapsed. Boxes that share an edge (two
+ * vertically stacked line boxes, or the merge pass above snapping two edges
+ * to the same value) emit the same coordinate twice in a row in the raw
+ * traversal — a zero-length "edge" that can't be rounded (there is no
+ * direction to fillet a corner along), so it must be removed before the
+ * rounding pass, not just left as a degenerate control point.
+ */
+function unionVertices(boxes: Box[]): Point[] {
+  const pts: Point[] = [];
+  for (const bx of boxes) {
+    pts.push({ x: bx.r, y: bx.t });
+    pts.push({ x: bx.r, y: bx.b });
+  }
+  for (let i = boxes.length - 1; i >= 0; i--) {
+    pts.push({ x: boxes[i].l, y: boxes[i].b });
+    pts.push({ x: boxes[i].l, y: boxes[i].t });
+  }
+  const deduped: Point[] = [];
+  for (const p of pts) {
+    const last = deduped[deduped.length - 1];
+    if (!last || last.x !== p.x || last.y !== p.y) deduped.push(p);
+  }
+  // The traversal can also close on itself exactly (the first and last
+  // points coincide) for some box shapes — drop the trailing duplicate so
+  // the polygon doesn't get a zero-length closing edge either.
+  if (deduped.length > 1) {
+    const first = deduped[0];
+    const last = deduped[deduped.length - 1];
+    if (first.x === last.x && first.y === last.y) deduped.pop();
+  }
+  return deduped;
+}
+
+function round2(n: number): number {
+  return Math.round(n * 100) / 100;
+}
+
+/** Point on the segment from `from` to `to`, at distance `dist` from `from`. */
+function pointToward(from: Point, to: Point, dist: number): Point {
+  const dx = to.x - from.x;
+  const dy = to.y - from.y;
+  const len = Math.hypot(dx, dy) || 1; // guard a zero-length edge (shouldn't occur post-dedup, but don't divide by zero if it does)
+  const t = dist / len;
+  return { x: round2(from.x + dx * t), y: round2(from.y + dy * t) };
+}
+
+/**
+ * The traced outline, restored: same stepped union as `buildUnionPath`, but
+ * with every vertex rounded off — this is the actual fix for "uneven and
+ * janky." A rectilinear staircase reads as sharp and mechanical, especially
+ * at the notches where a selection starts/ends mid-line; rounding every
+ * corner (not just the outer four) turns it into a single continuous,
+ * organic-looking trace.
+ *
+ * Technique: for each vertex, back off by `radius` along BOTH adjacent edges
+ * (clamped to half the shorter of the two, per-vertex — a short run, like a
+ * single narrow selected word, must not get corners so large they touch or
+ * overshoot each other and distort the shape into a lozenge) to get two
+ * "shoulder" points, draw a straight line to the first, then a quadratic
+ * Bézier (control point = the original sharp corner) out to the second.
+ * Quadratic, not arc/`A`: the SVG arc command's parameters (rx, ry,
+ * x-axis-rotation, large-arc-flag, sweep-flag, x, y) aren't uniformly
+ * "coordinates to shift" the way `shiftPath` needs — a naive shift would
+ * corrupt the flags — while a quadratic's two (x,y) pairs shift exactly like
+ * `M`/`L` already do.
+ */
+export function buildRoundedOutlinePath(boxes: Box[], radius = 6): string {
+  if (boxes.length === 0) return '';
+  const pts = unionVertices(boxes);
+  const n = pts.length;
+  if (n < 3) return ''; // not enough distinct vertices to form a shape at all
+
+  const cmds: string[] = [];
+  for (let i = 0; i < n; i++) {
+    const prev = pts[(i - 1 + n) % n];
+    const cur = pts[i];
+    const next = pts[(i + 1) % n];
+
+    const distPrev = Math.hypot(cur.x - prev.x, cur.y - prev.y);
+    const distNext = Math.hypot(next.x - cur.x, next.y - cur.y);
+    const rr = Math.min(radius, distPrev / 2, distNext / 2);
+
+    const shoulderIn = pointToward(cur, prev, rr);
+    const shoulderOut = pointToward(cur, next, rr);
+
+    cmds.push(i === 0 ? `M ${shoulderIn.x} ${shoulderIn.y}` : `L ${shoulderIn.x} ${shoulderIn.y}`);
+    cmds.push(`Q ${cur.x} ${cur.y} ${shoulderOut.x} ${shoulderOut.y}`);
+  }
+  cmds.push('Z');
+  return cmds.join(' ');
+}
+
 /**
- * Shifts every coordinate pair in a `buildUnionPath` string by (dx, dy).
+ * Shifts every coordinate pair in a path string by (dx, dy).
  *
  * Task 8's artifact-reference clip-path needs this: `d` is built in VIEWPORT
- * coordinates (use-reference-geometry.ts's `origin = {left:0,top:0}` — a
- * carry-over from when it also fed a `position:fixed; inset:0` traced SVG,
- * `.reference-trace`, since removed in a dev-review pass; the viewport-origin
- * choice stayed because it's still the natural coordinate space for a
- * `getBoundingClientRect()`-derived path). CSS `clip-path: path(...)`
- * resolves its coordinates against the top-left of the
- * CLIPPED ELEMENT'S OWN reference box (border-box by default) — the same rule
- * `polygon()`/`circle()` use for percentages — NOT the viewport. The lifted
- * clone's box is pinned at the source's rect, not at (0,0), so its path must
- * be re-expressed relative to that box's own origin: pass
- * `shiftPath(d, -rect.left, -rect.top)`.
+ * coordinates (use-reference-geometry.ts's `origin = {left:0,top:0}` — the
+ * traced outline is `position:fixed; inset:0`, so the viewport IS its
+ * coordinate space). CSS `clip-path: path(...)` resolves its coordinates
+ * against the top-left of the CLIPPED ELEMENT'S OWN reference box (border-box
+ * by default) — the same rule `polygon()`/`circle()` use for percentages —
+ * NOT the viewport. The lifted clone's box is pinned at the source's rect,
+ * not at (0,0), so its path must be re-expressed relative to that box's own
+ * origin: pass `shiftPath(d, -rect.left, -rect.top)`.
  *
- * Only handles the M/L pairs `buildUnionPath` emits (no curves, no relative
- * commands) — sufficient because it is the only producer of this path format.
+ * Handles `M`/`L` (one coordinate pair) and `Q` (two pairs — control point,
+ * then endpoint; both shift the same way, since a Bézier control point lives
+ * in the same coordinate space as the curve itself) — the full command
+ * vocabulary `buildUnionPath` and `buildRoundedOutlinePath` emit between
+ * them. `Z` carries no coordinates and passes through untouched.
  */
 export function shiftPath(d: string, dx: number, dy: number): string {
   if (!d) return d;
-  return d.replace(/([ML]) (-?[\d.]+) (-?[\d.]+)/g, (_match, cmd: string, x: string, y: string) =>
-    `${cmd} ${Number(x) + dx} ${Number(y) + dy}`,
-  );
+  return d.replace(/([MLQ])((?:\s+-?[\d.]+){2,4})/g, (_match, cmd: string, nums: string) => {
+    const values = nums.trim().split(/\s+/).map(Number);
+    const shifted = values.map((v: number, i: number) => (i % 2 === 0 ? v + dx : v + dy));
+    return `${cmd} ${shifted.join(' ')}`;
+  });
 }
diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
index 4398a4206..1d400c007 100644
--- a/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
+++ b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
@@ -3,238 +3,255 @@
 // tests under `tests/**/*.tsx`; this file lives under `src/**/*.test.ts`
 // and would otherwise run in the default `node` env with no `window`.
 //
-// What's testable here vs. not: jsdom has no real layout engine, so every
-// DOMRect it hands back (getBoundingClientRect) is zeroed and Range does not
-// even implement getClientRects() at all (confirmed empirically against this
-// repo's jsdom version) — there is no way to assert real pixel coordinates or
-// spy through the prototype in this environment. What IS provable, and what
-// these tests pin: (1) which CODE PATH the hook takes — range-in-host vs.
-// containment-fallback vs. no-anchor — proven by stubbing an own-property
-// getClientRects directly on the Range instance (spyOn can't wrap a method
-// jsdom never defines) and checking which stub the hook actually called, and
-// (2) that every listener + the ResizeObserver registered on mount is torn
-// down on unmount. A real dev-instance visual check of the traced outline
-// (does it actually wrap the selection, not the whole bubble) is still
-// required before shipping — see the task report.
+// Restored 2026-07-28 with a NEW contract: the hook used to trace
+// `anchor.range`/`anchor.host` (the SOURCE's position). It now traces the
+// `.reference-mark` elements inside a given CONTAINER (the reference-lift
+// clone) instead — see the WHY comment in use-reference-geometry.ts for why
+// (fixes the travelling-card-outlines-empty-space bug by construction). Every
+// test below exercises the new container-based contract; the old anchor-based
+// tests are gone, not just extended, because the anchor is no longer this
+// hook's input at all.
 //
-// IMPORTANT test-authoring gotcha hit while writing this file: passing
-// `useReferenceGeometry(makeAnchor(host, range))` INLINE inside the
-// `renderHook(() => ...)` callback creates a brand-new `anchor` object
-// identity on every internal re-render. The hook's effect depends on
-// `anchor` BY REFERENCE, and `measure()` always calls `setGeom({ ...new
-// object... })` even when content is unchanged — so a fresh identity each
-// render drove an unbounded render loop (reproduced as a multi-GB OOM, not a
-// hang) that has nothing to do with the hook's real behavior: production
-// `anchor` comes from stable context state. Every anchor below is therefore
-// constructed ONCE, outside the renderHook callback.
+// What's testable here vs. not: jsdom has no real layout engine, so every
+// DOMRect it hands back (getBoundingClientRect) is zeroed. What IS provable,
+// and what these tests pin: (1) which marks get queried and measured, (2)
+// that `d` reflects `reference-geometry.ts`'s pipeline (toBoxes ->
+// mergeAdjacentBoxes -> buildRoundedOutlinePath) run over their rects, (3)
+// that resize/scroll listeners and the transitionrun/transitionend tracking
+// listeners are registered and torn down correctly, and (4) the no-marks /
+// no-container / inactive fallbacks. A real dev-instance visual check (does
+// the outline actually hug the highlighted text, does it track smoothly
+// during the FLIP travel) is still required before shipping — see the task
+// report; jsdom has no CSS transition engine, so `transitionrun` never
+// actually fires here, only the listener registration is provable.
 import { describe, it, expect, vi, afterEach } from 'vitest';
 import { renderHook, cleanup } from '@testing-library/react';
 import { useReferenceGeometry } from './use-reference-geometry';
-import type { ReferenceAnchor } from '../../state/reference-context';
-
-// jsdom doesn't implement ResizeObserver (same stub as InputBar.test.tsx /
-// PreferencesPopup.test.tsx). Spy-able here (not a bare no-op) because the
-// cleanup test needs to prove disconnect() actually fires on unmount.
-class SpyResizeObserver {
-  static instances: SpyResizeObserver[] = [];
-  observe = vi.fn();
-  disconnect = vi.fn();
-  unobserve = vi.fn();
-  constructor(_cb: ResizeObserverCallback) {
-    SpyResizeObserver.instances.push(this);
-  }
-}
+import { toBoxes, mergeAdjacentBoxes, buildRoundedOutlinePath } from './reference-geometry';
 
 afterEach(() => {
   cleanup();
-  SpyResizeObserver.instances = [];
   vi.restoreAllMocks();
 });
 
-function makeAnchor(host: Element, range: Range | null): ReferenceAnchor {
-  return { host, range, selection: null };
+// jsdom implements Element.getClientRects() (unlike Range.getClientRects(),
+// which it does NOT implement — see the old version of this file), but
+// always returns an empty list (no layout engine) — stub it per-mark, same
+// idiom the old file used for Range.
+function stubMarkRects(mark: Element, rects: DOMRect[]) {
+  (mark as unknown as { getClientRects: () => DOMRectList }).getClientRects = () =>
+    rects as unknown as DOMRectList;
 }
 
-// jsdom's Range has no getClientRects at all (not even a no-op) — vi.spyOn
-// requires the property to already exist, so wrap it as a plain own-property
-// stub instead. Returns a vi.fn() the test can assert on directly.
-function stubGetClientRects(range: Range, rects: DOMRect[]) {
-  const stub = vi.fn(() => rects as unknown as DOMRectList);
-  (range as unknown as { getClientRects: typeof stub }).getClientRects = stub;
-  return stub;
+function makeContainerRef(container: HTMLElement | null) {
+  return { current: container };
 }
 
 describe('useReferenceGeometry', () => {
-  it('returns an empty path when anchor is null', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const { result } = renderHook(() => useReferenceGeometry(null));
+  it('returns an empty path when the container is null', () => {
+    const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(null), true, 'k1'));
     expect(result.current.d).toBe('');
   });
 
-  it('uses the range rects when the range is contained in the host', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div');
-    document.body.appendChild(host);
-    const text = document.createTextNode('hello world');
-    host.appendChild(text);
-
-    const range = document.createRange();
-    range.selectNodeContents(text);
-    // Prove the range branch ran (not the host-box fallback) by checking
-    // which stub the hook actually called.
-    const rectsStub = stubGetClientRects(range, [
-      { left: 1, right: 2, top: 3, bottom: 4, width: 1, height: 1 } as DOMRect,
-    ]);
-    const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect');
-
-    const anchor = makeAnchor(host, range); // constructed once — see file header
-    const { result } = renderHook(() => useReferenceGeometry(anchor));
-
-    expect(rectsStub).toHaveBeenCalled();
-    expect(hostRectSpy).not.toHaveBeenCalled();
-    // `d` is non-empty proof the range branch's rect fed the path builder —
-    // the raw rects themselves are no longer exposed (Task 8 deleted the
-    // unused `rects` field; nothing outside this hook ever consumed it once
-    // the artifact clip-path switched to reusing `d` directly).
-    expect(result.current.d).not.toBe('');
+  it('returns an empty path when inactive, even with a real container and marks', () => {
+    const container = document.createElement('div');
+    const mark = document.createElement('mark');
+    mark.className = 'reference-mark';
+    container.appendChild(mark);
+    document.body.appendChild(container);
+    stubMarkRects(mark, [{ left: 0, top: 0, right: 10, bottom: 20, width: 10, height: 20 } as DOMRect]);
 
-    document.body.removeChild(host);
+    const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(container), false, 'k1'));
+    expect(result.current.d).toBe('');
+
+    document.body.removeChild(container);
   });
 
-  // The containment guard is the subtle, load-bearing requirement carried
-  // over from the withdrawn surroundContents() design (see the WHY comment in
-  // use-reference-geometry.ts): a Range whose commonAncestorContainer is NOT
-  // inside the host must be treated as if there were no selection at all, and
-  // the hook must fall back to the whole-host box instead of tracing the
-  // (out-of-bounds) range.
-  it('falls back to the whole-host box when the range escapes the host (containment guard)', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div');
-    host.appendChild(document.createTextNode('inside host'));
-    document.body.appendChild(host);
-
-    // A range over content that lives OUTSIDE host — host.contains(...) is false.
-    const outside = document.createElement('div');
-    const outsideText = document.createTextNode('outside host');
-    outside.appendChild(outsideText);
-    document.body.appendChild(outside);
-
-    const range = document.createRange();
-    range.selectNodeContents(outsideText);
-    expect(host.contains(range.commonAncestorContainer)).toBe(false); // sanity check on the fixture itself
-
-    const rectsStub = stubGetClientRects(range, [{ left: 999, right: 999, top: 999, bottom: 999, width: 1, height: 1 } as DOMRect]);
-    const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({
-      left: 10, right: 20, top: 30, bottom: 40, width: 10, height: 10,
-    } as DOMRect);
-
-    const anchor = makeAnchor(host, range); // constructed once — see file header
-    const { result } = renderHook(() => useReferenceGeometry(anchor));
-
-    // The range must never even be consulted once containment fails.
-    expect(rectsStub).not.toHaveBeenCalled();
-    expect(hostRectSpy).toHaveBeenCalled();
-    expect(result.current.d).not.toBe('');
+  it('returns an empty path when the container has no .reference-mark descendants', () => {
+    const container = document.createElement('div');
+    container.textContent = 'whole-element reference, no partial selection';
+    document.body.appendChild(container);
+
+    const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
+    expect(result.current.d).toBe('');
+
+    document.body.removeChild(container);
+  });
+
+  it('traces the .reference-mark descendants, running their rects through the same geometry pipeline reference-geometry.ts exposes', () => {
+    const container = document.createElement('div');
+    const mark = document.createElement('mark');
+    mark.className = 'reference-mark';
+    container.appendChild(mark);
+    document.body.appendChild(container);
+
+    const rect = { left: 12.4, top: 8.6, right: 92.2, bottom: 28.3, width: 79.8, height: 19.7 } as DOMRect;
+    stubMarkRects(mark, [rect]);
+
+    const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
+
+    const expectedD = buildRoundedOutlinePath(
+      mergeAdjacentBoxes(toBoxes([rect], { left: 0, top: 0 } as DOMRect)),
+    );
+    expect(expectedD).not.toBe('');
+    expect(result.current.d).toBe(expectedD);
+
+    document.body.removeChild(container);
+  });
 
-    document.body.removeChild(host);
-    document.body.removeChild(outside);
+  it('collects rects from MULTIPLE marks (a selection spanning several text runs)', () => {
+    const container = document.createElement('div');
+    const markA = document.createElement('mark');
+    markA.className = 'reference-mark';
+    const markB = document.createElement('mark');
+    markB.className = 'reference-mark';
+    container.append(markA, markB);
+    document.body.appendChild(container);
+
+    const rectA = { left: 40, top: 0, right: 100, bottom: 20, width: 60, height: 20 } as DOMRect;
+    const rectB = { left: 0, top: 20, right: 60, bottom: 40, width: 60, height: 20 } as DOMRect;
+    stubMarkRects(markA, [rectA]);
+    stubMarkRects(markB, [rectB]);
+
+    const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
+
+    const expectedD = buildRoundedOutlinePath(
+      mergeAdjacentBoxes(toBoxes([rectA, rectB], { left: 0, top: 0 } as DOMRect)),
+    );
+    expect(result.current.d).toBe(expectedD);
+    expect(result.current.d).toContain('Q'); // rounded corners, not the old sharp-stepped path
+
+    document.body.removeChild(container);
   });
 
-  it('falls back to the whole-host box when anchor.range is null (whole-element reference)', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div');
-    document.body.appendChild(host);
-    const hostRectSpy = vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({
-      left: 0, right: 5, top: 0, bottom: 5, width: 5, height: 5,
-    } as DOMRect);
+  it('re-measures when remeasureKey changes (a new reference replaces the clone/marks)', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const mark1 = document.createElement('mark');
+    mark1.className = 'reference-mark';
+    container.appendChild(mark1);
+    stubMarkRects(mark1, [{ left: 0, top: 0, right: 10, bottom: 20, width: 10, height: 20 } as DOMRect]);
+
+    const { result, rerender } = renderHook(
+      ({ key }: { key: string }) => useReferenceGeometry(makeContainerRef(container), true, key),
+      { initialProps: { key: 'ref-1' } },
+    );
+    const firstD = result.current.d;
+    expect(firstD).not.toBe('');
+
+    // Simulate the clone-population effect replacing the marks for a NEW
+    // reference (same container node, reused — see ReferenceOverlay.tsx).
+    container.replaceChildren();
+    const mark2 = document.createElement('mark');
+    mark2.className = 'reference-mark';
+    container.appendChild(mark2);
+    stubMarkRects(mark2, [{ left: 200, top: 200, right: 260, bottom: 220, width: 60, height: 20 } as DOMRect]);
 
-    const anchor = makeAnchor(host, null); // constructed once — see file header
-    const { result } = renderHook(() => useReferenceGeometry(anchor));
+    rerender({ key: 'ref-2' });
 
-    expect(hostRectSpy).toHaveBeenCalled();
+    expect(result.current.d).not.toBe(firstD);
     expect(result.current.d).not.toBe('');
 
-    document.body.removeChild(host);
+    document.body.removeChild(container);
   });
 
-  it('returns an empty path when the host has been disconnected from the DOM', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div'); // never appended -> isConnected === false
-    const anchor = makeAnchor(host, null); // constructed once — see file header
-    const { result } = renderHook(() => useReferenceGeometry(anchor));
-    expect(result.current.d).toBe('');
+  it('exposes a stable remeasure() that recomputes on demand', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+
+    const { result, rerender } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
+    expect(result.current.d).toBe(''); // no marks yet
+
+    const mark = document.createElement('mark');
+    mark.className = 'reference-mark';
+    container.appendChild(mark);
+    stubMarkRects(mark, [{ left: 0, top: 0, right: 10, bottom: 20, width: 10, height: 20 } as DOMRect]);
+
+    // Nothing in React knows the DOM changed — an explicit remeasure() call
+    // (exactly what ReferenceOverlay.tsx's positioning effects do right
+    // after setting left/top/transform) is what picks it up.
+    result.current.remeasure();
+    rerender();
+    expect(result.current.d).not.toBe('');
+
+    document.body.removeChild(container);
   });
 
-  it('registers resize/scroll listeners and a ResizeObserver on mount, and tears every one of them down on unmount', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div');
-    document.body.appendChild(host);
-    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({
-      left: 0, right: 1, top: 0, bottom: 1, width: 1, height: 1,
-    } as DOMRect);
+  it('registers resize/scroll listeners AND transitionrun/transitionend/transitioncancel listeners on the container, tearing every one of them down on unmount', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
 
-    const addSpy = vi.spyOn(window, 'addEventListener');
-    const removeSpy = vi.spyOn(window, 'removeEventListener');
+    const addWindowSpy = vi.spyOn(window, 'addEventListener');
+    const removeWindowSpy = vi.spyOn(window, 'removeEventListener');
+    const addContainerSpy = vi.spyOn(container, 'addEventListener');
+    const removeContainerSpy = vi.spyOn(container, 'removeEventListener');
 
-    const anchor = makeAnchor(host, null); // constructed once — see file header
-    const { unmount } = renderHook(() => useReferenceGeometry(anchor));
+    const { unmount } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
 
-    expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
+    expect(addWindowSpy).toHaveBeenCalledWith('resize', expect.any(Function));
     // capture: true is load-bearing — scroll doesn't bubble, and the chat /
     // artifact panes are the actual scrollers, not window itself.
-    expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
-    expect(SpyResizeObserver.instances).toHaveLength(1);
-    expect(SpyResizeObserver.instances[0].observe).toHaveBeenCalledWith(host);
-    expect(SpyResizeObserver.instances[0].disconnect).not.toHaveBeenCalled();
+    expect(addWindowSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
+    // Native transition-tracking listeners (see the WHY comment in
+    // use-reference-geometry.ts on why these are native DOM events, not a
+    // React effect dependency on the transform value).
+    expect(addContainerSpy).toHaveBeenCalledWith('transitionrun', expect.any(Function));
+    expect(addContainerSpy).toHaveBeenCalledWith('transitionend', expect.any(Function));
+    expect(addContainerSpy).toHaveBeenCalledWith('transitioncancel', expect.any(Function));
 
     unmount();
 
-    expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
-    expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
-    expect(SpyResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
+    expect(removeWindowSpy).toHaveBeenCalledWith('resize', expect.any(Function));
+    expect(removeWindowSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
+    expect(removeContainerSpy).toHaveBeenCalledWith('transitionrun', expect.any(Function));
+    expect(removeContainerSpy).toHaveBeenCalledWith('transitionend', expect.any(Function));
+    expect(removeContainerSpy).toHaveBeenCalledWith('transitioncancel', expect.any(Function));
 
-    document.body.removeChild(host);
+    document.body.removeChild(container);
   });
 
-  it('does not register any listeners when anchor is null (nothing to leak)', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const addSpy = vi.spyOn(window, 'addEventListener');
-    const { unmount } = renderHook(() => useReferenceGeometry(null));
-    expect(addSpy).not.toHaveBeenCalledWith('scroll', expect.any(Function), true);
-    expect(SpyResizeObserver.instances).toHaveLength(0);
+  it('does not register any listeners when inactive (nothing to leak)', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const addContainerSpy = vi.spyOn(container, 'addEventListener');
+
+    const { unmount } = renderHook(() => useReferenceGeometry(makeContainerRef(container), false, 'k1'));
+    expect(addContainerSpy).not.toHaveBeenCalledWith('transitionrun', expect.any(Function));
     unmount(); // must not throw with nothing to clean up
+
+    document.body.removeChild(container);
   });
 
-  it('swapping anchor from a live host to null tears down the previous listeners (no post-unmount setState leak)', () => {
-    (global as any).ResizeObserver = SpyResizeObserver;
-    const host = document.createElement('div');
-    document.body.appendChild(host);
-    vi.spyOn(host, 'getBoundingClientRect').mockReturnValue({
-      left: 0, right: 1, top: 0, bottom: 1, width: 1, height: 1,
-    } as DOMRect);
-
-    const initialAnchor = makeAnchor(host, null); // constructed once — see file header
-    // Explicit generic args: renderHook infers its Props type param from
-    // BOTH the callback's parameter AND `initialProps` together and narrows
-    // to the non-null `{ anchor: ReferenceAnchor }` from initialProps alone
-    // even with the callback annotated `| null` — leaving the later
-    // `rerender({ anchor: null })` call failing to typecheck. Pinning the
-    // generics directly sidesteps the inference instead of fighting it.
-    const { result, rerender } = renderHook<{ d: string }, { anchor: ReferenceAnchor | null }>(
-      ({ anchor }) => useReferenceGeometry(anchor),
-      { initialProps: { anchor: initialAnchor } },
-    );
+  it('does not register any listeners when the container is null', () => {
+    const addWindowSpy = vi.spyOn(window, 'addEventListener');
+    const { unmount } = renderHook(() => useReferenceGeometry(makeContainerRef(null), true, 'k1'));
+    expect(addWindowSpy).not.toHaveBeenCalledWith('scroll', expect.any(Function), true);
+    unmount(); // must not throw
+  });
+
+  it('going from active to inactive tears down the previous listeners and clears the path', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const mark = document.createElement('mark');
+    mark.className = 'reference-mark';
+    container.appendChild(mark);
+    stubMarkRects(mark, [{ left: 0, top: 0, right: 10, bottom: 20, width: 10, height: 20 } as DOMRect]);
+
+    const removeContainerSpy = vi.spyOn(container, 'removeEventListener');
+
+    const { result, rerender } = renderHook<
+      { d: string; remeasure: () => void },
+      { active: boolean }
+    >(({ active }) => useReferenceGeometry(makeContainerRef(container), active, 'k1'), {
+      initialProps: { active: true },
+    });
     expect(result.current.d).not.toBe('');
-    expect(SpyResizeObserver.instances[0].disconnect).not.toHaveBeenCalled();
 
-    rerender({ anchor: null });
+    rerender({ active: false });
 
-    // The effect cleanup for the PREVIOUS (non-null) anchor must have run
-    // before the new (null) effect body — React guarantees this ordering —
-    // so the old ResizeObserver is disconnected and geometry is cleared.
-    expect(SpyResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
+    expect(removeContainerSpy).toHaveBeenCalledWith('transitionrun', expect.any(Function));
     expect(result.current.d).toBe('');
 
-    document.body.removeChild(host);
+    document.body.removeChild(container);
   });
 });
diff --git a/desktop/src/renderer/components/reference/use-reference-geometry.ts b/desktop/src/renderer/components/reference/use-reference-geometry.ts
index 62278b9f5..0c266d191 100644
--- a/desktop/src/renderer/components/reference/use-reference-geometry.ts
+++ b/desktop/src/renderer/components/reference/use-reference-geometry.ts
@@ -1,75 +1,152 @@
-import { useCallback, useEffect, useState } from 'react';
-import { toBoxes, buildUnionPath } from './reference-geometry';
-import type { ReferenceAnchor } from '../../state/reference-context';
+import { useCallback, useEffect, useState, type RefObject } from 'react';
+import { toBoxes, mergeAdjacentBoxes, buildRoundedOutlinePath } from './reference-geometry';
+
+// The traced outline is `position:fixed; inset:0` (globals.css), so its own
+// coordinate space IS the viewport — no host offset to subtract.
+const VIEWPORT_ORIGIN = { left: 0, top: 0 } as DOMRect;
 
 /**
- * Live geometry for the traced outline.
+ * Live geometry for the traced outline AND (for artifact references) the
+ * clip-path — one measurement, two consumers, so they can never drift apart.
+ *
+ * Restored 2026-07-28 after a dev-review pass deleted the outline entirely.
+ * The ORIGINAL version measured `anchor.range`/`anchor.host` — the SOURCE's
+ * position — which is exactly what made the outline "uneven and janky" in a
+ * way no amount of geometry smoothing could fix on its own: for a travelling
+ * chat reference, the source is the empty space the bubble flew away from,
+ * not where the highlighted text actually is. This version anchors to the
+ * `` elements INSIDE THE CLONE instead — which
+ * is wherever the highlight actually is, for BOTH a travelling chat clone and
+ * a pinned artifact clone, by construction (it's the clone's own rendered
+ * layout, transform included, that getClientRects() reads).
  *
- * Re-derives rects from the DOM on every measure pass rather than storing a
- * DOMRect[] snapshot — stored rects go stale the instant the transcript
- * scrolls, the window resizes, or the drawer opens (spec §3.1).
+ * `containerRef` is the reference-lift node — stable across the whole
+ * held-reference lifetime (see ReferenceOverlay.tsx's liftRef comment on
+ * node reuse across reference changes) — so this hook re-queries
+ * `.reference-mark` fresh on every measure rather than holding onto element
+ * references that go stale when the clone is replaced.
  *
- * Returns an empty path when the source is gone; the overlay falls back to a
- * non-anchored centred card in that case (spec §7).
+ * No marks (a whole-message/whole-file reference with no partial selection)
+ * -> empty `d`, no outline. That case already has its own "this is the
+ * reference" signal (the travelling card's own ring, or the artifact clone
+ * simply being the whole undimmed clone) — tracing a box around content that
+ * already fills the entire clone would just duplicate it, not clarify
+ * anything. See the outline-restore report for the fuller reasoning.
  */
-export function useReferenceGeometry(anchor: ReferenceAnchor | null): { d: string } {
+export function useReferenceGeometry(
+  containerRef: RefObject,
+  active: boolean,
+  // Identity of the CURRENT reference. Not read directly — only used to force
+  // a fresh measure() when a new reference (and therefore a freshly-cloned
+  // set of marks) replaces the old one, since containerRef's own identity
+  // never changes (the node is reused, not remounted).
+  remeasureKey: unknown,
+): { d: string; remeasure: () => void } {
   const [geom, setGeom] = useState<{ d: string }>({ d: '' });
 
   const measure = useCallback(() => {
-    if (!anchor) { setGeom({ d: '' }); return; }
-    const host = anchor.host;
-    if (!host.isConnected) { setGeom({ d: '' }); return; }
-
-    // Trace the SELECTION when there is one (Destin's 9B call); fall back to
-    // the whole host element's box when there isn't — which is exactly the
-    // no-selection case that already references the entire message.
-    // A live Range re-measures itself as the page scrolls — no stored rects, no
-    // DOM mutation. If React ever replaces these nodes the Range yields no rects
-    // and we fall through to the whole-host outline, which is the designed
-    // fallback (spec 7).
-    //
-    // The containment check is load-bearing. The withdrawn surroundContents()
-    // design REJECTED a selection spanning element boundaries (it throws), so a
-    // cross-bubble drag produced a null anchor automatically. cloneRange()
-    // accepts it happily, so that signal is gone and we must re-derive it here:
-    // a Range escaping its host would otherwise trace an outline around content
-    // the reference does not actually cover.
-    const inHost = !!anchor.range && host.contains(anchor.range.commonAncestorContainer);
-    // Array.from, not a spread: this project's tsconfig lib list is
-    // ["ES2022", "DOM"] without "DOM.Iterable", so DOMRectList has no
-    // Symbol.iterator in the type system (tsc TS2488) even though it's
-    // array-like at runtime. Array.from works off .length/index access
-    // instead of iteration, so it needs no lib change. Matches this
-    // codebase's existing idiom for DOM collections (see
-    // html-inline-assets.ts, MascotRig.tsx).
-    const runRects = inHost ? Array.from(anchor.range!.getClientRects()) : [];
-    const rects = runRects.length ? runRects : [host.getBoundingClientRect()];
-
-    // Viewport-relative: the trace SVG is position:fixed, so the "host" origin
-    // for toBoxes is the viewport itself. Task 8's artifact clip-path also
-    // consumes `d` in this same viewport coordinate system (see the WHY
-    // comment on ReferenceOverlay.tsx's shiftPath call for how it's
-    // re-expressed relative to the clone's own box before use).
-    const origin = { left: 0, top: 0 } as DOMRect;
-    setGeom({ d: buildUnionPath(toBoxes(rects as DOMRect[], origin)) });
-  }, [anchor]);
+    const container = containerRef.current;
+    if (!container || !active) {
+      setGeom((g) => (g.d === '' ? g : { d: '' }));
+      return;
+    }
+    const marks = container.querySelectorAll('.reference-mark');
+    if (marks.length === 0) {
+      setGeom((g) => (g.d === '' ? g : { d: '' }));
+      return;
+    }
+    const rects: DOMRect[] = [];
+    marks.forEach((mark) => {
+      rects.push(...(Array.from(mark.getClientRects()) as DOMRect[]));
+    });
+    const boxes = mergeAdjacentBoxes(toBoxes(rects, VIEWPORT_ORIGIN));
+    const d = buildRoundedOutlinePath(boxes);
+    // Skip the setState when nothing actually changed — the resize/scroll
+    // listeners below fire far more often than the geometry actually moves
+    // (e.g. a scroll of an unrelated pane), and an unnecessary setState would
+    // trigger a re-render (and, transitively, a re-run of the artifact clip
+    // effect that depends on `d`) for no visible change.
+    setGeom((g) => (g.d === d ? g : { d }));
+  }, [containerRef, active]);
 
+  // Baseline: mount-time measure, plus re-measure on window resize and on
+  // ANY ancestor scroller scrolling (capture: true — scroll does not
+  // bubble), same as the pre-restoration hook. Also re-runs measure() fresh
+  // whenever `remeasureKey` (the reference identity) changes, since that's
+  // when the clone — and its marks — got replaced.
   useEffect(() => {
     measure();
-    if (!anchor) return;
+    // Nothing to listen for without a real node — also keeps this a true
+    // no-op (no leaked listeners) when a caller passes `active: true` before
+    // its ref has attached, which shouldn't happen in practice (React
+    // attaches refs before effects run) but costs nothing to guard.
+    if (!active || !containerRef.current) return;
     window.addEventListener('resize', measure);
-    // capture:true so scrolling ANY ancestor scroller (chat-scroll, the artifact
-    // pane) re-measures — scroll does not bubble.
     window.addEventListener('scroll', measure, true);
-    const ro = new ResizeObserver(measure);
-    const host = anchor.host;
-    if (host) ro.observe(host);
     return () => {
       window.removeEventListener('resize', measure);
       window.removeEventListener('scroll', measure, true);
-      ro.disconnect();
     };
-  }, [anchor, measure]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [active, remeasureKey, measure]);
+
+  // Track the FLIP travel: `.reference-lift`'s CSS transition (globals.css)
+  // fires native `transitionrun`/`transitionend`/`transitioncancel` events on
+  // the node whenever its `transform` changes — both the entry glide-to-
+  // centre AND the cancel return-trip (ReferenceOverlay.tsx's `beginExit`)
+  // just reassign `transform`, so both are covered by the same pair of
+  // listeners, with no extra wiring needed at the call site.
+  //
+  // Deliberately native DOM events, not a React effect keyed on the
+  // transform value: this is what guarantees the polling loop below can
+  // NEVER touch node.style.transform/left/top itself — it only calls
+  // `measure()`, which is local state internal to THIS hook. ReferenceOverlay
+  // already carries a hard-won fix for a bug shaped exactly like the one this
+  // would reintroduce if `d` leaked into the FLIP effect's own dependency
+  // array (see its WHY comment on why the FLIP effect's deps are
+  // `[reference, travels]` only, never `d`) — this hook's whole design keeps
+  // that boundary intact by construction: geometry-tracking and
+  // position-setting are two different effects that share no dependency.
+  //
+  // Not testable in jsdom (no real CSS transition engine — see the file
+  // header on use-reference-geometry.test.ts for what jsdom can and can't
+  // prove here); what IS provable, and what the tests below pin, is that the
+  // listeners are attached and torn down correctly.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container || !active) return;
+    let rafId: number | null = null;
+    const tick = () => {
+      measure();
+      rafId = window.requestAnimationFrame(tick);
+    };
+    const start = () => {
+      if (rafId === null) tick();
+    };
+    const stop = () => {
+      if (rafId !== null) {
+        window.cancelAnimationFrame(rafId);
+        rafId = null;
+      }
+      measure(); // final settle measurement so the outline lands exactly, not one frame stale
+    };
+    container.addEventListener('transitionrun', start);
+    container.addEventListener('transitionend', stop);
+    container.addEventListener('transitioncancel', stop);
+    return () => {
+      container.removeEventListener('transitionrun', start);
+      container.removeEventListener('transitionend', stop);
+      container.removeEventListener('transitioncancel', stop);
+      if (rafId !== null) window.cancelAnimationFrame(rafId);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [active, remeasureKey, measure]);
 
-  return geom;
+  // Exposed so ReferenceOverlay's positioning effects can force an immediate
+  // re-measure right after they set left/top/transform — belt-and-braces
+  // alongside the automatic listeners above, so the FIRST paint after a
+  // reference is set (or after the entry FLIP's rAF-deferred "Last" position
+  // lands) is correct without waiting on a resize/scroll/transition event
+  // that may not come.
+  return { d: geom.d, remeasure: measure };
 }
diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css
index a1d52e8df..8c0346a45 100644
--- a/desktop/src/renderer/styles/globals.css
+++ b/desktop/src/renderer/styles/globals.css
@@ -910,64 +910,63 @@ body[data-reference-held] .bottom-float {
    ═══════════════════════════════════════════════════════════════════════════ */
 .reference-scrim {
   --ref-stroke: var(--accent);
+  --ref-wash: color-mix(in oklab, var(--accent) 12%, transparent);
   --ref-glow: color-mix(in oklab, var(--accent) 45%, transparent);
   /* 2.5x the standard overlay shadow: reads as lifted on light themes
      (0.2 -> 0.5) without going murky on dark ones (0.1 -> 0.25). */
   --ref-lift-shadow: 0 24px 64px rgba(0, 0, 0, calc(var(--shadow-strength, 0.15) * 2.5));
 }
 
-/* Dev-review fix C/D removed the traced SVG outline entirely (`.reference-
-   trace`, its wash/outline paths, and the `reference-trace-in`/
-   `reference-breathe` keyframes that drove it) — it was both "the weird
-   black box" around an artifact selection and "uneven and janky" as an
-   outline. `--ref-wash` (the fill it used) went with it; `--ref-stroke` and
-   `--ref-glow` survive below, reused by the travelling clone's own ring.
-   The artifact clip-path (ReferenceOverlay.tsx) still does the real work of
-   keeping only the referenced lines bright above the dim — it never rendered
-   through this SVG, just shared its `d` geometry. */
-
-/* Dev-review fix B: the highlighted span inside a reference clone (chat and
-   artifact both — see apply-highlight.ts). Theme tokens only: a literal
-   colour here would break every community theme, same rule as the rest of
-   this block. */
-/* Dev-review follow-up round 2: "my highlighted artifact viewer selections
-   aren't focused/selected at all." Investigated before changing anything —
-   `anchor.selection` IS captured on the artifact path too (build-reference.ts
-   ->computeSelectionOffsets, same call used by the chat path; pinned by
-   build-reference.test.ts's "artifact references also capture selection
-   offsets" case), and ReferenceOverlay.tsx's mark-application effect isn't
-   kind-gated, so applyHighlightMark DOES reach the artifact clone. clip-path
-   isn't cropping it away either — the mark's offsets and the clip's `d` are
-   both derived from the SAME captured Range, so they land in the same place.
-   The actual gap: for an ARTIFACT reference the clip-path ALREADY undims
-   almost exactly the marked span (that's the whole mechanism keeping only
-   the referenced lines bright — see the clip effect's WHY comment in
-   ReferenceOverlay.tsx), so a translucent background tint on top of a region
-   that's already "the bright part" reads as barely-there — there's nothing
-   BUT the dim/bright boundary distinguishing it, and that boundary has no
-   edge of its own. The travelling (chat) case never had this problem: its
-   clone is undimmed everywhere, so the mark was the ONLY signal — but a flat
-   tint is just as weak a signal there too, it's only less noticeable because
-   the ring+glow on the whole travelling card (`--ref-stroke`/`--ref-glow`
-   above) was already doing that job for it. Fix: give the mark its OWN edge
-   — an inset ring, independent of whatever's dimmed around it — so it reads
-   as "this text is selected" on its own terms, not by contrast alone. Not a
-   traced outline around the union bounding box (explicitly rejected as "a
-   weird black box" / "uneven and janky" — see the dev-review fix C/D block
-   below): this rides on the SAME per-run `` elements apply-highlight.ts
-   already produces, one per covered text run (naturally one per source line
-   for CM6 — each `.cm-line` is its own text node), so multi-line selections
-   get several small rings that hug each line's own text rather than one
-   rectangle spanning the whole selection. box-decoration-break: clone makes
-   a SINGLE mark that itself wraps across visual lines (a raw 
-   multi-line selection is one continuous text node — MarkdownView.tsx's
-   non-CM6 path) render the ring on every line fragment too, instead of the
-   default 'slice' behavior leaving the wrap points looking open-ended. */
+/* Traced outline around the highlighted selection, restored 2026-07-28 after
+   a dev-review pass deleted it as "uneven and janky" / "a weird black box".
+   What's different this time (see reference-geometry.ts and
+   use-reference-geometry.ts for the full reasoning):
+     1. Anchored to the `` elements INSIDE THE CLONE, not the source —
+        so it traces wherever the highlight actually is, for a travelling
+        chat card too, instead of the empty space the source left behind.
+     2. The geometry itself is smoothed: pixel-snapped, near-identical
+        adjacent line edges merged, degenerate boxes dropped, every corner
+        rounded (clamped per-vertex) instead of a raw stepped polygon. A
+        stepped/notched shape with sharp 90° corners is what actually read as
+        "a weird black box" — a filled, hard-edged region looks heavier and
+        more like a UI glitch than a soft, rounded highlight does. */
+.reference-trace { position: fixed; inset: 0; pointer-events: none; overflow: visible; }
+.reference-trace path.wash { fill: var(--ref-wash); stroke: none; }
+.reference-trace path.outline {
+  fill: none;
+  stroke: var(--ref-stroke);
+  stroke-width: 1.5;
+  stroke-linejoin: round;
+  filter: drop-shadow(0 0 5px var(--ref-glow));
+  stroke-dasharray: 100;
+  stroke-dashoffset: 100;
+  animation: reference-trace-in 620ms 120ms cubic-bezier(.4, 0, .2, 1) forwards,
+             reference-breathe 2800ms 740ms ease-in-out infinite;
+}
+@keyframes reference-trace-in { to { stroke-dashoffset: 0; } }
+@keyframes reference-breathe { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
+
+/* The highlighted span inside a reference clone (chat and artifact both —
+   see apply-highlight.ts). Theme tokens only: a literal colour here would
+   break every community theme, same rule as the rest of this block.
+   Restoration note: this used to ALSO carry its own inset ring (added as a
+   stand-in while the traced outline above was deleted — "my highlighted
+   artifact viewer selections aren't focused/selected at all"). With the
+   outline restored, the ring is now a literal duplicate of it — same union
+   region, same accent colour, drawn twice — so it's removed here; the
+   background tint stays as a distinct, complementary signal (it marks the
+   EXACT covered text run character-for-character, which the smoothed/merged
+   outline geometry only approximates). See the outline-restore report for
+   the full "which treatments stay" reasoning, including why the travelling
+   card's own ring+glow (below) is judged NOT redundant with this outline:
+   the card ring means "this is the referenced message"; this outline means
+   "this is the specific span selected within it" — two different claims,
+   coincident only when the whole message is what's selected (the no-marks
+   case, where the outline doesn't render at all — see useReferenceGeometry). */
 .reference-mark {
   background: color-mix(in oklab, var(--accent) 30%, transparent);
   color: inherit;
   border-radius: var(--radius-sm);
-  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 55%, transparent);
   box-decoration-break: clone;
   -webkit-box-decoration-break: clone;
 }
@@ -1048,11 +1047,11 @@ body[data-reference-held] .bottom-float {
   overflow-y: auto;
 }
 
-/* Reduced effects (Task 9): drops the travel easing and the amplified glow —
-   leaving a static accent ring and the standard overlay shadow. Every new
-   visual effect in this app gets one of these branches; no exceptions.
-   (The trace-animation/breathing-pulse half of this comment's original scope
-   was removed along with `.reference-trace` itself — dev-review fix C/D.)
+/* Reduced effects (Task 9): static outline, no trace-in animation, no
+   breathing pulse, no glow — plus a static accent ring on the travelling
+   card and the standard (non-amplified) overlay shadow. Every new visual
+   effect in this app gets one of these branches; no exceptions. Restored
+   alongside `.reference-trace` itself (2026-07-28).
 
    Keyed off data-reduced stamped by ReferenceOverlay.tsx, NOT an html
    attribute: verified 2026-07-26 that theme-engine.ts applies reducedEffects
@@ -1067,6 +1066,12 @@ body[data-reference-held] .bottom-float {
    that wrapper the same box-shadow treatment as the card, same class of bug
    the non-reduced `.reference-lift > .reference-lift-card` rule above was
    already scoped to avoid. Scoped to `.reference-lift-card` here too. */
+.reference-trace[data-reduced="true"] path.outline {
+  animation: none;
+  stroke-dashoffset: 0;
+  stroke-width: 2;
+  filter: none;
+}
 .reference-lift[data-reduced="true"] { transition: none; }
 /* Only the travelling (chat) case gets the amplified `--ref-lift-shadow` in
    the first place (the non-travelling/artifact rule just above sets
@@ -1084,6 +1089,7 @@ body[data-reference-held] .bottom-float {
 }
 
 @media (prefers-reduced-motion: reduce) {
+  .reference-trace path.outline { animation: none; stroke-dashoffset: 0; }
   .reference-lift { transition: none; }
 }
 

From cf44e6c35052db4c2ab939827f79ab3a516a29ff Mon Sep 17 00:00:00 2001
From: Destin 
Date: Tue, 28 Jul 2026 13:33:49 -0700
Subject: [PATCH 27/27] fix(reference): union same-line mark rects before the
 outline traversal

A selection that crosses a syntax-highlighting token boundary makes
apply-highlight.ts emit one  per covered text node, so a single
visual line can hand back more than one client rect. toBoxes fed every
rect straight to buildRoundedOutlinePath's down-right/up-left traversal,
which only forms a valid simple polygon with at most one box per line --
feeding it 2 boxes on one line produced a self-intersecting path whose
fill bled into a bounding-box-shaped region behind the correctly-tight
mark highlights (Destin's 2026-07-28 report: outline enclosing text that
was never selected, alongside what looked like a second, larger box).

toBoxes now groups rects landing on the same line (by raw vertical
midpoint, pre-padding) and unions each group into one box first.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../reference/reference-geometry.test.ts      | 82 +++++++++++++++++++
 .../reference/reference-geometry.ts           | 82 ++++++++++++++++---
 2 files changed, 153 insertions(+), 11 deletions(-)

diff --git a/desktop/src/renderer/components/reference/reference-geometry.test.ts b/desktop/src/renderer/components/reference/reference-geometry.test.ts
index 0d3d3be93..4b2876954 100644
--- a/desktop/src/renderer/components/reference/reference-geometry.test.ts
+++ b/desktop/src/renderer/components/reference/reference-geometry.test.ts
@@ -71,6 +71,29 @@ describe('toBoxes', () => {
     // boxes are never sub-pixel tall/wide).
     expect(toBoxes([rect(120, 70, 0.4, 20)], host, 0)).toHaveLength(0);
   });
+
+  // Outline-geometry-fix (2026-07-28): a selection that crosses a
+  // syntax-highlighting token boundary makes apply-highlight.ts emit ONE
+  //  per covered text node (see its WHY comment), so ONE visual line
+  // can hand back MULTIPLE client rects. Before this fix, toBoxes passed
+  // every rect through untouched, and buildRoundedOutlinePath's traversal
+  // (down all right edges, then back up all left edges) only forms a valid
+  // simple polygon with at most one box per line — 2 boxes on one line plus
+  // 1 on the next produced a SELF-CROSSING path (verified against the
+  // pre-fix pipeline: the point (320, 20) appeared twice, at unrelated
+  // positions in the traversal). This is currently unproven against the
+  // pre-fix code and fails against it (3 boxes returned instead of 2).
+  it('unions multiple rects on the same visual line into ONE box, before padding', () => {
+    const lineY = { t: 0, h: 20 };
+    const tokenA = rect(280, lineY.t, 40, lineY.h); // e.g. "test"
+    const tokenB = rect(320, lineY.t, 60, lineY.h); // e.g. " file." (adjacent token, same line)
+    const nextLine = rect(20, 20, 280, 20);
+    const out = toBoxes([tokenA, tokenB, nextLine], { left: 0, top: 0 } as DOMRect, 0);
+    expect(out).toEqual([
+      box(280, 0, 380, 20), // tokenA + tokenB unioned into one box, own extent kept tight
+      box(20, 20, 300, 40),
+    ]);
+  });
 });
 
 describe('mergeAdjacentBoxes', () => {
@@ -143,6 +166,65 @@ describe('buildRoundedOutlinePath', () => {
   });
 });
 
+// Outline-geometry-fix (2026-07-28 report): "This is a random temporary
+// [test file.] / [Created on 2026-07-28.] / You can safely delete this."
+// ([...] = actually selected) rendered with the outline enclosing the
+// unselected head of line 1 too, plus what looked like a second, larger
+// rectangle behind the tight highlight boxes. Root cause: line 1's selected
+// run ("test file.") crosses a syntax-highlighting token boundary, so
+// apply-highlight.ts emits 2 marks (2 client rects) for that ONE line — see
+// its WHY comment — and the pre-fix `toBoxes` handed all 3 rects (2 for
+// line 1, 1 for line 2) straight to buildRoundedOutlinePath's
+// down-right-edges/up-left-edges traversal, which only forms a valid simple
+// polygon with ONE box per line. Feeding it 2-boxes-then-1 produced a
+// self-crossing path (verified directly: (320,20) appeared twice, at
+// unrelated points in the traversal) whose FILL bled into a bounding-box-
+// shaped region around the whole 2-line selection — the "second rectangle."
+// This is currently unproven against the pre-fix pipeline and fails against
+// it (an un-grouped pipeline yields 3 boxes, and the emitted path is
+// self-crossing rather than a clean step-in on line 1).
+describe('full pipeline: mid-line-start selection crossing a token boundary (outline-geometry-fix)', () => {
+  it('steps in on line 1 at the selection start, not the far left of the line', () => {
+    const host = { left: 0, top: 0 } as DOMRect;
+    // Line 1: "This is a random temporary test file." — only "test file."
+    // (crossing a token boundary into 2 marks) is selected, starting well
+    // right of the line's own left edge (0).
+    const tokenA = { left: 280, top: 0, right: 320, bottom: 20, width: 40, height: 20 } as DOMRect; // "test"
+    const tokenB = { left: 320, top: 0, right: 380, bottom: 20, width: 60, height: 20 } as DOMRect; // " file."
+    // Line 2: "Created on 2026-07-28." — selected in full, starting at the
+    // line's own left margin.
+    const fullLine2 = { left: 20, top: 20, right: 300, bottom: 40, width: 280, height: 20 } as DOMRect;
+
+    const boxes = mergeAdjacentBoxes(toBoxes([tokenA, tokenB, fullLine2], host));
+    // Exactly one box per LINE (2, not 3) — the same-line tokens were
+    // unioned, not left as separate steps for the traversal to trip over.
+    expect(boxes).toHaveLength(2);
+
+    const d = buildRoundedOutlinePath(boxes);
+    // A valid, closed, non-empty path — not the '' a self-crossing/garbled
+    // shape would still technically produce, but the actual assertion that
+    // matters is the notch below.
+    expect(d.startsWith('M')).toBe(true);
+    expect(d.endsWith('Z')).toBe(true);
+
+    // The load-bearing assertion: rebuilding line 1's box by hand (union of
+    // its two tokens' own extent, [280, 380]) and line 2's box, run through
+    // toBoxes individually (so the default `pad` is applied identically to
+    // the real pipeline) then the same merge+build steps, must match
+    // byte-for-byte. If the pipeline were still leaking a 3rd box or a
+    // bounding-box fallback, this would diverge from the hand-built notch.
+    const line1Rect = { left: 280, top: 0, right: 380, bottom: 20, width: 100, height: 20 } as DOMRect;
+    const line2Rect = { left: 20, top: 20, right: 300, bottom: 40, width: 280, height: 20 } as DOMRect;
+    const handBuilt = [...toBoxes([line1Rect], host), ...toBoxes([line2Rect], host)];
+    expect(buildRoundedOutlinePath(mergeAdjacentBoxes(handBuilt))).toBe(d);
+
+    // And explicitly: line 1's outline never reaches x=0 (the far left of
+    // "This is a random temporary", never selected) — it starts at the
+    // selection's own left edge.
+    expect(d).not.toMatch(/\b0(\.\d+)? 0\b/); // no vertex at (0, 0)-ish on line 1's top edge
+  });
+});
+
 // Task 8: clip-path correction. clip-path: path() resolves against the
 // clipped element's OWN border box, not the viewport, so a viewport-relative
 // `d` must be re-expressed relative to the clone's own rect before use.
diff --git a/desktop/src/renderer/components/reference/reference-geometry.ts b/desktop/src/renderer/components/reference/reference-geometry.ts
index d1d336dd9..c13a75874 100644
--- a/desktop/src/renderer/components/reference/reference-geometry.ts
+++ b/desktop/src/renderer/components/reference/reference-geometry.ts
@@ -28,14 +28,67 @@ export type Box = { l: number; r: number; t: number; b: number };
  * tall, so anything under 0.5px is measurement noise, not content.
  */
 export function toBoxes(rects: DOMRect[], host: DOMRect, pad = 2): Box[] {
-  return rects
-    .filter((r) => r.width > 0.5 && r.height > 0.5)
-    .map((r) => ({
-      l: Math.round(r.left - host.left - pad),
-      r: Math.round(r.right - host.left + pad),
-      t: Math.round(r.top - host.top - pad),
-      b: Math.round(r.bottom - host.top + pad),
-    }))
+  const real = rects.filter((r) => r.width > 0.5 && r.height > 0.5);
+
+  // Union rects that land on the SAME rendered line, BEFORE padding.
+  //
+  // Fix (2026-07-28 outline-geometry-fix): a selection that starts or ends
+  // mid-line can cross a syntax-highlighting token boundary — apply-highlight.ts
+  // deliberately wraps EACH covered text node in its own  when that
+  // happens ("a selection that straddles two  tokens... gets one 
+  // per covered node" — see its WHY comment), so one partially-selected LINE
+  // can hand back MULTIPLE client rects, not one. buildRoundedOutlinePath's
+  // traversal (down every box's right edge, then back up every box's left
+  // edge) only produces a valid, non-self-crossing polygon when there is AT
+  // MOST ONE box per line — feeding it >1 box for the same line interleaves
+  // that line's edges with the NEXT line's, producing a self-intersecting
+  // path whose fill (`.reference-trace path.wash`) bleeds outside the actual
+  // highlighted runs. That bleed is what read as "a larger box around the
+  // whole two-line region" alongside the correctly-tight `.reference-mark`
+  // backgrounds (Destin, 2026-07-28 screenshot report) — confirmed by
+  // feeding this exact shape (2 marks on line 1, 1 on line 2) through the
+  // pre-fix pipeline and inspecting the emitted path: (320,20) and (320,0)
+  // each appeared twice, at unrelated points in the traversal.
+  //
+  // Grouped by comparing RAW (unpadded) vertical midpoints with a small (1px)
+  // epsilon: same-line fragments share a near-identical midpoint (sub-pixel
+  // apart at most, from font-metric rounding); genuinely adjacent — but
+  // different — lines are a full line-height apart, so this can never
+  // conflate two real lines into one. Done here, pre-pad, deliberately: `pad`
+  // is added symmetrically to both edges of every box, so it never shifts a
+  // box's own vertical MIDPOINT — but comparing PADDED [t,b] *ranges* for
+  // overlap would false-positive on any two ordinarily-stacked lines with
+  // zero natural gap between them, since padding pushes both toward each
+  // other by `pad` each. That's the normal case for body text, so padded-
+  // range overlap was rejected as the grouping test.
+  const sorted = [...real].sort((a, b) => a.top - b.top);
+  const lines: DOMRect[][] = [];
+  for (const r of sorted) {
+    const curMid = (r.top + r.bottom) / 2;
+    const line = lines[lines.length - 1];
+    if (line) {
+      const lineMid = (line[0].top + line[0].bottom) / 2;
+      if (Math.abs(curMid - lineMid) <= 1) {
+        line.push(r);
+        continue;
+      }
+    }
+    lines.push([r]);
+  }
+
+  return lines
+    .map((group) => {
+      const l = Math.min(...group.map((r) => r.left));
+      const r = Math.max(...group.map((r) => r.right));
+      const t = Math.min(...group.map((r) => r.top));
+      const b = Math.max(...group.map((r) => r.bottom));
+      return {
+        l: Math.round(l - host.left - pad),
+        r: Math.round(r - host.left + pad),
+        t: Math.round(t - host.top - pad),
+        b: Math.round(b - host.top + pad),
+      };
+    })
     .sort((a, b) => a.t - b.t);
 }
 
@@ -51,9 +104,16 @@ export function toBoxes(rects: DOMRect[], host: DOMRect, pad = 2): Box[] {
  * above: real content differences (a selection starting mid-line, a shorter
  * final line) are always at least one character's width apart — several
  * pixels at minimum — so a 2px tolerance merges only the noise, never a
- * genuine edge. `boxes` is assumed pre-sorted by `t` (toBoxes already does
- * this), so "consecutive in the array" is "consecutive top-to-bottom" —
- * exactly the adjacency the visible staircase comes from.
+ * genuine edge. `boxes` is assumed pre-sorted by `t`, AND at most one box per
+ * visual line (toBoxes already guarantees both — see its WHY comment on the
+ * line-grouping pass), so "consecutive in the array" is "consecutive
+ * top-to-bottom, one step per line" — exactly the adjacency the visible
+ * staircase comes from. Callers that skip toBoxes and hand-build a `Box[]`
+ * with >1 box on the same line (as some tests deliberately do, to test this
+ * function in isolation) will see this smoothing pass compare across those
+ * too — harmless for hand-built same-line-adjacent boxes in existing tests,
+ * but not a substitute for toBoxes' grouping, which is what actually
+ * prevents the self-crossing traversal bug (see toBoxes).
  */
 export function mergeAdjacentBoxes(boxes: Box[], tolerance = 2): Box[] {
   if (boxes.length < 2) return boxes;