sessions: source-message deep links from the graph + session Find (Cmd/Ctrl+F)

This commit is contained in:
amosblomqvist
2026-10-13 08:26:31 +03:00
parent 664c1bfed2
commit cdd8ac047e
14 changed files with 831 additions and 13 deletions
+24 -6
View File
@@ -6,7 +6,7 @@ import { visibleQueuedSteers } from "@/components/session-core";
import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint";
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
ConversationContent,
@@ -34,6 +34,9 @@ import { StatsBar } from "@/components/stats-bar";
import { useServerEvent, useServerResync } from "@/components/server-events";
import {
isProviderKeyRefusal,
firstSourceTextParts,
messageAnchorId,
textPartMessageId,
kbdEventForAction as kbdEventForChat,
needsContinuation,
type ChatInput,
@@ -48,6 +51,8 @@ import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"
import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
import { SessionFind } from "@/components/session-find";
import { SessionMessageNavigation } from "@/components/session-navigation";
import { PracticeDock } from "@/components/practice-dock";
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
@@ -234,6 +239,10 @@ export function Chat({
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
const scrollContextRef = useRef<StickToBottomContext | null>(null);
const sourceTextParts = useMemo(() => firstSourceTextParts(messages), [messages]);
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
@@ -1301,10 +1310,12 @@ export function Chat({
)}
</TopBar>
<div className="relative flex min-h-0 flex-1">
<Conversation className="h-full min-w-0 flex-1" initial="instant">
<SessionFind scopeRef={sessionFindScopeRef} scrollContextRef={scrollContextRef} />
<div ref={sessionFindScopeRef} className="relative flex min-h-0 flex-1">
<Conversation className="h-full min-w-0 flex-1" initial="instant" contextRef={scrollContextRef}>
<SessionMessageNavigation messages={messages} />
<ConversationContent className="min-h-full gap-0 p-0">
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
<div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
{messages.length === 0 && !busy && (
<ConversationEmptyState
title="New session"
@@ -1456,8 +1467,15 @@ export function Chat({
);
}
switch (part.type) {
case "text":
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
case "text": {
const sourceId = textPartMessageId(part.id);
const anchor = sourceId && sourceTextParts.get(sourceId) === part.id ? messageAnchorId(sourceId) : undefined;
return (
<div key={part.id || pi} id={anchor} data-source-message-id={sourceId ?? undefined} className="lh-source-passage">
<MessageResponse>{part.text}</MessageResponse>
</div>
);
}
case "reasoning":
return (
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
+3 -2
View File
@@ -66,7 +66,7 @@
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock";
@@ -1668,7 +1668,8 @@ export function GraphView({
return (
<div key={sourceId} className="flex items-center gap-1.5">
<Link
href={`/chat/${sourceId}`}
href={propositionSessionHref(selected.node, sourceId)}
scroll={false}
className="min-w-0 flex-1 truncate rounded-md px-1 py-1 text-sm text-foreground transition-colors hover:bg-accent"
>
{snapshot?.sessions[sourceId] ?? sourceId}
+6
View File
@@ -129,6 +129,12 @@ export function KeyboardShortcuts() {
case "cheat-sheet":
handlersRef.current.setCheatOpen(true);
break;
case "find":
// The mounted surface owns Find (session text / graph nodes).
// Claim Mod+F so Chromium's whole-window finder cannot open too.
e.preventDefault();
window.dispatchEvent(new CustomEvent(kbdEventForAction(action)));
break;
default:
window.dispatchEvent(new CustomEvent(kbdEventForAction(action)));
}
+1 -1
View File
@@ -544,7 +544,7 @@ function QuestionDockForm({
{/* Question area — scrolls internally if very tall; the bottom row stays put.
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
long questions short — owner round, 2026-10-05). */}
<div className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "probe"
? "Probe"
+156
View File
@@ -0,0 +1,156 @@
import { findSessionTextMatches } from "@/src/core";
/** Only marked session surfaces enter Find. Offscreen scroller content counts;
* hidden/unmounted sections do not. Never walk embedded documents or drafts.
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
const EXCLUDED = [
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas",
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
].join(",");
interface TextPoint { node: Text; offset: number }
interface DisplayCharacter { start: TextPoint; end: TextPoint; collapsedSpace: boolean }
interface DisplayRun { text: string; characters: DisplayCharacter[] }
const BLOCK_DISPLAYS = new Set(["block", "flex", "grid", "list-item", "table", "table-row", "table-cell", "table-caption", "flow-root"]);
function styleOf(element: Element): CSSStyleDeclaration | null {
const view = element.ownerDocument.defaultView;
// Test DOMs (linkedom) may ship no CSS OM — degrade to attribute-only
// visibility checks; real browsers always implement it.
return typeof view?.getComputedStyle === "function" ? view.getComputedStyle(element) : null;
}
function hidden(element: Element, style: CSSStyleDeclaration | null): boolean {
return element.matches(EXCLUDED)
|| !!style && (style.display === "none" || style.visibility === "hidden"
|| style.visibility === "collapse" || style.opacity === "0" || style.contentVisibility === "hidden");
}
function displayedRoot(root: Element): boolean {
for (let element: Element | null = root; element; element = element.parentElement) {
if (hidden(element, styleOf(element))) return false;
}
return true;
}
/** Preserve a source-point map while collapsing ordinary HTML whitespace.
* This lets a phrase span inline markup, without concatenating separate cards
* or paragraphs. Preformatted code/user text keeps its literal whitespace. */
function displayRuns(root: Element): DisplayRun[] {
const runs: DisplayRun[] = [];
let text = "";
let characters: DisplayCharacter[] = [];
const flush = () => {
if (characters.at(-1)?.collapsedSpace) {
text = text.slice(0, -1);
characters.pop();
}
if (text) runs.push({ text, characters });
text = "";
characters = [];
};
const append = (node: Text, whiteSpace: string) => {
const collapse = !["pre", "pre-wrap", "break-spaces"].includes(whiteSpace);
for (let i = 0; i < node.data.length; i++) {
const char = node.data[i]!;
if (whiteSpace === "pre-line" && /[\r\n]/.test(char)) { flush(); continue; }
const space = collapse && /[\t\n\r\f ]/.test(char);
if (space && !text) continue;
if (space && characters.at(-1)?.collapsedSpace) {
characters.at(-1)!.end = { node, offset: i + 1 };
continue;
}
text += space ? " " : char;
characters.push({ start: { node, offset: i }, end: { node, offset: i + 1 }, collapsedSpace: space });
}
};
const visit = (element: Element) => {
const style = styleOf(element);
if (hidden(element, style)) return;
if (element.tagName === "BR") { flush(); return; }
const block = !!style && BLOCK_DISPLAYS.has(style.display);
// Without a CSS OM (test DOMs), the HTML defaults stand: only pre-family
// tags keep literal whitespace.
const whiteSpace = style?.whiteSpace ?? (element.matches("pre, listing, plaintext") ? "pre" : "normal");
if (block) flush();
for (const child of element.childNodes) {
if (child.nodeType === 3) append(child as Text, whiteSpace);
else if (child.nodeType === 1) visit(child as Element);
}
if (block) flush();
};
visit(root);
flush();
return runs;
}
/** Minimal AbstractRange stand-in for DOMs without live Range support
* (linkedom in tests). Real browsers always take document.createRange. */
class StaticRangeLike {
startContainer: Node = null as unknown as Node;
startOffset = 0;
endContainer: Node = null as unknown as Node;
endOffset = 0;
setStart(node: Node, offset: number): void { this.startContainer = node; this.startOffset = offset; }
setEnd(node: Node, offset: number): void { this.endContainer = node; this.endOffset = offset; }
get collapsed(): boolean { return this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
toString(): string {
const text = (node: Node) => (node.nodeType === 3 ? (node as Text).data : "");
if (this.startContainer === this.endContainer) return text(this.startContainer).slice(this.startOffset, this.endOffset);
// Cross-node: gather the document-order text between the boundaries.
const texts: Text[] = [];
const walk = (node: Node | null): void => {
if (!node) return;
if (node.nodeType === 3) texts.push(node as Text);
for (const child of node.childNodes) walk(child);
};
walk(this.startContainer.ownerDocument);
const start = texts.indexOf(this.startContainer as Text);
const end = texts.indexOf(this.endContainer as Text);
let out = text(this.startContainer).slice(this.startOffset);
for (let i = start + 1; i < end && i >= 0; i++) out += texts[i]!.data;
if (end >= 0) out += text(this.endContainer).slice(0, this.endOffset);
return out;
}
}
export function sessionFindRanges(scope: HTMLElement, query: string): Range[] {
if (!query) return [];
const roots = [
...(scope.matches("[data-session-find-root]") ? [scope] : []),
...scope.querySelectorAll<HTMLElement>("[data-session-find-root]"),
];
const ranges: Range[] = [];
for (const root of roots) {
// A nested marker must not double-count its text.
if (roots.some((other) => other !== root && other.contains(root)) || !displayedRoot(root)) continue;
for (const run of displayRuns(root)) {
for (const match of findSessionTextMatches(run.text, query)) {
const start = run.characters[match.start]!.start;
const end = run.characters[match.end - 1]!.end;
const native = scope.ownerDocument.createRange?.();
const range = typeof native?.setStart === "function" ? native : (new StaticRangeLike() as unknown as Range);
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
ranges.push(range);
}
}
}
return ranges;
}
/** Frozen boundary identity, rather than a mutable live Range, survives text
* appends while Find is open. A re-render may replace nodes; then the caller
* clamps the old index without pulling the reader to a different passage. */
export interface SessionRangeIdentity {
start: Node; startOffset: number; end: Node; endOffset: number;
}
export function sessionRangeIdentity(range: Range): SessionRangeIdentity {
return { start: range.startContainer, startOffset: range.startOffset, end: range.endContainer, endOffset: range.endOffset };
}
export function sameSessionRange(range: Range, identity: SessionRangeIdentity | null): boolean {
return !!identity && range.startContainer === identity.start && range.startOffset === identity.startOffset
&& range.endContainer === identity.end && range.endOffset === identity.endOffset;
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
import { kbdEventForAction, stepSessionMatch } from "@/src/core";
import {
sameSessionRange, sessionFindRanges, sessionRangeIdentity, type SessionRangeIdentity,
} from "@/components/session-find-dom";
import { scrollSessionTarget, type SessionScrollContext } from "@/components/session-navigation";
export const SESSION_FIND_HIGHLIGHT = "lh-session-find";
export const SESSION_FIND_ACTIVE_HIGHLIGHT = "lh-session-find-active";
/** ::highlight() rules live OUTSIDE globals.css on purpose: the PostCSS/
* Tailwind v4 pipeline rejects the Custom Highlight pseudo-element as a
* parse error, which downed the whole stylesheet. A tiny <style> element,
* injected once on first client mount, ships them verbatim. */
const FIND_STYLE_ID = "lh-session-find-style";
export function injectFindStyles(): void {
if (document.getElementById(FIND_STYLE_ID)) return;
const style = document.createElement("style");
style.id = FIND_STYLE_ID;
style.textContent = `
::highlight(${SESSION_FIND_HIGHLIGHT}) { background-color: color-mix(in oklab, var(--primary) 25%, transparent); }
::highlight(${SESSION_FIND_ACTIVE_HIGHLIGHT}) { background-color: var(--primary); color: var(--primary-foreground); }`;
document.head.appendChild(style);
}
export function clearSessionFindHighlights(): void {
if (typeof CSS === "undefined") return;
CSS.highlights?.delete(SESSION_FIND_HIGHLIGHT);
CSS.highlights?.delete(SESSION_FIND_ACTIVE_HIGHLIGHT);
}
function publishHighlights(ranges: readonly Range[], index: number): void {
if (typeof CSS === "undefined" || !CSS.highlights || typeof Highlight === "undefined") return;
// Avoid a huge argument spread when a short query matches many times.
const all = new Highlight();
for (const range of ranges) all.add(range);
CSS.highlights.set(SESSION_FIND_HIGHLIGHT, all);
const active = new Highlight();
active.priority = 1;
if (ranges[index]) active.add(ranges[index]!);
CSS.highlights.set(SESSION_FIND_ACTIVE_HIGHLIGHT, active);
}
/** One-Esc law: a surfaced menu/dialog/cheat sheet outranks this non-modal
* bar, regardless of where focus happened to be left. Closed Radix exits
* must not block Find once they have stopped being a surfaced layer. */
export function sessionFindHasForegroundLayer(doc: Document): boolean {
return Array.from(doc.querySelectorAll(
'[data-lh-menu], [data-radix-popper-content-wrapper], [role="menu"], [role="dialog"], [data-testid="cheat-sheet"]',
)).some((element) => {
if (element.getAttribute("data-state") === "closed" || element.hasAttribute("hidden")) return false;
const view = doc.defaultView;
if (typeof view?.getComputedStyle !== "function") return true;
const style = view.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
});
}
/** Mounted for the lifetime of ONE Chat (which is keyed by chatId). Closing
* keeps the query/reading position; changing sessions resets all local state.
* The bar lives outside scopeRef, so it never finds its own query or count. */
export function SessionFind({ scopeRef, scrollContextRef }: {
scopeRef: RefObject<HTMLDivElement | null>;
scrollContextRef: RefObject<SessionScrollContext | null>;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [result, setResult] = useState({ index: -1, count: 0 });
useEffect(injectFindStyles, []);
const inputRef = useRef<HTMLInputElement | null>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const rangesRef = useRef<Range[]>([]);
const indexRef = useRef(-1);
const identityRef = useRef<SessionRangeIdentity | null>(null);
const lastQueryRef = useRef<string | null>(null);
const close = useCallback(() => {
clearSessionFindHighlights();
setOpen(false);
if (previousFocus.current?.isConnected) previousFocus.current.focus({ preventScroll: true });
}, []);
useEffect(() => {
const onFind = () => {
if (sessionFindHasForegroundLayer(document)) return;
if (!open) previousFocus.current = document.activeElement as HTMLElement | null;
setOpen(true);
// A second Mod+F focuses/selects the retained query, not a second bar.
if (open) { inputRef.current?.focus({ preventScroll: true }); inputRef.current?.select(); }
};
window.addEventListener(kbdEventForAction("find"), onFind);
return () => window.removeEventListener(kbdEventForAction("find"), onFind);
}, [open]);
useEffect(() => {
if (!open) return;
inputRef.current?.focus({ preventScroll: true });
inputRef.current?.select();
const onEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || sessionFindHasForegroundLayer(document)) return;
event.preventDefault();
event.stopPropagation();
close();
};
document.addEventListener("keydown", onEscape);
return () => document.removeEventListener("keydown", onEscape);
}, [open, close]);
useEffect(() => {
if (!open || !scopeRef.current) return;
const scope = scopeRef.current;
let frame: number | null = null;
const refresh = (selectFirst: boolean) => {
const ranges = sessionFindRanges(scope, query);
let index = selectFirst ? 0 : ranges.findIndex((range) => sameSessionRange(range, identityRef.current));
if (index < 0) index = Math.max(0, Math.min(indexRef.current, ranges.length - 1));
if (!ranges.length) index = -1;
rangesRef.current = ranges;
indexRef.current = index;
identityRef.current = ranges[index] ? sessionRangeIdentity(ranges[index]!) : null;
setResult((old) => old.index === index && old.count === ranges.length ? old : { index, count: ranges.length });
publishHighlights(ranges, index);
if (selectFirst && ranges[index]) scrollSessionTarget(ranges[index]!, scope, scrollContextRef.current);
};
// New queries start at the session's first match. Reopening the retained
// query or receiving streamed content only refreshes; neither yanks scroll.
refresh(lastQueryRef.current !== query);
lastQueryRef.current = query;
const schedule = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => { frame = null; refresh(false); });
};
const observer = new MutationObserver(schedule);
observer.observe(scope, { subtree: true, childList: true, characterData: true, attributes: true,
attributeFilter: ["hidden", "style", "class", "data-state", "aria-expanded"] });
window.addEventListener("resize", schedule);
return () => {
observer.disconnect();
window.removeEventListener("resize", schedule);
if (frame !== null) window.cancelAnimationFrame(frame);
clearSessionFindHighlights();
};
}, [open, query, scopeRef, scrollContextRef]);
// Also clear on a session switch when Find was already closed.
useEffect(() => clearSessionFindHighlights, []);
const step = (direction: 1 | -1) => {
const ranges = rangesRef.current;
const index = stepSessionMatch(indexRef.current, ranges.length, direction);
indexRef.current = index;
identityRef.current = ranges[index] ? sessionRangeIdentity(ranges[index]!) : null;
setResult({ index, count: ranges.length });
publishHighlights(ranges, index);
if (ranges[index] && scopeRef.current) scrollSessionTarget(ranges[index]!, scopeRef.current, scrollContextRef.current);
};
if (!open) return null;
const buttonClass = "flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40";
return (
<div
role="search"
aria-label="Session Find"
data-testid="session-find"
className="lh-no-drag flex h-10 shrink-0 items-center gap-2 border-b border-border px-6 font-ui text-sm"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.stopPropagation(); // NEVER reach question/global Enter submits.
if ((event.target as HTMLElement).tagName !== "INPUT" || event.nativeEvent.isComposing) return;
event.preventDefault();
step(event.shiftKey ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
}
}}
>
<SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
aria-label="Find in session"
placeholder="Find in session…"
value={query}
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
className="lh-input min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
/>
<output aria-live="polite" data-testid="session-find-count" className="shrink-0 text-xs tabular-nums text-muted-foreground">
{query ? `${result.index + 1}/${result.count}` : ""}
</output>
<button type="button" aria-label="Previous match" title="Previous match (Shift+Enter)" className={buttonClass}
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(-1)}>
<ChevronUpIcon className="size-4" aria-hidden />
</button>
<button type="button" aria-label="Next match" title="Next match (Enter)" className={buttonClass}
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(1)}>
<ChevronDownIcon className="size-4" aria-hidden />
</button>
<button type="button" aria-label="Close Find" title="Close Find (Esc)" className={buttonClass} onClick={close}>
<XIcon className="size-4" aria-hidden />
</button>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
import { messageAnchorId, messageIdFromHash, type LhViewMessage } from "@/src/core";
export type SessionScrollContext = Pick<StickToBottomContext, "scrollRef" | "stopScroll">;
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Scroll only the session's own containers (including tall question/code
* areas), never sidebar/chrome. Inner containers move first, then the main
* transcript. stopScroll releases the sticky-bottom animation BEFORE jumping. */
export function scrollSessionTarget(target: Range | HTMLElement, scope: HTMLElement, context: SessionScrollContext | null): void {
const node = "startContainer" in target ? target.startContainer : target;
const main = context?.scrollRef.current;
if (main?.contains(node)) context!.stopScroll();
for (let element = node.parentElement; element && scope.contains(element); element = element.parentElement) {
const view = element.ownerDocument.defaultView;
const style = typeof view?.getComputedStyle === "function" ? view.getComputedStyle(element) : null;
if (element !== main && (!style || !/(auto|scroll)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight)) continue;
const rect = target.getBoundingClientRect();
const container = element.getBoundingClientRect();
element.scrollTo({ top: element.scrollTop + rect.top - container.top - 16, behavior: "instant" });
}
}
/** React owns message markup; this controller adds no messages or content.
* Native hash/back-forward navigation retries after the view arrives, and
* subsequent streaming updates must NOT repeat the source jump. */
export function SessionMessageNavigation({ messages }: { messages: readonly LhViewMessage[] }) {
const context = useStickToBottomContext();
const { scrollRef, stopScroll } = context;
const lastJump = useRef<{ hash: string; target: HTMLElement } | null>(null);
const frame = useRef<number | null>(null);
const flash = useRef<{ target: HTMLElement; timer: number } | null>(null);
const clearFlash = useCallback(() => {
if (!flash.current) return;
window.clearTimeout(flash.current.timer);
flash.current.target.classList.remove("lh-source-highlight");
flash.current = null;
}, []);
const jump = useCallback(() => {
const hash = window.location.hash;
const messageId = messageIdFromHash(hash);
const main = scrollRef.current;
const target = messageId ? document.getElementById(messageAnchorId(messageId)) : null;
if (!main || !target || !main.contains(target)) return;
if (lastJump.current?.hash === hash && lastJump.current.target === target) return;
stopScroll();
if (frame.current !== null) window.cancelAnimationFrame(frame.current);
// Dock height and the initial ResizeObserver settle before the jump.
// A second stop cancels any initial bottom-follow queued in that frame.
frame.current = window.requestAnimationFrame(() => {
frame.current = null;
if (!target.isConnected || window.location.hash !== hash) return;
stopScroll();
scrollSessionTarget(target, main, { scrollRef, stopScroll });
clearFlash();
target.classList.add("lh-source-highlight");
flash.current = { target, timer: window.setTimeout(clearFlash, 2_000) };
lastJump.current = { hash, target };
});
}, [scrollRef, stopScroll, clearFlash]);
useIsoLayoutEffect(() => { jump(); }, [jump, messages]);
useEffect(() => {
const onHash = () => {
lastJump.current = null;
clearFlash();
jump();
};
window.addEventListener("hashchange", onHash);
window.addEventListener("popstate", onHash);
return () => {
window.removeEventListener("hashchange", onHash);
window.removeEventListener("popstate", onHash);
if (frame.current !== null) window.cancelAnimationFrame(frame.current);
frame.current = null;
lastJump.current = null;
clearFlash();
};
}, [jump, clearFlash]);
return null;
}