diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 573b39a74..bc1cff02b 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -30,6 +30,7 @@ import TerminalRightSlot from './components/TerminalRightSlot'; import { ChatProvider, useChatDispatch, useChatStore } from './state/chat-context'; import { artifactReducer, initialArtifactState } from './state/artifact-tracker'; import { ArtifactProvider } from './state/ArtifactContext'; +import { ReferenceProvider } from './state/reference-context'; import { categorizeArtifact } from '../shared/artifacts/categorization'; import { resolveTrackedPath } from '../shared/artifacts/resolve-tracked-path'; // Central slash-command router — also used by the drawer so drawer-initiated @@ -92,6 +93,7 @@ import { ZoomOverlay } from './components/ZoomOverlay'; import { RemoteSnapshotExporter } from './components/RemoteSnapshotExporter'; import RemoteUnsupportedNotice from './components/RemoteUnsupportedNotice'; import { ContextMenuHost } from './components/context-menu/ContextMenuHost'; +import { ReferenceOverlay } from './components/reference/ReferenceOverlay'; import { BuddyMascotApp } from './components/buddy/BuddyMascotApp'; import { BuddyChatApp } from './components/buddy/BuddyChatApp'; import { BuddyBarApp } from './components/buddy/BuddyBarApp'; @@ -2616,9 +2618,13 @@ function AppInner() { ) : null; return ( - // ArtifactProvider: exposes artifact state + dispatch to the entire AppInner - // subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider, - // etc.) because artifact operations may eventually consume chat/theme context. + // ReferenceProvider: holds the "Ask Claude about this" pending reference, + // parked per session so it can't leak between conversations. Outside + // ArtifactProvider because the artifact viewer is one of its two sources. + + {/* ArtifactProvider: exposes artifact state + dispatch to the entire AppInner + subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider, + etc.) because artifact operations may eventually consume chat/theme context. */}
{/* Mount-only: listens for chat:export-snapshot from main, serializes @@ -2632,6 +2638,9 @@ function AppInner() { (copy/paste, Ask about this, file-pill actions). Opens only over surfaces it owns; leaves the terminal and other chrome untouched. */} + {/* Mount-only: the held "Ask Claude about this" reference — window-wide + dim, traced outline, and the lifted source card. */} + {/* Main area — relative so bottom-float chrome can position against it. When a Phase-2 full-screen destination is active, hide the chat chrome entirely. Unmounting via `hidden` is cleaner than z-index @@ -3397,6 +3406,7 @@ function AppInner() { />
+
); } diff --git a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx index ef708f1d4..b549e7388 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx @@ -304,6 +304,50 @@ describe('AssistantTurnBubble — memo comparator (streaming perf)', () => { expect(mdRenders.length).toBeGreaterThan(rendersAfterMount); }); + + it('DOES re-render when `streaming` flips with the SAME turn object reference (crash/error path)', () => { + // Pins the bug: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session), + // which flips isThinking false WITHOUT replacing the turn object in + // session.assistantTurns (unlike TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, + // which both do assistantTurns.set(id, {...turn, ...})). For a text-only turn with + // no tool groups, `turn` stays === across the re-render and the per-group loop in + // the comparator never runs — `streaming` must be compared explicitly or the memo + // silently blocks the update and data-streaming stays "true" forever, permanently + // disabling "Ask about this" on a finished message. + const turn: AssistantTurn = { + id: 'turn_streaming', + segments: [{ type: 'text' as const, content: 'hello world', messageId: 'turn_streaming-msg' }], + timestamp: 0, + stopReason: null, + model: null, + usage: null, + anthropicRequestId: null, + }; + const toolGroups = new Map(); + const toolCalls = new Map(); + + const props = { turn, toolGroups, toolCalls, sessionId: 'test', showTimestamps: false }; + const { container, rerender } = render( + + + + ); + + const bubbleEl = container.querySelector('.assistant-bubble'); + expect(bubbleEl).not.toBeNull(); + expect(bubbleEl).toHaveAttribute('data-streaming', 'true'); + + // Same `turn` object reference — only `streaming` changes, exactly what + // endTurn() produces on the crash/error paths. + rerender( + + + + ); + + const bubbleElAfter = container.querySelector('.assistant-bubble'); + expect(bubbleElAfter).not.toHaveAttribute('data-streaming'); + }); }); describe('splitIntoBubbles — BUG A (tool group mis-attribution after interleaved reasoning)', () => { diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index f994a5ca8..37c9be03c 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -18,6 +18,9 @@ interface Props { /** Session provider — drives provider-aware stop-reason copy (native vs Claude). */ provider?: SessionProvider; showTimestamps: boolean; + /** True only for the turn currently being written. Gates "Ask about this" — + * the reference card is a static clone and would freeze mid-sentence. */ + streaming?: boolean; } // Non-end_turn stop reasons rendered inline under the affected turn. @@ -327,6 +330,14 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean { if (prev.sessionId !== next.sessionId) return false; if (prev.provider !== next.provider) return false; if (prev.showTimestamps !== next.showTimestamps) return false; + // WHY: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session), + // which flips isThinking false WITHOUT replacing the turn object (unlike + // TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, which both do + // assistantTurns.set(id, {...turn, ...})). For a text-only turn with no + // tool groups, `streaming` going true->false is otherwise the ONLY prop + // that changes — miss it here and data-streaming stays "true" forever, + // permanently disabling "Ask about this" on a finished message. + if (prev.streaming !== next.streaming) return false; // Same turn object (checked above) ⇒ same segments ⇒ same group IDs. We only // need to walk one side's IDs. @@ -343,7 +354,7 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean { return true; } -export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps }: Props) { +export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps, streaming }: Props) { // Read opt-in metadata preference here so the strip below only renders when // the user has explicitly turned it on in PreferencesPopup (default false). const { showTurnMetadata } = useTheme(); @@ -371,7 +382,10 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC const isLastBubble = i === bubbles.length - 1; return (
-
+
{bubble.reasoning && ( )} diff --git a/desktop/src/renderer/components/ChatView.tsx b/desktop/src/renderer/components/ChatView.tsx index 626d1d587..7a418bc4c 100644 --- a/desktop/src/renderer/components/ChatView.tsx +++ b/desktop/src/renderer/components/ChatView.tsx @@ -730,6 +730,9 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane sessionId={sessionId} provider={provider} showTimestamps={showTimestamps} + // Only the LAST entry can be mid-stream; everything above + // it is complete and safe to reference. + streaming={state.isThinking && idx === state.timeline.length - 1} /> ); break; diff --git a/desktop/src/renderer/components/InputBar.reference.test.tsx b/desktop/src/renderer/components/InputBar.reference.test.tsx new file mode 100644 index 000000000..bcb870463 --- /dev/null +++ b/desktop/src/renderer/components/InputBar.reference.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +// Pins the two composer contracts of the held reference (spec 2026-07-26 §3.5): +// 1. the placeholder announces the reference, and +// 2. promptText is prepended EXACTLY ONCE at send, then the reference clears — +// while the user's own draft is never touched by a cancel. +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ReferenceProvider, useReference, type PendingReference } from '../state/reference-context'; +import { composeOutgoing, placeholderFor } from './InputBar'; + +const REF: PendingReference = { + kind: 'chat-text', + label: '"the reducer preserves…"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, +}; + +describe('placeholderFor', () => { + it('falls back to the default with no reference', () => { + expect(placeholderFor(null, false)).toBe('Message Claude...'); + }); + + it('announces the held reference', () => { + expect(placeholderFor(REF, false)).toBe('Ask Claude about "the reducer preserves…"'); + }); + + it('the approval gate still wins over a held reference', () => { + expect(placeholderFor(REF, true)).toBe('Waiting for approval...'); + }); + + // Gap 2 (task-4-report.md "Concerns" #2): minimal (terminal view) send + // paths write straight to the PTY and never call composeOutgoing, so a + // reference can never be consumed there. Announcing it in the placeholder + // would promise a scaffold that will never be sent — minimal must silence + // it even though a reference IS held (default `minimal` arg is `false`, + // which is why every other test above still sees it announced). + it('does not announce a held reference in minimal (terminal view) mode', () => { + expect(placeholderFor(REF, false, true)).toBe('Message Claude...'); + }); +}); + +describe('composeOutgoing', () => { + it('returns the draft unchanged with no reference', () => { + expect(composeOutgoing('why?', null)).toBe('why?'); + }); + + it('prepends promptText exactly once', () => { + expect(composeOutgoing('why?', REF)).toBe(REF.promptText + 'why?'); + }); + + it('sends the scaffold alone when the draft is empty', () => { + expect(composeOutgoing('', REF)).toBe(REF.promptText); + }); +}); + +// Hoisted to module scope (matches reference-context.test.tsx's Probe idiom): +// tsc's definite-assignment check only exempts USAGE inside a nested closure +// (e.g. act(() => api.foo())) — a direct `expect(api.reference)` in the SAME +// scope as a local `let api` declaration still trips TS2454 "used before +// being assigned", even though render() has synchronously run Probe by then. +let api: ReturnType; +function Probe() { api = useReference(); return null; } + +describe('cancel does not touch the draft', () => { + it('clearReference leaves composer state alone', () => { + render(); + act(() => api.setReference(REF)); + act(() => api.clearReference()); + // The context owns ONLY the reference — it has no draft to clobber. + expect(api.reference).toBeNull(); + }); +}); diff --git a/desktop/src/renderer/components/InputBar.test.tsx b/desktop/src/renderer/components/InputBar.test.tsx index 9f9795cd7..ad0e2191c 100644 --- a/desktop/src/renderer/components/InputBar.test.tsx +++ b/desktop/src/renderer/components/InputBar.test.tsx @@ -5,8 +5,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react'; import { ChatProvider, useChatDispatch, useChatStore } from '../state/chat-context'; import { SkillProvider } from '../state/skill-context'; +// Task 4: InputBar now calls useReference() unconditionally (placeholder + +// send-time scaffold assembly), which throws outside a ReferenceProvider — +// every render site below needs the wrapper, same sessionId as the InputBar +// under test (App.tsx scopes ReferenceProvider by sessionId the same way). +// useReference/PendingReference are additionally needed by the gap-1/gap-2 +// regression tests below, which read/set the held reference directly via a +// Probe component (same idiom as InputBar.reference.test.tsx). +import { ReferenceProvider, useReference, type PendingReference } from '../state/reference-context'; import InputBar, { InputBarHandle } from './InputBar'; +// Hoisted to module scope (mirrors InputBar.reference.test.tsx's Probe idiom): +// tsc's definite-assignment check only exempts USAGE inside a nested closure +// relative to the declaration — a local `let api` inside an `it(...)` body +// with a direct `expect(api...)` in the same scope still trips TS2454, even +// though render() has synchronously run Probe by then. Module scope makes +// every usage (inside a nested `it()` closure) exempt. +let referenceApi: ReturnType; +function ReferenceProbe() { referenceApi = useReference(); return null; } + // jsdom (per this repo's vitest.config.ts) has no global setupFiles/polyfills — // useScrollFade (mounted unconditionally by InputBar's textarea) reaches for // ResizeObserver, which jsdom doesn't implement. A no-op stub is enough since @@ -74,7 +91,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -114,7 +133,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -150,7 +171,9 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi render( - + + + , ); @@ -174,6 +197,310 @@ describe('InputBar native send — failure keeps the draft (reviewer Critical fi }); }); +// Gap 1 (task-4-report.md "Concerns" #1): a failed async native send restores +// the draft (tested above) but, before this fix, silently dropped the held +// reference — clearReference() already ran synchronously in send() right +// after the optimistic sendMessage() returned true, before the ack settled. +// The user got their text back with the "Ask Claude about X" scaffold gone, +// and resending would have silently omitted it. +describe('InputBar native send — failure also restores the held reference (gap 1 fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"earlier text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { + supported: true, + send: vi.fn(), + }, + session: { + sendInput: vi.fn(), + }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('restores the reference alongside the draft when the ack is failed', async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "earlier text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'hello world' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + // send() clears the reference synchronously on the optimistic path — + // this is correct and unchanged (spec §7's success-path clear). + expect(referenceApi.reference).toBeNull(); + expect(textarea.value).toBe(''); + + resolveAck!({ status: 'failed', reason: 'not-live' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(textarea.value).toBe('hello world'); + }); + // The fix: the reference travels back with the draft instead of staying + // gone. Without it, this stays null and the test fails. + expect(referenceApi.reference).toEqual(REF); + }); + + it('does NOT clobber a newer reference the user set during the ack round-trip', async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "earlier text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'first message' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + expect(referenceApi.reference).toBeNull(); + + // User picks a NEW reference while the first send's ack is still in flight. + const REF2: PendingReference = { ...REF, label: '"newer text"' }; + act(() => { referenceApi.setReference(REF2); }); + + resolveAck!({ status: 'failed', reason: 'queue-full' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + // The guard (`cur ?? reference`) must have refused to overwrite — the + // newer reference survives, the lost first reference does not reappear. + expect(referenceApi.reference).toEqual(REF2); + }); +}); + +// Reviewer Important fix: the ONE app-wide ReferenceProvider is keyed to +// whichever session is currently active (App.tsx wraps it around the whole +// tree, scoped by the active sessionId — see reference-context.tsx's +// per-session parking effect). If the user switches sessions while a native +// send's ack is still in flight, the gap-1 restore above (`setReference((cur) +// => cur ?? reference)`) would otherwise write the ORIGINAL session's +// reference into the NEW session's live slot — quoted content from a +// different conversation silently attaching to the next message sent from +// the session now on screen. This harness renders InputBar AND +// ReferenceProvider both keyed off one `sessionId` prop (mirroring how +// App.tsx wires them together) so `rerender` with a new sessionId reproduces +// an actual session switch, not just a prop tweak on InputBar alone. +describe('InputBar native send — session switch during flight must not leak the reference (reviewer Critical fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"session A text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { + supported: true, + send: vi.fn(), + }, + session: { + sendInput: vi.fn(), + }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + function Harness({ sessionId, onToast }: { sessionId: string; onToast: (m: string) => void }) { + return ( + + + + + + + + + ); + } + + it("does NOT attach session A's reference to session B after the user switches sessions mid-flight", async () => { + let resolveAck: (v: any) => void; + const ack = new Promise((resolve) => { resolveAck = resolve; }); + (window as any).claude.native.send.mockReturnValue(ack); + + const onToast = vi.fn(); + const { rerender } = render(); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "session A text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'hello from A' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + // Optimistic send already cleared the reference (unchanged, correct + // success-path behavior) — the failure branch's restore is what's under + // test here. + expect(referenceApi.reference).toBeNull(); + + // The user switches to a different session BEFORE session A's native + // ack comes back — sessionId prop changes on BOTH InputBar and + // ReferenceProvider together, exactly as App.tsx wires them. + rerender(); + // Session B has never held a reference — its live slot is null. + expect(referenceApi.reference).toBeNull(); + + // NOW session A's send fails. + resolveAck!({ status: 'failed', reason: 'not-live' }); + + await waitFor(() => { + expect(onToast).toHaveBeenCalled(); + }); + // The fix: session A's reference must NOT leak into session B's live + // slot. Without the sessionId guard, `setReference((cur) => cur ?? + // reference)` sees session B's null slot and unconditionally refills it + // with session A's REF — this assertion catches exactly that leak. + expect(referenceApi.reference).toBeNull(); + }); +}); + +// Gap 2 (task-4-report.md "Concerns" #2): terminal view's send paths +// (handleSubmit's `minimal` branch, the textarea onKeyDown's `minimal` +// branch) write straight to the PTY and never call sendMessage/ +// composeOutgoing, so a reference held in chat view would otherwise survive +// invisibly if the user switched to terminal view and sent from there — the +// scaffold is never sent AND the reference never clears. This suite covers +// the "clearing behavior" half (placeholderFor's silencing is covered by the +// pure-function test in InputBar.reference.test.tsx); driving an actual PTY +// send in jsdom isn't attempted here per the brief's guidance. +describe('InputBar — minimal (terminal) mode clears a held reference (gap 2 fix)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"earlier text"', + promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + (window as any).claude = { + native: { supported: true, send: vi.fn() }, + session: { sendInput: vi.fn() }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('clears a reference held from chat view when the composer switches into minimal mode', () => { + const { rerender } = render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + expect(referenceApi.reference).toEqual(REF); + expect(screen.getByPlaceholderText('Ask Claude about "earlier text"')).toBeInTheDocument(); + + // Same sessionId, same provider instances — only `minimal` flips, exactly + // like the real chat-view -> terminal-view toggle (Ctrl+`). + rerender( + + + + + + + + , + ); + + // Without the fix, this stays REF — nothing ever clears it, and the + // "Ask Claude about ..." placeholder would keep promising a scaffold + // that terminal view's send path can never deliver. + expect(referenceApi.reference).toBeNull(); + }); + + it('clears a reference set while the composer is already in minimal mode', () => { + render( + + + + + + + + , + ); + + act(() => { referenceApi.setReference(REF); }); + + // The effect's dep array includes `reference` (not just `minimal`), so it + // re-fires on this set and clears it right back out rather than only + // catching the chat-to-terminal transition. + expect(referenceApi.reference).toBeNull(); + }); +}); + // Task 10 (Destin placement ruling): the stop control moved from beside the // ThinkingIndicator in ChatView into the composer row, immediately left of // the send button. Same visibility gate (isThinking && attentionState === @@ -211,8 +538,10 @@ describe('InputBar — stop button (Task 10 placement)', () => { render( - - + + + + , ); @@ -298,7 +627,9 @@ describe('InputBar — InputBarHandle hasDraft/fillDraft (Task 11)', () => { render( - + + + , ); @@ -370,9 +701,11 @@ describe('InputBar native send — queued ack dispatches QUEUED_MESSAGE_ADDED, n render( - - - + + + + + , ); @@ -393,3 +726,101 @@ describe('InputBar native send — queued ack dispatches QUEUED_MESSAGE_ADDED, n ]); }); }); + +// Regression: InputBar used to dispatch USER_PROMPT/QUEUED_MESSAGE_ADDED with +// the user's raw draft (bubbleMessage.content) while the PTY/native send +// actually carried the reference scaffold prepended (outgoing.ptyText). +// chat-reducer.ts's TRANSCRIPT_USER_MESSAGE dedup matches the confirming +// transcript event against a PENDING timeline entry by exact content +// equality — so the mismatch meant no match was ever found, and the +// transcript event appended a SECOND bubble containing the full scaffold. +// The fix: dispatch outgoing.content (the exact string sent) instead. This +// test drives InputBar's real send() path with a held reference, then feeds +// the resulting content back through TRANSCRIPT_USER_MESSAGE exactly the way +// the transcript watcher would, and asserts on the REAL reducer's timeline — +// not a synthetic reducer-only fixture — so it actually exercises the +// InputBar call site the bug lived in. +describe('InputBar CC/PTY send — dispatches the true sent text so transcript dedup matches (no duplicate bubble)', () => { + const REF: PendingReference = { + kind: 'chat-text', + label: '"earlier text"', + promptText: 'In an earlier message, you said:\n"Done! Created a test file at test-temp.txt."\n\nThe user has a follow-up: ', + anchor: null, + }; + + beforeEach(() => { + (global as any).ResizeObserver = NoopResizeObserver; + capturedDispatch = null; + (window as any).claude = { + native: { supported: true, send: vi.fn() }, + session: { sendInput: vi.fn() }, + skills: { + list: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), + getChips: vi.fn().mockResolvedValue([]), + getCuratedDefaults: vi.fn().mockResolvedValue([]), + }, + }; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('the optimistic USER_PROMPT carries the scaffold; TRANSCRIPT_USER_MESSAGE confirms it in place (no duplicate)', () => { + let capturedStore: ReturnType | null = null; + function StoreCapture() { capturedStore = useChatStore(); return null; } + + render( + + + + + + + + + + , + ); + act(() => { capturedDispatch!({ type: 'SESSION_INIT', sessionId: 'sess-1' }); }); + + act(() => { referenceApi.setReference(REF); }); + const textarea = screen.getByPlaceholderText('Ask Claude about "earlier text"') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "what's in it?" } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + const session = capturedStore!.getState().get('sess-1')!; + expect(session.timeline).toHaveLength(1); + const sent = session.timeline[0]; + if (sent.kind !== 'user') throw new Error('expected a user timeline entry'); + + // The dispatched content must be the FULL sent text — scaffold plus + // draft, sanitized the same way buildOutgoingMessage sanitizes what goes + // to the PTY — not just the raw draft. Before the fix this was + // "what's in it?" alone. + const expectedOutgoing = (REF.promptText + "what's in it?").replace(/[\r\n]+/g, ' ').trim(); + expect(sent.message.content).toBe(expectedOutgoing); + expect(sent.pending).toBe(true); + + // Claude Code's transcript watcher reports back exactly what it received. + act(() => { + capturedDispatch!({ + type: 'TRANSCRIPT_USER_MESSAGE', + sessionId: 'sess-1', + uuid: 'uuid-1', + text: expectedOutgoing, + timestamp: Date.now(), + }); + }); + + const after = capturedStore!.getState().get('sess-1')!; + const userEntries = after.timeline.filter((e) => e.kind === 'user'); + // Pre-fix, no pending entry matched this text (draft-only != scaffold+ + // draft), so this would be 2: the raw "what's in it?" bubble plus a + // second bubble holding the full scaffold text. + expect(userEntries).toHaveLength(1); + if (userEntries[0].kind === 'user') expect(userEntries[0].pending).toBe(false); + }); +}); diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index 9a8f2207a..470ba47ab 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -19,18 +19,19 @@ import type { NativeSendResult } from '../../shared/types'; import { useScrollFade } from '../hooks/useScrollFade'; import { useStreamingGate } from '../hooks/useStreamingGate'; import { isAndroid } from '../platform'; +import { useReference, type PendingReference } from '../state/reference-context'; export interface InputBarHandle { clear: () => void; // Task 11 (cancel/edit queued messages): the edit-refill idiom. There was no // existing "external surface reads/replaces InputBar's draft" mechanism — - // `initialInput` fills once per session id (already consumed for an ACTIVE - // session) and the `youcoded:compose-insert` CustomEvent only prepends - // fire-and-forget (no way to check emptiness first, which the brief's - // ordering — refuse BEFORE removing the queued entry — requires). Extending - // this existing ref (already used by App for `clear()`) with a synchronous - // read + an unconditional replace was the smallest addition that supports - // the required check-then-act sequence; see task-11-report.md. + // `initialInput` only fills once per session id (already consumed for an + // ACTIVE session), which can't support a check-then-act sequence (the + // brief's ordering — refuse BEFORE removing the queued entry — requires + // reading emptiness first). Extending this existing ref (already used by + // App for `clear()`) with a synchronous read + an unconditional replace was + // the smallest addition that supports the required sequence; see + // task-11-report.md. /** True when the composer currently holds a non-empty (trimmed) draft. */ hasDraft: () => boolean; /** Replace the composer's content with `text` and focus it. Caller must call @@ -102,6 +103,33 @@ function sendFailureCopy(result: NativeSendResult | undefined): string { return 'The message could not be sent — no response from the session host.'; } +/** + * Composer placeholder. A held reference replaces "Message Claude..." so the + * empty box states what the next message is about (spec 2026-07-26 §2.1). + * The approval gate outranks it — that copy is a hard block, not a hint. + */ +export function placeholderFor(reference: PendingReference | null, disabled: boolean, minimal = false): string { + if (disabled) return 'Waiting for approval...'; + // Fix (gap 2, task-4-report.md): `minimal` (terminal view) send paths write + // straight to the PTY and never call composeOutgoing (see handleSubmit / + // the textarea's onKeyDown below) — the reference can never be consumed + // there, so announcing it here would promise a scaffold that will never be + // sent. The reference is a chat-composer concept; it goes silent in + // minimal mode. See the clearReference-on-minimal effect in the component + // for why nothing stays held invisibly either. + if (reference && !minimal) return `Ask Claude about ${reference.label}`; + return 'Message Claude...'; +} + +/** + * Assembles what actually goes to Claude. The scaffold lives in the reference, + * NOT in the textarea — this is the whole point of the 2026-07-26 redesign, so + * the user's draft is only ever their own words. + */ +export function composeOutgoing(draft: string, reference: PendingReference | null): string { + return reference ? reference.promptText + draft : draft; +} + const InputBar = forwardRef(function InputBar({ sessionId, disabled, minimal, compact, view, onOpenDrawer, onCloseDrawer, onDrawerSearch, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker, initialInput, provider }, ref) { const [text, setText] = useState(''); const [attachments, setAttachments] = useState([]); @@ -127,10 +155,43 @@ const InputBar = forwardRef(function InputBar({ sessionId // predicate Task 6 used in ChatView (isThinking && attentionState==='ok'). const showStop = useStreamingGate(sessionId); + // The "Ask Claude about this" held reference (spec 2026-07-26). Scoped by + // ReferenceProvider per-session (App.tsx), same parking idiom as draftsRef + // below — reference and draft are independent pieces of per-session state. + const { reference, setReference, clearReference } = useReference(); + + // Fix (gap 2, task-4-report.md): terminal view's send paths (handleSubmit's + // minimal branch, the textarea's onKeyDown minimal branch) write straight + // to the PTY and never touch composeOutgoing/sendMessage, so a reference + // held while the composer is in minimal mode can never be consumed or + // announced (placeholderFor also silences it above, in minimal mode). A + // reference held while its source is off-screen and unconsumable is worse + // than no reference — it would look live in chat view but do nothing here, + // then resurface in chat view later as a stale, forgotten scaffold. + // Deliberately NOT attempting to prepend the scaffold to the PTY write + // instead: desktop/CLAUDE.md documents Windows-ConPTY chunking constraints + // (SAFE_ATOMIC_LEN / echo-driven submit) that a multi-line scaffold prepend + // would trip. Clearing outright is the explicit, honest behavior. + // `reference` is in the dep array (not just `minimal`) so this also covers + // a reference getting set WHILE already in minimal mode (e.g. some future + // caller of setReference that isn't chat-view-gated) — not only the + // chat-to-terminal transition. + useEffect(() => { + if (minimal && reference) clearReference(); + }, [minimal, reference, clearReference]); + // Per-session draft store — keeps input text and attachments separate // across sessions so switching away and back preserves your draft. const draftsRef = useRef>(new Map()); const prevSessionRef = useRef(sessionId); + // Fix (reviewer Critical, cross-session reference leak): always mirrors the + // CURRENTLY rendered sessionId prop — unlike the `sessionId` a send's async + // callback closes over (which is frozen at the moment that particular send + // started), this ref is live. The native-send failure path below compares + // its closure's sessionId against this ref to detect "the user switched + // sessions while the ack was still in flight" before restoring a reference. + const activeSessionIdRef = useRef(sessionId); + useEffect(() => { activeSessionIdRef.current = sessionId; }, [sessionId]); useEffect(() => { const prev = prevSessionRef.current; if (prev === sessionId) return; @@ -293,27 +354,6 @@ const InputBar = forwardRef(function InputBar({ sessionId return () => window.removeEventListener('buddy:attach-file', listener); }, [addFiles]); - // External "insert into composer" entry point — the chat right-click menu's - // "Ask about this" action dispatches this window CustomEvent with a pre-built - // quote + follow-up scaffold. Mirrors buddy:attach-file so no prop threading - // is needed. We PREPEND the scaffold and drop the caret right after it, so any - // draft the user was already typing survives as the follow-up text. - useEffect(() => { - const listener = (e: Event) => { - const insert = (e as CustomEvent<{ text?: string }>).detail?.text; - if (!insert) return; - setText((prev) => insert + prev); - requestAnimationFrame(() => { - const el = inputRef.current; - if (!el) return; - el.focus(); - el.setSelectionRange(insert.length, insert.length); - }); - }; - window.addEventListener('youcoded:compose-insert', listener); - return () => window.removeEventListener('youcoded:compose-insert', listener); - }, []); - const removeAttachment = useCallback((path: string) => { setAttachments((prev) => prev.filter((a) => a.path !== path)); }, []); @@ -382,12 +422,12 @@ const InputBar = forwardRef(function InputBar({ sessionId // Dispatcher may rewrite the message (e.g. strip escape-hatch backslash) const effectiveMessage = dispatchResult.rewritten ?? message; - // One sanitized source string for BOTH the optimistic bubble and the PTY - // send. The transcript confirms the bubble by EXACT content match, so if - // the bubble kept newlines the send stripped, a multiline message could - // never be confirmed — `pending` stayed set forever and - // useSubmitConfirmation fired a stray recovery \r. See outgoing-message.ts. - const outgoing = buildOutgoingMessage(effectiveMessage, files.map((f) => f.path)); + // The held reference's scaffold is prepended HERE, at send — it was + // never in the textarea. On a refused send (the gate above, or + // `disabled` below) we return before this point, so the reference + // survives alongside the draft (spec §7) — clearReference() only runs + // on the success path in `send()`. + const outgoing = buildOutgoingMessage(composeOutgoing(effectiveMessage, reference), files.map((f) => f.path)); if (!outgoing) return true; // nothing to send — treat as consumed if (disabled) return false; @@ -432,6 +472,36 @@ const InputBar = forwardRef(function InputBar({ sessionId // clobber newer input. setText((cur) => (cur.trim() ? cur : effectiveMessage)); setAttachments((cur) => (cur.length > 0 ? cur : files)); + // Fix (gap self-reported in task-4-report.md): `send()` also ran + // clearReference() synchronously in that same post-return block, + // so a failed ack was restoring the draft but leaving the + // reference gone — the user got their text back with the "Ask + // Claude about X" scaffold silently dropped, and resending would + // no longer include it. The draft and its reference are one unit + // (composeOutgoing prepends the scaffold to the draft at send + // time): restoring one without the other silently changes what + // gets sent. Guarded the same way as text/attachments above — + // only refill if nothing newer was set during the round-trip. + // + // Fix (reviewer Critical, cross-session reference leak): ALSO + // guarded on session identity. There is exactly one + // ReferenceProvider for the whole app, keyed to whichever session + // is currently active — its per-session parking effect swaps the + // live slot the instant the user switches sessions. `sessionId` + // here is frozen at the moment THIS send started (closure); if + // the user switched sessions before this ack came back, + // `activeSessionIdRef.current` now names the NEW session while + // `reference` still holds the OLD session's quoted content. + // Restoring unconditionally would silently write session A's + // reference into session B's live slot — the next message sent + // from session B would silently inject another conversation's + // quoted content into Claude's context. Only restore when the + // send's session is still the one on screen. (The setText/ + // setAttachments restores just above have this same race but are + // out of scope for this fix — see task-4-report.md.) + if (sessionId === activeSessionIdRef.current) { + setReference((cur) => cur ?? reference); + } return; } // Task 12: a 'queued' ack dispatches QUEUED_MESSAGE_ADDED instead of @@ -445,6 +515,15 @@ const InputBar = forwardRef(function InputBar({ sessionId type: 'QUEUED_MESSAGE_ADDED', sessionId, queueId: result.queueId, + // Fix (duplicate-bubble bug): must be outgoing.content, the exact + // string that was actually sent (scaffold included when a + // reference was held) — NOT the user's raw draft. chat-reducer's + // TRANSCRIPT_USER_MESSAGE dedup matches the confirming transcript + // event against this timeline entry by CONTENT EQUALITY. A raw + // draft here never equals what Claude echoes back (which includes + // the prepended scaffold), so no pending match is found and the + // transcript event appends a SECOND bubble — the user's own "?" + // in one bubble, the whole scaffold in another. content: outgoing.content, timestamp: Date.now(), }); @@ -452,6 +531,8 @@ const InputBar = forwardRef(function InputBar({ sessionId dispatch({ type: 'USER_PROMPT', sessionId, + // Fix (duplicate-bubble bug) — see the QUEUED_MESSAGE_ADDED + // comment just above; same reasoning applies here. content: outgoing.content, timestamp: Date.now(), attachments: files.map((f) => f.path), @@ -467,6 +548,11 @@ const InputBar = forwardRef(function InputBar({ sessionId dispatch({ type: 'USER_PROMPT', sessionId, + // Fix (duplicate-bubble bug) — see the QUEUED_MESSAGE_ADDED comment + // in the native branch above: this must be outgoing.content, the + // exact string sent (scaffold included), so TRANSCRIPT_USER_MESSAGE's + // content-equality dedup actually finds this pending entry instead of + // appending a second bubble. content: outgoing.content, timestamp: Date.now(), // Exact attachment paths so UserMessage can render each as a clickable @@ -505,7 +591,7 @@ const InputBar = forwardRef(function InputBar({ sessionId }, submitStart); return true; }, - [sessionId, disabled, dispatch, view, provider, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], + [sessionId, disabled, dispatch, view, provider, reference, setReference, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker], ); // Auto-resize textarea to fit content, up to 3 lines then scroll @@ -550,10 +636,14 @@ const InputBar = forwardRef(function InputBar({ sessionId setText(''); setAttachments([]); draftsRef.current.delete(sessionId); // Clear stored draft after sending + // Reference is consumed once its scaffold has been sent (spec §7). A + // refused send returns above, before this line, so the reference + // survives alongside the draft — see sendMessage's outgoing comment. + clearReference(); onCloseDrawer?.(); // Reset height after clearing if (inputRef.current) inputRef.current.style.height = 'auto'; - }, [text, attachments, sendMessage, onCloseDrawer, sessionId]); + }, [text, attachments, sendMessage, onCloseDrawer, sessionId, clearReference]); // Keep sendRef pointing at the latest send so the global keydown handler // (which can't depend on send without thrashing the listener) stays current @@ -632,6 +722,17 @@ const InputBar = forwardRef(function InputBar({ sessionId return (
@@ -771,7 +872,7 @@ const InputBar = forwardRef(function InputBar({ sessionId } }} onPaste={handlePaste} - placeholder={disabled ? 'Waiting for approval...' : 'Message Claude...'} + placeholder={placeholderFor(reference, !!disabled, !!minimal)} disabled={disabled} // Text color is transparent so the mirror div behind it shows // through (with animated keyword spans). caret-color keeps the diff --git a/desktop/src/renderer/components/UserMessage.test.tsx b/desktop/src/renderer/components/UserMessage.test.tsx new file mode 100644 index 000000000..35910700a --- /dev/null +++ b/desktop/src/renderer/components/UserMessage.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +// Pins the inline-reply render (spec 2026-07-26 §2): a message whose content +// is a reference scaffold renders as a quoted strip + follow-up, not the raw +// scaffold string. A plain message renders exactly as before. +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import UserMessage from './UserMessage'; +import { buildScaffold, buildArtifactScaffold, LEAD_ASSISTANT, LEAD_CODE } from './context-menu/reference-prompt'; +import type { ChatMessage } from '../../shared/types'; + +afterEach(cleanup); + +function msg(content: string, overrides: Partial = {}): ChatMessage { + return { id: 'm1', role: 'user', content, timestamp: 1000, ...overrides }; +} + +describe('UserMessage — plain messages are untouched', () => { + it('renders ordinary typed text as before, no reply chrome', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('what is the plan for today?'); + expect(container.querySelector('.border-l-2')).toBeNull(); + }); + + it('does not treat a message that merely mentions the marker text as a reference', () => { + const { container } = render( + , + ); + expect(container.querySelector('.border-l-2')).toBeNull(); + }); +}); + +describe('UserMessage — chat-text reference renders as an inline reply', () => { + const content = buildScaffold(LEAD_ASSISTANT, 'Done! Created a test file.', false) + "what's in it?"; + + it('renders the quote and the follow-up, not the raw scaffold', () => { + render(); + expect(screen.getByText('Done! Created a test file.')).toBeInTheDocument(); + expect(screen.getByText("what's in it?")).toBeInTheDocument(); + // The raw lead-in string must not appear verbatim as its own text node — + // it's consumed by the parser, not dumped into the bubble. + expect(screen.queryByText(LEAD_ASSISTANT, { exact: false })).toBeNull(); + }); + + it('a short quote has no show more/less toggle', () => { + render(); + expect(screen.queryByRole('button', { name: /show more/i })).toBeNull(); + }); +}); + +describe('UserMessage — chat-code reference renders monospaced', () => { + it('renders the fenced code monospaced once expanded', () => { + const content = buildScaffold(LEAD_CODE, 'const x = 1;', true) + 'what does x do?'; + const { container } = render(); + // Collapsed by default (pill); the quote text is present in the pill but the + // monospace treatment belongs to the expanded panel. + fireEvent.click(screen.getByRole('button', { expanded: false })); + expect(screen.getByText('const x = 1;')).toBeInTheDocument(); + expect(container.querySelector('.font-mono')).not.toBeNull(); + }); +}); + +describe('UserMessage — artifact reference renders a compact descriptor line', () => { + it('renders the descriptor as a static pill with no toggle', () => { + const content = buildArtifactScaffold('lines 12-14', 'src/state/chat-reducer.ts') + 'what happens here?'; + render(); + // Renders as the same pill shape as a quote, but static — an artifact + // reference is already a short descriptor, so it never expands. + expect(screen.getByText(/lines 12-14 of chat-reducer\.ts/)).toBeInTheDocument(); + expect(screen.getByText('what happens here?')).toBeInTheDocument(); + expect(screen.queryByRole('button', { expanded: false })).toBeNull(); + }); +}); + +describe('UserMessage — reference collapses to a pill and expands to a panel', () => { + const longQuote = 'x'.repeat(300); + const content = buildScaffold(LEAD_ASSISTANT, longQuote, false) + 'ok'; + + it('is collapsed to a pill by default, so the bubble stays the size of what was typed', () => { + render(); + const pill = screen.getByRole('button', { expanded: false }); + expect(pill).toBeInTheDocument(); + expect(pill.tagName).toBe('BUTTON'); // routes through the Button primitive + // The panel's label only exists once expanded. + expect(screen.queryByText(/claude said/i)).toBeNull(); + }); + + it('expands into the labelled panel, then collapses again', () => { + render(); + fireEvent.click(screen.getByRole('button', { expanded: false })); + + expect(screen.getByText(/claude said/i)).toBeInTheDocument(); + expect(screen.getByText(longQuote)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /hide/i })); + expect(screen.queryByText(/claude said/i)).toBeNull(); + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument(); + }); + + it('a SHORT quote collapses too — the pill is unconditional, not length-gated', () => { + const short = buildScaffold(LEAD_ASSISTANT, 'tiny quote', false) + 'ok'; + render(); + expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/renderer/components/UserMessage.tsx b/desktop/src/renderer/components/UserMessage.tsx index c40c2b0c6..358bb3984 100644 --- a/desktop/src/renderer/components/UserMessage.tsx +++ b/desktop/src/renderer/components/UserMessage.tsx @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ChatMessage } from '../../shared/types'; import LinkableText from './LinkableText'; import { splitFlowingKeywords } from './FlowingKeywords'; import { formatBubbleTime } from '../utils/format-time'; import { detectFilepaths } from '../hooks/useInlineFilepathDetector'; import { FilepathToken } from './FilepathToken'; +import { parseReferencePrompt, type ParsedReference } from './context-menu/reference-prompt'; +import { Button } from './ui/Button'; interface Props { message: ChatMessage; @@ -24,6 +26,125 @@ function renderTextRun(text: string, keyPrefix: string): React.ReactNode[] { ); } +// Filepath-pill + flowing-keyword treatment shared by the plain-message body +// AND a reference reply's follow-up text — one copy so the two can't drift. +function renderMessageBody(text: string, sessionId: string, keyPrefix: string): React.ReactNode[] { + const matches = detectFilepaths(text); + if (matches.length === 0) return renderTextRun(text, keyPrefix); + const out: React.ReactNode[] = []; + let cursor = 0; + matches.forEach((m, mi) => { + if (m.start > cursor) out.push(...renderTextRun(text.slice(cursor, m.start), `${keyPrefix}${mi}`)); + out.push(); + cursor = m.end; + }); + if (cursor < text.length) out.push(...renderTextRun(text.slice(cursor), `${keyPrefix}end`)); + return out; +} + +/** The quoted-reference strip inside a user bubble. + * + * Destin picked options B+D from the dev-review mockup, which compose into one + * control rather than two: D's pill IS the collapsed state, B's labelled panel + * IS the expanded one. Collapsed by default so the bubble stays roughly the + * size of what the user actually typed — the reference is present without + * competing with their own words. + * + * The toggle is a real + ); + } + + // Expanded: the labelled inset panel. + return ( +
+
+ + {label} + +
+
+ {quote} +
+
+ ); +} + +function baseName(p: string): string { + return p.replace(/\\/g, '/').split('/').pop() || p; +} + +/** Compact one-line summary for an artifact reference — no collapsing needed, + * it's already a short descriptor ("lines 12-14 of chat-reducer.ts"), not a + * quoted body. */ +function artifactSummary(descriptor: string, path: string): string { + return descriptor.startsWith('line') ? `${descriptor} of ${baseName(path)}` : `${descriptor} — ${baseName(path)}`; +} + +function ReferenceReplyBody({ parsed, sessionId }: { parsed: ParsedReference; sessionId: string }) { + const followUp = renderMessageBody(parsed.followUp, sessionId, 'f'); + if (parsed.kind === 'artifact') { + return ( + <> + {/* An artifact reference is already a short descriptor + ("lines 12-14 of chat-reducer.ts"), not a quoted body — it renders + as the same pill shape but never needs to expand. */} +
+ + + {artifactSummary(parsed.descriptor, parsed.path)} + +
+ {followUp} + + ); + } + return ( + <> + + {followUp} + + ); +} + export default React.memo(function UserMessage({ message, sessionId, showTimestamps }: Props) { const content = message.content; @@ -44,33 +165,29 @@ export default React.memo(function UserMessage({ message, sessionId, showTimesta if (i < attachments.length - 1 || text.length > 0) attachmentPills.push(' '); } + // Ask Claude About This (spec 2026-07-26): the timeline entry now stores the + // TRUE sent text — scaffold included — so TRANSCRIPT_USER_MESSAGE's + // content-equality dedup can actually match it (see InputBar.tsx's send() + // fix). That means a referenced message's `content` is the raw scaffold + // string, not just the user's words — recover the pieces here and render + // an inline reply block instead of dumping the scaffold as plain text. + const parsed = parseReferencePrompt(text); + // Detect filepaths in the (remaining) typed text and render each as a // clickable pill that opens in the artifact viewer, same as assistant // messages. Non-path spans keep the flowing-keyword + URL-link treatment. // NOTE: this covers the LIVE bubble; a reloaded-from-transcript message // loses attachment paths (the transcript stores images as blocks, not // paths), so pills there fall back to plain text. - const matches = detectFilepaths(text); - - let body: React.ReactNode[]; - if (matches.length === 0) { - body = renderTextRun(text, 't'); - } else { - body = []; - let cursor = 0; - matches.forEach((m, mi) => { - if (m.start > cursor) body.push(...renderTextRun(text.slice(cursor, m.start), `t${mi}`)); - body.push(); - cursor = m.end; - }); - if (cursor < text.length) body.push(...renderTextRun(text.slice(cursor), 'tend')); - } - body = [...attachmentPills, ...body]; + const body: React.ReactNode[] = parsed + ? [] + : renderMessageBody(text, sessionId, 't'); + const rendered = [...attachmentPills, ...body]; return (
- {body} + {rendered} {showTimestamps && (
{formatBubbleTime(message.timestamp)} diff --git a/desktop/src/renderer/components/buddy/BuddyChat.test.tsx b/desktop/src/renderer/components/buddy/BuddyChat.test.tsx new file mode 100644 index 000000000..1962ace60 --- /dev/null +++ b/desktop/src/renderer/components/buddy/BuddyChat.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +// Regression test for the CRITICAL finding: opening a Buddy companion window +// crashed blank. useReference() (reference-context.tsx) used to throw +// unconditionally when no ReferenceProvider ancestor was mounted, and +// InputBar calls useReference() on every render (placeholder text + send-time +// scaffold assembly). Both Buddy hosting strategies — BuddyChatApp.tsx +// (separate-window: Windows/macOS/X11) and BuddyOverlayApp.tsx (Linux +// Wayland overlay) — mount BuddyChat's `` under +// exactly `ThemeProvider > ChatProvider`, with NO ReferenceProvider (App.tsx: +// "Buddy windows render as isolated placeholders without main-app +// providers"), and the buddy early-returns happen before App.tsx's +// wrap with no ErrorBoundary around them. This test pins +// the exact provider stack the Buddy windows actually ship so it can't drift +// from BuddyChatApp.tsx/BuddyOverlayApp.tsx without someone noticing. +// +// There were previously NO tests anywhere under +// src/renderer/components/buddy/ — this is why the full green suite missed +// the regression; this file is the first. +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ThemeProvider } from '../../state/theme-context'; +import { ChatProvider } from '../../state/chat-context'; +import InputBar from '../InputBar'; + +// useScrollFade (mounted unconditionally by InputBar's textarea) reaches for +// ResizeObserver, which jsdom doesn't implement — same stub InputBar.test.tsx +// uses. +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('InputBar under the Buddy provider stack (no ReferenceProvider)', () => { + afterEach(() => { + cleanup(); + }); + + it('mounts without throwing — matches BuddyChatApp/BuddyOverlayApp exactly (ThemeProvider > ChatProvider, no ReferenceProvider)', () => { + (global as any).ResizeObserver = NoopResizeObserver; + + // `compact` matches BuddyChat.tsx's actual usage (``) — compact hides QuickChips, + // which is the one other renderer piece under InputBar that reads a + // context (SkillProvider) neither Buddy tree provides, so this render + // faithfully reproduces what ships rather than papering over a second + // missing provider. + expect(() => { + render( + + + + + , + ); + }).not.toThrow(); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/ContextMenu.tsx b/desktop/src/renderer/components/context-menu/ContextMenu.tsx index 1e26fe755..b7b75ee6f 100644 --- a/desktop/src/renderer/components/context-menu/ContextMenu.tsx +++ b/desktop/src/renderer/components/context-menu/ContextMenu.tsx @@ -139,6 +139,7 @@ export function ContextMenu({ role="menuitem" disabled={entry.disabled} aria-disabled={entry.disabled || undefined} + title={entry.hint} tabIndex={-1} onClick={() => run(entry)} className={[ diff --git a/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx b/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx new file mode 100644 index 000000000..4a1c4d758 --- /dev/null +++ b/desktop/src/renderer/components/context-menu/ContextMenuHost.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +// Fix: pin jsdom explicitly (see ReferenceOverlay.test.tsx / use-esc-close.test.tsx +// for why) — this file lives under src/**/*.test.tsx, outside vitest.config.ts's +// tests/**/*.tsx auto-jsdom glob. +// +// Issue C (final review): "second 'Ask about this' while one is held -> +// replaces it" (spec §7) was dead for chat references. `.reference-scrim` is a +// window-wide `pointer-events: auto` layer that sits ABOVE `.chat-scroll` +// while a reference is held, so a right-click on a dimmed chat message hits +// the scrim first — and buildContextMenu's `.chat-scroll` ancestry gate then +// bails, because the scrim itself is portaled OUTSIDE `.chat-scroll` +// (ReferenceOverlay.tsx portals straight to document.body). The fix teaches +// ContextMenuHost's contextmenu handler to resolve the TRUE element under the +// pointer via `document.elementsFromPoint` whenever the raw event target is +// part of the reference overlay's chrome. +// +// jsdom does not implement `elementsFromPoint` at all (verified: it's simply +// undefined on `document`) — these tests STUB it explicitly rather than +// asserting anything about real hit-testing/paint order, which only a real +// browser can confirm. That is called out again at each stub site. +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; +import { ReferenceProvider, useReference, type PendingReference } from '../../state/reference-context'; +import { ContextMenuHost } from './ContextMenuHost'; + +// jsdom does not implement elementsFromPoint, so the tests below install it. +// Capture whatever was there (normally nothing) and put it back after each +// test — leaving a stub on the shared `document` would change how any later +// test in the same environment feature-detects it. +const originalEFP = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint'); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + if (originalEFP) Object.defineProperty(document, 'elementsFromPoint', originalEFP); + else delete (document as { elementsFromPoint?: unknown }).elementsFromPoint; + vi.unstubAllGlobals(); +}); + +// Test-only bridge so assertions can read the CURRENT held reference without +// reaching into ContextMenuHost's private state — mirrors ReferenceOverlay. +// test.tsx's SetsReference idiom, just reading instead of writing. +function ReadsReference({ onValue }: { onValue: (r: PendingReference | null) => void }) { + const { reference } = useReference(); + onValue(reference); + return null; +} + +function renderHost() { + let latest: PendingReference | null = null; + const utils = render( + + + { latest = r; }} /> + , + ); + return { ...utils, getReference: () => latest }; +} + +// Builds the DOM shape a held chat reference dims: a `.reference-scrim` +// portaled straight to document.body (exactly how ReferenceOverlay.tsx +// portals it — a SIBLING of the chat tree, not an ancestor of it), and a +// separate `.chat-scroll > .user-bubble` subtree underneath it in paint +// order, standing in for the real transcript still visible (dimmed) through +// the scrim. +function buildDimmedChatDom() { + const scrim = document.createElement('div'); + scrim.className = 'reference-scrim'; + document.body.appendChild(scrim); + + const chatScroll = document.createElement('div'); + chatScroll.className = 'chat-scroll'; + const bubble = document.createElement('div'); + bubble.className = 'user-bubble'; + bubble.textContent = 'the dimmed message'; + chatScroll.appendChild(bubble); + document.body.appendChild(chatScroll); + + return { scrim, chatScroll, bubble }; +} + +describe('ContextMenuHost: right-click through the reference scrim (Issue C)', () => { + it('resolves past the scrim to the real dimmed message and opens an actionable menu', () => { + const { scrim, bubble } = buildDimmedChatDom(); + + // STUB, not a real browser hit-test: jsdom has no elementsFromPoint at + // all. This asserts the MECHANISM (ContextMenuHost consults it and picks + // the first non-overlay element), not real paint-order coordinates — a + // dev-instance check is still required to confirm actual hit-testing at + // a real (x, y). + document.elementsFromPoint = vi.fn().mockReturnValue([scrim, bubble, document.body, document.documentElement]); + + renderHost(); + + // The contextmenu event's native target is the SCRIM (what a real + // browser would hit-test first, since it's pointer-events:auto and + // covers the whole window) — not the bubble underneath it. + fireEvent.contextMenu(scrim, { clientX: 40, clientY: 60 }); + + expect(document.elementsFromPoint).toHaveBeenCalledWith(40, 60); + // Before the fix this menu never opens: buildContextMenu(scrim, ...) + // bails at the `.chat-scroll` gate because the scrim is portaled outside + // `.chat-scroll` entirely. + // getByRole throws if not found — this project has no jest-dom matchers + // registered (see other test files: they assert with .not.toBeNull(), + // not .toBeInTheDocument()), so the presence check IS the throw-or-not. + expect(screen.getByRole('menuitem', { name: 'Ask about this' })).not.toBeNull(); + }); + + it('running "Ask about this" resolved through the scrim replaces the held reference', () => { + const { scrim, bubble } = buildDimmedChatDom(); + document.elementsFromPoint = vi.fn().mockReturnValue([scrim, bubble, document.body, document.documentElement]); + + const initial: PendingReference = { + kind: 'chat-text', + label: 'the FIRST held reference', + promptText: 'x', + anchor: null, + }; + + // Object wrapper, not a bare `let`: TS 5.9.3 narrows a `let` that is only + // ever reassigned inside a closure to `never` at later property reads + // (TS2339). Same workaround the context-menu tests already use. + const seen: { current: PendingReference | null } = { current: null }; + render( + + + { seen.current = r; }} /> + + , + ); + expect(seen.current).toBe(initial); + + fireEvent.contextMenu(scrim, { clientX: 40, clientY: 60 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'Ask about this' })); + + // setReference always overwrites (reference-context.tsx) — the state + // layer already supported replacement; what was broken is that the UI + // could never REACH it for chat. `current` is now a DIFFERENT object + // than `initial`, built from the bubble resolved through the scrim. + const replaced = seen.current; + expect(replaced).not.toBeNull(); + expect(replaced).not.toBe(initial); + expect(replaced?.label).toContain('the dimmed message'); + }); + + it('does not touch elementsFromPoint at all when no reference is held (scoped fast path)', () => { + // Deliberately NO `.reference-scrim` in the DOM and NO stub for + // elementsFromPoint — jsdom doesn't implement it, so if the fix called it + // unconditionally (instead of gating on "target is under the scrim"), + // this test would throw a TypeError and fail. Passing proves the "no + // reference held -> behave exactly as today" requirement. + const chatScroll = document.createElement('div'); + chatScroll.className = 'chat-scroll'; + const bubble = document.createElement('div'); + bubble.className = 'user-bubble'; + bubble.textContent = 'a normal message, nothing held'; + chatScroll.appendChild(bubble); + document.body.appendChild(chatScroll); + + renderHost(); + + fireEvent.contextMenu(bubble, { clientX: 10, clientY: 10 }); + + // getByRole throws if not found — this project has no jest-dom matchers + // registered (see other test files: they assert with .not.toBeNull(), + // not .toBeInTheDocument()), so the presence check IS the throw-or-not. + expect(screen.getByRole('menuitem', { name: 'Ask about this' })).not.toBeNull(); + }); + + it('a right-click entirely outside any overlay chrome or actionable surface still opens nothing (regression guard)', () => { + buildDimmedChatDom(); // scrim present, but we click a target OUTSIDE it + const plainDiv = document.createElement('div'); + document.body.appendChild(plainDiv); + + renderHost(); + fireEvent.contextMenu(plainDiv, { clientX: 5, clientY: 5 }); + + expect(screen.queryByRole('menu')).toBeNull(); + }); +}); + +// Sibling bridge for the "replaces" test above — sets its value exactly once +// on mount, same shape as ReferenceOverlay.test.tsx's SetsReference. +function SetsInitial({ value }: { value: PendingReference }) { + const { setReference } = useReference(); + React.useEffect(() => { setReference(value); }, []); + return null; +} diff --git a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx index c8a0de1ab..faffc9aee 100644 --- a/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx +++ b/desktop/src/renderer/components/context-menu/ContextMenuHost.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { buildContextMenu, type MenuEntry } from './build-menu'; import { ContextMenu } from './ContextMenu'; +import { useReference } from '../../state/reference-context'; // Single app-wide right-click host. Listens for `contextmenu` (capture) on the // document, asks build-menu what (if anything) applies to the target, and — only @@ -10,21 +11,58 @@ import { ContextMenu } from './ContextMenu'; type MenuState = { x: number; y: number; entries: MenuEntry[] }; +// Issue C (final review): resolve the TRUE element under the pointer when the +// reference overlay's scrim (or any of its chrome — the trace svg, the +// lifted clone card, the cancel button) sits on top. `.reference-scrim` is a +// window-wide `pointer-events: auto` layer (ReferenceOverlay.tsx) rendered +// while a reference is held, so a plain `e.target` lookup during a +// right-click over a dimmed chat message always resolves to the SCRIM, not +// the message — and buildContextMenu's `.chat-scroll` ancestry gate then +// bails, because the scrim lives outside `.chat-scroll`. That silently killed +// spec §7's "second 'Ask about this' while one is held → replaces it" for +// chat. (Artifact references happened to keep working, but by accident: the +// lifted clone is pinned exactly over the source and cloneNode(true) copies +// the `data-artifact-viewer`/`data-doc-path` attributes onto it, so a +// right-click that lands on the CLONE resolves via that attribute check +// before build-menu.ts's `.chat-scroll` gate is ever reached. That's a +// coincidence of the artifact clone's markup, not something chat can lean +// on.) `elementsFromPoint` walks every element painted at this point, +// TOPMOST first, honoring real stacking order — so this finds whatever is +// genuinely under the cursor (the dimmed message, or the real artifact +// container beneath a clipped/pinned clone) instead of trusting `e.target`. +function resolveContextMenuTarget(e: MouseEvent): HTMLElement | null { + const raw = e.target as HTMLElement | null; + if (!raw) return null; + // Fast path: no reference held (`.reference-scrim` isn't in the DOM at + // all — ReferenceOverlay renders null) or the click landed somewhere + // outside the overlay's chrome entirely. Leaves today's behavior + // untouched byte-for-byte in the common case. + if (!raw.closest('.reference-scrim')) return raw; + const stack = document.elementsFromPoint(e.clientX, e.clientY); + const real = stack.find((el) => !el.closest('.reference-scrim')); + return (real as HTMLElement) ?? null; +} + export function ContextMenuHost() { const [menu, setMenu] = useState(null); + // "Ask about this" now hands the menu a PendingReference instead of + // dispatching the old composer-scaffold CustomEvent — see reference-context.tsx. + const { setReference } = useReference(); useEffect(() => { const onContextMenu = (e: MouseEvent) => { - const target = e.target as HTMLElement | null; + const target = resolveContextMenuTarget(e); if (!target) return; - const entries = buildContextMenu(target); + const entries = buildContextMenu(target, setReference); if (!entries) return; // not our surface — leave the default behavior alone e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY, entries }); }; document.addEventListener('contextmenu', onContextMenu, true); return () => document.removeEventListener('contextmenu', onContextMenu, true); - }, []); + // setReference is useCallback-stable (reference-context.tsx), so listing it + // here does not re-subscribe the listener on every render. + }, [setReference]); if (!menu) return null; return setMenu(null)} />; diff --git a/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx b/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx index 3c9de93c4..a196cd787 100644 --- a/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx +++ b/desktop/src/renderer/components/context-menu/build-menu-cm6.test.tsx @@ -13,6 +13,7 @@ import { render, act } from '@testing-library/react'; import { buildContextMenu } from './build-menu'; import { CodeEditorView } from '../artifact-views/CodeEditorView'; import { editorViewWithin } from '../artifact-views/cm/editor-registry'; +import type { PendingReference } from '../../state/reference-context'; // Minimal geometry shims CM6 needs under jsdom (it measures constantly; jsdom // implements none of it). Zero-rects are fine — we never assert layout. @@ -59,21 +60,32 @@ function selectLine(view: any, lineNo: number, throughLine?: number) { view.dispatch({ selection: { anchor: from, head: to } }); }); const text = view.state.sliceDoc(from, to); - vi.spyOn(window, 'getSelection').mockReturnValue({ toString: () => text } as any); + // Fuller Selection stub: buildArtifactReference (Task 3 wiring) also calls + // captureRange() to populate the reference's anchor, which needs rangeCount / + // isCollapsed / getRangeAt — not just toString(). A real DOM Selection always + // has these; only this synthetic CM6 stub didn't. + vi.spyOn(window, 'getSelection').mockReturnValue({ + toString: () => text, + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => ({ cloneRange: () => ({} as Range) }), + } as any); } describe('CM6 artifact context menu (real component)', () => { it('cites the TRUE line number for a selection far beyond any rendered viewport', () => { const { container, view } = mountEditor(); selectLine(view, 800); - const entries = buildContextMenu(container)!; + // Object wrapper, not a bare `let`: a `let` reassigned only inside the + // callback narrows to `never` at this read site under TS 5.9's control + // flow analysis (confirmed in isolation) — property access on it then + // fails to typecheck even though the runtime value is correct. + const captured: { ref: PendingReference | null } = { ref: null }; + const entries = buildContextMenu(container, (r) => { captured.ref = r; })!; const ask = entries.find((e: any) => e.id === 'ask') as any; expect(ask, 'Ask about this must exist for a CM6 selection').toBeTruthy(); - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - const composed = (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? ''; + const composed = captured.ref?.promptText ?? ''; expect(composed).toContain('line 800'); expect(composed).toContain('"src/big.ts"'); }); @@ -81,13 +93,11 @@ describe('CM6 artifact context menu (real component)', () => { it('cites a range across lines', () => { const { container, view } = mountEditor(); selectLine(view, 42, 45); - const entries = buildContextMenu(container)!; + const captured: { ref: PendingReference | null } = { ref: null }; + const entries = buildContextMenu(container, (r) => { captured.ref = r; })!; const ask = entries.find((e: any) => e.id === 'ask') as any; - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - const composed = (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? ''; + const composed = captured.ref?.promptText ?? ''; expect(composed).toContain('lines 42-45'); }); @@ -97,7 +107,7 @@ describe('CM6 artifact context menu (real component)', () => { // Right-click lands on a node inside .cm-content (contenteditable=false in // read mode) — the artifact branch must win. const target = (container.querySelector('.cm-content') as HTMLElement) ?? container; - const entries = buildContextMenu(target)!; + const entries = buildContextMenu(target, () => {})!; expect(entries.some((e: any) => e.id === 'ask')).toBe(true); expect(entries.some((e: any) => e.id === 'paste')).toBe(false); }); @@ -132,7 +142,7 @@ describe('CM6 artifact context menu (real component)', () => { ); const content = utils.container.querySelector('.cm-content[contenteditable="true"]') as HTMLElement; expect(content, 'editing CM6 must expose an editable .cm-content').toBeTruthy(); - const entries = buildContextMenu(content)!; + const entries = buildContextMenu(content, () => {})!; expect(entries.some((e: any) => e.id === 'paste')).toBe(true); expect(entries.some((e: any) => e.id === 'select-all')).toBe(true); }); diff --git a/desktop/src/renderer/components/context-menu/build-menu.test.tsx b/desktop/src/renderer/components/context-menu/build-menu.test.tsx index 6a41a2117..a9a3e572c 100644 --- a/desktop/src/renderer/components/context-menu/build-menu.test.tsx +++ b/desktop/src/renderer/components/context-menu/build-menu.test.tsx @@ -2,8 +2,9 @@ // Pins the artifact-viewer branch of the right-click menu: the "Ask about this" // scaffold must cite SOURCE LINE NUMBERS for raw text/code views and fall back to // a quote for rendered markdown (whose DOM doesn't map back to source lines). -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { buildContextMenu } from './build-menu'; +import type { PendingReference } from '../../state/reference-context'; // Builds the DOM shape MarkdownView emits for raw text (txt) and rendered md. // CODE files no longer use this shape — CodeMirror replaced CodeView, and its @@ -32,17 +33,16 @@ function selectWithin(node: Node, start: number, end: number) { sel.addRange(range); } -// Runs the menu's "Ask about this" action and returns the text it would insert -// into the composer (delivered via the youcoded:compose-insert CustomEvent). -function composedTextFor(container: HTMLElement): string | null { - const entries = buildContextMenu(container); +// Runs the menu's "Ask about this" action and returns the reference it produces. +// (v1 delivered a string via the youcoded:compose-insert CustomEvent; that event +// is retired — the action now hands a PendingReference to the host's callback.) +function referenceFor(container: HTMLElement): PendingReference | null { + let captured: PendingReference | null = null; + const entries = buildContextMenu(container, (r) => { captured = r; }); const ask = entries?.find((e) => e.type === 'item' && e.id === 'ask'); if (!ask || ask.type !== 'item') return null; - const spy = vi.fn(); - window.addEventListener('youcoded:compose-insert', spy); ask.run(); - window.removeEventListener('youcoded:compose-insert', spy); - return (spy.mock.calls[0]?.[0] as CustomEvent)?.detail?.text ?? null; + return captured; } const FILE = 'alpha\nbravo\ncharlie\ndelta'; @@ -56,7 +56,7 @@ describe('artifact viewer context menu', () => { it('cites a single source line for a one-line selection', () => { const { container, pre } = mountViewer({ path: 'docs/notes.txt', source: 'raw', body: FILE }); selectWithin(pre, 6, 11); // "bravo" — second line - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing line 2 from "docs/notes.txt". Respond to the following prompt accordingly:\n\n', ); }); @@ -64,7 +64,7 @@ describe('artifact viewer context menu', () => { it('cites a line RANGE for a multi-line selection', () => { const { container, pre } = mountViewer({ path: 'src/app.ts', source: 'raw', body: FILE }); selectWithin(pre, 6, 19); // "bravo\ncharlie" — lines 2-3 - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing lines 2-3 from "src/app.ts". Respond to the following prompt accordingly:\n\n', ); }); @@ -72,21 +72,21 @@ describe('artifact viewer context menu', () => { it('falls back to a quote for rendered markdown (no reliable source mapping)', () => { const { container, pre } = mountViewer({ path: 'README.md', source: 'rendered', body: FILE }); selectWithin(pre, 6, 11); - expect(composedTextFor(container)).toBe( + expect(referenceFor(container)?.promptText).toBe( 'The user is referencing "bravo" from "README.md". Respond to the following prompt accordingly:\n\n', ); }); it('offers no "Ask about this" without a selection — the whole file is never implied', () => { const { container } = mountViewer({ path: 'docs/notes.txt', source: 'raw', body: FILE }); - const entries = buildContextMenu(container); + const entries = buildContextMenu(container, () => {}); expect(entries?.some((e) => e.type === 'item' && e.id === 'ask')).toBe(false); }); it('leaves non-artifact, non-chat surfaces alone (no menu hijack)', () => { const stray = document.createElement('div'); document.body.appendChild(stray); - expect(buildContextMenu(stray)).toBeNull(); + expect(buildContextMenu(stray, () => {})).toBeNull(); }); it('gives the artifact edit textarea a cut/copy/paste menu', () => { @@ -94,7 +94,28 @@ describe('artifact viewer context menu', () => { ta.className = 'artifact-edit-textarea'; ta.value = 'draft text'; document.body.appendChild(ta); - const ids = buildContextMenu(ta)?.filter((e) => e.type === 'item').map((e: any) => e.id); + const ids = buildContextMenu(ta, () => {})?.filter((e) => e.type === 'item').map((e: any) => e.id); expect(ids).toEqual(['cut', 'copy', 'paste', 'select-all']); }); }); + +describe('streaming turns', () => { + it('disables Ask about this with a hint while the turn is still writing', () => { + const bubble = document.createElement('div'); + bubble.className = 'assistant-bubble'; + bubble.setAttribute('data-streaming', 'true'); + bubble.textContent = 'partial resp'; + const scroll = document.createElement('div'); + scroll.className = 'chat-scroll'; + scroll.appendChild(bubble); + document.body.appendChild(scroll); + + const entries = buildContextMenu(bubble, () => {})!; + const ask = entries.find((e) => e.type === 'item' && e.id === 'ask'); + expect(ask).toBeDefined(); // disabled, NOT removed + expect(ask!.type === 'item' && ask!.disabled).toBe(true); + expect(ask!.type === 'item' && ask!.hint).toBe( + 'Unavailable while Claude is still writing this message', + ); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/build-menu.ts b/desktop/src/renderer/components/context-menu/build-menu.ts index e6f3bc516..e17812802 100644 --- a/desktop/src/renderer/components/context-menu/build-menu.ts +++ b/desktop/src/renderer/components/context-menu/build-menu.ts @@ -2,6 +2,10 @@ import { isAndroid, isRemoteMode } from '../../platform'; import { copyText, readText } from './clipboard'; import { editorViewFor } from '../artifact-views/cm/editor-registry'; import type { MenuIconName } from './menu-icons'; +import { buildChatReference, buildCodeReference, buildArtifactReference } from './build-reference'; +import type { PendingReference } from '../../state/reference-context'; + +type OnReference = (r: PendingReference) => void; // Builds the chat right-click menu for a given DOM target. Pure inspection of // the DOM + current selection → a list of entries; the host owns positioning, @@ -17,6 +21,10 @@ export type MenuEntry = kbd?: string; primary?: boolean; disabled?: boolean; + /** Hover hint, rendered as `title`. Used to explain a DISABLED row. + * Native `title=` is the documented tool for plain hover hints; + * AnchorTip is for rich click-open info (AnchorTip.tsx:23-25). */ + hint?: string; run: () => void | Promise; } | { type: 'sep' }; @@ -39,6 +47,14 @@ function closestBubble(el: Element): Element | null { return el.closest('.assistant-bubble, .user-bubble'); } +// The lifted reference card is a static clone, so a still-streaming message +// would freeze mid-sentence inside it. Disabled (not hidden) per Destin +// 2026-07-26 — a vanishing row reads worse than a greyed one. +const STREAMING_HINT = 'Unavailable while Claude is still writing this message'; +function isStreaming(el: Element | null): boolean { + return el?.closest('[data-streaming="true"]') != null; +} + function baseName(p: string): string { return p.replace(/\\/g, '/').split('/').pop() || p; } @@ -52,19 +68,6 @@ function selectElementContents(el: Element): void { sel.addRange(range); } -// "Ask about this" drops a quoted reference + follow-up scaffold into the -// composer (InputBar listens for this CustomEvent — see InputBar.tsx). Simple v1 -// per Destin (2026-07-17): plain prompt text, no new plumbing. The caret lands -// right after the scaffold so any existing draft becomes the follow-up. -function askAboutThis(text: string): void { - window.dispatchEvent(new CustomEvent('youcoded:compose-insert', { detail: { text } })); -} - -function scaffold(lead: string, body: string, fenced: boolean): string { - const quoted = fenced ? '```\n' + body + '\n```' : `"${body}"`; - return `${lead}\n${quoted}\n\nThe user has a follow-up: `; -} - // Copy + Select all — shared tail for every read-only chat menu. function textBasics(bubble: Element | null): MenuEntry[] { const sel = selectionText(); @@ -179,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 charlie
12:55 AM
'; + document.body.appendChild(el); + + selectWithin(el, 6, 11); // selects within el.firstChild -> "bravo" + const ref = buildChatReference(el, el)!; + expect(ref.anchor?.selection).toEqual({ start: 6, end: 11 }); + }); + + it('artifact references also capture selection offsets, relative to the container', () => { + const container = document.createElement('div'); + container.setAttribute('data-artifact-viewer', 'true'); + container.setAttribute('data-doc-path', 'docs/notes.txt'); + container.setAttribute('data-artifact-source', 'raw'); + const pre = document.createElement('pre'); + pre.textContent = 'alpha\nbravo\ncharlie'; + container.appendChild(pre); + document.body.appendChild(container); + + selectWithin(pre, 6, 11); // "bravo" + const ref = buildArtifactReference(container)!; + expect(ref.anchor?.selection).toEqual({ start: 6, end: 11 }); + expect(container.textContent!.slice(6, 11)).toBe('bravo'); + }); +}); + +describe('buildCodeReference', () => { + it('fences the code block and strips trailing newlines', () => { + const pre = document.createElement('pre'); + 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 = 'body
12:55 AM
'; + document.body.appendChild(el); + const before = el.outerHTML; + buildChatReference(el, el); + expect(el.outerHTML).toBe(before); + }); +}); diff --git a/desktop/src/renderer/components/context-menu/build-reference.ts b/desktop/src/renderer/components/context-menu/build-reference.ts 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 (
+        <>
+          
+          
+        
+      );
+    }
+
+    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 ;
+  }
+
+  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); +}