;
}
| { 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,58 +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)),
];
}
-// 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}"`;
-}
-
-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.
@@ -238,39 +205,43 @@ function artifactMenu(container: HTMLElement): MenuEntry[] {
const sel = selectionText().trim();
const entries: MenuEntry[] = [];
if (sel && path) {
- const ref = describeArtifactSelection(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
@@ -291,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.
@@ -305,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
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..6339f7783
--- /dev/null
+++ b/desktop/src/renderer/components/context-menu/build-reference.test.ts
@@ -0,0 +1,260 @@
+// @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?.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', () => {
+ const el = mountBubble('assistant-bubble', ' ');
+ expect(buildChatReference(el, el)).toBeNull();
+ });
+});
+
+// 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 charlie12: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');
+ 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: ',
+ );
+ });
+
+ 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', () => {
+ 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();
+ });
+
+ 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();
+ });
+});
+
+describe('quote excludes bubble chrome', () => {
+ it('does not sweep the timestamp into the quote', () => {
+ // .bubble-timestamp renders INSIDE the bubble (UserMessage.tsx:75,
+ // AssistantTurnBubble.tsx:437). A plain textContent read produced
+ // `...as needed.12:55 AM` in the scaffold — caught in dev review.
+ const el = document.createElement('div');
+ el.className = 'assistant-bubble';
+ el.innerHTML = 'Done! Created a test file.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 = 'body12: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
new file mode 100644
index 000000000..9396badad
--- /dev/null
+++ b/desktop/src/renderer/components/context-menu/build-reference.ts
@@ -0,0 +1,210 @@
+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
+// 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.
+ */
+
+// 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;
+ // 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();
+}
+
+/**
+ * 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();
+ return flat.length <= max ? flat : flat.slice(0, max) + '…';
+}
+
+function selectionText(): string {
+ return window.getSelection()?.toString() ?? '';
+}
+
+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() || elementQuote(bubble ?? target)) ?? '';
+ 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')
+ ? LEAD_ASSISTANT
+ : bubble?.classList.contains('user-bubble')
+ ? LEAD_USER
+ : LEAD_NEUTRAL;
+
+ 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, selection },
+ };
+}
+
+export function buildCodeReference(pre: HTMLElement): PendingReference {
+ const code = pre.innerText.replace(/\n+$/, '');
+ return {
+ kind: 'chat-code',
+ label: truncateLabel(code),
+ promptText: buildScaffold(LEAD_CODE, code, true),
+ // 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 },
+ };
+}
+
+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 range = captureRange();
+ const selection = range ? computeSelectionOffsets(container, range) : null;
+
+ 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: buildArtifactScaffold(ref, path),
+ anchor: { host: container, range, selection },
+ };
+}
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);
+}
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..491dd4a84
--- /dev/null
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.test.tsx
@@ -0,0 +1,1300 @@
+// @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, useState } from 'react';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+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, 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;
+ });
+}
+
+// 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();
+ });
+});
+
+// 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
+// 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 (
+ <>
+ {
+ // Single synchronous event handler → React 18 automatic batching
+ // → ONE commit covers both updates, so ConcurrentOverlay's push
+ // effect and ReferenceOverlay's own push effect fire in the SAME
+ // passive-effect flush. This is the reproduction of Finding 3's
+ // race — NOT the same thing as the already-fixed self-registration
+ // off-by-one (that one only ever involved ReferenceOverlay's own
+ // single push; this one involves two independent pushes landing
+ // together).
+ setOtherOpen(true);
+ setReference(value);
+ }}
+ >
+ trigger
+
+
+ >
+ );
+ }
+
+ render(
+
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('trigger'));
+ act(() => {});
+
+ 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, hides the source, and restores it byte-identical on clear', () => {
+ const host = makeHost('the referenced message');
+ const originalOuterHTML = host.outerHTML;
+
+ const { rerender } = renderOverlay({
+ kind: 'chat-text',
+ label: 'x',
+ promptText: 'x',
+ anchor: { host, range: null, selection: 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. 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');
+
+ // 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);
+ });
+
+ 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);
+ // 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: { start: 0, end: 'const x = 1;'.length } },
+ });
+ 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 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
+ // 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));
+
+ marksSpy.mockRestore();
+
+ 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, selection: 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);
+ });
+
+ // 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`
+// (`[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, selection: 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 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: { start: 0, end: 'const x = 1;'.length } },
+ });
+ 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);
+ 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 = 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);
+ });
+});
+
+// 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';
+ 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, selection: null } };
+ }
+
+ 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('renders no outline for a whole-file ARTIFACT reference either', () => {
+ renderOverlay(withHost('artifact'));
+ act(() => {});
+ expect(document.querySelector('.reference-trace')).toBeNull();
+ 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)', () => {
+ 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(
+
+
+
+
+
+
+
+ ,
+ );
+ }
+
+ // 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. 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);
+ 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);
+ 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 } },
+ };
+ }
+
+ // 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')).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')?.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 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(() => {});
+
+ const lift = document.querySelector('.reference-lift');
+ 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(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();
+ });
+});
+
+// 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, selection: 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, selection: 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, selection: 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);
+ });
+});
+
+// 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 send ;
+ }
+
+ 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 (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(/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
new file mode 100644
index 000000000..c32d85bb1
--- /dev/null
+++ b/desktop/src/renderer/components/reference/ReferenceOverlay.tsx
@@ -0,0 +1,613 @@
+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';
+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';
+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).
+ *
+ * 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
+ * asking about." Both the chat and artifact surfaces share this one scrim.
+ */
+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 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: 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
+ // 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)
+
+ // 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]);
+
+ // 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)
+ // 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
+ // 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).
+ //
+ // 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 || !travels) return;
+ 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';
+ remeasure(); // outline still needs to trace the (centred) marks even without a live FLIP
+ return;
+ }
+ node.removeAttribute('data-detached');
+
+ 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';
+ // 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(() => {
+ 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)`;
+ // 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
+ // 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).
+ //
+ // 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;
+ 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';
+ remeasure();
+ return;
+ }
+ node.removeAttribute('data-detached');
+
+ 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}`). `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';
+ // `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
+ // 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; }
+ // 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).
+ //
+ // 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.
+ //
+ // 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]);
+
+ // 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');
+ 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;
+
+ return createPortal(
+
+ {/* 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
+ (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. */}
+ {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.
+
+
+
+ )}
+
+ {/* 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.
+
+
+
+ )}
+ ,
+ document.body,
+ );
+}
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.test.ts b/desktop/src/renderer/components/reference/reference-geometry.test.ts
new file mode 100644
index 000000000..4b2876954
--- /dev/null
+++ b/desktop/src/renderer/components/reference/reference-geometry.test.ts
@@ -0,0 +1,263 @@
+// 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, buildRoundedOutlinePath, mergeAdjacentBoxes, toBoxes, shiftPath, 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);
+ });
+
+ // 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);
+ });
+
+ // 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', () => {
+ 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);
+ });
+});
+
+// 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.
+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);
+ });
+
+ // 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
new file mode 100644
index 000000000..c13a75874
--- /dev/null
+++ b/desktop/src/renderer/components/reference/reference-geometry.ts
@@ -0,0 +1,289 @@
+/**
+ * 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.
+ */
+
+export type Box = { l: number; r: number; t: number; b: number };
+
+/**
+ * Host-relative, padded, sorted line boxes from raw client rects.
+ *
+ * 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[] {
+ 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);
+}
+
+/**
+ * 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`, 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;
+ // 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 '';
+ 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(' ');
+}
+
+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 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}` — 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)`.
+ *
+ * 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(/([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
new file mode 100644
index 000000000..1d400c007
--- /dev/null
+++ b/desktop/src/renderer/components/reference/use-reference-geometry.test.ts
@@ -0,0 +1,257 @@
+// @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`.
+//
+// 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.
+//
+// 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 { toBoxes, mergeAdjacentBoxes, buildRoundedOutlinePath } from './reference-geometry';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// 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;
+}
+
+function makeContainerRef(container: HTMLElement | null) {
+ return { current: container };
+}
+
+describe('useReferenceGeometry', () => {
+ it('returns an empty path when the container is null', () => {
+ const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(null), true, 'k1'));
+ expect(result.current.d).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]);
+
+ const { result } = renderHook(() => useReferenceGeometry(makeContainerRef(container), false, 'k1'));
+ expect(result.current.d).toBe('');
+
+ document.body.removeChild(container);
+ });
+
+ 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);
+ });
+
+ 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('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]);
+
+ rerender({ key: 'ref-2' });
+
+ expect(result.current.d).not.toBe(firstD);
+ expect(result.current.d).not.toBe('');
+
+ document.body.removeChild(container);
+ });
+
+ 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 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 addWindowSpy = vi.spyOn(window, 'addEventListener');
+ const removeWindowSpy = vi.spyOn(window, 'removeEventListener');
+ const addContainerSpy = vi.spyOn(container, 'addEventListener');
+ const removeContainerSpy = vi.spyOn(container, 'removeEventListener');
+
+ const { unmount } = renderHook(() => useReferenceGeometry(makeContainerRef(container), true, 'k1'));
+
+ 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(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(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(container);
+ });
+
+ 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('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('');
+
+ rerender({ active: false });
+
+ expect(removeContainerSpy).toHaveBeenCalledWith('transitionrun', expect.any(Function));
+ expect(result.current.d).toBe('');
+
+ 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
new file mode 100644
index 000000000..0c266d191
--- /dev/null
+++ b/desktop/src/renderer/components/reference/use-reference-geometry.ts
@@ -0,0 +1,152 @@
+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 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).
+ *
+ * `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.
+ *
+ * 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(
+ 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(() => {
+ 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();
+ // 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);
+ window.addEventListener('scroll', measure, true);
+ return () => {
+ window.removeEventListener('resize', measure);
+ window.removeEventListener('scroll', measure, true);
+ };
+ // 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]);
+
+ // 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/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
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..bcce6edf9
--- /dev/null
+++ b/desktop/src/renderer/state/reference-context.tsx
@@ -0,0 +1,130 @@
+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 = {
+ /** 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;
+ /**
+ * 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 = {
+ 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;
+ /**
+ * 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;
+};
+
+type ReferenceApi = {
+ reference: PendingReference | null;
+ // 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;
+};
+
+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]);
+
+ // 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
+ // re-render on unrelated parent renders (react-renderer rule).
+ const value = useMemo(
+ () => ({ reference, setReference, clearReference }),
+ [reference, setReference, clearReference],
+ );
+
+ 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);
+ return ctx ?? INERT_API;
+}
diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css
index 98c66b1b4..8c0346a45 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
@@ -880,6 +903,196 @@ body[data-mode="buddy-chat"] #theme-bg {
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));
+}
+
+/* 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-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+/* 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 {
+ pointer-events: auto;
+}
+
+/* The cloned bubble must FILL the lifted wrapper.
+ Chat bubbles carry `max-w-[85%]` (AssistantTurnBubble.tsx / UserMessage.tsx)
+ so they occupy 85% of the transcript row. The wrapper is already sized to the
+ bubble's own measured rect, so that percentage resolves a SECOND time against
+ it — leaving the card 15% narrower than its wrapper, with dead space on the
+ right and the cancel button pinned out in it (Destin, dev review: "the weird
+ extra clear space to the right of the centered message bubble"). Unlayered
+ CSS beats Tailwind's @layer utilities, so this wins without !important. */
+.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
+ border-radius, so the ring follows its rounded shape instead of boxing it.
+ This replaces the source-anchored SVG trace for travelling references —
+ it hugs the card and travels with it, so nothing is left behind. */
+.reference-lift[data-travels="true"] > .reference-lift-card > * {
+ box-shadow:
+ 0 0 0 1.5px var(--ref-stroke),
+ 0 0 18px var(--ref-glow),
+ var(--ref-lift-shadow);
+}
+/* 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 > * {
+ box-shadow: none;
+ max-height: none;
+ 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;
+}
+
+/* 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
+ 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. */
+/* Targets the CLONE (`> * `), matching where the ring + glow now live. Keeps a
+ plain 1.5px ring so the card is still identifiable as the reference, drops
+ the accent glow and the amplified lift shadow. */
+.reference-lift[data-reduced="true"][data-travels="true"] > .reference-lift-card > * {
+ box-shadow:
+ 0 0 0 1.5px var(--ref-stroke),
+ 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
@@ -2050,3 +2263,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);
+}