Add logical cross-message transcript selection / 新增跨消息逻辑选区 (#8224)

* feat(desktop): add logical transcript selection

Problem:
Cross-message transcript selection either lost native endpoints when virtual rows unmounted or retained every selected row in the DOM.

Root cause:
Selection state depended on live DOM ranges, and Markdown rows had no frozen readable-text projection for virtualized content.

Fix:
- promote cross-row pointer drags to UTF-16 logical selection snapshots
- render selection overlays only for mounted rows and keep virtualization bounded
- derive copy text from the Markdown HAST, including LaTeX, code, and tables
- guard copy and Add to Chat against stale tabs and asynchronous results
- add browser, store, projection, cache, and clipboard coverage

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- go run ./tools/repolint

Known baseline:
- test:bench still hits the retained-heap gate on both this branch and main-v2

* fix(desktop): preserve logical selection endpoints

Problem:
Logical transcript selection could slice Markdown at incompatible offsets, miss the final pointer position, or survive pointer cancellation.

Root cause:
The HAST projection inserted different block separators than the DOM adapter, pointerup stopped dragging before applying its coordinates, and pointercancel shared pointerup button filtering.

Fix:
Use one canonical structural newline within Markdown rows, keep source fallbacks in native mode, apply pointerup coordinates before settling, clear canceled gestures independently, and avoid full Transcript rerenders for logical focus frames.

Verification:
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm test:motion
- pnpm build
- go run ./tools/repolint

* Stabilize logical transcript selection

Problem:
- Cross-page logical selections could lose their overlay after virtual rows committed.
- Long transcripts recomputed layout styles for every cold row estimate.
- Settled selections retained projection pins outside the final selected interval.

Root cause:
- Focus reconciliation could run before the virtualizer committed replacement DOM, overlay observers restarted on every selection snapshot, and cache pins tracked no row positions.

Fix:
- Reconcile focus after virtual DOM mutations and add a bounded overlay stabilization paint.
- Cache one layout snapshot per measurement epoch and reuse it for row estimates.
- Trim frozen rows and release unselected projection pins when logical selection settles.

Verification:
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm test:motion
- pnpm build
- go run ./tools/repolint

* Fix logical transcript selection races

Problem:
Linux browser runs could lose the logical selection overlay. Streaming tokens also rebuilt every historical text projection, expanded offscreen reasoning was omitted, and edge scrolling kept requesting frames at a loaded-history boundary.

Root cause:
Logical focus reconciliation depended on DOM mutation timing, live content was mixed into the full row projection pass, reasoning membership depended on mounted DOM, and the edge loop rescheduled without confirming movement.

Fix:
Reconcile focus after virtual range commits, split stable and live selection projections, use the structural row model for reasoning membership, stop edge scrolling when no offset is written, and cover each path with deterministic tests.

Verification:
- pnpm test:transcript
- CI=true pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm test:motion
- pnpm build
- go run ./tools/repolint
- pnpm test:bench (all relevant gates pass; existing retained-heap baseline remains above its repository gate)

* Reconcile selection on virtualizer commits

Problem:
Linux desktop CI could finish edge scrolling at the loaded-history boundary with zero logical-selection overlay rectangles.

Root cause:
TanStack direct DOM updates can advance the final virtual window without a React virtualRevision commit, and the boundary path stopped before one last caret remap.

Fix:
Reconcile logical focus from the virtualizer onChange callback and schedule exactly one final focus pass when edge scrolling reaches the loaded-history boundary.

Verification:
- pnpm test:transcript
- CI=true pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm build
- go run ./tools/repolint

* Cover both virtual selection commit paths

Problem:
Linux desktop CI could still finish a logical edge-selection with no visible overlay after a virtual page transition.

Root cause:
TanStack direct-DOM range changes can skip a React commit, while its onChange callback can also run before replacement row DOM commits. Either notification alone leaves one ordering uncovered.

Fix:
Reconcile the logical caret from both the virtualizer callback and the post-commit virtual revision. The existing RAF guard coalesces duplicate notifications, and the loaded-history boundary still schedules only one final focus pass.

Verification:
- pnpm test:transcript
- CI=true pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm build
- go run ./tools/repolint

* Reject stale virtual caret targets

Problem:
Linux Chromium repeatedly completed an upward logical drag with mounted rows but no overlay rectangles.

Root cause:
After transformed virtual rows changed, caretPositionFromPoint could return a still-mounted row that no longer matched the pointer's geometric hit target. Accepting that stale row left the logical focus below the visible window.

Fix:
Resolve the geometric selectable root first and accept caret API offsets only when they belong to that root. Otherwise use the visible root fallback. Remove the redundant post-commit callback and cover the stale-caret case with a deterministic DOM test.

Verification:
- pnpm test:transcript
- CI=true pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm build
- go run ./tools/repolint

* Improve transcript selection CI diagnostics

Problem:
The Linux browser gate reports zero logical-selection overlay rectangles even though the same gesture passes locally, leaving no structural evidence to distinguish caret mapping from Range layout failures.

Root cause:
The benchmark only reported row count and scroll-owner data at the assertion boundary.

Fix:
Record structural, content-free browser diagnostics for the edge hit, caret row, mounted and visible selectable rows, scroll geometry, and rows with positive DOM Range rectangles.

Safety:
No message text, selected text, clipboard data, or row identifiers are logged; production code and behavior are unchanged.

Tests:
- node --check desktop/frontend/bench/transcript-selection.mjs
- CI=true pnpm test:transcript-browser

* Reconcile logical focus after virtual commits

Problem:
On Linux, edge scrolling could mount a new virtual page while logical selection focus remained on the prior page, leaving no selected mounted row for the overlay to paint.

Root cause:
The focus RAF ran in the same frame as TanStack virtualizer updates. Coalescing then discarded the commit-triggered request, so narrow mounted windows could keep the caret one virtual page behind.

Fix:
Use a bounded two-frame reconciliation: the first frame lets the virtualizer commit its direct DOM update and the second resolves the caret against that committed page. Pointerup and cleanup still cancel the tracked frame and apply the exact final focus synchronously.

Safety:
The scheduler retains at most one tracked focus frame, does not add scroll writes, does not log transcript content, and keeps the existing bundle budget.

Tests:
- pnpm test:transcript
- CI=true pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm build
- go run ./tools/repolint

* Make transcript drag depth deterministic

Problem:
The Linux browser test reached a valid logical overlay but copied only 14 turns because a fixed six-second edge-scroll duration covered fewer rows under Ubuntu font and layout metrics.

Root cause:
The gesture depth was time-based even though the assertion was content-depth based.

Fix:
Wait until the active drag reaches the first 30 percent of the loaded transcript scroll range before evaluating and copying. The existing 20+ turn assertion remains unchanged.

Safety:
This changes only the production-build browser benchmark, does not reduce coverage, and does not log transcript or selected text.

Tests:
- node --check desktop/frontend/bench/transcript-selection.mjs
- CI=true pnpm test:transcript-browser

* Settle transcript drag on visible text

Problem:
After reaching the required history depth, the Linux benchmark could stop inside a viewport-filling tool row. The settled selection correctly released its offscreen overscan row, so a visible-overlay assertion had no visible selectable target.

Root cause:
The gesture completion predicate checked scroll depth but not whether selectable transcript text intersected the viewport.

Fix:
Keep edge scrolling until both the 30-percent history depth and a visible selectable body are present before pointerup.

Safety:
The test still requires bounded DOM, a painted dragging and settled overlay, 20+ copied turns, and restoration after scrolling away and back. Product code and privacy behavior are unchanged.

Tests:
- node --check desktop/frontend/bench/transcript-selection.mjs
- CI=true pnpm test:transcript-browser

* Stabilize deep transcript drag sampling

Problem: the browser gate sampled logical selection while edge auto-scroll was still advancing, so machine-dependent row heights could leave the caret on a transient or tool-only row and occasionally cover only 19 turns.

Root cause: a scroll-depth ratio was used as a proxy for the intended selectable endpoint, and the assertion raced the selection focus and overlay animation frames.

Fix: stop edge scrolling at a neutral viewport point, locate a visible deterministic early-turn body using user wheel input, move the live pointer onto it, and wait for the logical overlay before sampling diagnostics.

Safety: production code and selection semantics are unchanged; the gate still requires 20+ turns, bounded virtual DOM, selection-only scroll ownership, overlay restoration, copy parity, and the 2MiB cleanup budget.

Tests: CI=true pnpm test:transcript-browser; pnpm test:transcript; node --check bench/transcript-selection.mjs; git diff --check
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 00:23:50 +08:00
1 parent d74ed6ea03
commit 2507187e97
41 files changed
+2854 -378

No files matched your search

+224 -27
View File
@@ -44,6 +44,14 @@ try {
await waitForServer();
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const cdp = await page.context().newCDPSession(page);
await cdp.send("Performance.enable");
const retainedHeap = async () => {
await cdp.send("HeapProfiler.collectGarbage");
await page.waitForTimeout(100);
const metrics = await cdp.send("Performance.getMetrics");
return metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
};
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
@@ -53,6 +61,22 @@ try {
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
), undefined, { timeout: 30_000 });
await page.waitForTimeout(300);
for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (transcript) transcript.scrollTop = 0;
});
await page.waitForTimeout(100);
const older = page.locator(".transcript__older");
if (await older.count() === 0) break;
await older.click();
await page.waitForTimeout(350);
}
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (transcript) transcript.scrollTop = transcript.scrollHeight;
});
await page.waitForTimeout(250);
for (let index = 0; index < 20; index += 1) {
const visibleSelectable = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
@@ -94,20 +118,15 @@ try {
return {
start: { x: startX, y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
activate: { x: Math.min(start.right - 2, startX + 30), y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
edge: { x: startX, y: viewport.top + 24 },
edge: { x: startX, y: viewport.top + 2 },
};
});
assert(points != null, "bench transcript exposes a selectable visible message");
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__trackTranscriptWrites = true;
const original = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = function (...args) {
if (window.__trackTranscriptWrites && this.classList?.contains("transcript")) {
window.__transcriptProgrammaticWrites.push(args);
}
return original.apply(this, args);
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
window.__transcriptProgrammaticWrites.push({ owner, top });
};
});
@@ -125,41 +144,219 @@ try {
await page.mouse.move(points.edge.x, points.edge.y, { steps: 4 });
await page.waitForTimeout(60);
}
await page.mouse.move(points.edge.x, points.edge.y);
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return false;
const max = transcript.scrollHeight - transcript.clientHeight;
return max > 0 && transcript.scrollTop <= max * 0.3;
}, undefined, { timeout: 30_000 });
const neutralPoint = await page.evaluate(() => {
const rect = document.querySelector(".transcript")?.getBoundingClientRect();
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
});
assert(neutralPoint != null, "deep logical drag keeps the transcript mounted");
await page.mouse.move(neutralPoint.x, neutralPoint.y);
await page.waitForTimeout(100);
let logicalFocusPoint = null;
for (let index = 0; index < 40 && !logicalFocusPoint; index += 1) {
logicalFocusPoint = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const root = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((element) => {
const rect = element.getBoundingClientRect();
const turn = element.textContent?.match(/\bbench turn (\d+):/);
return turn && Number(turn[1]) <= 16
&& rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
});
if (!root) return null;
const rect = root.getBoundingClientRect();
return {
x: Math.min(rect.right - 2, rect.left + 8),
y: (Math.max(rect.top, viewport.top) + Math.min(rect.bottom, viewport.bottom)) / 2,
};
});
if (!logicalFocusPoint) {
await page.mouse.wheel(0, -250);
await page.waitForTimeout(50);
}
}
assert(logicalFocusPoint != null, "deep logical drag settles over a visible 20+ turn target");
await page.mouse.move(logicalFocusPoint.x, logicalFocusPoint.y);
await page.waitForFunction(
() => document.querySelectorAll(".transcript-selection-overlay__rect").length > 0,
undefined,
{ timeout: 5_000 },
);
const during = await page.evaluate(() => {
const during = await page.evaluate(({ x, y }) => {
const selection = document.getSelection();
const writes = window.__transcriptProgrammaticWrites ?? [];
const transcript = document.querySelector(".transcript");
const viewport = transcript?.getBoundingClientRect();
const rowIndex = (node) => {
const element = node instanceof Element ? node : node?.parentElement;
const value = element?.closest(".transcript__row")?.dataset.index;
return value == null ? null : Number(value);
};
const selectableRoots = [...document.querySelectorAll("[data-transcript-selectable]")];
const visibleSelectableRows = selectableRoots
.filter((root) => {
const rect = root.getBoundingClientRect();
return viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
})
.map(rowIndex);
const positiveRangeRows = selectableRoots
.filter((root) => {
const range = document.createRange();
range.selectNodeContents(root);
return [...range.getClientRects()].some((rect) => rect.width > 0 && rect.height > 0);
})
.map(rowIndex);
const hit = document.elementFromPoint(x, y);
const caret = document.caretPositionFromPoint?.(x, y)?.offsetNode
?? document.caretRangeFromPoint?.(x, y)?.startContainer;
return {
collapsed: selection?.isCollapsed ?? true,
anchorConnected: selection?.anchorNode?.isConnected ?? false,
focusConnected: selection?.focusNode?.isConnected ?? false,
rows: document.querySelectorAll(".transcript__row").length,
writes: window.__transcriptProgrammaticWrites?.length ?? -1,
mode: document.querySelector(".transcript")?.dataset.scrollMode,
anchorRow: selection?.anchorNode?.parentElement?.closest(".transcript__row")?.dataset.rowKey,
focusRow: selection?.focusNode?.parentElement?.closest(".transcript__row")?.dataset.rowKey,
writeCount: writes.length,
writeOwners: [...new Set(writes.map((write) => write.owner))],
mode: transcript?.dataset.scrollMode,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: transcript?.scrollTop ?? null,
scrollHeight: transcript?.scrollHeight ?? null,
clientHeight: transcript?.clientHeight ?? null,
hitRow: rowIndex(hit),
hitSelectable: Boolean(hit?.closest("[data-transcript-selectable]")),
caretRow: rowIndex(caret),
mountedSelectableRows: selectableRoots.map(rowIndex),
visibleSelectableRows,
positiveRangeRows,
};
});
assert(!during.collapsed, "real mouse drag creates a non-collapsed cross-page selection");
assert(during.anchorConnected && during.focusConnected, "native Selection endpoints remain connected while scrolling");
assert(during.anchorRow !== during.focusRow, `real drag crosses selectable rows (${during.anchorRow} → ${during.focusRow})`);
assert(during.rows > baselineRows, `native fallback retains the selected row interval (${baselineRows} → ${during.rows})`);
if (during.writes !== 0) {
throw new Error(`selection gesture in mode ${during.mode} allowed ${during.writes} programmatic transcript scroll writes`);
}, logicalFocusPoint);
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
assert(during.mode === "logical-selecting", `cross-page drag remains owned by logical selection (${during.mode})`);
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
throw new Error(`logical gesture admitted non-selection scroll owners: ${JSON.stringify(during.writeOwners)}`);
}
assert(true, "selection gesture rejects programmatic transcript scroll writes");
assert(true, "logical gesture rejects every non-selection programmatic scroll owner");
await page.mouse.up();
await page.keyboard.press("Escape");
await page.waitForTimeout(250);
const settled = await page.evaluate(() => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
}));
assert(settled.mode === "manual", "pointerup settles logical selection without a delayed page jump");
assert(settled.overlayRects > 0, "settled logical selection keeps its visible overlay");
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
transcript.dispatchEvent(new Event("scroll"));
});
await page.waitForTimeout(200);
await page.evaluate((top) => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = top;
transcript.dispatchEvent(new Event("scroll"));
}, settled.scrollTop);
await page.waitForTimeout(250);
const restoredRects = await page.locator(".transcript-selection-overlay__rect").count();
assert(restoredRects > 0, "logical overlay restores after selected rows scroll out and back in");
await page.evaluate(() => {
window.__logicalClipboardText = null;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (text) => { window.__logicalClipboardText = text; } },
});
});
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
const copied = await page.evaluate(() => window.__logicalClipboardText);
const copiedTurns = (copied.match(/bench turn /g) ?? []).length;
assert(copiedTurns >= 20, `logical copy resolves a 20+ turn frozen snapshot (${copiedTurns} turns)`);
await page.waitForTimeout(100);
const after = await page.evaluate(() => {
window.__trackTranscriptWrites = false;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
return {
collapsed: document.getSelection()?.isCollapsed ?? true,
rows: document.querySelectorAll(".transcript__row").length,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
};
});
assert(after.collapsed, "Escape clears the retained native selection");
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing selection restores the normal virtual DOM window");
assert(after.collapsed, "logical copy leaves no synthetic browser Range behind");
assert(after.overlayRects === 0, "successful copy clears the logical overlay");
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
const selectionHeapBaseline = await retainedHeap();
const forwardPoints = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
const viewport = transcript.getBoundingClientRect();
const textRects = [...transcript.querySelectorAll("[data-transcript-selectable]")].flatMap((element) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const rects = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
rects.push(...range.getClientRects());
}
return rects;
}).filter((rect) => rect.width > 8 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4);
const start = textRects[0];
if (!start) return null;
const y = (Math.max(start.top, viewport.top + 4) + Math.min(start.bottom, viewport.bottom - 4)) / 2;
return {
start: { x: start.left + 2, y },
activate: { x: Math.min(start.right - 2, start.left + 32), y },
edge: { x: start.left + 2, y: viewport.bottom - 2 },
};
});
assert(forwardPoints != null, "settled reverse selection leaves a viewport where forward selection can start");
await page.evaluate(() => {
window.__transcriptProgrammaticWrites = [];
window.__logicalClipboardText = null;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
window.__transcriptProgrammaticWrites.push({ owner, top });
};
});
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
await page.mouse.down();
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
for (let index = 0; index < 8; index += 1) {
await page.mouse.wheel(0, 650);
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y, { steps: 4 });
await page.waitForTimeout(60);
}
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y);
await page.waitForTimeout(6_000);
const forwardDuring = await page.evaluate(() => ({
mode: document.querySelector(".transcript")?.dataset.scrollMode,
rows: document.querySelectorAll(".transcript__row").length,
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
}));
assert(forwardDuring.mode === "logical-selecting", "downward cross-page drag also promotes to logical selection");
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
await page.mouse.up();
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
const forwardCopiedTurns = await page.evaluate(() => (window.__logicalClipboardText.match(/bench turn /g) ?? []).length);
assert(forwardCopiedTurns >= 20, `forward logical copy resolves a 20+ turn frozen snapshot (${forwardCopiedTurns} turns)`);
await page.waitForFunction(() => document.querySelectorAll(".transcript-selection-overlay__rect").length === 0);
const retainedSelectionBytes = Math.max(0, (await retainedHeap()) - selectionHeapBaseline);
assert(retainedSelectionBytes <= 2 * 1024 * 1024, `cleared logical selection retains at most 2MiB (${(retainedSelectionBytes / 1024 / 1024).toFixed(2)}MiB)`);
await page.evaluate(() => { window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; });
} finally {
await browser?.close();
preview.kill("SIGTERM");
+1 -1
View File
@@ -19,7 +19,7 @@
"test:workspace": "tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-virtualization.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
@@ -80,7 +80,7 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// Native Web Animations and frame-batched scrolling avoid an eager animation
// runtime. Goal request observability plus transcript scroll arbitration,
// selection lifecycle fencing, and measurement invalidation add small
// logical selection state/DOM adapters, and measurement invalidation add small
// always-available contracts; keep raw allowance tight while gzip stays flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_218 * 1024);
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_240 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -59,8 +59,20 @@ if (motionScript.includes("transcript-virtualization.test.tsx")) {
}
const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
if (transcriptScript !== "tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-virtualization.test.tsx") {
throw new Error("motion-ci-contract: test:transcript must own the transcript selection and virtualization suites");
for (const required of [
"transcript-selection-runtime.test.ts",
"scroll-manager.test.tsx",
"transcript-selection-retention.test.tsx",
"transcript-logical-selection.test.ts",
"markdown-pipeline.test.tsx",
"message-selection-copy.test.ts",
"transcript-selection-menu.test.tsx",
"transcript-store.test.ts",
"transcript-virtualization.test.tsx",
]) {
if (!transcriptScript.includes(required)) {
throw new Error(`motion-ci-contract: test:transcript must include ${required}`);
}
}
const transcriptCommand = "pnpm --dir frontend test:transcript";
@@ -9,7 +9,7 @@ import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import MarkdownHistory from "../components/MarkdownHistory";
import { parseMarkdownToBlocks, markdownContentRevision } from "../lib/markdownPipeline";
import { parseMarkdown, markdownContentRevision } from "../lib/markdownPipeline";
import {
disposeMarkdownWorkerClient,
MarkdownWorkerClient,
@@ -81,7 +81,7 @@ const parseCalls: string[] = [];
const newSpyClient = () => new MarkdownWorkerClient({
parseInProcess: (text) => {
parseCalls.push(text);
return parseMarkdownToBlocks(text);
return parseMarkdown(text);
},
});
@@ -95,14 +95,14 @@ console.log("\nmarkdown history rendering");
// A deferred fake worker keeps the parse in flight so the fallback can be
// observed; the global Worker stub routes the client down the worker path.
(globalThis as { Worker?: unknown }).Worker = class {};
let resolveParse: ((blocks: ReturnType<typeof parseMarkdownToBlocks>) => void) | null = null;
let resolveParse: ((result: ReturnType<typeof parseMarkdown>) => void) | null = null;
const deferred = new MarkdownWorkerClient({
createWorker: () => Promise.resolve({
onmessage: null,
onerror: null,
postMessage(request) {
resolveParse = (blocks) => {
const message = { data: { id: request.id, blocks } };
resolveParse = (result) => {
const message = { data: { id: request.id, result } };
(this.onmessage as ((event: unknown) => void) | null)?.(message);
};
},
@@ -116,7 +116,7 @@ console.log("\nmarkdown history rendering");
});
eq(rootEl.textContent, text, "fallback shows the full text while parsing");
await act(async () => {
resolveParse?.(parseMarkdownToBlocks(text));
resolveParse?.(parseMarkdown(text));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(rootEl.querySelector('.md[data-markdown-blocks="3"]'), "parsed blocks render after the worker resolves");
@@ -9,6 +9,7 @@
import { createElement, Fragment, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { JSDOM } from "jsdom";
import { normalizeMath } from "../components/mathNormalize";
import { createComponents } from "../components/markdownComponents";
import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
@@ -18,11 +19,13 @@ import {
estimateHastBytes,
markdownContentRevision,
markdownUrlTransform,
parseMarkdown,
parseMarkdownToBlocks,
parseMarkdownToHast,
sliceHastBlocks,
type MarkdownBlock,
} from "../lib/markdownPipeline";
import { projectTranscriptSelectableDom } from "../lib/transcriptSelectionDom";
let passed = 0;
let failed = 0;
@@ -64,6 +67,17 @@ function renderBlocks(blocks: MarkdownBlock[]): string {
);
}
function projectRenderedBlocks(blocks: MarkdownBlock[]): string {
const dom = new JSDOM(`<!doctype html><body><div id="root" data-transcript-selectable>${renderBlocks(blocks)}</div></body>`);
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
const root = dom.window.document.getElementById("root") as HTMLElement;
const projected = projectTranscriptSelectableDom(root).text;
dom.window.close();
return projected;
}
console.log("\nmarkdown pipeline parity");
const bigCode = "```js\n" + "const value = compute(index); // keep this line long enough\n".repeat(4000) + "```";
@@ -176,5 +190,46 @@ for (const unsafe of [
ok(estimateHastBytes(bigBlocks) > bytes, "hast byte estimate grows with content");
}
console.log("\nmarkdown selection projection");
{
const result = parseMarkdown([
"# 标题 😀",
"",
"段落 [链接文字](https://example.com) 与 $x^2$。",
"",
"内联 **粗体** *斜体*。",
"",
"- 第一项",
"- 第二项",
"",
"```ts",
"const value = 1;",
"```",
"",
"| 名称 | 值 |",
"| --- | --- |",
"| 一 | 1 |",
].join("\n"));
eq(
result.selectionText,
"标题 😀\n段落 链接文字 与 $x^2$。\n内联 粗体 斜体。\n第一项\n第二项\nconst value = 1;\n名称\t值\n一\t1",
"selection projection preserves readable structure, code, tables, CJK, emoji and LaTeX",
);
eq(
result.selectionText,
projectRenderedBlocks(result.blocks),
"selection projection uses the same UTF-16 text as the rendered DOM adapter",
);
eq(result.selectionRevision, markdownContentRevision(result.selectionText), "selection revision fingerprints projected UTF-16 text");
}
{
const rows = Array.from({ length: 52 }, (_, index) => `| row-${index} | ${index} |`).join("\n");
const result = parseMarkdown(`| name | value |\n| --- | --- |\n${rows}`);
ok(result.blocks.some((block) => block.virtualTable), "large plain table uses the virtual table representation");
ok(result.selectionText.includes("row-51\t51"), "virtual table projection includes rows that never mount in the DOM");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -14,7 +14,7 @@ import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { createServer } from "vite";
import type { MarkdownBlock } from "../lib/markdownPipeline";
import type { MarkdownBlock, MarkdownParseResult } from "../lib/markdownPipeline";
import type { MarkdownWorkerClient as MarkdownWorkerClientType } from "../lib/markdownWorkerClient";
let passed = 0;
@@ -76,6 +76,11 @@ const WORKER_BLOCKS: MarkdownBlock[] = [{
children: [{ type: "text", value: "WORKER-PARSED-FINAL" }],
}],
}];
const WORKER_RESULT: MarkdownParseResult = {
blocks: WORKER_BLOCKS,
selectionText: "WORKER-PARSED-FINAL",
selectionRevision: 1,
};
console.log("\nmarkdown streaming → worker final parse");
@@ -87,7 +92,7 @@ console.log("\nmarkdown streaming → worker final parse");
onerror: null,
postMessage(request: { id: number; text: string }) {
parseCalls.push(request.text);
respond = () => this.onmessage?.({ data: { id: request.id, blocks: WORKER_BLOCKS } });
respond = () => this.onmessage?.({ data: { id: request.id, result: WORKER_RESULT } });
},
terminate() {},
};
@@ -11,7 +11,7 @@ import {
type MarkdownParseResponse,
type MarkdownWorkerLike,
} from "../lib/markdownWorkerClient";
import type { MarkdownBlock } from "../lib/markdownPipeline";
import type { MarkdownBlock, MarkdownParseResult } from "../lib/markdownPipeline";
let passed = 0;
let failed = 0;
@@ -47,8 +47,8 @@ class FakeWorker implements MarkdownWorkerLike {
postMessage(request: MarkdownParseRequest): void {
this.sent.push(request);
}
respond(id: number, blocks: MarkdownBlock[]): void {
this.onmessage?.({ data: { id, blocks } } as MessageEvent<MarkdownParseResponse>);
respond(id: number, result: MarkdownParseResult): void {
this.onmessage?.({ data: { id, result } } as MessageEvent<MarkdownParseResponse>);
}
fail(id: number, error: string): void {
this.onmessage?.({ data: { id, error } } as MessageEvent<MarkdownParseResponse>);
@@ -62,6 +62,7 @@ class FakeWorker implements MarkdownWorkerLike {
}
const BLOCKS: MarkdownBlock[] = [{ key: "b0", children: [{ type: "text", value: "hi" }] }];
const RESULT: MarkdownParseResult = { blocks: BLOCKS, selectionText: "hi", selectionRevision: 1 };
console.log("\nmarkdown worker client");
@@ -72,9 +73,9 @@ console.log("\nmarkdown worker client");
const handle = client.parse("hello");
await tick();
eq(worker.sent.length, 1, "request posted to the worker");
worker.respond(worker.sent[0].id, BLOCKS);
const blocks = await handle.promise;
eq(blocks, BLOCKS, "response resolves with parsed blocks");
worker.respond(worker.sent[0].id, RESULT);
const result = await handle.promise;
eq(result, RESULT, "response resolves with the full parse result");
eq(client.pendingCount, 0, "pending map drains after a response");
}
@@ -88,7 +89,7 @@ console.log("\nmarkdown worker client");
const cancelled = await handle.promise;
eq(cancelled, undefined, "cancelled parse resolves undefined");
eq(worker.terminated, 1, "cancelling active work terminates the stale parser");
worker.respond(worker.sent[0].id, BLOCKS); // late response for a dead id
worker.respond(worker.sent[0].id, RESULT); // late response for a dead id
await tick();
eq(client.pendingCount, 0, "late response for a cancelled id is dropped");
}
@@ -144,8 +145,8 @@ console.log("\nmarkdown worker client");
const retry = client.parse("again");
await tick();
eq(creations, 2, "a fresh parse recreates the worker after a crash");
worker.respond(worker.sent[worker.sent.length - 1].id, BLOCKS);
eq(await retry.promise, BLOCKS, "recreated worker serves the retry");
worker.respond(worker.sent[worker.sent.length - 1].id, RESULT);
eq(await retry.promise, RESULT, "recreated worker serves the retry");
}
// ── in-process fallback when Worker is unavailable ───────────────────────────
@@ -155,11 +156,11 @@ console.log("\nmarkdown worker client");
const client = new MarkdownWorkerClient({
parseInProcess: (text) => {
seen.push(text);
return BLOCKS;
return RESULT;
},
});
const blocks = await client.parse("fallback text").promise;
eq(blocks, BLOCKS, "fallback resolves parsed blocks without a Worker");
eq(blocks, RESULT, "fallback resolves the parse result without a Worker");
eq(seen.join(","), "fallback text", "fallback receives the exact source text");
(globalThis as { Worker?: unknown }).Worker = class {};
}
@@ -194,9 +195,9 @@ console.log("\nmarkdown worker client");
const id = worker.sent[worker.sent.length - 1].id;
if (cycle % 3 === 0) {
handle.cancel();
worker.respond(id, BLOCKS); // dropped
worker.respond(id, RESULT); // dropped
} else if (cycle % 3 === 1) {
worker.respond(id, BLOCKS);
worker.respond(id, RESULT);
} else {
worker.fail(id, `error ${cycle}`);
}
@@ -219,7 +220,7 @@ console.log("\nmarkdown worker client");
const b = client.parse("b");
await tick();
eq(worker.sent.length, 1, "worker queue runs only one parse at a time");
worker.respond(worker.sent[0].id, BLOCKS);
worker.respond(worker.sent[0].id, RESULT);
await a.promise;
await tick();
ok(worker.sent[1].id > worker.sent[0].id, "request ids increase monotonically");
@@ -8,9 +8,11 @@ import { JSDOM } from "jsdom";
import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
import { normalizeMath } from "../components/mathNormalize";
import {
installMessageSelectionCopy,
messageSelectionContextText,
messageSelectionCopyText,
} from "../lib/messageSelectionCopy";
import { transcriptSelectionStore, type TranscriptSelectableRow } from "../lib/transcriptSelectionStore";
let passed = 0;
let failed = 0;
@@ -257,5 +259,60 @@ function inlineKatex(source: string, rendered: string): string {
);
}
{
const dom = new JSDOM("<!doctype html><body><div class=\"msg__body\">logical</div></body>");
installDOMGlobals(dom);
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
const writes: string[] = [];
Object.defineProperty(dom.window.navigator, "clipboard", {
configurable: true,
value: { writeText: async (text: string) => { writes.push(text); } },
});
let finish!: (text: string) => void;
const delayed = new Promise<string>((resolve) => { finish = resolve; });
const rows: TranscriptSelectableRow[] = [{
rowKey: "a",
sourceText: "fallback",
contentRevision: 1,
resolveText: () => delayed,
}];
transcriptSelectionStore.clear("copy-test-reset");
transcriptSelectionStore.beginNative("tab-copy");
transcriptSelectionStore.promoteToLogical(
"tab-copy",
{ rowKey: "a", textOffset: 0, affinity: "forward" },
{ rowKey: "a", textOffset: 8, affinity: "forward" },
rows,
);
transcriptSelectionStore.settleLogical();
const uninstall = installMessageSelectionCopy(document);
const staleCopy = new window.Event("copy", { bubbles: true, cancelable: true });
document.dispatchEvent(staleCopy);
eq(staleCopy.defaultPrevented, true, "logical copy claims the event before async projection resolves");
transcriptSelectionStore.clear("tab-switch");
finish("resolved");
await new Promise((resolve) => setTimeout(resolve, 0));
eq(writes.length, 0, "late logical copy result cannot write after its snapshot is cleared");
transcriptSelectionStore.beginNative("tab-copy");
transcriptSelectionStore.promoteToLogical(
"tab-copy",
{ rowKey: "a", textOffset: 0, affinity: "forward" },
{ rowKey: "a", textOffset: 8, affinity: "forward" },
[{ ...rows[0], resolveText: async () => "resolved" }],
);
transcriptSelectionStore.settleLogical();
document.dispatchEvent(new window.Event("copy", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
eq(writes[0], "resolved", "logical keyboard copy writes the frozen text projection");
eq(transcriptSelectionStore.getSnapshot().mode, "none", "successful logical keyboard copy clears the snapshot");
uninstall();
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,293 @@
// Run: tsx src/__tests__/transcript-logical-selection.test.ts
import { JSDOM } from "jsdom";
import {
domPointToTranscriptOffset,
domRangeForTranscriptOffsets,
projectTranscriptSelectableDom,
transcriptSelectionPointFromClient,
transcriptSelectionProjectionReadyForNode,
} from "../lib/transcriptSelectionDom";
import {
TranscriptSelectionStore,
type TranscriptSelectableRow,
type TranscriptSelectionPoint,
} from "../lib/transcriptSelectionStore";
import {
mergeTranscriptSelectableRows,
transcriptLiveSelectableRows,
transcriptSelectableRows,
userMessageSelectionText,
} from "../lib/transcriptSelectionText";
import type { TranscriptRow } from "../lib/transcriptRows";
import { TranscriptMarkdownCache, type ParsedMarkdownValue } from "../lib/transcriptMarkdownCache";
import { formatSelectedTextContext, formatSelectionLabels } from "../lib/selectedTextContext";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
const point = (rowKey: string, textOffset: number): TranscriptSelectionPoint => ({
rowKey,
textOffset,
affinity: "forward",
});
const row = (rowKey: string, text: string, revision = 1, pin?: () => () => void): TranscriptSelectableRow => ({
rowKey,
sourceText: text,
contentRevision: revision,
resolveText: async () => text,
pin,
});
console.log("\nlogical transcript selection store");
{
const store = new TranscriptSelectionStore();
const rows = [row("a", "alpha"), row("b", "bravo"), row("c", "charlie")];
const id = store.beginNative("tab-a");
store.updateNativeRange(point("a", 2), point("c", 3));
eq(store.promoteToLogical("tab-a", point("a", 2), point("c", 3), rows), id, "cross-row selection promotes in the source tab");
let redundantUpdates = 0;
const unsubscribe = store.subscribe(() => { redundantUpdates += 1; });
store.updateLogicalFocus(point("c", 3));
unsubscribe();
eq(redundantUpdates, 0, "unchanged caret reconciliation does not republish the selection snapshot");
store.settleLogical();
eq(await store.resolveText(id), "pha\n\nbravo\n\ncha", "forward selection resolves partial endpoints and full middle rows");
eq(store.getSnapshot().mode, "logical-settled", "pointer release settles logical selection");
}
{
const store = new TranscriptSelectionStore();
const rows = [
row("answer-a", "alpha"),
{ ...row("offscreen-reasoning", "expanded thought"), kind: "reasoning" as const },
row("answer-b", "bravo"),
];
const id = store.beginNative("tab-offscreen-reasoning");
store.promoteToLogical(
"tab-offscreen-reasoning",
point("answer-a", 0),
point("answer-b", 5),
rows,
);
eq(
await store.resolveText(id),
"alpha\n\nexpanded thought\n\nbravo",
"expanded offscreen reasoning remains in logical selection even when its DOM is not mounted",
);
}
{
const store = new TranscriptSelectionStore();
const rows = [row("a", "A😀B"), row("b", "second")];
const id = store.beginNative("tab-r");
store.promoteToLogical("tab-r", point("b", 4), point("a", 3), rows);
eq(store.getSnapshot().direction, "backward", "reverse drag records direction");
eq(await store.resolveText(id), "B\n\nseco", "reverse drag still copies document order using UTF-16 offsets");
}
{
let pins = 0;
const store = new TranscriptSelectionStore();
const rows = [row("a", "one", 1, () => { pins += 1; return () => { pins -= 1; }; }), row("b", "two")];
store.beginNative("tab-p");
store.promoteToLogical("tab-p", point("a", 0), point("b", 3), rows);
eq(pins, 1, "promotion pins active cached projections");
ok(store.validateRowChanges([row("a", "one more", 2)]), "append-only live content keeps the frozen selected prefix");
eq(store.getSnapshot().mode, "logical-dragging", "incremental live validation does not clear an append-only selection");
ok(!store.validateRowChanges([row("a", "replaced", 3)]), "non-append live replacement invalidates selected content");
eq(store.getSnapshot().mode, "none", "invalid content clears the logical selection");
eq(pins, 0, "selection cleanup releases projection pins");
}
{
const cache = new TranscriptMarkdownCache(16);
const store = new TranscriptSelectionStore();
const rows = ["a", "b", "c", "d"].map((rowKey) => row(
rowKey,
rowKey,
1,
() => cache.pin(rowKey, 1),
));
const value = (source: string): ParsedMarkdownValue => ({
source,
blocks: [],
selectionText: source,
selectionRevision: 1,
bytes: 8,
});
store.beginNative("tab-budget");
store.promoteToLogical("tab-budget", point("b", 0), point("c", 1), rows);
for (const entry of rows) cache.set(entry.rowKey, 1, value(entry.sourceText));
eq(cache.bytes, 32, "dragging may temporarily pin every prospective selection row");
store.settleLogical();
eq(cache.bytes, 16, "settling releases unselected projections back to the cache budget");
eq(cache.size(), 2, "settled selection retains only its final row interval");
eq([...store.getSnapshot().contentRevisions.keys()].join(","), "b,c", "settled snapshot drops revisions outside its final interval");
store.clear("done");
}
{
let resolve!: (text: string) => void;
const deferred = new Promise<string>((done) => { resolve = done; });
const store = new TranscriptSelectionStore();
const deferredRow: TranscriptSelectableRow = {
rowKey: "a",
sourceText: "fallback",
contentRevision: 1,
resolveText: () => deferred,
};
const id = store.beginNative("tab-stale");
store.promoteToLogical("tab-stale", point("a", 0), point("a", 8), [deferredRow]);
const pending = store.resolveText(id);
store.clear("tab-switch");
resolve("resolved");
eq(await pending, "", "late async projection cannot resolve a cleared snapshot");
ok(!store.isCurrent(id, "tab-stale"), "cleared snapshot id cannot be reused by async consumers");
}
console.log("\nlogical transcript DOM adapter");
{
const dom = new JSDOM(`<!doctype html><body>
<div class="transcript__row" data-row-key="a">
<div id="root" data-transcript-selectable="message">
<p>Hello <strong>world</strong><br>next</p>
<p><strong>bold</strong> <em>italic</em></p>
<span class="katex" data-latex-source="x^2"><span aria-hidden="true">rendered</span></span>
<button>Copy</button>
<table><tbody><tr><td>A</td><td>1</td></tr><tr><td>B</td><td>2</td></tr></tbody></table>
</div>
<div data-transcript-selectable="reasoning">visible thought</div>
</div>
</body>`);
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Range = dom.window.Range;
const root = document.getElementById("root") as HTMLElement;
ok(transcriptSelectionProjectionReadyForNode(root.firstChild), "rendered Markdown DOM is eligible for logical promotion");
const projection = projectTranscriptSelectableDom(root);
eq(projection.text, "Hello world\nnext\nbold italic\n$x^2$\nA\t1\nB\t2", "DOM projection filters controls and restores formula/table structure");
const hello = root.querySelector("p")?.firstChild as Text;
eq(domPointToTranscriptOffset(root, hello, 3), 3, "DOM text boundary maps to a UTF-16 projection offset");
const range = domRangeForTranscriptOffsets(root, 6, 11);
eq(range?.toString(), "world", "logical offsets map back to a DOM highlight range");
}
{
const dom = new JSDOM(`<!doctype html><body>
<div class="transcript__row" data-row-key="stale"><div id="stale" data-transcript-selectable="message">stale</div></div>
<div class="transcript__row" data-row-key="visible"><div id="visible" data-transcript-selectable="message">visible</div></div>
</body>`);
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Range = dom.window.Range;
const stale = document.getElementById("stale") as HTMLElement;
const visible = document.getElementById("visible") as HTMLElement;
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => visible });
Object.defineProperty(document, "caretPositionFromPoint", {
configurable: true,
value: () => ({ offsetNode: stale.firstChild, offset: 2 }),
});
Object.defineProperty(visible, "getBoundingClientRect", {
configurable: true,
value: () => ({ left: 0, right: 100, top: 0, bottom: 20, width: 100, height: 20 }),
});
eq(
transcriptSelectionPointFromClient(document, 10, 10)?.rowKey,
"visible",
"client caret rejects a stale virtual row outside the geometric hit target",
);
dom.window.close();
}
{
const dom = new JSDOM(`<!doctype html><body>
<div class="transcript__row" data-row-key="fallback">
<div data-transcript-selectable="message"><div data-transcript-selection-source-fallback>**raw**</div></div>
</div>
</body>`);
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
const raw = document.querySelector("[data-transcript-selection-source-fallback]")?.firstChild ?? null;
ok(!transcriptSelectionProjectionReadyForNode(raw), "plain Markdown source fallback stays in native selection mode");
dom.window.close();
}
console.log("\nuser transcript selection projection");
{
const selected = [{ id: "s1", text: "quoted context" }];
const labels = formatSelectionLabels(selected);
const submit = `question @/tmp/report.md\n\n${formatSelectedTextContext(selected)}`;
eq(
userMessageSelectionText(`question @/tmp/report.md ${labels}`, submit),
"question\n\nquoted context",
"user projection keeps body and selected context while filtering attachment/card metadata",
);
}
console.log("\nstreaming transcript selection projection");
{
const rows: TranscriptRow[] = [
{
kind: "answer",
key: "a:history",
item: { kind: "assistant", id: "history", text: "stable history", reasoning: "", streaming: false },
},
{
kind: "answer",
key: "a:live",
item: { kind: "assistant", id: "live", text: "", reasoning: "", streaming: true },
},
];
const base = transcriptSelectableRows(rows);
const byKey = new Map(base.map((entry) => [entry.rowKey, entry]));
const firstLive = transcriptLiveSelectableRows(byKey, {
id: "live",
text: "one",
reasoning: "",
reasoningComplete: true,
});
const secondLive = transcriptLiveSelectableRows(byKey, {
id: "live",
text: "one two",
reasoning: "",
reasoningComplete: true,
});
eq(firstLive.length, 1, "a streaming token projects only the active selectable row");
const firstMerged = mergeTranscriptSelectableRows(base, firstLive);
const secondMerged = mergeTranscriptSelectableRows(base, secondLive);
ok(firstMerged[0] === base[0] && secondMerged[0] === base[0], "successive stream updates reuse every historical projection object");
eq(secondMerged[1]?.sourceText, "one two", "the live override tracks the newest streamed answer text");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -24,6 +24,10 @@ import { createRoot } from "react-dom/client";
import { TranscriptSelectionMenu } from "../components/TranscriptSelectionMenu";
import { LocaleProvider } from "../lib/i18n";
import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
import {
transcriptSelectionStore,
type TranscriptSelectableRow,
} from "../lib/transcriptSelectionStore";
let passed = 0;
let failed = 0;
@@ -410,5 +414,115 @@ console.log("\ntranscript selection menu");
dom.window.close();
}
{
const dom = installDom();
const clipboard: string[] = [];
const additions: string[] = [];
(window as unknown as { runtime: { ClipboardSetText: (text: string) => Promise<boolean> } }).runtime = {
ClipboardSetText: async (text: string) => {
clipboard.push(text);
return true;
},
};
document.body.insertAdjacentHTML(
"beforeend",
'<div class="transcript__row" data-row-key="row-a"><div class="msg__body" data-transcript-selectable="message">alpha</div></div>' +
'<div class="transcript__row" data-row-key="row-b"><div class="msg__body" data-transcript-selectable="message">bravo</div></div>',
);
const first = document.querySelector<HTMLElement>('[data-row-key="row-a"] .msg__body')!;
const root = createRoot(document.getElementById("root") as HTMLElement);
const logicalRows = (resolvers?: Partial<Record<string, () => Promise<string>>>): TranscriptSelectableRow[] => [
{
rowKey: "row-a",
sourceText: "alpha",
contentRevision: 1,
resolveText: resolvers?.["row-a"] ?? (async () => "alpha"),
},
{
rowKey: "row-b",
sourceText: "bravo",
contentRevision: 1,
resolveText: resolvers?.["row-b"] ?? (async () => "bravo"),
},
];
const selectLogical = (rows = logicalRows()) => {
transcriptSelectionStore.clear("test-reset");
transcriptSelectionStore.beginNative("tab-logical");
transcriptSelectionStore.promoteToLogical(
"tab-logical",
{ rowKey: "row-a", textOffset: 1, affinity: "forward" },
{ rowKey: "row-b", textOffset: 3, affinity: "forward" },
rows,
);
transcriptSelectionStore.settleLogical();
};
await act(async () => {
root.render(
<LocaleProvider>
<TranscriptSelectionMenu resetKey="tab-logical" onAddToChat={(text) => additions.push(text)} />
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
selectLogical();
await flushTimers();
});
const logicalAdd = document.querySelector<HTMLButtonElement>(".transcript-selection-action button");
ok(logicalAdd != null, "settled logical selection exposes Add to Chat by snapshot id");
await act(async () => {
logicalAdd?.click();
await flushTimers();
});
eq(additions[0], "lpha\n\nbra", "logical Add to Chat resolves partial endpoints in document order");
eq(transcriptSelectionStore.getSnapshot().mode, "none", "logical Add to Chat clears the consumed snapshot");
await act(async () => {
selectLogical();
await flushTimers();
});
const logicalContextEvent = await dispatchContextMenu(first);
eq(logicalContextEvent.defaultPrevented, true, "right-click inside a logical selection opens the app menu");
const logicalCopy = document.querySelector<HTMLButtonElement>('.context-menu [role="menuitem"]');
await act(async () => {
logicalCopy?.click();
await flushTimers();
});
eq(clipboard[0], "lpha\n\nbra", "logical right-click Copy resolves the frozen snapshot");
eq(transcriptSelectionStore.getSnapshot().mode, "none", "successful logical Copy clears the snapshot");
let finishProjection!: (text: string) => void;
const delayedProjection = new Promise<string>((resolve) => { finishProjection = resolve; });
await act(async () => {
selectLogical(logicalRows({ "row-a": () => delayedProjection }));
await flushTimers();
});
const staleAdd = document.querySelector<HTMLButtonElement>(".transcript-selection-action button");
await act(async () => {
staleAdd?.click();
await flushTimers();
});
await act(async () => {
root.render(
<LocaleProvider>
<TranscriptSelectionMenu resetKey="tab-after-switch" onAddToChat={(text) => additions.push(text)} />
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
finishProjection("alpha");
await flushTimers();
});
eq(additions.length, 1, "tab switch discards a late logical Add to Chat projection");
await act(async () => {
transcriptSelectionStore.clear("test-cleanup");
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,119 @@
// Run: node --import tsx src/__tests__/transcript-selection-overlay.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { TranscriptSelectionOverlay } from "../components/TranscriptSelectionOverlay";
import { transcriptSelectionStore, type TranscriptSelectableRow } from "../lib/transcriptSelectionStore";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Range = dom.window.Range;
globalThis.MutationObserver = dom.window.MutationObserver;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
globalThis.requestAnimationFrame = (callback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
globalThis.cancelAnimationFrame = (id) => {
frames.delete(id);
};
async function flushFramesOnce() {
const pending = Array.from(frames.values());
frames.clear();
await act(async () => {
for (const callback of pending) callback(performance.now());
});
}
async function drainFrames() {
while (frames.size > 0) await flushFramesOnce();
}
const rect = {
left: 20,
right: 100,
top: 30,
bottom: 50,
width: 80,
height: 20,
x: 20,
y: 30,
toJSON: () => ({}),
} as DOMRect;
let layoutReady = false;
Object.defineProperty(dom.window.Range.prototype, "getClientRects", {
configurable: true,
value: () => layoutReady ? [rect] : [],
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: () => rect,
});
const rows: TranscriptSelectableRow[] = [
{ rowKey: "a", sourceText: "alpha", contentRevision: 1, resolveText: async () => "alpha" },
{ rowKey: "b", sourceText: "bravo", contentRevision: 1, resolveText: async () => "bravo" },
];
transcriptSelectionStore.clear("test-reset");
transcriptSelectionStore.beginNative("tab-overlay");
transcriptSelectionStore.promoteToLogical(
"tab-overlay",
{ rowKey: "a", textOffset: 0, affinity: "forward" },
{ rowKey: "b", textOffset: 5, affinity: "forward" },
rows,
);
console.log("\ntranscript logical selection overlay");
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(
<div className="transcript__virtual-sizer">
<TranscriptSelectionOverlay tabId="tab-overlay" scrollElement={null} virtualRevision="a:0|b:20" />
<div className="transcript__row" data-row-key="a"><div data-transcript-selectable="message">alpha</div></div>
<div className="transcript__row" data-row-key="b"><div data-transcript-selectable="message">bravo</div></div>
</div>,
);
});
await flushFramesOnce();
ok(document.querySelectorAll(".transcript-selection-overlay__rect").length === 0, "a pre-layout range does not draw zero-sized rectangles");
ok(frames.size > 0, "a selected mounted range schedules a stabilization paint");
layoutReady = true;
await drainFrames();
ok(document.querySelectorAll(".transcript-selection-overlay__rect").length > 0, "the stabilization paint restores logical selection highlighting");
await act(async () => root.unmount());
transcriptSelectionStore.clear("test-cleanup");
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,12 +1,13 @@
// Run: node --import tsx src/__tests__/transcript-selection-retention.test.tsx
import { JSDOM } from "jsdom";
import React, { useEffect } from "react";
import React, { useEffect, useLayoutEffect, useRef } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Range as VirtualRange } from "@tanstack/react-virtual";
import { useTranscriptSelectionRetention } from "../lib/useTranscriptSelectionRetention";
import type { TranscriptScrollMode } from "../lib/transcriptScrollController";
import { transcriptSelectionStore, type TranscriptSelectableRow } from "../lib/transcriptSelectionStore";
type RetentionApi = ReturnType<typeof useTranscriptSelectionRetention>;
@@ -35,6 +36,7 @@ globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
@@ -58,21 +60,36 @@ async function drainFrames() {
}
}
async function flushFramesOnce() {
const pending = Array.from(frames.entries());
frames.clear();
await act(async () => {
for (const [, callback] of pending) callback(performance.now());
});
}
const rowIndexByKey = new Map([
["row-a", 0],
["tool", 1],
["row-b", 2],
]);
const selectableRows: TranscriptSelectableRow[] = [
{ rowKey: "row-a", sourceText: "alpha", contentRevision: 1, resolveText: async () => "alpha" },
{ rowKey: "row-b", sourceText: "bravo", contentRevision: 1, resolveText: async () => "bravo", kind: "reasoning" },
];
function Harness({
tabId,
onReady,
setMode,
virtualRevision = 0,
}: {
tabId: string;
onReady: (api: RetentionApi) => void;
setMode: (mode: TranscriptScrollMode, reason?: string) => void;
virtualRevision?: number;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// Transcript resets its scroll generation before the selection hook's own
// tab-reset effect runs. The selection reset must not overwrite this mode.
useEffect(() => setMode("tail-follow", "generation-reset"), [setMode, tabId]);
@@ -80,14 +97,19 @@ function Harness({
tabId,
revealSignal: 0,
rowIndexByKey,
selectableRows,
scrollRef,
setScrollMode: setMode,
cancelStreamingScroll: () => {},
captureViewportAnchor: () => ({ rowKey: "row-a", viewportOffset: 0, generation: 0 }),
reconcileViewportAnchor: () => true,
});
useLayoutEffect(() => {
retention.reconcileLogicalFocus();
}, [retention.reconcileLogicalFocus, virtualRevision]);
useEffect(() => onReady(retention), [onReady, retention]);
return (
<div onPointerDownCapture={retention.onPointerDownCapture}>
<div ref={scrollRef} onPointerDownCapture={retention.onPointerDownCapture}>
<div className="transcript__row" data-row-key="row-a"><div data-transcript-selectable="message">alpha</div></div>
<div className="transcript__row" data-row-key="tool">tool</div>
<div className="transcript__row" data-row-key="row-b"><div data-transcript-selectable="reasoning">bravo</div></div>
@@ -142,6 +164,79 @@ await drainFrames();
eq(document.getSelection()?.isCollapsed, true, "keyboard copy releases the native browser range after the copy event");
eq(api?.rangeExtractor(virtualRange), [0], "keyboard copy releases selection-only virtual rows");
const first = document.querySelector<HTMLElement>("[data-row-key='row-a'] [data-transcript-selectable]")!;
const last = document.querySelector<HTMLElement>("[data-row-key='row-b'] [data-transcript-selectable]")!;
const caretDocument = document as Document & {
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
};
let committedCaretOffset: number | null = null;
caretDocument.caretPositionFromPoint = (x) => ({
offsetNode: last.firstChild!,
offset: committedCaretOffset ?? (x >= 100 ? 5 : x >= 50 ? 2 : 1),
});
await act(async () => {
first.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 0, clientY: 10 }));
const range = document.createRange();
range.setStart(first.firstChild!, 0);
range.setEnd(last.firstChild!, 1);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new window.Event("selectionchange"));
});
eq(transcriptSelectionStore.getSnapshot().mode, "logical-dragging", "cross-row selection promotes before the pointer gesture settles");
await flushFramesOnce();
eq(frames.size, 1, "loaded-history boundary schedules one final focus reconciliation");
await flushFramesOnce();
eq(frames.size, 1, "the final focus reconciliation waits for the virtual DOM commit");
await flushFramesOnce();
eq(frames.size, 0, "edge scrolling stops scheduling frames at the loaded-history boundary");
committedCaretOffset = 2;
await act(async () => {
root.render(<Harness tabId="tab-b" onReady={onReady} setMode={setMode} virtualRevision={1} />);
});
api?.reconcileLogicalFocus();
await flushFramesOnce();
eq(frames.size, 1, "a coalesced virtual commit retains one post-commit focus reconciliation");
await flushFramesOnce();
eq(transcriptSelectionStore.getSnapshot().focus?.textOffset, 2, "virtual range commit re-resolves the logical focus without relying on DOM mutation delivery");
committedCaretOffset = null;
await act(async () => {
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, button: 0, clientX: 100, clientY: 10 }));
});
eq(transcriptSelectionStore.getSnapshot().mode, "logical-settled", "cross-row selection settles in logical mode when caret APIs are available");
eq(transcriptSelectionStore.getSnapshot().focus?.textOffset, 5, "pointerup applies its exact final focus before settling");
eq(mode, "manual", "settled logical selection releases scroll ownership");
await act(async () => {
first.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 0, clientY: 10 }));
document.dispatchEvent(new window.MouseEvent("pointercancel", { bubbles: true, button: -1 }));
});
eq(transcriptSelectionStore.getSnapshot().mode, "none", "pointercancel clears selection even when the event has no pressed button");
eq(api?.rangeExtractor(virtualRange), [0], "pointercancel releases selection-only virtual rows");
eq(mode, "manual", "pointercancel releases selection scroll ownership");
last.setAttribute("data-transcript-selection-source-fallback", "");
await act(async () => {
first.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 0, clientY: 10 }));
const range = document.createRange();
range.setStart(first.firstChild!, 0);
range.setEnd(last.firstChild!, 2);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new window.Event("selectionchange"));
});
eq(transcriptSelectionStore.getSnapshot().mode, "native-dragging", "plain Markdown fallback does not promote incompatible offsets");
await act(async () => {
document.dispatchEvent(new window.MouseEvent("pointercancel", { bubbles: true, button: -1 }));
});
last.removeAttribute("data-transcript-selection-source-fallback");
delete caretDocument.caretPositionFromPoint;
await act(async () => root.unmount());
dom.window.close();
@@ -8,9 +8,12 @@ import {
} from "../lib/transcriptScrollController";
import { createSelectionRangeExtractor } from "../lib/transcriptSelectionRange";
import {
EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT,
TranscriptHeightCache,
createTranscriptMeasureElement,
estimateTranscriptRowHeightForLayout,
estimateTranscriptContentHeight,
readTranscriptLayoutSnapshot,
} from "../lib/transcriptHeightCache";
let passed = 0;
@@ -47,7 +50,7 @@ function ok(condition: unknown, label: string) {
const measuredCache = new TranscriptHeightCache();
const measure = createTranscriptMeasureElement({
tabId: "measured-tab",
getLayoutElement: () => null,
getLayoutSnapshot: () => EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT,
cache: measuredCache,
});
const element = { dataset: { rowKey: "measured-row" } } as unknown as HTMLDivElement;
@@ -57,6 +60,41 @@ function ok(condition: unknown, label: string) {
equal(measuredCache.get("measured-tab", "w:0", "measured-row"), 246, "ResizeObserver row growth refreshes the height cache");
}
// Layout style reads happen once per layout epoch, not once per row while
// TanStack materializes an unmeasured long transcript.
{
const original = globalThis.getComputedStyle;
let styleReads = 0;
Object.defineProperty(globalThis, "getComputedStyle", {
configurable: true,
value: () => {
styleReads += 1;
return {
fontSize: "14px",
fontFamily: "sans-serif",
lineHeight: "20px",
letterSpacing: "normal",
} as CSSStyleDeclaration;
},
});
const layout = readTranscriptLayoutSnapshot({ clientWidth: 641 } as HTMLElement);
const cache = new TranscriptHeightCache();
for (let index = 0; index < 10_000; index += 1) {
estimateTranscriptRowHeightForLayout({
cache,
tabId: "long-tab",
layout,
rowKey: String(index),
row: undefined,
fallback: 100,
});
}
equal(styleReads, 1, "10,000 cold row estimates reuse one layout snapshot");
equal(layout.width, 640, "content estimates use the same width bucket as cached measurements");
if (original) Object.defineProperty(globalThis, "getComputedStyle", { configurable: true, value: original });
else Reflect.deleteProperty(globalThis, "getComputedStyle");
}
function equal(actual: unknown, expected: unknown, label: string) {
ok(JSON.stringify(actual) === JSON.stringify(expected), `${label}: ${JSON.stringify(actual)}`);
}
@@ -326,7 +326,13 @@ console.log("\ntranscript store");
// ── markdown cache budget + LRU ─────────────────────────────────────────────
{
const store = new TranscriptStore(new FakeBackend([]), { markdownBudgetBytes: 120 });
const parsed = (text: string) => ({ source: text, blocks: [], bytes: text.length * 2 });
const parsed = (text: string) => ({
source: text,
blocks: [],
selectionText: text,
selectionRevision: 1,
bytes: text.length * 2,
});
store.setMarkdown("e1", 1, parsed("a".repeat(20))); // 40 bytes
store.setMarkdown("e2", 1, parsed("b".repeat(20)));
store.setMarkdown("e3", 1, parsed("c".repeat(20)));
@@ -335,6 +341,11 @@ console.log("\ntranscript store");
eq(store.getMarkdown("e2", 1), undefined, "markdown LRU evicts the least-recently-used entry");
ok(store.getMarkdown("e1", 1) !== undefined, "recently read markdown entry survives");
eq(store.getMarkdown("e1", 2), undefined, "markdown entries key on entryId + revision");
const release = store.pinMarkdown("e1", 1);
store.setMarkdown("e5", 1, parsed("e".repeat(50)));
ok(store.getMarkdown("e1", 1) !== undefined, "active selection pins its markdown projection");
release();
}
// ── lazy content refs ───────────────────────────────────────────────────────
@@ -54,7 +54,7 @@ export function InlineAssistantReasoning({ item, onManualOpen }: { item: Assista
<span>{running ? t("msg.thinkingRunning") : t("msg.thinking")}</span>
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
</button>
{open ? <div className="turn-collapse__inline-reasoning"><Markdown text={visibleReasoning} streaming={running} /></div>
{open ? <div className="turn-collapse__inline-reasoning reasoning__body" data-transcript-selectable="reasoning"><Markdown text={visibleReasoning} streaming={running} /></div>
: <ReasoningSummary text={shown.reasoning} streaming={running} onOpen={toggle} />}
</div>
);
+6 -4
View File
@@ -302,7 +302,7 @@ const StreamingMarkdownTail = memo(function StreamingMarkdownTail({ text }: { te
previousTextRef.current = text;
}, [text]);
return <div ref={elementRef} className="md md--stream-tail" />;
return <div ref={elementRef} className="md md--stream-tail" data-transcript-selection-source-fallback />;
});
export const Markdown = memo(function Markdown({
@@ -359,7 +359,7 @@ export const Markdown = memo(function Markdown({
const committedView = (
<>
<Suspense fallback={<div className="md">{renderedText}</div>}>
<Suspense fallback={<div className="md" data-transcript-selection-source-fallback>{renderedText}</div>}>
{sections.length === 1 ? (
<MarkdownRenderer text={renderedText} plainStatusBlocks={plainStatusBlocks} />
) : (
@@ -377,12 +377,14 @@ export const Markdown = memo(function Markdown({
if (streaming || legacyMode) return committedView;
return (
<Suspense fallback={<div className="md">{text}</div>}>
<Suspense fallback={<div className="md" data-transcript-selection-source-fallback>{text}</div>}>
<MarkdownHistory
text={text}
plainStatusBlocks={plainStatusBlocks}
entryId={entryId}
fallback={wasStreamingRef.current ? committedView : <div className="md">{text}</div>}
fallback={wasStreamingRef.current
? committedView
: <div className="md" data-transcript-selection-source-fallback>{text}</div>}
onParsed={handleWorkerParsed}
onError={handleWorkerError}
/>
@@ -91,16 +91,18 @@ export const MarkdownHistory = memo(function MarkdownHistory({
const handle = getMarkdownWorkerClient().parse(text);
let cancelled = false;
handle.promise
.then((parsedBlocks) => {
if (cancelled || !parsedBlocks) return;
.then((result) => {
if (cancelled || !result) return;
if (entryId) {
getTranscriptStore().setMarkdown(entryId, revision, {
source: text,
blocks: parsedBlocks,
bytes: text.length * 2 + estimateHastBytes(parsedBlocks),
blocks: result.blocks,
selectionText: result.selectionText,
selectionRevision: result.selectionRevision,
bytes: text.length * 2 + result.selectionText.length * 2 + estimateHastBytes(result.blocks),
});
}
setParsed({ text, blocks: parsedBlocks });
setParsed({ text, blocks: result.blocks });
onParsed?.();
})
.catch(() => {
+5 -5
View File
@@ -484,13 +484,13 @@ export function UserMessage({
</form>
) : imSource ? (
<div className="im-source-card">
<div className="im-source-card__head">
<div className="im-source-card__head" data-transcript-selection-ignore>
<MessageSquare size={14} />
<span>{t("msg.fromIm", { source: sourceLabel })}</span>
</div>
{displayText && <div className="im-source-card__text">{displayText}</div>}
{(imSource.sender || imSource.chat) && (
<div className="im-source-card__meta">
<div className="im-source-card__meta" data-transcript-selection-ignore>
{imSource.sender && <span>{t("msg.imSender", { id: imSource.sender })}</span>}
{imSource.chat && <span>{imSource.chat}</span>}
</div>
@@ -519,7 +519,7 @@ export function UserMessage({
return (
<div className="msg-pasted" key={seg.key}>
<div className="msg-pasted-block">
<div className="msg-pasted-head">
<div className="msg-pasted-head" data-transcript-selection-ignore>
{seg.kind === "chat" ? <MessageSquare size={15} /> : <FileText size={15} />}
<span className="msg-pasted-label">{seg.block.label}</span>
<div className="msg-pasted-actions">
@@ -545,9 +545,9 @@ export function UserMessage({
})}
</>
)}
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
{failed && <div className="msg__send-failed" data-transcript-selection-ignore>{t("msg.sendFailed")}</div>}
{orderedAttachments.length > 0 && (
<div className="msg-attachments" aria-label={t("msg.attachments")}>
<div className="msg-attachments" aria-label={t("msg.attachments")} data-transcript-selection-ignore>
{orderedAttachments.map((attachment, index) => {
const isImage = attachment.kind === "image";
const el = (
+18 -22
View File
@@ -18,11 +18,11 @@ import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
import { useTranscriptScrollController } from "../lib/useTranscriptScrollController";
import { useTranscriptSelectionRetention } from "../lib/useTranscriptSelectionRetention";
import { useTranscriptMeasurementInvalidation } from "../lib/useTranscriptMeasurementInvalidation";
import { useTranscriptRowMeasurements } from "../lib/useTranscriptRowMeasurements";
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, type QuestionAnchor } from "../lib/transcriptGrouping";
import {
buildTranscriptRows,
buildTurnModels,
estimateTranscriptRowSize,
foldMapWithReasoningOpen,
foldMapWithToggle,
foldSegmentStates,
@@ -46,10 +46,9 @@ import { noteTranscriptRowCounts } from "../lib/sessionDiagnostics";
import { useReasoningDisplayMode } from "../lib/reasoningDisplayPreference";
import { InlineAssistantReasoning } from "./InlineAssistantReasoning";
import { LiveStreamContext } from "./LiveStreamContext";
import { createTranscriptMeasureElement, estimateCachedTranscriptRowHeight, transcriptHeightCache, transcriptLayoutSignature } from "../lib/transcriptHeightCache";
import { useTranscriptSelectableRows } from "../lib/useTranscriptSelectableRows";
import { TranscriptSelectionOverlay } from "./TranscriptSelectionOverlay";
type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
const QUESTION_NAV_MIN_COUNT = 2;
type AssistantReasoningDisplay = "normal" | "hide";
@@ -102,7 +101,6 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
/>
);
});
const VIRTUAL_OVERSCAN_ROWS = 8;
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -116,7 +114,6 @@ function useTick(on: boolean): number {
}, [on]);
return Date.now();
}
function formatWorkDuration(durationMs: number, t: ReturnType<typeof useT>): string {
if (!Number.isFinite(durationMs) || durationMs <= 0) return "";
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
@@ -126,7 +123,6 @@ function formatWorkDuration(durationMs: number, t: ReturnType<typeof useT>): str
if (seconds <= 0) return t("transcript.durationMinutes", { m: minutes });
return t("transcript.durationMinutesSeconds", { m: minutes, s: seconds });
}
function workStatusLabel(durationMs: number, running: boolean, t: ReturnType<typeof useT>): string {
const duration = formatWorkDuration(durationMs, t);
if (running) {
@@ -134,7 +130,6 @@ function workStatusLabel(durationMs: number, running: boolean, t: ReturnType<typ
}
return duration ? t("transcript.workedDuration", { duration }) : t("transcript.worked");
}
function assistantAnswerOnly(item: AssistantItem): AssistantItem {
return { ...item, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
}
@@ -612,29 +607,22 @@ export function Transcript({
rows.forEach((row, index) => map.set(String(row.key), index));
return map;
}, [rows]);
const [selectableRows, liveSelectableRows] = useTranscriptSelectableRows(rows, live);
const selectionRetention = useTranscriptSelectionRetention({
tabId,
revealSignal,
rowIndexByKey,
selectableRows,
selectableRowOverrides: liveSelectableRows,
scrollRef,
setScrollMode,
writeOffset,
cancelStreamingScroll: cancelStreamingAutoScroll,
captureViewportAnchor,
reconcileViewportAnchor,
});
const getRowKey = useCallback((index: number) => `${tabId ?? ""}:${String(rows[index]?.key ?? index)}`, [rows, tabId]);
const estimateRowSize = useCallback((index: number) => {
const row = rows[index];
const signature = transcriptLayoutSignature(scrollRef.current);
const cached = transcriptHeightCache.get(tabId ?? "", signature, String(row?.key ?? index));
if (cached != null) return cached;
return estimateCachedTranscriptRowHeight(row, scrollRef.current?.clientWidth ?? 0, estimateTranscriptRowSize(row));
}, [rows, scrollRef, tabId]);
const measureRowSize = useMemo(() => createTranscriptMeasureElement({
tabId: tabId ?? "",
getLayoutElement: () => scrollRef.current,
}), [scrollRef, tabId]);
const { estimateSize: estimateRowSize, layoutSnapshotRef, measureElement: measureRowSize } = useTranscriptRowMeasurements(tabId, rows);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
@@ -655,9 +643,15 @@ export function Transcript({
directDomUpdates: true,
// Batch ResizeObserver measurements into one layout read per frame.
useAnimationFrameWithResizeObserver: true,
onChange: () => selectionRetention.reconcileLogicalFocus(),
});
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => canVirtualizerAdjust();
useTranscriptMeasurementInvalidation({ scrollRef, virtualizer, selectionActive: selectionRetention.active });
useTranscriptMeasurementInvalidation({
scrollRef,
layoutSnapshotRef,
virtualizer,
selectionActive: selectionRetention.active,
});
const sizerRef = useCallback(
(el: HTMLDivElement | null) => {
@@ -667,6 +661,7 @@ export function Transcript({
[virtualizer, entranceRef],
);
const virtualItems = virtualizer.getVirtualItems();
const virtualRevision = virtualItems.map((item) => `${item.key}:${item.start}:${item.size}`).join("|");
useEffect(() => {
noteTranscriptRowCounts(virtualItems.length, rows.length);
}, [virtualItems.length, rows.length]);
@@ -855,6 +850,7 @@ export function Transcript({
<LiveStreamContext.Provider value={live}>
<div ref={sizerRef} className="transcript__virtual-sizer">
<TranscriptSelectionOverlay tabId={tabId ?? ""} scrollElement={scrollRef.current} virtualRevision={virtualRevision} />
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { MessageSquare } from "lucide-react";
import { ContextMenu, type ContextMenuPoint } from "./ContextMenu";
import { messageSelectionContextText } from "../lib/messageSelectionCopy";
import { messageSelectionContextText, TRANSCRIPT_COPY_FAILED_EVENT } from "../lib/messageSelectionCopy";
import { writeClipboardText } from "../lib/clipboard";
import {
detectShortcutPlatform,
@@ -12,18 +12,13 @@ import {
useGlobalShortcut,
} from "../lib/keyboardShortcuts";
import { useT } from "../lib/i18n";
import { transcriptSelectionStore } from "../lib/transcriptSelectionStore";
import { rowKeyForNode, transcriptSelectionPointClientRect } from "../lib/transcriptSelectionDom";
import { useToast } from "../lib/toast";
// Inside the Wails shell main.tsx suppresses the webview's default context
// menu (its Reload/Back/Inspect entries can navigate away from the app), which
// also removes the native Copy menu for selected transcript text — ⌘C still
// works, but the right-click path is dead. This mounts one document-level
// listener that offers an app-drawn Copy menu whenever a suppressed selection
// menu would have applied, gated exactly like the ⌘C interceptor. It stays
// inert in a plain browser (no window.runtime), where the native menu opens.
type SelectionAction = {
text: string;
point: ContextMenuPoint;
};
type SelectionAction =
| { kind: "native"; text: string; point: ContextMenuPoint }
| { kind: "logical"; snapshotId: number; sourceTabId: string; point: ContextMenuPoint };
const ACTION_EDGE_GAP = 8;
@@ -33,29 +28,29 @@ export function TranscriptSelectionMenu({
onAddToChat,
}: {
enabled?: boolean;
// Identifies the transcript the selection was made in (the active tab).
// The overlay captures only text, while onAddToChat routes to whatever is
// active at click time — so any surviving overlay must be discarded when
// the source changes or a selection from session A could land in session B.
resetKey?: string | number;
onAddToChat?: (text: string) => void;
}) {
const t = useT();
const [point, setPoint] = useState<ContextMenuPoint | null>(null);
const [text, setText] = useState("");
const { showToast } = useToast();
const logicalSnapshot = useSyncExternalStore(
transcriptSelectionStore.subscribe,
transcriptSelectionStore.getSnapshot,
transcriptSelectionStore.getSnapshot,
);
const [menu, setMenu] = useState<SelectionAction | null>(null);
const [action, setAction] = useState<SelectionAction | null>(null);
const [actionPoint, setActionPoint] = useState<ContextMenuPoint | null>(null);
const actionRef = useRef<HTMLDivElement>(null);
// Escape dismisses the floating action but browsers keep the text selection,
// so the trailing keyup would immediately re-show it. Remember the dismissed
// selection and stay hidden until it changes or a new pointer gesture lands.
const dismissedRef = useRef<string | null>(null);
const dismissedRef = useRef<string | number | null>(null);
const previousResetKeyRef = useRef(resetKey);
const activeResetKeyRef = useRef(resetKey);
activeResetKeyRef.current = resetKey;
const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []);
const [shortcutRevision, setShortcutRevision] = useState(0);
useEffect(() => onShortcutsChanged(() => setShortcutRevision((value) => value + 1)), []);
const addShortcut = useMemo(
() => formatShortcutCombo(resolvedShortcutCombo("selection.addToChat", shortcutPlatform), shortcutPlatform),
// shortcutRevision re-resolves the combo after the user rebinds it in settings.
// eslint-disable-next-line react-hooks/exhaustive-deps
[shortcutPlatform, shortcutRevision],
);
@@ -65,34 +60,92 @@ export function TranscriptSelectionMenu({
setActionPoint(null);
}, []);
// Drop every piece of captured selection state when the source transcript
// changes: the floating action, the copy menu, and the Escape-dismissal
// memory all describe the previous tab's content.
const lastResetKeyRef = useRef(resetKey);
const reportCopyFailure = useCallback(() => {
showToast(t("diag.copyFailed"), "error");
}, [showToast, t]);
useEffect(() => {
if (lastResetKeyRef.current === resetKey) return;
lastResetKeyRef.current = resetKey;
const onFailure = () => reportCopyFailure();
document.addEventListener(TRANSCRIPT_COPY_FAILED_EVENT, onFailure);
return () => document.removeEventListener(TRANSCRIPT_COPY_FAILED_EVENT, onFailure);
}, [reportCopyFailure]);
useEffect(() => {
if (previousResetKeyRef.current === resetKey) return;
previousResetKeyRef.current = resetKey;
dismissedRef.current = null;
setPoint(null);
setText("");
setMenu(null);
closeAction();
document.getSelection()?.removeAllRanges();
transcriptSelectionStore.clear("tab-switch");
}, [closeAction, resetKey]);
const addSelectionToChat = useCallback(() => {
const resolveLogical = useCallback(async (selection: Extract<SelectionAction, { kind: "logical" }>) => {
const text = await transcriptSelectionStore.resolveText(selection.snapshotId);
if (
!text
|| !transcriptSelectionStore.isCurrent(selection.snapshotId, selection.sourceTabId)
|| String(activeResetKeyRef.current ?? "") !== selection.sourceTabId
) return null;
return text;
}, []);
const copySelection = useCallback(async (selection: SelectionAction) => {
if (selection.kind === "native") {
const native = document.getSelection();
const rangeSnapshot = native && !native.isCollapsed ? {
anchorNode: native.anchorNode,
anchorOffset: native.anchorOffset,
focusNode: native.focusNode,
focusOffset: native.focusOffset,
} : null;
const copied = await writeClipboardText(selection.text);
const current = document.getSelection();
if (!copied) {
reportCopyFailure();
return;
}
if (
rangeSnapshot
&& current
&& !current.isCollapsed
&& current.anchorNode === rangeSnapshot.anchorNode
&& current.anchorOffset === rangeSnapshot.anchorOffset
&& current.focusNode === rangeSnapshot.focusNode
&& current.focusOffset === rangeSnapshot.focusOffset
) current.removeAllRanges();
return;
}
const text = await resolveLogical(selection);
if (!text) return;
const copied = await writeClipboardText(text);
if (!transcriptSelectionStore.isCurrent(selection.snapshotId, selection.sourceTabId)) return;
if (!copied) {
reportCopyFailure();
return;
}
transcriptSelectionStore.clear("copy");
closeAction();
}, [closeAction, reportCopyFailure, resolveLogical]);
const addSelectionToChat = useCallback(async () => {
if (!action || !onAddToChat) return;
const selectedText = action.text;
document.getSelection()?.removeAllRanges();
if (action.kind === "native") {
document.getSelection()?.removeAllRanges();
closeAction();
onAddToChat(action.text);
return;
}
const text = await resolveLogical(action);
if (!text) return;
transcriptSelectionStore.clear("add-to-chat");
closeAction();
onAddToChat(selectedText);
}, [action, closeAction, onAddToChat]);
onAddToChat(text);
}, [action, closeAction, onAddToChat, resolveLogical]);
// The shortcut is registered in SHORTCUT_DEFINITIONS so settings can rebind
// it and conflict-check it against other actions; it only arms while the
// floating action is visible.
useGlobalShortcut(
"selection.addToChat",
addSelectionToChat,
() => { void addSelectionToChat(); },
[],
Boolean(action) && enabled && Boolean(onAddToChat),
);
@@ -119,14 +172,45 @@ export function TranscriptSelectionMenu({
});
}, [action]);
useEffect(() => {
if (!enabled || !onAddToChat || logicalSnapshot.mode !== "logical-settled") {
if (action?.kind === "logical") closeAction();
return;
}
if (logicalSnapshot.tabId !== String(resetKey ?? "") || !logicalSnapshot.focus) return;
if (dismissedRef.current === logicalSnapshot.id) return;
const rect = transcriptSelectionPointClientRect(logicalSnapshot.focus);
setAction({
kind: "logical",
snapshotId: logicalSnapshot.id,
sourceTabId: logicalSnapshot.tabId,
point: rect ? { left: rect.right, top: rect.bottom + 8 } : { left: 12, top: 12 },
});
}, [action?.kind, closeAction, enabled, logicalSnapshot, onAddToChat, resetKey]);
useEffect(() => {
const onContextMenu = (event: MouseEvent) => {
if (!enabled || typeof window === "undefined" || !window.runtime) return;
const snapshot = transcriptSelectionStore.getSnapshot();
const rowKey = rowKeyForNode(event.target instanceof Node ? event.target : null);
if (
rowKey
&& (snapshot.mode === "logical-dragging" || snapshot.mode === "logical-settled")
&& transcriptSelectionStore.isRowSelected(snapshot.id, rowKey)
) {
event.preventDefault();
setMenu({
kind: "logical",
snapshotId: snapshot.id,
sourceTabId: snapshot.tabId,
point: menuPointFromEvent(event),
});
return;
}
const selected = messageSelectionContextText(document, event.target);
if (selected == null) return;
event.preventDefault();
setText(selected);
setPoint(menuPointFromEvent(event));
setMenu({ kind: "native", text: selected, point: menuPointFromEvent(event) });
};
document.addEventListener("contextmenu", onContextMenu);
return () => document.removeEventListener("contextmenu", onContextMenu);
@@ -134,32 +218,30 @@ export function TranscriptSelectionMenu({
useEffect(() => {
if (enabled && onAddToChat) return;
setPoint(null);
setText("");
setMenu(null);
closeAction();
document.getSelection()?.removeAllRanges();
transcriptSelectionStore.clear("selection-actions-disabled");
}, [closeAction, enabled, onAddToChat]);
useEffect(() => {
if (!enabled || !onAddToChat) {
closeAction();
return;
}
if (!enabled || !onAddToChat) return;
let frame: number | null = null;
const showForTarget = (target: EventTarget | null) => {
if (transcriptSelectionStore.isLogical()) return;
const selected = messageSelectionContextText(document, target);
const selection = document.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(selection.rangeCount - 1) : null;
if (selected == null || !range) {
dismissedRef.current = null;
closeAction();
if (action?.kind === "native") closeAction();
return;
}
if (dismissedRef.current === selected) return;
dismissedRef.current = null;
const rect = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null;
setAction({
kind: "native",
text: selected,
point: rect && (rect.width > 0 || rect.height > 0)
? { left: rect.right, top: rect.bottom + 8 }
@@ -181,14 +263,13 @@ export function TranscriptSelectionMenu({
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
const target = event.target instanceof Element ? event.target : null;
// Keep the selection alive while the action itself is clicked so the
// button can consume the captured text. Every other left-click starts a
// new gesture; clear the old range immediately instead of relying on
// WebView-specific selectionchange ordering.
if (target?.closest(".transcript-selection-action")) return;
const snapshot = transcriptSelectionStore.getSnapshot();
if (snapshot.mode === "logical-dragging" || snapshot.mode === "logical-settled") {
transcriptSelectionStore.clear("new-pointer");
}
const selection = document.getSelection();
if (!selection || selection.isCollapsed) return;
selection.removeAllRanges();
if (selection && !selection.isCollapsed) selection.removeAllRanges();
dismissedRef.current = null;
closeAction();
};
@@ -200,26 +281,29 @@ export function TranscriptSelectionMenu({
scheduleShow(target);
};
const onSelectionChange = () => {
if (transcriptSelectionStore.isLogical()) return;
const selection = document.getSelection();
if (!selection || selection.isCollapsed || selection.toString().trim() === "") {
dismissedRef.current = null;
closeAction();
if (action?.kind === "native") closeAction();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !action) return;
dismissedRef.current = action.text;
dismissedRef.current = action.kind === "native" ? action.text : action.snapshotId;
if (action.kind === "logical") transcriptSelectionStore.clear("escape");
closeAction();
};
const close = () => closeAction();
const closeNative = () => {
if (action?.kind === "native") closeAction();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("keyup", onKeyUp);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("selectionchange", onSelectionChange);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", closeNative);
window.addEventListener("scroll", closeNative, true);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", onPointerDown);
@@ -227,54 +311,30 @@ export function TranscriptSelectionMenu({
document.removeEventListener("keyup", onKeyUp);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("selectionchange", onSelectionChange);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", closeNative);
window.removeEventListener("scroll", closeNative, true);
};
}, [action, closeAction, enabled, onAddToChat]);
return <>
<ContextMenu
open={point != null}
point={point}
open={menu != null}
point={menu?.point ?? null}
minWidth={140}
ariaLabel={t("common.copy")}
items={[
{
key: "copy",
label: t("common.copy"),
shortcut: formatShortcutCombo(
shortcutPlatform === "darwin" ? { key: "c", meta: true } : { key: "c", ctrl: true },
shortcutPlatform,
),
onSelect: () => {
const selection = document.getSelection();
const snapshot = selection && !selection.isCollapsed
? {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
}
: null;
void writeClipboardText(text).then((copied) => {
const current = document.getSelection();
if (
!copied
|| !snapshot
|| !current
|| current.isCollapsed
|| current.anchorNode !== snapshot.anchorNode
|| current.anchorOffset !== snapshot.anchorOffset
|| current.focusNode !== snapshot.focusNode
|| current.focusOffset !== snapshot.focusOffset
) return;
current.removeAllRanges();
});
setPoint(null);
},
items={[{
key: "copy",
label: t("common.copy"),
shortcut: formatShortcutCombo(
shortcutPlatform === "darwin" ? { key: "c", meta: true } : { key: "c", ctrl: true },
shortcutPlatform,
),
onSelect: () => {
if (menu) void copySelection(menu);
setMenu(null);
},
]}
onClose={() => setPoint(null)}
}]}
onClose={() => setMenu(null)}
/>
{action && typeof document !== "undefined" && createPortal(
<div
@@ -289,7 +349,7 @@ export function TranscriptSelectionMenu({
}}
onMouseDown={(event) => event.preventDefault()}
>
<button type="button" onClick={addSelectionToChat}>
<button type="button" onClick={() => void addSelectionToChat()}>
<MessageSquare size={14} aria-hidden="true" />
<span>{t("selection.addToChat")}</span>
<kbd>{addShortcut}</kbd>
@@ -300,16 +360,10 @@ export function TranscriptSelectionMenu({
</>;
}
// The keyboard context-menu key fires contextmenu at (0, 0); anchor the menu
// to the selection instead so it opens next to the highlighted text.
function menuPointFromEvent(event: MouseEvent): ContextMenuPoint {
if (event.clientX > 0 || event.clientY > 0) {
return { left: event.clientX, top: event.clientY };
}
if (event.clientX > 0 || event.clientY > 0) return { left: event.clientX, top: event.clientY };
const range = document.getSelection()?.rangeCount ? document.getSelection()?.getRangeAt(0) : null;
const rect = typeof range?.getBoundingClientRect === "function" ? range.getBoundingClientRect() : null;
if (rect && (rect.width > 0 || rect.height > 0)) {
return { left: rect.left, top: rect.bottom + 4 };
}
if (rect && (rect.width > 0 || rect.height > 0)) return { left: rect.left, top: rect.bottom + 4 };
return { left: 12, top: 12 };
}
@@ -0,0 +1,128 @@
import { memo, useCallback, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { domRangeForTranscriptOffsets, projectTranscriptSelectableDom } from "../lib/transcriptSelectionDom";
import { transcriptSelectionStore } from "../lib/transcriptSelectionStore";
type SelectionRect = { left: number; top: number; width: number; height: number };
function sameRects(left: readonly SelectionRect[], right: readonly SelectionRect[]): boolean {
return left.length === right.length && left.every((rect, index) => {
const other = right[index];
return other
&& Math.abs(rect.left - other.left) < 0.25
&& Math.abs(rect.top - other.top) < 0.25
&& Math.abs(rect.width - other.width) < 0.25
&& Math.abs(rect.height - other.height) < 0.25;
});
}
export const TranscriptSelectionOverlay = memo(function TranscriptSelectionOverlay({
tabId,
scrollElement,
virtualRevision,
}: {
tabId: string;
scrollElement: HTMLElement | null;
virtualRevision: string;
}) {
const snapshot = useSyncExternalStore(
transcriptSelectionStore.subscribe,
transcriptSelectionStore.getSnapshot,
transcriptSelectionStore.getSnapshot,
);
const overlayRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const retryRef = useRef(0);
const measureRef = useRef<() => void>(() => {});
const [rects, setRects] = useState<SelectionRect[]>([]);
const measure = useCallback(() => {
frameRef.current = null;
const overlay = overlayRef.current;
const sizer = overlay?.parentElement;
const current = transcriptSelectionStore.getSnapshot();
if (
!overlay
|| !sizer
|| current.tabId !== tabId
|| (current.mode !== "logical-dragging" && current.mode !== "logical-settled")
) {
retryRef.current = 0;
setRects((current) => current.length === 0 ? current : []);
return;
}
const sizerRect = sizer.getBoundingClientRect();
const next: SelectionRect[] = [];
const roots = sizer.querySelectorAll<HTMLElement>(".transcript__row[data-row-key] [data-transcript-selectable]");
for (const root of roots) {
const rowKey = root.closest<HTMLElement>(".transcript__row[data-row-key]")?.dataset.rowKey;
if (!rowKey) continue;
const projection = projectTranscriptSelectableDom(root);
const bounds = transcriptSelectionStore.rowBounds(current.id, rowKey, projection.text.length);
if (!bounds || bounds.start === bounds.end) continue;
const range = domRangeForTranscriptOffsets(root, bounds.start, bounds.end);
if (!range || typeof range.getClientRects !== "function") continue;
for (const rect of Array.from(range.getClientRects())) {
if (rect.width <= 0 || rect.height <= 0) continue;
next.push({
left: rect.left - sizerRect.left,
top: rect.top - sizerRect.top,
width: rect.width,
height: rect.height,
});
}
}
setRects((current) => sameRects(current, next) ? current : next);
// Direct virtualizer DOM updates and browser layout can land after the RAF.
// A bounded retry prevents a logical selection from staying invisible.
if (next.length === 0 && retryRef.current < 4) {
retryRef.current += 1;
frameRef.current = requestAnimationFrame(() => measureRef.current());
return;
}
retryRef.current = 0;
}, [tabId]);
measureRef.current = measure;
const schedule = useCallback(() => {
if (frameRef.current !== null) return;
retryRef.current = 0;
frameRef.current = requestAnimationFrame(measure);
}, [measure]);
useLayoutEffect(() => {
schedule();
}, [schedule, snapshot, virtualRevision]);
useLayoutEffect(() => {
const overlay = overlayRef.current;
const sizer = overlay?.parentElement;
if (!overlay || !sizer) return;
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
resize?.observe(sizer);
if (scrollElement) resize?.observe(scrollElement);
const mutation = typeof MutationObserver === "undefined" ? null : new MutationObserver(schedule);
mutation?.observe(sizer, { childList: true, subtree: true, characterData: true });
scrollElement?.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
return () => {
resize?.disconnect();
mutation?.disconnect();
scrollElement?.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
};
}, [schedule, scrollElement]);
return (
<div ref={overlayRef} className="transcript-selection-overlay" aria-hidden="true">
{rects.map((rect, index) => (
<span
key={`${index}:${rect.left}:${rect.top}`}
className="transcript-selection-overlay__rect"
style={rect}
/>
))}
</div>
);
});
@@ -4,7 +4,7 @@
// JSON-serializable HAST blocks. The worker never cancels in-flight work; the
// client drops stale responses by request id.
import { parseMarkdownToBlocks } from "../lib/markdownPipeline";
import { parseMarkdown } from "../lib/markdownPipeline";
import type { MarkdownParseRequest, MarkdownParseResponse } from "../lib/markdownWorkerClient";
const workerScope = globalThis as unknown as {
@@ -15,7 +15,7 @@ const workerScope = globalThis as unknown as {
workerScope.onmessage = (event) => {
const { id, text } = event.data;
try {
workerScope.postMessage({ id, blocks: parseMarkdownToBlocks(text) });
workerScope.postMessage({ id, result: parseMarkdown(text) });
} catch (error) {
workerScope.postMessage({
id,
@@ -0,0 +1,9 @@
/** Deterministic UTF-16 content fingerprint used by transcript caches. */
export function contentRevision(text: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
@@ -0,0 +1,37 @@
import type { RootContent as HastRootContent } from "hast";
import type { SelectionProjectionBlock } from "./markdownSelectionProjection";
/** Allocation-free byte-weight estimate for parsed transcript Markdown. */
export function estimateHastBytes(blocks: readonly SelectionProjectionBlock[]): number {
let bytes = 0;
const walk = (node: HastRootContent): void => {
bytes += 48;
if (node.type === "text" || node.type === "comment") {
bytes += (node.value?.length ?? 0) * 2;
return;
}
if (node.type === "element") {
bytes += node.tagName.length * 2;
for (const key in node.properties) {
if (!Object.prototype.hasOwnProperty.call(node.properties, key)) continue;
const value = node.properties[key];
bytes += key.length * 2;
if (typeof value === "string") bytes += value.length * 2;
else if (Array.isArray(value)) bytes += value.length * 16;
else bytes += 16;
}
for (const child of node.children) walk(child);
}
};
for (const block of blocks) {
if (block.virtualTable) {
bytes += block.virtualTable.header.reduce((total, cell) => total + 24 + cell.length * 2, 0);
bytes += block.virtualTable.rows.reduce(
(total, row) => total + 24 + row.reduce((rowTotal, cell) => rowTotal + 24 + cell.length * 2, 0),
0,
);
}
for (const child of block.children) walk(child);
}
return bytes;
}
+21 -45
View File
@@ -34,6 +34,9 @@ import {
type VirtualMarkdownTableData,
} from "./largeMarkdownTable";
import { isLocalFileHref } from "./localFileUrl";
import { contentRevision } from "./contentRevision";
import { markdownSelectionTextFromBlocks } from "./markdownSelectionProjection";
export { estimateHastBytes } from "./markdownByteEstimate";
export type { HastRoot, HastRootContent };
@@ -45,6 +48,12 @@ export interface MarkdownBlock {
virtualTable?: VirtualMarkdownTableData;
}
export interface MarkdownParseResult {
blocks: MarkdownBlock[];
selectionText: string;
selectionRevision: number;
}
const SAFE_PROTOCOL_RE = /^(https?|ircs?|mailto|xmpp)$/i;
// Mirror of react-markdown's exported defaultUrlTransform (which itself mirrors
@@ -223,55 +232,22 @@ export function parseMarkdownToBlocks(text: string): MarkdownBlock[] {
});
}
/** Parse once and derive both the render tree and copy projection. */
export function parseMarkdown(text: string): MarkdownParseResult {
const blocks = parseMarkdownToBlocks(text);
const selectionText = markdownSelectionTextFromBlocks(blocks);
return {
blocks,
selectionText,
selectionRevision: contentRevision(selectionText),
};
}
/**
* Content-derived cache revision for the transcript markdown cache: an FNV-1a
* fingerprint of the source text. Cache entries also store the source itself,
* so a (practically impossible) hash collision is caught by comparison.
*/
export function markdownContentRevision(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/**
* Byte-weight estimate for parsed blocks, used by the transcript store's
* byte-weighted markdown LRU. Counts node overhead plus string payloads; no
* serialization is performed (that would allocate a second full copy).
*/
export function estimateHastBytes(blocks: readonly MarkdownBlock[]): number {
let bytes = 0;
const walk = (node: HastRootContent): void => {
bytes += 48;
if (node.type === "text" || node.type === "comment") {
bytes += (node.value?.length ?? 0) * 2;
return;
}
if (node.type === "element") {
bytes += node.tagName.length * 2;
for (const key in node.properties) {
if (!Object.prototype.hasOwnProperty.call(node.properties, key)) continue;
const value = node.properties[key];
bytes += key.length * 2;
if (typeof value === "string") bytes += value.length * 2;
else if (Array.isArray(value)) bytes += value.length * 16;
else bytes += 16;
}
for (const child of node.children) walk(child);
}
};
for (const block of blocks) {
if (block.virtualTable) {
bytes += block.virtualTable.header.reduce((total, cell) => total + 24 + cell.length * 2, 0);
bytes += block.virtualTable.rows.reduce(
(total, row) => total + 24 + row.reduce((rowTotal, cell) => rowTotal + 24 + cell.length * 2, 0),
0,
);
}
for (const child of block.children) walk(child);
}
return bytes;
return contentRevision(text);
}
@@ -0,0 +1,152 @@
import type { Element as HastElement, RootContent as HastRootContent } from "hast";
import type { VirtualMarkdownTableData } from "./largeMarkdownTable";
export interface SelectionProjectionBlock {
children: HastRootContent[];
virtualTable?: VirtualMarkdownTableData;
}
const BLOCK_TAGS = new Set([
"address", "article", "aside", "blockquote", "dd", "details", "div", "dl", "dt",
"figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
"header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "summary", "ul",
]);
const IGNORE_TAGS = new Set(["button", "input", "option", "script", "select", "style", "textarea"]);
function classNames(node: HastElement): string[] {
const value: unknown = node.properties.className;
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
return typeof value === "string" ? value.split(/\s+/).filter(Boolean) : [];
}
function property(node: HastElement, ...names: string[]): unknown {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(node.properties, name)) return node.properties[name];
}
return undefined;
}
function isHidden(node: HastElement): boolean {
const ariaHidden = property(node, "ariaHidden", "aria-hidden");
const ignored = property(node, "dataTranscriptSelectionIgnore", "data-transcript-selection-ignore");
return ariaHidden === true || ariaHidden === "true" || ignored !== undefined;
}
function latexSource(node: HastElement): string | null {
const direct = property(node, "dataLatexSource", "data-latex-source");
if (typeof direct === "string" && direct) return direct;
const find = (children: readonly HastRootContent[]): string | null => {
for (const child of children) {
if (child.type !== "element") continue;
if (child.tagName === "annotation" && property(child, "encoding") === "application/x-tex") {
return child.children.map((part) => part.type === "text" ? part.value : "").join("");
}
const nested = find(child.children);
if (nested) return nested;
}
return null;
};
return find(node.children);
}
class TextProjection {
private value = "";
text(text: string): void {
this.value += text;
}
break(lines = 1): void {
if (!this.value) return;
const trailing = /\n*$/.exec(this.value)?.[0].length ?? 0;
if (trailing < lines) this.value += "\n".repeat(lines - trailing);
}
result(): string {
return this.value.replace(/^\n+|\n+$/g, "");
}
}
function nodeText(node: HastRootContent): string {
const output = new TextProjection();
projectNode(node, output);
return output.result();
}
function tableRows(node: HastElement): HastElement[] {
const rows: HastElement[] = [];
const walk = (element: HastElement) => {
if (element.tagName === "tr") {
rows.push(element);
return;
}
for (const child of element.children) if (child.type === "element") walk(child);
};
walk(node);
return rows;
}
function projectTable(node: HastElement, output: TextProjection): void {
const rows = tableRows(node);
rows.forEach((row, rowIndex) => {
const cells = row.children.filter(
(child): child is HastElement => child.type === "element" && (child.tagName === "td" || child.tagName === "th"),
);
output.text(cells.map(nodeText).join("\t"));
if (rowIndex < rows.length - 1) output.break(1);
});
}
function projectNode(node: HastRootContent, output: TextProjection, preserveWhitespace = false): void {
if (node.type === "text") {
if (!preserveWhitespace && /^\s+$/.test(node.value) && /[\r\n]/.test(node.value)) return;
output.text(node.value);
return;
}
if (node.type !== "element") return;
if (IGNORE_TAGS.has(node.tagName) || isHidden(node)) return;
const classes = classNames(node);
if (classes.includes("katex-display") || classes.includes("katex")) {
const source = latexSource(node);
if (source) output.text(classes.includes("katex-display") ? `$$\n${source}\n$$` : `$${source}$`);
return;
}
if (node.tagName === "br") {
output.break(1);
return;
}
if (node.tagName === "img") {
const alt = property(node, "alt");
if (typeof alt === "string") output.text(alt);
return;
}
if (node.tagName === "table") {
projectTable(node, output);
return;
}
const block = BLOCK_TAGS.has(node.tagName);
if (block) output.break(1);
const childPreservesWhitespace = preserveWhitespace || node.tagName === "pre" || node.tagName === "code";
for (const child of node.children) projectNode(child, output, childPreservesWhitespace);
if (block) output.break(1);
}
export function virtualMarkdownTableSelectionText(data: VirtualMarkdownTableData): string {
return [data.header, ...data.rows].map((row) => row.join("\t")).join("\n");
}
/** Project rendered, user-readable text from the same HAST used for display. */
export function markdownSelectionTextFromBlocks(blocks: readonly SelectionProjectionBlock[]): string {
const projected = blocks.map((block) => {
if (block.virtualTable) return virtualMarkdownTableSelectionText(block.virtualTable);
const output = new TextProjection();
for (const child of block.children) projectNode(child, output);
return output.result();
});
// Keep this separator identical to the rendered DOM adapter. Logical
// selection endpoints are measured against that DOM projection, so adding
// an extra newline between parser blocks would shift every later offset.
return projected.filter((text) => text !== "").join("\n");
}
@@ -21,7 +21,7 @@
// so this module stays light enough for the eager transcript graph, and the
// remark+katex stack only ever lands in lazy chunks / the inline worker.
import type { MarkdownBlock } from "./markdownPipeline";
import type { MarkdownParseResult } from "./markdownPipeline";
import { registerMarkdownWorkerDiagnostics } from "./sessionDiagnostics";
type MarkdownPipelineModule = typeof import("./markdownPipeline");
@@ -38,7 +38,7 @@ export interface MarkdownParseRequest {
export interface MarkdownParseResponse {
id: number;
blocks?: MarkdownBlock[];
result?: MarkdownParseResult;
error?: string;
}
@@ -50,8 +50,8 @@ export interface MarkdownWorkerLike {
}
export interface MarkdownParseHandle {
/** Resolves parsed blocks, or undefined when cancelled/disposed. */
promise: Promise<MarkdownBlock[] | undefined>;
/** Resolves the render blocks and selection projection together. */
promise: Promise<MarkdownParseResult | undefined>;
/** Drop the response when it arrives; resolves the promise with undefined. */
cancel(): void;
}
@@ -60,11 +60,11 @@ export interface MarkdownWorkerClientOptions {
/** Override worker creation (tests inject a synchronous fake). */
createWorker?: () => Promise<MarkdownWorkerLike>;
/** Override the in-process fallback parse (tests inject a spy). */
parseInProcess?: (text: string) => MarkdownBlock[];
parseInProcess?: (text: string) => MarkdownParseResult;
}
interface PendingRequest {
resolve(blocks: MarkdownBlock[] | undefined): void;
resolve(result: MarkdownParseResult | undefined): void;
reject(error: Error): void;
/** performance.now() at parse() time, for parse-latency diagnostics. */
startedAt: number;
@@ -78,7 +78,7 @@ function nowMs(): number {
export class MarkdownWorkerClient {
private readonly createWorker?: () => Promise<MarkdownWorkerLike>;
private readonly parseInProcess?: (text: string) => MarkdownBlock[];
private readonly parseInProcess?: (text: string) => MarkdownParseResult;
private worker: MarkdownWorkerLike | null = null;
private workerPromise: Promise<MarkdownWorkerLike | null> | null = null;
private readonly pending = new Map<number, PendingRequest>();
@@ -120,7 +120,7 @@ export class MarkdownWorkerClient {
}
const id = this.nextId;
this.nextId += 1;
const promise = new Promise<MarkdownBlock[] | undefined>((resolve, reject) => {
const promise = new Promise<MarkdownParseResult | undefined>((resolve, reject) => {
this.pending.set(id, { resolve, reject, startedAt: nowMs(), text, state: "queued" });
});
const cancel = () => {
@@ -188,15 +188,15 @@ export class MarkdownWorkerClient {
const injected = this.parseInProcess;
const run = injected
? async () => injected(text)
: () => loadPipeline().then((pipeline) => pipeline.parseMarkdownToBlocks(text));
: () => loadPipeline().then((pipeline) => pipeline.parseMarkdown(text));
void run().then(
(blocks) => {
(result) => {
const entry = this.pending.get(id);
if (entry) {
this.pending.delete(id);
this.noteSettled(entry);
if (this.disposed) entry.resolve(undefined);
else entry.resolve(blocks);
else entry.resolve(result);
}
if (this.activeRequestId === id) this.activeRequestId = null;
this.fallbackActive = false;
@@ -246,7 +246,7 @@ export class MarkdownWorkerClient {
if (response.error !== undefined) {
entry.reject(new Error(response.error));
} else {
entry.resolve(response.blocks ?? []);
entry.resolve(response.result ?? { blocks: [], selectionText: "", selectionRevision: 0 });
}
void this.pump();
}
@@ -1,4 +1,8 @@
import { writeClipboardText } from "./clipboard";
import { transcriptSelectionStore } from "./transcriptSelectionStore";
const MESSAGE_SELECTION_SELECTOR = ".msg__body, .reasoning__body";
export const TRANSCRIPT_COPY_FAILED_EVENT = "reasonix:transcript-copy-failed";
export interface MessageSelectionCopyState {
text: string;
@@ -69,6 +73,21 @@ function rangeIntersectsNode(range: Range, node: Node): boolean {
export function installMessageSelectionCopy(doc: Document = document): () => void {
const onCopy = (event: ClipboardEvent) => {
const logical = transcriptSelectionStore.getSnapshot();
if (logical.mode === "logical-dragging" || logical.mode === "logical-settled") {
if (isEditableTarget(event.target)) return;
const snapshotId = logical.id;
const sourceTabId = logical.tabId;
event.preventDefault();
void transcriptSelectionStore.resolveText(snapshotId).then(async (text) => {
if (!text || !transcriptSelectionStore.isCurrent(snapshotId, sourceTabId)) return;
const copied = await writeClipboardText(text);
if (!transcriptSelectionStore.isCurrent(snapshotId, sourceTabId)) return;
if (copied) transcriptSelectionStore.clear("copy");
else doc.dispatchEvent(new CustomEvent(TRANSCRIPT_COPY_FAILED_EVENT));
});
return;
}
const selection = doc.getSelection();
const text = messageSelectionCopyText({
text: restoreLatexInSelection(selection),
@@ -6,6 +6,16 @@ export interface TranscriptHeightCacheOptions {
maxRowsPerTab?: number;
}
export type TranscriptLayoutSnapshot = {
signature: string;
width: number;
};
export const EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT: TranscriptLayoutSnapshot = {
signature: "w:0",
width: 0,
};
type TabMeasurements = Map<string, number>;
export class TranscriptHeightCache {
@@ -60,11 +70,11 @@ export const transcriptHeightCache = new TranscriptHeightCache();
export function createTranscriptMeasureElement({
tabId,
getLayoutElement,
getLayoutSnapshot,
cache = transcriptHeightCache,
}: {
tabId: string;
getLayoutElement: () => HTMLElement | null;
getLayoutSnapshot: () => TranscriptLayoutSnapshot;
cache?: TranscriptHeightCache;
}) {
return (
@@ -75,7 +85,7 @@ export function createTranscriptMeasureElement({
const height = measureVirtualElement(element, entry, instance);
cache.set(
tabId,
transcriptLayoutSignature(getLayoutElement()),
getLayoutSnapshot().signature,
element.dataset.rowKey ?? String(element.dataset.index ?? ""),
height,
);
@@ -83,17 +93,25 @@ export function createTranscriptMeasureElement({
};
}
export function transcriptLayoutSignature(element: HTMLElement | null): string {
if (!element || typeof getComputedStyle !== "function") return "w:0";
export function readTranscriptLayoutSnapshot(element: HTMLElement | null): TranscriptLayoutSnapshot {
if (!element || typeof getComputedStyle !== "function") return EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT;
const style = getComputedStyle(element);
const widthBucket = Math.round(element.clientWidth / 32) * 32;
return [
`w:${widthBucket}`,
`fs:${style.fontSize}`,
`ff:${style.fontFamily}`,
`lh:${style.lineHeight}`,
`ls:${style.letterSpacing}`,
].join("|");
const width = element.clientWidth;
const widthBucket = Math.round(width / 32) * 32;
return {
signature: [
`w:${widthBucket}`,
`fs:${style.fontSize}`,
`ff:${style.fontFamily}`,
`lh:${style.lineHeight}`,
`ls:${style.letterSpacing}`,
].join("|"),
width: widthBucket,
};
}
export function transcriptLayoutSignature(element: HTMLElement | null): string {
return readTranscriptLayoutSnapshot(element).signature;
}
export function estimateTranscriptContentHeight(kind: "user" | "answer" | "extension", text: string, width: number): number {
@@ -111,3 +129,22 @@ export function estimateCachedTranscriptRowHeight(row: TranscriptRow | undefined
if (row.kind === "reasoning") return Math.max(fallback, estimateTranscriptContentHeight("answer", row.item.reasoning, width));
return fallback;
}
export function estimateTranscriptRowHeightForLayout({
cache = transcriptHeightCache,
tabId,
layout,
rowKey,
row,
fallback,
}: {
cache?: TranscriptHeightCache;
tabId: string;
layout: TranscriptLayoutSnapshot;
rowKey: string;
row: TranscriptRow | undefined;
fallback: number;
}): number {
const cached = cache.get(tabId, layout.signature, rowKey);
return cached ?? estimateCachedTranscriptRowHeight(row, layout.width, fallback);
}
@@ -0,0 +1,72 @@
import type { MarkdownBlock } from "./markdownPipeline";
export interface ParsedMarkdownValue {
source: string;
blocks: MarkdownBlock[];
selectionText: string;
selectionRevision: number;
/** Source/projection UTF-16 bytes plus the estimated HAST weight. */
bytes: number;
}
/** Byte-bounded LRU whose active selection entries may be pinned. */
export class TranscriptMarkdownCache {
private readonly entries = new Map<string, { value: ParsedMarkdownValue; bytes: number }>();
private readonly pins = new Map<string, number>();
bytes = 0;
evictions = 0;
constructor(readonly budgetBytes: number) {}
private key(entryId: string, revision: number): string {
return `${entryId}@${revision}`;
}
get(entryId: string, revision: number): ParsedMarkdownValue | undefined {
const key = this.key(entryId, revision);
const entry = this.entries.get(key);
if (!entry) return undefined;
this.entries.delete(key);
this.entries.set(key, entry);
return entry.value;
}
set(entryId: string, revision: number, value: ParsedMarkdownValue): void {
const key = this.key(entryId, revision);
const previous = this.entries.get(key);
if (previous) this.bytes -= previous.bytes;
const bytes = Math.max(0, value.bytes);
this.entries.set(key, { value, bytes });
this.bytes += bytes;
this.enforceBudget();
}
pin(entryId: string, revision: number): () => void {
const key = this.key(entryId, revision);
this.pins.set(key, (this.pins.get(key) ?? 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
const count = this.pins.get(key) ?? 0;
if (count <= 1) this.pins.delete(key);
else this.pins.set(key, count - 1);
this.enforceBudget();
};
}
size(): number {
return this.entries.size;
}
private enforceBudget(): void {
while (this.bytes > this.budgetBytes && this.entries.size > 1) {
const victimKey = Array.from(this.entries.keys()).find((key) => !this.pins.has(key));
if (!victimKey) break;
const victim = this.entries.get(victimKey);
if (victim) this.bytes -= victim.bytes;
this.entries.delete(victimKey);
this.evictions += 1;
}
}
}
@@ -0,0 +1,300 @@
import type { TranscriptSelectionPoint } from "./transcriptSelectionStore";
export const TRANSCRIPT_SELECTABLE_SELECTOR = "[data-transcript-selectable]";
export const TRANSCRIPT_ROW_SELECTOR = ".transcript__row[data-row-key]";
const TRANSCRIPT_SOURCE_FALLBACK_SELECTOR = "[data-transcript-selection-source-fallback]";
type ProjectionSegment = {
node: Node;
start: number;
end: number;
atomic: boolean;
};
export type DomSelectionProjection = {
text: string;
segments: readonly ProjectionSegment[];
};
const BLOCK_TAGS = new Set([
"ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DETAILS", "DIV", "DL", "DT",
"FIGCAPTION", "FIGURE", "FOOTER", "FORM", "H1", "H2", "H3", "H4", "H5", "H6",
"HEADER", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "SUMMARY", "UL",
]);
const IGNORE_TAGS = new Set(["BUTTON", "INPUT", "OPTION", "SCRIPT", "SELECT", "STYLE", "TEXTAREA"]);
function elementForNode(node: Node | null): Element | null {
if (!node) return null;
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
}
export function selectableRootForNode(node: Node | null): HTMLElement | null {
return elementForNode(node)?.closest<HTMLElement>(TRANSCRIPT_SELECTABLE_SELECTOR) ?? null;
}
export function rowKeyForNode(node: Node | null): string | null {
return elementForNode(node)?.closest<HTMLElement>(TRANSCRIPT_ROW_SELECTOR)?.dataset.rowKey ?? null;
}
/**
* Plain Markdown fallbacks expose source characters rather than the readable
* HAST projection. Keep the browser's native range until the canonical
* rendered projection is mounted, otherwise the frozen UTF-16 offsets can
* address different characters when the worker result arrives.
*/
export function transcriptSelectionProjectionReadyForNode(node: Node | null): boolean {
const root = selectableRootForNode(node);
return root != null
&& !root.matches(TRANSCRIPT_SOURCE_FALLBACK_SELECTOR)
&& !root.querySelector(TRANSCRIPT_SOURCE_FALLBACK_SELECTOR);
}
function ignored(element: HTMLElement): boolean {
return IGNORE_TAGS.has(element.tagName)
|| element.getAttribute("aria-hidden") === "true"
|| element.hasAttribute("data-transcript-selection-ignore");
}
function formulaSource(element: HTMLElement): string | null {
const annotation = element.querySelector<HTMLElement>('annotation[encoding="application/x-tex"]');
return annotation?.textContent ?? element.dataset.latexSource ?? null;
}
export function projectTranscriptSelectableDom(root: HTMLElement): DomSelectionProjection {
let text = "";
const segments: ProjectionSegment[] = [];
const append = (value: string, node: Node, atomic = false) => {
if (!value) return;
const start = text.length;
text += value;
segments.push({ node, start, end: text.length, atomic });
};
const lineBreak = (lines = 1) => {
if (!text) return;
const trailing = /\n*$/.exec(text)?.[0].length ?? 0;
if (trailing < lines) text += "\n".repeat(lines - trailing);
};
const projectTable = (table: HTMLElement) => {
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>("tr"));
rows.forEach((row, rowIndex) => {
const cells = Array.from(row.children).filter(
(cell): cell is HTMLElement => cell instanceof HTMLElement && (cell.tagName === "TH" || cell.tagName === "TD"),
);
cells.forEach((cell, cellIndex) => {
walk(cell, false);
if (cellIndex < cells.length - 1) append("\t", cell, true);
});
if (rowIndex < rows.length - 1) append("\n", row, true);
});
};
const walk = (node: Node, honorBlock = true): void => {
if (node.nodeType === Node.TEXT_NODE) {
const value = node.textContent ?? "";
const parent = node.parentElement;
if (/^\s+$/.test(value) && /[\r\n]/.test(value) && !parent?.closest("pre, code")) return;
append(value, node);
return;
}
if (!(node instanceof HTMLElement) || ignored(node)) return;
if (node.matches(".katex-display, .katex")) {
const source = formulaSource(node);
if (source) append(node.classList.contains("katex-display") ? `$$\n${source}\n$$` : `$${source}$`, node, true);
return;
}
if (node.tagName === "BR") {
lineBreak(1);
return;
}
if (node.tagName === "IMG") {
append(node.getAttribute("alt") ?? "", node, true);
return;
}
if (node.tagName === "TABLE") {
lineBreak(1);
projectTable(node);
lineBreak(1);
return;
}
const block = honorBlock && BLOCK_TAGS.has(node.tagName);
if (block) lineBreak(1);
for (const child of node.childNodes) walk(child);
if (block) lineBreak(1);
};
for (const child of root.childNodes) walk(child);
return { text: text.replace(/^\n+|\n+$/g, ""), segments };
}
function pointInsideNode(pointNode: Node, candidate: Node): boolean {
return pointNode === candidate || (candidate instanceof Element && candidate.contains(pointNode));
}
function collapsedRange(doc: Document, node: Node, offset: number): globalThis.Range | null {
try {
const range = doc.createRange();
range.setStart(node, Math.max(0, Math.min(offset, node.nodeType === Node.TEXT_NODE ? node.textContent?.length ?? 0 : node.childNodes.length)));
range.collapse(true);
return range;
} catch {
return null;
}
}
function pointBeforeNode(point: globalThis.Range, node: Node): boolean {
const doc = node.ownerDocument;
if (!doc) return false;
try {
const start = doc.createRange();
start.selectNode(node);
start.collapse(true);
return point.compareBoundaryPoints(globalThis.Range.START_TO_START, start) <= 0;
} catch {
return false;
}
}
export function domPointToTranscriptOffset(
root: HTMLElement,
node: Node,
offset: number,
clientX?: number,
): number | null {
if (!root.contains(node) && root !== node) return null;
const projection = projectTranscriptSelectableDom(root);
const point = collapsedRange(root.ownerDocument, node, offset);
for (const segment of projection.segments) {
if (segment.node.nodeType === Node.TEXT_NODE && segment.node === node) {
return Math.max(segment.start, Math.min(segment.end, segment.start + offset));
}
if (segment.atomic && pointInsideNode(node, segment.node)) {
if (clientX !== undefined && segment.node instanceof Element) {
const rect = segment.node.getBoundingClientRect();
return clientX < rect.left + rect.width / 2 ? segment.start : segment.end;
}
return offset <= 0 ? segment.start : segment.end;
}
if (point && pointBeforeNode(point, segment.node)) return segment.start;
}
return projection.text.length;
}
export function transcriptSelectionPointFromDom(
node: Node | null,
offset: number,
clientX?: number,
): TranscriptSelectionPoint | null {
const root = selectableRootForNode(node);
const rowKey = rowKeyForNode(node);
if (!root || !rowKey || !node) return null;
const textOffset = domPointToTranscriptOffset(root, node, offset, clientX);
return textOffset == null ? null : { rowKey, textOffset, affinity: "forward" };
}
type CaretDocument = Document & {
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
caretRangeFromPoint?: (x: number, y: number) => globalThis.Range | null;
};
function nearestSelectableRoot(doc: Document, x: number, y: number): HTMLElement | null {
const direct = doc.elementFromPoint?.(x, y)?.closest<HTMLElement>(TRANSCRIPT_SELECTABLE_SELECTOR);
if (direct) return direct;
const roots = Array.from(doc.querySelectorAll<HTMLElement>(TRANSCRIPT_SELECTABLE_SELECTOR))
.filter((root) => root.getClientRects().length > 0);
let nearest: HTMLElement | null = null;
let distance = Number.POSITIVE_INFINITY;
for (const root of roots) {
const rect = root.getBoundingClientRect();
const vertical = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
const horizontal = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
const next = vertical * 4 + horizontal;
if (next < distance) {
distance = next;
nearest = root;
}
}
return nearest;
}
export function transcriptSelectionPointFromClient(doc: Document, x: number, y: number): TranscriptSelectionPoint | null {
const caretDoc = doc as CaretDocument;
const root = nearestSelectableRoot(doc, x, y);
const position = caretDoc.caretPositionFromPoint?.(x, y);
if (position && (!root || selectableRootForNode(position.offsetNode) === root)) {
const point = transcriptSelectionPointFromDom(position.offsetNode, position.offset, x);
if (point) return point;
}
const range = caretDoc.caretRangeFromPoint?.(x, y);
if (range && (!root || selectableRootForNode(range.startContainer) === root)) {
const point = transcriptSelectionPointFromDom(range.startContainer, range.startOffset, x);
if (point) return point;
}
if (!root) return null;
const projection = projectTranscriptSelectableDom(root);
const rowKey = rowKeyForNode(root);
if (!rowKey) return null;
const rect = root.getBoundingClientRect();
const textOffset = y < rect.top || (y <= rect.bottom && x < rect.left + rect.width / 2) ? 0 : projection.text.length;
return { rowKey, textOffset, affinity: "forward" };
}
function boundaryForOffset(
root: HTMLElement,
projection: DomSelectionProjection,
offset: number,
end: boolean,
): { node: Node; offset: number } {
const clamped = Math.max(0, Math.min(projection.text.length, offset));
for (const segment of projection.segments) {
if (clamped < segment.start || (clamped === segment.start && !end)) {
const parent = segment.node.parentNode;
if (parent) return { node: parent, offset: Array.prototype.indexOf.call(parent.childNodes, segment.node) };
}
if (clamped <= segment.end) {
if (!segment.atomic && segment.node.nodeType === Node.TEXT_NODE) {
return { node: segment.node, offset: Math.max(0, Math.min(segment.end - segment.start, clamped - segment.start)) };
}
const parent = segment.node.parentNode;
if (parent) {
const index = Array.prototype.indexOf.call(parent.childNodes, segment.node);
return { node: parent, offset: index + (clamped > segment.start || end ? 1 : 0) };
}
}
}
return { node: root, offset: root.childNodes.length };
}
export function domRangeForTranscriptOffsets(root: HTMLElement, start: number, end: number): globalThis.Range | null {
const projection = projectTranscriptSelectableDom(root);
if (start <= 0 && end >= projection.text.length) {
const range = root.ownerDocument.createRange();
range.selectNodeContents(root);
return range;
}
const from = boundaryForOffset(root, projection, start, false);
const to = boundaryForOffset(root, projection, end, true);
try {
const range = root.ownerDocument.createRange();
range.setStart(from.node, from.offset);
range.setEnd(to.node, to.offset);
return range;
} catch {
return null;
}
}
export function transcriptSelectionPointClientRect(point: TranscriptSelectionPoint): DOMRect | null {
const row = Array.from(document.querySelectorAll<HTMLElement>(TRANSCRIPT_ROW_SELECTOR))
.find((candidate) => candidate.dataset.rowKey === point.rowKey);
const root = row?.querySelector<HTMLElement>(TRANSCRIPT_SELECTABLE_SELECTOR);
if (!root) return null;
const projection = projectTranscriptSelectableDom(root);
const offset = Math.max(0, Math.min(projection.text.length, point.textOffset));
const start = offset > 0 ? offset - 1 : 0;
const end = offset < projection.text.length ? offset + 1 : projection.text.length;
const range = domRangeForTranscriptOffsets(root, start, end);
if (!range || typeof range.getClientRects !== "function") return null;
const rects = Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0);
return (point.affinity === "backward" ? rects[0] : rects[rects.length - 1]) ?? null;
}
@@ -0,0 +1,327 @@
export type TranscriptSelectionPoint = {
rowKey: string;
/** UTF-16 offset in the row's readable text projection. */
textOffset: number;
affinity: "forward" | "backward";
};
export type TranscriptSelectionMode =
| "none"
| "native-dragging"
| "native-settled"
| "logical-dragging"
| "logical-settled";
export type TranscriptSelectionSnapshot = {
id: number;
tabId: string;
mode: TranscriptSelectionMode;
anchor?: TranscriptSelectionPoint;
focus?: TranscriptSelectionPoint;
direction?: "forward" | "backward";
contentRevisions: ReadonlyMap<string, number>;
};
export type TranscriptSelectableRow = {
rowKey: string;
/** Collapsed reasoning is absent from the row model; mounted state is irrelevant. */
kind?: "message" | "reasoning";
contentRevision: number;
/** Frozen source; append-only changes keep the selected prefix valid. */
sourceText: string;
resolveText: () => Promise<string>;
/** Pins an existing or future cached projection until selection cleanup. */
pin?: () => () => void;
};
type FrozenSelection = {
rows: readonly TranscriptSelectableRow[];
rowIndex: ReadonlyMap<string, number>;
releases: Array<{ rowIndex: number; release: () => void }>;
};
const EMPTY_REVISIONS: ReadonlyMap<string, number> = new Map();
function pointDirection(
anchor: TranscriptSelectionPoint,
focus: TranscriptSelectionPoint,
rowIndex: ReadonlyMap<string, number>,
): "forward" | "backward" {
const anchorIndex = rowIndex.get(anchor.rowKey) ?? 0;
const focusIndex = rowIndex.get(focus.rowKey) ?? 0;
if (anchorIndex !== focusIndex) return anchorIndex < focusIndex ? "forward" : "backward";
return anchor.textOffset <= focus.textOffset ? "forward" : "backward";
}
function withAffinity(
point: TranscriptSelectionPoint,
affinity: TranscriptSelectionPoint["affinity"],
): TranscriptSelectionPoint {
return point.affinity === affinity ? point : { ...point, affinity };
}
function samePoint(left: TranscriptSelectionPoint, right: TranscriptSelectionPoint): boolean {
return left.rowKey === right.rowKey
&& left.textOffset === right.textOffset
&& left.affinity === right.affinity;
}
export class TranscriptSelectionStore {
private listeners = new Set<() => void>();
private nextId = 1;
private frozen: FrozenSelection | null = null;
private snapshot: TranscriptSelectionSnapshot = {
id: 0,
tabId: "",
mode: "none",
contentRevisions: EMPTY_REVISIONS,
};
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): TranscriptSelectionSnapshot => this.snapshot;
private publish(snapshot: TranscriptSelectionSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
private releaseFrozen(): void {
const frozen = this.frozen;
this.frozen = null;
if (!frozen) return;
for (const pinned of frozen.releases) pinned.release();
}
beginNative(tabId: string): number {
this.releaseFrozen();
const id = this.nextId++;
this.publish({ id, tabId, mode: "native-dragging", contentRevisions: EMPTY_REVISIONS });
return id;
}
updateNativeRange(anchor: TranscriptSelectionPoint, focus: TranscriptSelectionPoint): void {
if (this.snapshot.mode !== "native-dragging" && this.snapshot.mode !== "native-settled") return;
const direction = anchor.rowKey === focus.rowKey && anchor.textOffset > focus.textOffset ? "backward" : "forward";
this.publish({
...this.snapshot,
anchor: withAffinity(anchor, direction),
focus: withAffinity(focus, direction),
direction,
});
}
settleNative(): void {
if (this.snapshot.mode !== "native-dragging") return;
this.publish({ ...this.snapshot, mode: "native-settled" });
}
promoteToLogical(
tabId: string,
anchor: TranscriptSelectionPoint,
focus: TranscriptSelectionPoint,
rows: readonly TranscriptSelectableRow[],
): number | null {
if (this.snapshot.tabId !== tabId || this.snapshot.mode !== "native-dragging") return null;
const rowIndex = new Map(rows.map((row, index) => [row.rowKey, index]));
if (!rowIndex.has(anchor.rowKey) || !rowIndex.has(focus.rowKey)) return null;
this.releaseFrozen();
const releases: FrozenSelection["releases"] = [];
rows.forEach((row, index) => {
if (!row.pin) return;
try {
releases.push({ rowIndex: index, release: row.pin() });
} catch {
// Cache pinning is an optimization; selection stays functional.
}
});
this.frozen = { rows: [...rows], rowIndex, releases };
const direction = pointDirection(anchor, focus, rowIndex);
this.publish({
id: this.snapshot.id,
tabId,
mode: "logical-dragging",
anchor: withAffinity(anchor, direction),
focus: withAffinity(focus, direction),
direction,
contentRevisions: new Map(rows.map((row) => [row.rowKey, row.contentRevision])),
});
return this.snapshot.id;
}
updateLogicalFocus(focus: TranscriptSelectionPoint): void {
if (this.snapshot.mode !== "logical-dragging" || !this.snapshot.anchor || !this.frozen) return;
if (!this.frozen.rowIndex.has(focus.rowKey)) return;
const direction = pointDirection(this.snapshot.anchor, focus, this.frozen.rowIndex);
const anchor = withAffinity(this.snapshot.anchor, direction);
const nextFocus = withAffinity(focus, direction);
if (
this.snapshot.direction === direction
&& this.snapshot.focus
&& samePoint(this.snapshot.anchor, anchor)
&& samePoint(this.snapshot.focus, nextFocus)
) return;
this.publish({
...this.snapshot,
anchor,
focus: nextFocus,
direction,
});
}
settleLogical(): void {
const snapshot = this.snapshot;
const frozen = this.frozen;
if (snapshot.mode !== "logical-dragging" || !snapshot.anchor || !snapshot.focus || !frozen) return;
const anchorIndex = frozen.rowIndex.get(snapshot.anchor.rowKey);
const focusIndex = frozen.rowIndex.get(snapshot.focus.rowKey);
if (anchorIndex == null || focusIndex == null) {
this.clear("logical-endpoint-missing");
return;
}
const low = Math.min(anchorIndex, focusIndex);
const high = Math.max(anchorIndex, focusIndex);
const rows = frozen.rows.slice(low, high + 1);
const releases: FrozenSelection["releases"] = [];
for (const pinned of frozen.releases) {
if (pinned.rowIndex >= low && pinned.rowIndex <= high) {
releases.push({ rowIndex: pinned.rowIndex - low, release: pinned.release });
} else {
pinned.release();
}
}
this.frozen = {
rows,
rowIndex: new Map(rows.map((row, index) => [row.rowKey, index])),
releases,
};
this.publish({
...snapshot,
mode: "logical-settled",
contentRevisions: new Map(rows.map((row) => [row.rowKey, row.contentRevision])),
});
}
clear(_reason = "clear"): void {
this.releaseFrozen();
this.publish({
id: this.nextId++,
tabId: "",
mode: "none",
contentRevisions: EMPTY_REVISIONS,
});
}
isCurrent(snapshotId: number, tabId?: string): boolean {
return this.snapshot.id === snapshotId
&& this.snapshot.mode !== "none"
&& (tabId === undefined || this.snapshot.tabId === tabId);
}
isLogical(): boolean {
return this.snapshot.mode === "logical-dragging" || this.snapshot.mode === "logical-settled";
}
isRowSelected(snapshotId: number, rowKey: string): boolean {
return this.rowBounds(snapshotId, rowKey, Number.MAX_SAFE_INTEGER) !== null;
}
rowBounds(snapshotId: number, rowKey: string, textLength: number): { start: number; end: number } | null {
const snapshot = this.snapshot;
const frozen = this.frozen;
if (snapshot.id !== snapshotId || !snapshot.anchor || !snapshot.focus || !frozen) return null;
const anchorIndex = frozen.rowIndex.get(snapshot.anchor.rowKey);
const focusIndex = frozen.rowIndex.get(snapshot.focus.rowKey);
const rowIndex = frozen.rowIndex.get(rowKey);
if (anchorIndex == null || focusIndex == null || rowIndex == null) return null;
const lowIndex = Math.min(anchorIndex, focusIndex);
const highIndex = Math.max(anchorIndex, focusIndex);
if (rowIndex < lowIndex || rowIndex > highIndex) return null;
const clamp = (value: number) => Math.max(0, Math.min(textLength, value));
if (anchorIndex === focusIndex) {
return {
start: clamp(Math.min(snapshot.anchor.textOffset, snapshot.focus.textOffset)),
end: clamp(Math.max(snapshot.anchor.textOffset, snapshot.focus.textOffset)),
};
}
const lowPoint = anchorIndex < focusIndex ? snapshot.anchor : snapshot.focus;
const highPoint = anchorIndex < focusIndex ? snapshot.focus : snapshot.anchor;
return {
start: rowIndex === lowIndex ? clamp(lowPoint.textOffset) : 0,
end: rowIndex === highIndex ? clamp(highPoint.textOffset) : textLength,
};
}
validateRows(rows: readonly TranscriptSelectableRow[]): boolean {
const snapshot = this.snapshot;
const frozen = this.frozen;
if (!frozen || !snapshot.anchor || !snapshot.focus || !this.isLogical()) return true;
const current = new Map(rows.map((row) => [row.rowKey, row]));
const anchorIndex = frozen.rowIndex.get(snapshot.anchor.rowKey);
const focusIndex = frozen.rowIndex.get(snapshot.focus.rowKey);
if (anchorIndex == null || focusIndex == null) return false;
const low = Math.min(anchorIndex, focusIndex);
const high = Math.max(anchorIndex, focusIndex);
for (let index = low; index <= high; index += 1) {
const previous = frozen.rows[index];
const next = current.get(previous.rowKey);
if (!next || (next.contentRevision !== previous.contentRevision && !next.sourceText.startsWith(previous.sourceText))) {
this.clear("selected-row-changed");
return false;
}
}
return true;
}
/** Validate a small set of live rows without rebuilding a map of all history. */
validateRowChanges(rows: readonly TranscriptSelectableRow[]): boolean {
const snapshot = this.snapshot;
const frozen = this.frozen;
if (!frozen || !snapshot.anchor || !snapshot.focus || !this.isLogical()) return true;
const anchorIndex = frozen.rowIndex.get(snapshot.anchor.rowKey);
const focusIndex = frozen.rowIndex.get(snapshot.focus.rowKey);
if (anchorIndex == null || focusIndex == null) return false;
const low = Math.min(anchorIndex, focusIndex);
const high = Math.max(anchorIndex, focusIndex);
for (const next of rows) {
const index = frozen.rowIndex.get(next.rowKey);
if (index == null || index < low || index > high) continue;
const previous = frozen.rows[index];
if (next.contentRevision !== previous.contentRevision && !next.sourceText.startsWith(previous.sourceText)) {
this.clear("selected-row-changed");
return false;
}
}
return true;
}
async resolveText(snapshotId: number): Promise<string> {
const snapshot = this.snapshot;
const frozen = this.frozen;
if (snapshot.id !== snapshotId || !snapshot.anchor || !snapshot.focus || !frozen) return "";
const anchorIndex = frozen.rowIndex.get(snapshot.anchor.rowKey);
const focusIndex = frozen.rowIndex.get(snapshot.focus.rowKey);
if (anchorIndex == null || focusIndex == null) return "";
const low = Math.min(anchorIndex, focusIndex);
const high = Math.max(anchorIndex, focusIndex);
const values: string[] = [];
for (let index = low; index <= high; index += 1) {
const row = frozen.rows[index];
let text = row.sourceText;
try {
text = await row.resolveText();
} catch {
// Raw Markdown/source text is the final non-empty fallback.
}
const bounds = this.rowBounds(snapshotId, row.rowKey, text.length);
if (!bounds) return "";
values.push(text.slice(bounds.start, bounds.end));
}
return values.join("\n\n");
}
}
export const transcriptSelectionStore = new TranscriptSelectionStore();
@@ -0,0 +1,121 @@
import { parseAttachmentRefsForDisplay } from "./attachmentDisplay";
import { contentRevision } from "./contentRevision";
import { estimateHastBytes } from "./markdownByteEstimate";
import { stripMemoryCompilerExecution } from "./memoryCompilerDisplay";
import { parseSelectedTextContext, stripSelectionLabels } from "./selectedTextContext";
import { getMarkdownWorkerClient } from "./markdownWorkerClient";
import { getTranscriptStore } from "./transcriptStore";
import { historyEntryIdForItemId, type TranscriptRow } from "./transcriptRows";
import type { LiveStream } from "./useController";
import type { TranscriptSelectableRow } from "./transcriptSelectionStore";
const IM_SOURCE_START = "[[reasonix-im]]";
const IM_SOURCE_END = "[[/reasonix-im]]";
function imMessageBody(text: string): string {
if (!text.startsWith(IM_SOURCE_START)) return text;
const end = text.indexOf(IM_SOURCE_END);
return end < 0 ? text : text.slice(end + IM_SOURCE_END.length).replace(/^\r?\n/, "");
}
export function userMessageSelectionText(text: string, submitText?: string): string {
const actionText = stripMemoryCompilerExecution(imMessageBody(text));
const selected = parseSelectedTextContext(submitText);
const withoutLabels = stripSelectionLabels(actionText, selected);
const body = parseAttachmentRefsForDisplay(withoutLabels).text.trim();
return [body, ...selected.map((entry) => entry.text.trim())].filter(Boolean).join("\n\n");
}
async function markdownSelectionText(sourceText: string, entryId?: string): Promise<string> {
const revision = contentRevision(sourceText);
const store = getTranscriptStore();
const cached = entryId ? store.getMarkdown(entryId, revision) : undefined;
if (cached?.source === sourceText) return cached.selectionText;
try {
const result = await getMarkdownWorkerClient().parse(sourceText).promise;
if (!result) return sourceText;
if (entryId) {
const bytes = sourceText.length * 2 + result.selectionText.length * 2 + estimateHastBytes(result.blocks);
store.setMarkdown(entryId, revision, { source: sourceText, ...result, bytes });
}
return result.selectionText;
} catch {
try {
const pipeline = await import("./markdownPipeline");
return pipeline.parseMarkdown(sourceText).selectionText;
} catch {
return sourceText;
}
}
}
function markdownRow(rowKey: string, sourceText: string, entryId?: string): TranscriptSelectableRow {
const revision = contentRevision(sourceText);
return {
rowKey,
sourceText,
contentRevision: revision,
resolveText: () => markdownSelectionText(sourceText, entryId),
pin: entryId ? () => getTranscriptStore().pinMarkdown(entryId, revision) : undefined,
};
}
/** Stable readable projections for the structural transcript row model. */
export function transcriptSelectableRows(
rows: readonly TranscriptRow[],
): TranscriptSelectableRow[] {
const selectable: TranscriptSelectableRow[] = [];
for (const row of rows) {
const rowKey = String(row.key);
if (row.kind === "user") {
const sourceText = userMessageSelectionText(row.item.text, row.item.submitText);
selectable.push({
rowKey,
sourceText,
contentRevision: contentRevision(`${row.item.text}\u0000${row.item.submitText ?? ""}`),
resolveText: async () => sourceText,
});
continue;
}
if (row.kind === "answer") {
selectable.push(markdownRow(rowKey, row.item.text, historyEntryIdForItemId(row.item.id)));
continue;
}
if (row.kind === "reasoning") {
selectable.push({
...markdownRow(rowKey, row.item.reasoning, historyEntryIdForItemId(row.item.id)),
kind: "reasoning",
});
}
}
return selectable;
}
/**
* Project only the active stream rows. Token updates therefore hash at most
* the answer and reasoning bodies instead of every loaded history row.
*/
export function transcriptLiveSelectableRows(
rowsByKey: ReadonlyMap<string, TranscriptSelectableRow>,
live?: LiveStream,
): TranscriptSelectableRow[] {
if (!live) return [];
const rows: TranscriptSelectableRow[] = [];
const answerKey = `a:${live.id}`;
if (rowsByKey.has(answerKey)) rows.push(markdownRow(answerKey, live.text));
const reasoningKey = `r:${live.id}`;
if (rowsByKey.has(reasoningKey)) {
rows.push({ ...markdownRow(reasoningKey, live.reasoning), kind: "reasoning" });
}
return rows;
}
/** Materialize the current freeze snapshot only when selection needs it. */
export function mergeTranscriptSelectableRows(
rows: readonly TranscriptSelectableRow[],
overrides: readonly TranscriptSelectableRow[],
): readonly TranscriptSelectableRow[] {
if (overrides.length === 0) return rows;
return rows.map((row) => overrides.find((candidate) => candidate.rowKey === row.rowKey) ?? row);
}
+14 -52
View File
@@ -32,8 +32,9 @@
import { asArray } from "./array";
import { app } from "./bridge";
import type { MarkdownBlock } from "./markdownPipeline";
import { noteHistoryPage, registerTranscriptCacheDiagnostics } from "./sessionDiagnostics";
import { TranscriptMarkdownCache, type ParsedMarkdownValue } from "./transcriptMarkdownCache";
export type { ParsedMarkdownValue } from "./transcriptMarkdownCache";
import { fileDiffFromWire, summarizeFileDiff } from "./tools";
import {
historyToolError,
@@ -408,43 +409,22 @@ function applyResolvedField(rec: TranscriptRecord, ref: HistoryContentRef, data:
}
}
interface MarkdownCacheEntry {
value: ParsedMarkdownValue;
bytes: number;
}
/**
* Parsed-markdown cache payload (Phase E): the HAST blocks a worker parse
* produced, plus the exact source text they came from. Callers compare
* `source` against the text at hand before using the blocks — the entryId +
* revision key is a content hash, and this comparison is the backstop.
*/
export interface ParsedMarkdownValue {
source: string;
blocks: MarkdownBlock[];
/** Precomputed byte weight: source UTF-16 size + estimated HAST size. */
bytes: number;
}
export class TranscriptStore {
private readonly backend: TranscriptBackend;
private readonly maxResidentSessions: number;
private readonly historyBodyBudgetBytes: number;
private readonly markdownBudgetBytes: number;
/** Insertion-ordered (oldest first); touch re-inserts at the end. */
private readonly sessions = new Map<string, SessionTranscript>();
private readonly tabPins = new Map<string, { live: boolean; active: boolean }>();
private readonly listeners = new Map<string, Set<(change: TranscriptContentChange) => void>>();
private readonly markdownCache = new Map<string, MarkdownCacheEntry>();
private markdownBytes = 0;
private readonly markdown: TranscriptMarkdownCache;
private historyEvictions = 0;
private markdownEvictions = 0;
constructor(backend: TranscriptBackend, options: TranscriptStoreOptions = {}) {
this.backend = backend;
this.maxResidentSessions = Math.max(1, options.maxResidentSessions ?? DEFAULT_MAX_RESIDENT_SESSIONS);
this.historyBodyBudgetBytes = Math.max(0, options.historyBodyBudgetBytes ?? DEFAULT_HISTORY_BODY_BUDGET);
this.markdownBudgetBytes = Math.max(0, options.markdownBudgetBytes ?? DEFAULT_MARKDOWN_BUDGET);
this.markdown = new TranscriptMarkdownCache(Math.max(0, options.markdownBudgetBytes ?? DEFAULT_MARKDOWN_BUDGET));
}
// ── session identity / LRU ────────────────────────────────────────────────
@@ -608,10 +588,10 @@ export class TranscriptStore {
maxResidentSessions: this.maxResidentSessions,
bodyBytes: this.totalBodyBytes(),
bodyBudgetBytes: this.historyBodyBudgetBytes,
markdownBytes: this.markdownBytes,
markdownBudgetBytes: this.markdownBudgetBytes,
markdownBytes: this.markdown.bytes,
markdownBudgetBytes: this.markdown.budgetBytes,
historyEvictions: this.historyEvictions,
markdownEvictions: this.markdownEvictions,
markdownEvictions: this.markdown.evictions,
};
}
@@ -1026,38 +1006,20 @@ export class TranscriptStore {
// ── markdown cache (populated by the rendering/worker phase) ──────────────
private markdownKey(entryId: string, revision: number): string {
return `${entryId}@${revision}`;
}
getMarkdown(entryId: string, revision: number): ParsedMarkdownValue | undefined {
const key = this.markdownKey(entryId, revision);
const entry = this.markdownCache.get(key);
if (!entry) return undefined;
this.markdownCache.delete(key);
this.markdownCache.set(key, entry); // LRU touch
return entry.value;
return this.markdown.get(entryId, revision);
}
setMarkdown(entryId: string, revision: number, value: ParsedMarkdownValue): void {
const key = this.markdownKey(entryId, revision);
const previous = this.markdownCache.get(key);
if (previous) this.markdownBytes -= previous.bytes;
const bytes = Math.max(0, value.bytes);
this.markdownCache.set(key, { value, bytes });
this.markdownBytes += bytes;
while (this.markdownBytes > this.markdownBudgetBytes && this.markdownCache.size > 1) {
const oldest = this.markdownCache.keys().next();
if (oldest.done) break;
const victim = this.markdownCache.get(oldest.value);
if (victim) this.markdownBytes -= victim.bytes;
this.markdownCache.delete(oldest.value);
this.markdownEvictions += 1;
}
this.markdown.set(entryId, revision, value);
}
pinMarkdown(entryId: string, revision: number): () => void {
return this.markdown.pin(entryId, revision);
}
markdownCacheSize(): number {
return this.markdownCache.size;
return this.markdown.size();
}
// ── subscriptions ─────────────────────────────────────────────────────────
@@ -10,6 +10,12 @@ import {
type TranscriptViewportAnchor,
} from "./transcriptScrollController";
declare global {
interface Window {
__REASONIX_TRANSCRIPT_SCROLL_WRITE__?: (owner: TranscriptScrollOwner, top: number) => void;
}
}
const BOTTOM_THRESHOLD_PX = 80;
const TOUCH_SCROLL_THRESHOLD_PX = 2;
const SCROLL_BREAK_KEYS = new Set([
@@ -99,6 +105,7 @@ export function useScrollManager() {
const writeOffset = useCallback((owner: TranscriptScrollOwner, top: number, behavior: ScrollBehavior = "auto") => {
const el = scrollRef.current;
if (!el || !canTranscriptScrollOwnerWrite(modeRef.current, owner)) return false;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__?.(owner, top);
if (typeof el.scrollTo === "function") el.scrollTo({ top, behavior });
else el.scrollTop = top;
return true;
@@ -1,6 +1,6 @@
import { useEffect, useRef, type RefObject } from "react";
import { useEffect, useRef, type MutableRefObject, type RefObject } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { transcriptLayoutSignature } from "./transcriptHeightCache";
import { readTranscriptLayoutSnapshot, type TranscriptLayoutSnapshot } from "./transcriptHeightCache";
/**
* Invalidates TanStack's in-memory measurements when transcript width or root
@@ -9,10 +9,12 @@ import { transcriptLayoutSignature } from "./transcriptHeightCache";
*/
export function useTranscriptMeasurementInvalidation({
scrollRef,
layoutSnapshotRef,
virtualizer,
selectionActive,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
layoutSnapshotRef: MutableRefObject<TranscriptLayoutSnapshot>;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
selectionActive: boolean;
}) {
@@ -29,11 +31,17 @@ export function useTranscriptMeasurementInvalidation({
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
let signature = transcriptLayoutSignature(element);
const initial = readTranscriptLayoutSnapshot(element);
const initialChanged = initial.signature !== layoutSnapshotRef.current.signature;
layoutSnapshotRef.current = initial;
if (initialChanged) {
if (activeRef.current) pendingRef.current = true;
else virtualizer.measure();
}
const invalidateIfChanged = () => {
const next = transcriptLayoutSignature(element);
if (next === signature) return;
signature = next;
const next = readTranscriptLayoutSnapshot(element);
if (next.signature === layoutSnapshotRef.current.signature) return;
layoutSnapshotRef.current = next;
if (activeRef.current) {
pendingRef.current = true;
return;
@@ -51,5 +59,5 @@ export function useTranscriptMeasurementInvalidation({
mutationObserver?.disconnect();
window.removeEventListener("resize", invalidateIfChanged);
};
}, [scrollRef, virtualizer]);
}, [layoutSnapshotRef, scrollRef, virtualizer]);
}
@@ -0,0 +1,26 @@
import { useCallback, useMemo, useRef } from "react";
import { estimateTranscriptRowSize, type TranscriptRow } from "./transcriptRows";
import {
createTranscriptMeasureElement,
EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT,
estimateTranscriptRowHeightForLayout,
} from "./transcriptHeightCache";
export function useTranscriptRowMeasurements(tabId: string | undefined, rows: readonly TranscriptRow[]) {
const layoutSnapshotRef = useRef(EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT);
const estimateSize = useCallback((index: number) => {
const row = rows[index];
return estimateTranscriptRowHeightForLayout({
tabId: tabId ?? "",
layout: layoutSnapshotRef.current,
rowKey: String(row?.key ?? index),
row,
fallback: estimateTranscriptRowSize(row),
});
}, [rows, tabId]);
const measureElement = useMemo(() => createTranscriptMeasureElement({
tabId: tabId ?? "",
getLayoutSnapshot: () => layoutSnapshotRef.current,
}), [tabId]);
return { estimateSize, layoutSnapshotRef, measureElement };
}
@@ -0,0 +1,11 @@
import { useMemo } from "react";
import type { TranscriptRow } from "./transcriptRows";
import type { LiveStream } from "./useController";
import { transcriptLiveSelectableRows, transcriptSelectableRows } from "./transcriptSelectionText";
export function useTranscriptSelectableRows(rows: readonly TranscriptRow[], live?: LiveStream) {
const stable = useMemo(() => transcriptSelectableRows(rows), [rows]);
const byKey = useMemo(() => new Map(stable.map((row) => [row.rowKey, row])), [stable]);
const overrides = useMemo(() => transcriptLiveSelectableRows(byKey, live), [byKey, live]);
return [stable, overrides] as const;
}
@@ -1,26 +1,49 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import type { Range } from "@tanstack/react-virtual";
import { createSelectionRangeExtractor, type TranscriptSelectionRowRange } from "./transcriptSelectionRange";
import type { TranscriptScrollMode } from "./transcriptScrollController";
import type { TranscriptViewportAnchor } from "./transcriptScrollController";
import {
TRANSCRIPT_SELECTABLE_SELECTOR,
TRANSCRIPT_ROW_SELECTOR,
transcriptSelectionProjectionReadyForNode,
transcriptSelectionPointFromClient,
transcriptSelectionPointFromDom,
} from "./transcriptSelectionDom";
import { transcriptSelectionStore, type TranscriptSelectableRow } from "./transcriptSelectionStore";
import { mergeTranscriptSelectableRows } from "./transcriptSelectionText";
import type { TranscriptScrollMode, TranscriptScrollOwner, TranscriptViewportAnchor } from "./transcriptScrollController";
const SELECTABLE_SELECTOR = "[data-transcript-selectable]";
const ROW_SELECTOR = ".transcript__row[data-row-key]";
const EDGE_SCROLL_ZONE_PX = 48;
const EDGE_SCROLL_MIN_PX = 4;
const EDGE_SCROLL_MAX_PX = 24;
function elementForNode(node: Node | null): Element | null {
if (!node) return null;
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
}
type TrackedSelection = {
anchorKey: string;
focusKey: string;
dragging: boolean;
logical: boolean;
pointerId: number;
captureElement: HTMLElement;
};
function rowKeyForNode(node: Node | null): string | null {
return elementForNode(node)?.closest<HTMLElement>(ROW_SELECTOR)?.dataset.rowKey ?? null;
type CaretDocument = Document & {
caretPositionFromPoint?: (x: number, y: number) => unknown;
caretRangeFromPoint?: (x: number, y: number) => globalThis.Range | null;
};
function supportsCaretPoint(doc: Document): boolean {
const caretDoc = doc as CaretDocument;
return typeof caretDoc.caretPositionFromPoint === "function" || typeof caretDoc.caretRangeFromPoint === "function";
}
export function useTranscriptSelectionRetention({
tabId,
revealSignal,
rowIndexByKey,
selectableRows = [],
selectableRowOverrides = [],
scrollRef: providedScrollRef,
setScrollMode,
writeOffset = () => false,
cancelStreamingScroll,
captureViewportAnchor,
reconcileViewportAnchor,
@@ -28,75 +51,229 @@ export function useTranscriptSelectionRetention({
tabId?: string;
revealSignal: number;
rowIndexByKey: ReadonlyMap<string, number>;
selectableRows?: readonly TranscriptSelectableRow[];
selectableRowOverrides?: readonly TranscriptSelectableRow[];
scrollRef?: RefObject<HTMLDivElement | null>;
setScrollMode: (mode: TranscriptScrollMode, reason?: string) => void;
writeOffset?: (owner: TranscriptScrollOwner, top: number, behavior?: ScrollBehavior) => boolean;
cancelStreamingScroll: () => void;
captureViewportAnchor: () => TranscriptViewportAnchor | null;
reconcileViewportAnchor: (snapshot: TranscriptViewportAnchor | null) => boolean;
}) {
const selectionRef = useRef<{ anchorKey: string; focusKey: string; dragging: boolean } | null>(null);
const fallbackScrollRef = useRef<HTMLDivElement>(null);
const scrollRef = providedScrollRef ?? fallbackScrollRef;
const selectionRef = useRef<TrackedSelection | null>(null);
const [, setRevision] = useState(0);
const viewportAnchorRef = useRef<TranscriptViewportAnchor | null>(null);
const lifecycleGenerationRef = useRef(0);
const settleFramesRef = useRef(new Set<number>());
const focusFrameRef = useRef<number | null>(null);
const edgeFrameRef = useRef<number | null>(null);
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
const rowsRef = useRef(selectableRows);
const rowOverridesRef = useRef(selectableRowOverrides);
rowsRef.current = selectableRows;
rowOverridesRef.current = selectableRowOverrides;
const publish = useCallback(() => setRevision((value) => value + 1), []);
const cancelSettleFrames = useCallback(() => {
const cancelFrames = useCallback(() => {
for (const frame of settleFramesRef.current) cancelAnimationFrame(frame);
settleFramesRef.current.clear();
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
if (edgeFrameRef.current !== null) cancelAnimationFrame(edgeFrameRef.current);
focusFrameRef.current = null;
edgeFrameRef.current = null;
}, []);
const releasePointerCapture = useCallback((tracked: TrackedSelection | null) => {
if (!tracked) return;
try {
if (tracked.captureElement.hasPointerCapture(tracked.pointerId)) {
tracked.captureElement.releasePointerCapture(tracked.pointerId);
}
} catch {
// WebKit may drop capture before pointercancel/unmount.
}
}, []);
const clear = useCallback((reason = "clear") => {
if (!selectionRef.current) return;
const tracked = selectionRef.current;
if (!tracked && transcriptSelectionStore.getSnapshot().mode === "none") return;
lifecycleGenerationRef.current += 1;
cancelSettleFrames();
cancelFrames();
releasePointerCapture(tracked);
selectionRef.current = null;
lastPointerRef.current = null;
viewportAnchorRef.current = null;
transcriptSelectionStore.clear(reason);
setScrollMode("manual", reason);
publish();
}, [cancelSettleFrames, publish, setScrollMode]);
}, [cancelFrames, publish, releasePointerCapture, setScrollMode]);
const updateLogicalFocus = useCallback((pointer = lastPointerRef.current) => {
const tracked = selectionRef.current;
if (!tracked?.logical || !tracked.dragging || !pointer) return;
const focus = transcriptSelectionPointFromClient(document, pointer.x, pointer.y);
if (!focus) return;
transcriptSelectionStore.updateLogicalFocus(focus);
tracked.focusKey = focus.rowKey;
}, []);
const scheduleLogicalFocus = useCallback(() => {
const tracked = selectionRef.current;
if (!tracked?.logical || !tracked.dragging || focusFrameRef.current !== null) return;
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null;
updateLogicalFocus();
});
});
}, [updateLogicalFocus]);
const edgeScrollTick = useCallback(() => {
edgeFrameRef.current = null;
const tracked = selectionRef.current;
const pointer = lastPointerRef.current;
const scroll = scrollRef.current;
if (!tracked?.logical || !tracked.dragging || !pointer || !scroll) return;
const rect = scroll.getBoundingClientRect();
let speed = 0;
if (pointer.y < rect.top + EDGE_SCROLL_ZONE_PX) {
const ratio = Math.max(0, Math.min(1, (rect.top + EDGE_SCROLL_ZONE_PX - pointer.y) / EDGE_SCROLL_ZONE_PX));
speed = -(EDGE_SCROLL_MIN_PX + ratio * (EDGE_SCROLL_MAX_PX - EDGE_SCROLL_MIN_PX));
} else if (pointer.y > rect.bottom - EDGE_SCROLL_ZONE_PX) {
const ratio = Math.max(0, Math.min(1, (pointer.y - (rect.bottom - EDGE_SCROLL_ZONE_PX)) / EDGE_SCROLL_ZONE_PX));
speed = EDGE_SCROLL_MIN_PX + ratio * (EDGE_SCROLL_MAX_PX - EDGE_SCROLL_MIN_PX);
}
if (speed === 0) return;
const max = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
const next = Math.max(0, Math.min(max, scroll.scrollTop + speed));
if (next === scroll.scrollTop) {
scheduleLogicalFocus();
return;
}
if (!writeOffset("selection-edge-scroll", next)) return;
scheduleLogicalFocus();
edgeFrameRef.current = requestAnimationFrame(edgeScrollTick);
}, [scheduleLogicalFocus, scrollRef, writeOffset]);
const scheduleEdgeScroll = useCallback(() => {
if (edgeFrameRef.current === null) edgeFrameRef.current = requestAnimationFrame(edgeScrollTick);
}, [edgeScrollTick]);
const onPointerDownCapture = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
const target = event.target instanceof Element ? event.target : null;
const selectable = target?.closest(SELECTABLE_SELECTOR);
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
const selectable = target?.closest(TRANSCRIPT_SELECTABLE_SELECTOR);
if (!selectable) {
document.getSelection()?.removeAllRanges();
clear("new-pointer-outside-selection");
return;
}
const anchorKey = selectable.closest<HTMLElement>(ROW_SELECTOR)?.dataset.rowKey;
const anchorKey = selectable.closest<HTMLElement>(TRANSCRIPT_ROW_SELECTOR)?.dataset.rowKey;
if (!anchorKey) return;
clear("new-pointer-selection");
lifecycleGenerationRef.current += 1;
cancelSettleFrames();
cancelStreamingScroll();
viewportAnchorRef.current = captureViewportAnchor();
selectionRef.current = { anchorKey, focusKey: anchorKey, dragging: true };
selectionRef.current = {
anchorKey,
focusKey: anchorKey,
dragging: true,
logical: false,
pointerId: event.pointerId,
captureElement: event.currentTarget,
};
lastPointerRef.current = { x: event.clientX, y: event.clientY };
transcriptSelectionStore.beginNative(tabId ?? "");
setScrollMode("native-selecting", "pointerdown");
publish();
}, [cancelSettleFrames, cancelStreamingScroll, captureViewportAnchor, clear, publish, setScrollMode]);
}, [cancelStreamingScroll, captureViewportAnchor, clear, publish, setScrollMode, tabId]);
useEffect(() => {
const onSelectionChange = () => {
const tracked = selectionRef.current;
if (!tracked) return;
if (!tracked || tracked.logical) return;
const selection = document.getSelection();
if (!selection || selection.isCollapsed) {
if (!tracked.dragging) clear("selection-collapsed");
return;
}
const anchorKey = rowKeyForNode(selection.anchorNode);
const focusKey = rowKeyForNode(selection.focusNode);
if (!anchorKey || !focusKey) return;
if (tracked.anchorKey === anchorKey && tracked.focusKey === focusKey) return;
selectionRef.current = { ...tracked, anchorKey, focusKey };
const anchor = transcriptSelectionPointFromDom(selection.anchorNode, selection.anchorOffset);
const nativeFocus = transcriptSelectionPointFromDom(selection.focusNode, selection.focusOffset, lastPointerRef.current?.x);
const pointer = lastPointerRef.current;
const focus = nativeFocus ?? (pointer ? transcriptSelectionPointFromClient(document, pointer.x, pointer.y) : null);
if (!anchor || !focus) return;
tracked.anchorKey = anchor.rowKey;
tracked.focusKey = focus.rowKey;
transcriptSelectionStore.updateNativeRange(anchor, focus);
if (anchor.rowKey === focus.rowKey || !supportsCaretPoint(document)) {
publish();
return;
}
if (
!transcriptSelectionProjectionReadyForNode(selection.anchorNode)
|| !transcriptSelectionProjectionReadyForNode(selection.focusNode)
) {
publish();
return;
}
const snapshotId = transcriptSelectionStore.promoteToLogical(
tabId ?? "",
anchor,
focus,
mergeTranscriptSelectableRows(rowsRef.current, rowOverridesRef.current),
);
if (snapshotId == null) return;
tracked.logical = true;
selection.removeAllRanges();
setScrollMode("logical-selecting", "cross-row-selection");
try {
tracked.captureElement.setPointerCapture(tracked.pointerId);
} catch {
// Native fallback remains available when pointer capture is rejected.
}
scheduleEdgeScroll();
publish();
};
const onPointerMove = (event: PointerEvent) => {
const tracked = selectionRef.current;
if (!tracked?.dragging || event.pointerId !== tracked.pointerId) return;
lastPointerRef.current = { x: event.clientX, y: event.clientY };
if (!tracked.logical) return;
scheduleLogicalFocus();
scheduleEdgeScroll();
};
const finish = (event: PointerEvent) => {
if (event.button !== 0 || !selectionRef.current?.dragging) return;
const tracked = selectionRef.current;
if (event.button !== 0 || !tracked?.dragging || (event.pointerId !== tracked.pointerId && event.pointerId !== 0)) return;
if (tracked.logical) {
lastPointerRef.current = { x: event.clientX, y: event.clientY };
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
focusFrameRef.current = null;
if (edgeFrameRef.current !== null) cancelAnimationFrame(edgeFrameRef.current);
edgeFrameRef.current = null;
updateLogicalFocus(lastPointerRef.current);
tracked.dragging = false;
releasePointerCapture(tracked);
transcriptSelectionStore.settleLogical();
viewportAnchorRef.current = null;
setScrollMode("manual", "logical-settled");
publish();
return;
}
tracked.dragging = false;
releasePointerCapture(tracked);
const selection = document.getSelection();
if (!selection || selection.isCollapsed || selection.toString().trim() === "") {
clear("empty-pointerup");
return;
}
const settledSelection = { ...selectionRef.current, dragging: false };
transcriptSelectionStore.settleNative();
const settledSelection = { ...tracked };
const generation = lifecycleGenerationRef.current;
selectionRef.current = settledSelection;
publish();
@@ -113,10 +290,18 @@ export function useTranscriptSelectionRetention({
});
settleFramesRef.current.add(outerFrame);
};
const cancelGesture = (event: PointerEvent) => {
const tracked = selectionRef.current;
if (!tracked?.dragging || (event.pointerId !== tracked.pointerId && event.pointerId !== 0)) return;
document.getSelection()?.removeAllRanges();
clear("pointercancel");
};
const onCopy = () => {
const tracked = selectionRef.current;
const selection = document.getSelection();
if (!tracked || tracked.dragging || !selection || selection.isCollapsed) return;
if (!tracked || tracked.logical || tracked.dragging || !selection || selection.isCollapsed) return;
const generation = lifecycleGenerationRef.current;
const anchorNode = selection.anchorNode;
const anchorOffset = selection.anchorOffset;
@@ -139,36 +324,62 @@ export function useTranscriptSelectionRetention({
});
settleFramesRef.current.add(frame);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
document.getSelection()?.removeAllRanges();
clear("escape");
};
const onScroll = () => {
if (selectionRef.current?.logical) scheduleLogicalFocus();
};
const scroll = scrollRef.current;
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", finish);
document.addEventListener("pointercancel", finish);
document.addEventListener("pointercancel", cancelGesture);
document.addEventListener("copy", onCopy);
document.addEventListener("keydown", onKeyDown);
scroll?.addEventListener("scroll", onScroll, { passive: true });
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", finish);
document.removeEventListener("pointercancel", finish);
document.removeEventListener("pointercancel", cancelGesture);
document.removeEventListener("copy", onCopy);
document.removeEventListener("keydown", onKeyDown);
scroll?.removeEventListener("scroll", onScroll);
};
}, [clear, publish, reconcileViewportAnchor, setScrollMode]);
}, [clear, publish, reconcileViewportAnchor, releasePointerCapture, scheduleEdgeScroll, scheduleLogicalFocus, scrollRef, setScrollMode, tabId, updateLogicalFocus]);
useEffect(() => {
lifecycleGenerationRef.current += 1;
cancelSettleFrames();
const hadSelection = selectionRef.current !== null;
cancelFrames();
const tracked = selectionRef.current;
releasePointerCapture(tracked);
selectionRef.current = null;
lastPointerRef.current = null;
viewportAnchorRef.current = null;
document.getSelection()?.removeAllRanges();
if (hadSelection) publish();
}, [cancelSettleFrames, publish, revealSignal, tabId]);
transcriptSelectionStore.clear("transcript-generation-reset");
if (tracked) publish();
}, [cancelFrames, publish, releasePointerCapture, revealSignal, tabId]);
useEffect(() => cancelSettleFrames, [cancelSettleFrames]);
useEffect(() => cancelFrames, [cancelFrames]);
useEffect(() => transcriptSelectionStore.subscribe(() => {
const tracked = selectionRef.current;
if (!tracked?.logical || transcriptSelectionStore.getSnapshot().mode !== "none") return;
lifecycleGenerationRef.current += 1;
cancelFrames();
releasePointerCapture(tracked);
selectionRef.current = null;
lastPointerRef.current = null;
viewportAnchorRef.current = null;
setScrollMode("manual", "logical-selection-cleared");
publish();
}), [cancelFrames, publish, releasePointerCapture, setScrollMode]);
useEffect(() => {
const tracked = selectionRef.current;
@@ -176,12 +387,19 @@ export function useTranscriptSelectionRetention({
if (!rowIndexByKey.has(tracked.anchorKey) || !rowIndexByKey.has(tracked.focusKey)) {
document.getSelection()?.removeAllRanges();
clear("selection-endpoint-removed");
return;
}
}, [clear, rowIndexByKey]);
transcriptSelectionStore.validateRows(mergeTranscriptSelectableRows(selectableRows, rowOverridesRef.current));
}, [clear, rowIndexByKey, selectableRows]);
useEffect(() => {
if (!selectionRef.current?.logical) return;
transcriptSelectionStore.validateRowChanges(selectableRowOverrides);
}, [selectableRowOverrides]);
const rangeExtractor = useMemo(() => createSelectionRangeExtractor((): TranscriptSelectionRowRange | null => {
const tracked = selectionRef.current;
if (!tracked) return null;
if (!tracked || tracked.logical) return null;
const anchorIndex = rowIndexByKey.get(tracked.anchorKey);
const focusIndex = rowIndexByKey.get(tracked.focusKey);
return anchorIndex == null || focusIndex == null ? null : { anchorIndex, focusIndex };
@@ -190,6 +408,8 @@ export function useTranscriptSelectionRetention({
return {
clear,
active: selectionRef.current !== null,
logical: selectionRef.current?.logical ?? false,
reconcileLogicalFocus: scheduleLogicalFocus,
onPointerDownCapture,
rangeExtractor: (range: Range) => rangeExtractor(range),
};
+13
View File
@@ -3415,6 +3415,19 @@ a[href] {
left: 0;
width: 100%;
}
.transcript-selection-overlay {
position: absolute;
inset: 0;
z-index: var(--z-local-handle);
pointer-events: none;
overflow: visible;
}
.transcript-selection-overlay__rect {
position: absolute;
display: block;
border-radius: 2px;
background: color-mix(in srgb, Highlight 34%, transparent);
}
.transcript__row > * {
max-width: var(--maxw);
margin-left: auto;