sessions: source-message deep links from the graph + session Find (Cmd/Ctrl+F)
This commit is contained in:
+24
-6
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user