sessions: source-message deep links from the graph + session Find (Cmd/Ctrl+F)
This commit is contained in:
@@ -588,3 +588,13 @@ body {
|
|||||||
animation: none;
|
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
@@ -6,7 +6,7 @@ import { visibleQueuedSteers } from "@/components/session-core";
|
|||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
@@ -34,6 +34,9 @@ import { StatsBar } from "@/components/stats-bar";
|
|||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
import {
|
import {
|
||||||
isProviderKeyRefusal,
|
isProviderKeyRefusal,
|
||||||
|
firstSourceTextParts,
|
||||||
|
messageAnchorId,
|
||||||
|
textPartMessageId,
|
||||||
kbdEventForAction as kbdEventForChat,
|
kbdEventForAction as kbdEventForChat,
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
type ChatInput,
|
type ChatInput,
|
||||||
@@ -48,6 +51,8 @@ import { AbortTurnDialog, type AbortMode } from "@/components/abort-turn-dialog"
|
|||||||
import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react";
|
import { ArrowRightIcon, DumbbellIcon, WaypointsIcon } from "lucide-react";
|
||||||
import type { PlanBlockData } from "@/src/core";
|
import type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
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 { PracticeDock } from "@/components/practice-dock";
|
||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
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 } =
|
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
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).
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
setReattachSettled(() => reattachSettledRef.current());
|
setReattachSettled(() => reattachSettledRef.current());
|
||||||
|
|
||||||
@@ -1301,10 +1310,12 @@ export function Chat({
|
|||||||
)}
|
)}
|
||||||
</TopBar>
|
</TopBar>
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<SessionFind scopeRef={sessionFindScopeRef} scrollContextRef={scrollContextRef} />
|
||||||
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
<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">
|
<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 && (
|
{messages.length === 0 && !busy && (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
title="New session"
|
title="New session"
|
||||||
@@ -1456,8 +1467,15 @@ export function Chat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "text":
|
case "text": {
|
||||||
return <MessageResponse key={pi}>{part.text}</MessageResponse>;
|
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":
|
case "reasoning":
|
||||||
return (
|
return (
|
||||||
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
<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 { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
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 { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
import { useActivity } from "@/components/activity-dock";
|
||||||
@@ -1668,7 +1668,8 @@ export function GraphView({
|
|||||||
return (
|
return (
|
||||||
<div key={sourceId} className="flex items-center gap-1.5">
|
<div key={sourceId} className="flex items-center gap-1.5">
|
||||||
<Link
|
<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"
|
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}
|
{snapshot?.sessions[sourceId] ?? sourceId}
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ export function KeyboardShortcuts() {
|
|||||||
case "cheat-sheet":
|
case "cheat-sheet":
|
||||||
handlersRef.current.setCheatOpen(true);
|
handlersRef.current.setCheatOpen(true);
|
||||||
break;
|
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:
|
default:
|
||||||
window.dispatchEvent(new CustomEvent(kbdEventForAction(action)));
|
window.dispatchEvent(new CustomEvent(kbdEventForAction(action)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ function QuestionDockForm({
|
|||||||
{/* Question area — scrolls internally if very tall; the bottom row stays put.
|
{/* 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
|
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
|
||||||
long questions short — owner round, 2026-10-05). */}
|
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">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{toolName === "probe"
|
{toolName === "probe"
|
||||||
? "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;
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ export * from "./settings.ts";
|
|||||||
export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts";
|
export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts";
|
||||||
export * from "./settings-resolution.ts";
|
export * from "./settings-resolution.ts";
|
||||||
export * from "./search.ts"; // search port (search wave, 2026-09-20)
|
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 +
|
// 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
|
// shape rules + masking — pure, browser-safe (the auth page binds to the
|
||||||
// /api/keys routes built on it).
|
// /api/keys routes built on it).
|
||||||
|
|||||||
@@ -69,10 +69,9 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
|
|||||||
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
|
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
|
||||||
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
|
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
|
||||||
{ id: "toggle-user-thinking", label: "Toggle all your thinking", scope: "chat", binding: "mod+t" },
|
{ 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
|
// Mod+F is surface-scoped: graph node search and session text Find share
|
||||||
// chat-scoped transcript search rides the same event when it lands (the
|
// one action/binding. Only the currently mounted surface listens.
|
||||||
// graph's listener simply isn't mounted on chat pages).
|
{ id: "find", label: "Find", scope: "global", binding: "mod+f" },
|
||||||
{ id: "find", label: "Find (graph: node search)", scope: "graph", binding: "mod+f" },
|
|
||||||
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
|
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
|
||||||
// The question dock's row machine (wave B) — displayOnly: the dock owns
|
// The question dock's row machine (wave B) — displayOnly: the dock owns
|
||||||
// the keys while a question is expanded; the registry never dispatches
|
// the keys while a question is expanded; the registry never dispatches
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user