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
+10
View File
@@ -588,3 +588,13 @@ body {
animation: none;
}
}
/* Source-message arrival: a brief, static theme-colored spotlight. */
.lh-source-passage {
scroll-margin-top: 1rem;
}
.lh-source-highlight {
background-color: var(--accent);
outline: 1px solid var(--border);
border-radius: 0.25rem;
}
+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;
}
+2
View File
@@ -62,6 +62,8 @@ export * from "./settings.ts";
export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts";
export * from "./settings-resolution.ts";
export * from "./search.ts"; // search port (search wave, 2026-09-20)
export * from "./session-find.ts";
export * from "./session-navigation.ts";
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
// shape rules + masking — pure, browser-safe (the auth page binds to the
// /api/keys routes built on it).
+3 -4
View File
@@ -69,10 +69,9 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
{ id: "toggle-user-thinking", label: "Toggle all your thinking", scope: "chat", binding: "mod+t" },
// ⌘F is surface-scoped (owner key map): the graph focuses node search; a
// chat-scoped transcript search rides the same event when it lands (the
// graph's listener simply isn't mounted on chat pages).
{ id: "find", label: "Find (graph: node search)", scope: "graph", binding: "mod+f" },
// Mod+F is surface-scoped: graph node search and session text Find share
// one action/binding. Only the currently mounted surface listens.
{ id: "find", label: "Find", scope: "global", binding: "mod+f" },
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
// The question dock's row machine (wave B) — displayOnly: the dock owns
// the keys while a question is expanded; the registry never dispatches
+20
View File
@@ -0,0 +1,20 @@
/** Literal, case-insensitive session Find (owner, 2026-10-13). The DOM seam
* supplies only displayed text; no transcript/Markdown/TeX indexing lives here. */
export interface SessionTextMatch {
start: number;
end: number;
}
export function findSessionTextMatches(text: string, query: string): SessionTextMatch[] {
if (!query) return [];
// Escape the user's string, never expose regex semantics. RegExp retains
// ORIGINAL UTF-16 offsets, unlike lowercasing text (İ can change length).
const literal = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(literal, "giu");
return Array.from(text.matchAll(pattern), (m) => ({ start: m.index, end: m.index + m[0].length }));
}
export function stepSessionMatch(index: number, count: number, direction: 1 | -1): number {
if (!count) return -1;
return ((index + direction) % count + count) % count;
}
+47
View File
@@ -0,0 +1,47 @@
import type { GraphNode } from "./contracts/graph.ts";
import type { LhViewMessage } from "./view.ts";
/** Message-granular graph links (owner, 2026-10-13). Persisted provenance and
* native IDs already carry everything needed; only the render seam is new. */
export function messageAnchorId(messageId: string): string {
return `message-${messageId}`;
}
export function messageIdFromHash(hash: string): string | null {
try {
const anchor = decodeURIComponent(hash.replace(/^#/, ""));
return anchor.startsWith("message-") && anchor.length > "message-".length
? anchor.slice("message-".length)
: null;
} catch {
return null;
}
}
/** Old empty anchors do not mask a later real one. Ledger order, not date or
* lexical order, selects the FIRST recorded message in this source. */
export function propositionSessionHref(node: Pick<GraphNode, "provenance">, chatId: string): string {
const messageId = node.provenance.find((p) => p.chatId === chatId && p.messageId !== "")?.messageId;
return `/chat/${encodeURIComponent(chatId)}${messageId ? `#${encodeURIComponent(messageAnchorId(messageId))}` : ""}`;
}
/** projectView's text-part identity is <native message ID>-t<text index>.
* Assistant GROUP IDs cannot identify an individual extraction source. */
export function textPartMessageId(partId: string | undefined): string | null {
return partId?.match(/^(.*)-t\d+$/)?.[1] || null;
}
/** One DOM anchor per native teacher message, at its first non-empty prose
* block. Empty text blocks and intervening tools/thinking do not steal it. */
export function firstSourceTextParts(messages: readonly LhViewMessage[]): Map<string, string> {
const first = new Map<string, string>();
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (part.type !== "text" || typeof part.text !== "string" || !part.text.trim() || !part.id) continue;
const messageId = textPartMessageId(part.id);
if (messageId && !first.has(messageId)) first.set(messageId, part.id);
}
}
return first;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Session Find — the pure matcher plus the DOM seam (owner, 2026-10-13).
* The DOM seam runs under linkedom (styles resolve to defaults: display
* block/inline behave; the hidden/overlay paths are exercised through the
* selectors and inline styles linkedom DOES evaluate). The highlight/
* scroll lifecycle itself is browser-verified (SessionFind on the live
* page), not re-implemented here.
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseHTML } from "linkedom";
import { findSessionTextMatches, stepSessionMatch } from "../src/core/session-find.ts";
import { sessionFindRanges, sessionRangeIdentity, sameSessionRange } from "../components/session-find-dom.ts";
import { clearSessionFindHighlights, injectFindStyles, sessionFindHasForegroundLayer } from "../components/session-find.tsx";
const dom = parseHTML("<html><head><style></style></head><body></body></html>");
(globalThis as Record<string, unknown>).window = dom.window;
(globalThis as Record<string, unknown>).document = dom.document;
// linkedom ships no Highlight registry — stub the one field the clearer reads.
(globalThis as Record<string, unknown>).CSS = dom.window.CSS ?? { highlights: new Map() };
describe("findSessionTextMatches — literal, case-insensitive, regex-inert", () => {
it("finds every literal occurrence, any case — substrings included", () => {
assert.deepEqual(findSessionTextMatches("Cat and CAT", "cat"), [
{ start: 0, end: 3 }, { start: 8, end: 11 },
]);
assert.deepEqual(findSessionTextMatches("scat", "cat"), [{ start: 1, end: 4 }]);
});
it("regex metacharacters are inert", () => {
assert.deepEqual(findSessionTextMatches("a.b axb ?*+ (x) [y]", "a.b"), [{ start: 0, end: 3 }]);
assert.deepEqual(findSessionTextMatches("cost $5 now", "$5"), [{ start: 5, end: 7 }]);
assert.deepEqual(findSessionTextMatches("a\\b", "a\\b"), [{ start: 0, end: 3 }]);
});
it("empty query matches nothing; no query, no ranges", () => {
assert.deepEqual(findSessionTextMatches("anything", ""), []);
});
it("unicode-aware, and offsets are UTF-16 into the text as-is", () => {
assert.deepEqual(findSessionTextMatches("α αα", "αα"), [{ start: 2, end: 4 }]);
});
});
describe("stepSessionMatch — wraparound navigation", () => {
it("steps forward and wraps; backward and wraps; empty stays empty", () => {
assert.equal(stepSessionMatch(0, 3, 1), 1);
assert.equal(stepSessionMatch(2, 3, 1), 0);
assert.equal(stepSessionMatch(0, 3, -1), 2);
assert.equal(stepSessionMatch(1, 3, -1), 0);
assert.equal(stepSessionMatch(-1, 0, 1), -1);
});
});
describe("sessionFindRanges — the displayed-text DOM seam", () => {
function mount(html: string): void {
document.body.innerHTML = html;
}
it("matches text spanning inline markup, never across blocks", () => {
mount(`<div data-session-find-root="">
<p data-testid="p1">chain <strong>rule</strong> lives here</p>
<p data-testid="p2">chain rule</p>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "chain rule");
assert.equal(ranges.length, 2, "both paragraphs match; the first spans its <strong>");
const first = ranges[0]!;
assert.equal(first.startContainer.nodeName, "#text");
assert.equal(first.toString().length, "chain rule".length);
});
it("searches only marked session roots — chrome, dialogs, and the composer stay out", () => {
mount(`<div data-session-find-root=""><p>needle in the session</p></div>
<div data-testid="chrome"><p>needle in chrome</p></div>
<div role="dialog"><p>needle in a dialog</p></div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
});
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => {
mount(`<div data-session-find-root="">
<p>needle visible</p>
<p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);
assert.ok((ranges[0]!.startContainer as Text).data.includes("visible"));
});
it("collapsed whitespace matches: a phrase with spaces/tabs/newlines between words", () => {
mount(`<div data-session-find-root=""><p>the\n\tchain rule</p></div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "the chain rule");
assert.equal(ranges.length, 1);
});
it("code spans keep their literal whitespace (pre): no collapse", () => {
mount(`<div data-session-find-root=""><pre>a b</pre><p>a b</p></div>`);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "a b").length, 1);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "a b").length, 1);
});
it("empty query returns no ranges", () => {
mount(`<div data-session-find-root=""><p>text</p></div>`);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "").length, 0);
});
it("identity survives text appended after the match (streaming)", () => {
mount(`<div data-session-find-root=""><p>findme and more to come</p></div>`);
const [range] = sessionFindRanges(document.body as unknown as HTMLElement, "findme");
const identity = sessionRangeIdentity(range!);
const p = document.querySelector("p")!;
p.firstChild!.textContent = "findme and more text streamed in";
const [again] = sessionFindRanges(document.body as unknown as HTMLElement, "findme");
assert.ok(sameSessionRange(again!, identity), "same node/offsets: refresh holds the active match");
});
});
describe("sessionFindHasForegroundLayer — the one-Esc law", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("no surfaced layer when only session content exists", () => {
mount(`<div data-testid="user-message"><p>needle</p></div>`);
assert.equal(sessionFindHasForegroundLayer(document), false);
});
it("a surfaced menu/dialog/cheat sheet outranks the Find bar", () => {
for (const html of [
'<div data-lh-menu></div>',
'<div data-testid="cheat-sheet"></div>',
'<div role="dialog"></div>',
'<div data-radix-popper-content-wrapper></div>',
]) {
mount(html);
assert.equal(sessionFindHasForegroundLayer(document), true, html);
}
});
it("a closed Radix layer is not surfaced", () => {
mount(`<div role="dialog" data-state="closed"></div>`);
assert.equal(sessionFindHasForegroundLayer(document), false);
});
});
describe("injectFindStyles — ::highlight rules ship outside the PostCSS pipeline", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("injects both rules once, idempotently", () => {
document.head.innerHTML = "";
injectFindStyles();
injectFindStyles();
const styles = [...document.querySelectorAll("style")];
assert.equal(styles.length, 1, "one style element, never duplicated");
const css = styles[0]!.textContent ?? "";
assert.ok(css.includes("::highlight(lh-session-find) {"));
assert.ok(css.includes("::highlight(lh-session-find-active) {"));
});
});
describe("clearSessionFindHighlights — browser-API shaped, linkedom-safe", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("clears registered highlights without touching the DOM", () => {
mount(`<div data-session-find-root=""><p>needle</p></div>`);
const [range] = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
// linkedom ships no Highlight constructor — the publisher guards on it.
(CSS as { highlights?: Map<string, unknown> }).highlights = new Map([["lh-session-find", {}]]);
clearSessionFindHighlights();
assert.equal((CSS as { highlights?: Map<string, unknown> }).highlights!.size, 0);
assert.ok(range!, "a range existed and the DOM beneath it is untouched");
});
});
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
firstSourceTextParts, messageAnchorId, messageIdFromHash, propositionSessionHref, textPartMessageId,
} from "../src/core/session-navigation.ts";
import { projectView } from "../src/core/view.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
function teacher(id: string, content: unknown[]): LhMessage {
return { id, role: "assistant", content, stopReason: "stop", timestamp: 0, provider: "openrouter", model: "test" } as LhMessage;
}
describe("proposition source-session links (owner, 2026-10-13)", () => {
it("chooses the first recorded non-empty anchor for that source", () => {
const node = { provenance: [
{ chatId: "other", messageId: "unrelated" },
{ chatId: "chat_a", messageId: "" },
{ chatId: "chat_a", messageId: "msg_first" },
{ chatId: "chat_a", messageId: "msg_latest" },
] };
assert.equal(propositionSessionHref(node, "chat_a"), "/chat/chat_a#message-msg_first");
assert.equal(propositionSessionHref(node, "other"), "/chat/other#message-unrelated");
});
it("legacy chat-only/missing provenance remains an ordinary session link", () => {
assert.equal(propositionSessionHref({ provenance: [{ chatId: "c", messageId: "" }] }, "c"), "/chat/c");
assert.equal(propositionSessionHref({ provenance: [] }, "c"), "/chat/c");
});
it("encodes path/hash values and round-trips the actual message ID", () => {
const node = { provenance: [{ chatId: "chat /a", messageId: "msg /%#a" }] };
const href = propositionSessionHref(node, "chat /a");
assert.equal(href, "/chat/chat%20%2Fa#message-msg%20%2F%25%23a");
assert.equal(messageIdFromHash(href.slice(href.indexOf("#"))), "msg /%#a");
});
it("recognizes only non-empty, well-formed message hashes", () => {
assert.equal(messageAnchorId("msg_a"), "message-msg_a");
assert.equal(messageIdFromHash("#message-msg_a"), "msg_a");
for (const hash of ["", "#", "#message-", "#other-msg_a", "#message-%ZZ"]) {
assert.equal(messageIdFromHash(hash), null, hash);
}
});
});
describe("native message anchors survive assistant grouping", () => {
it("anchors each native teacher message, not only the group's first ID", () => {
const messages = [
teacher("msg_a", [{ type: "text", text: "First passage." }]),
teacher("msg_b", [{ type: "thinking", thinking: "Noise" }, { type: "text", text: "Second passage." }]),
teacher("msg_c", [{ type: "text", text: "Third passage." }]),
];
const view = projectView(messages);
assert.equal(view.length, 1, "three native messages form one assistant group");
assert.deepEqual([...firstSourceTextParts(view)], [
["msg_a", "msg_a-t0"], ["msg_b", "msg_b-t0"], ["msg_c", "msg_c-t0"],
]);
});
it("empty prose and tools do not steal the first non-empty prose anchor", () => {
const view = projectView([teacher("msg_a", [
{ type: "text", text: " \n " },
{ type: "toolCall", id: "tool_a", name: "probe", arguments: {} },
{ type: "text", text: "Actual source passage." },
{ type: "text", text: "Later prose from the same message." },
])]);
assert.deepEqual([...firstSourceTextParts(view)], [["msg_a", "msg_a-t1"]]);
});
it("learner text and teacher messages without prose acquire no source anchor", () => {
const view = projectView([
{ id: "user_a", role: "user", content: "Learner text", timestamp: 1 } as LhMessage,
teacher("msg_a", [{ type: "thinking", thinking: "Private reasoning" }]),
teacher("msg_b", [{ type: "text", text: "\n\t" }]),
]);
assert.equal(firstSourceTextParts(view).size, 0);
});
it("reads the suffix only, preserving IDs containing hyphens or -t themselves", () => {
assert.equal(textPartMessageId("msg_a-t12"), "msg_a");
assert.equal(textPartMessageId("msg-a-t4-t12"), "msg-a-t4");
for (const id of [undefined, "", "-t0", "msg_a-r0", "msg_a-twat"]) assert.equal(textPartMessageId(id), null);
});
});