From cdd8ac047e7216cd34f0621ae85842a0ffa305b3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 13 Oct 2026 08:26:31 +0300 Subject: [PATCH] sessions: source-message deep links from the graph + session Find (Cmd/Ctrl+F) --- app/globals.css | 10 ++ components/chat.tsx | 30 ++++- components/graph-view.tsx | 5 +- components/keyboard-shortcuts.tsx | 6 + components/question-dock.tsx | 2 +- components/session-find-dom.ts | 156 ++++++++++++++++++++++ components/session-find.tsx | 208 ++++++++++++++++++++++++++++++ components/session-navigation.tsx | 84 ++++++++++++ src/core/index.ts | 2 + src/core/keyboard.ts | 7 +- src/core/session-find.ts | 20 +++ src/core/session-navigation.ts | 47 +++++++ tests/session-find.test.tsx | 183 ++++++++++++++++++++++++++ tests/session-navigation.test.ts | 84 ++++++++++++ 14 files changed, 831 insertions(+), 13 deletions(-) create mode 100644 components/session-find-dom.ts create mode 100644 components/session-find.tsx create mode 100644 components/session-navigation.tsx create mode 100644 src/core/session-find.ts create mode 100644 src/core/session-navigation.ts create mode 100644 tests/session-find.test.tsx create mode 100644 tests/session-navigation.test.ts diff --git a/app/globals.css b/app/globals.css index bc538d7..5e61b68 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/components/chat.tsx b/components/chat.tsx index 7746ee1..616923b 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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(null); + const scrollContextRef = useRef(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({ )} -
- + +
+ + -
+
{messages.length === 0 && !busy && ( {part.text}; + case "text": { + const sourceId = textPartMessageId(part.id); + const anchor = sourceId && sourceTextParts.get(sourceId) === part.id ? messageAnchorId(sourceId) : undefined; + return ( +
+ {part.text} +
+ ); + } case "reasoning": return ( diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b4357f5..01baf4a 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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 (
{snapshot?.sessions[sourceId] ?? sourceId} diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index df809b8..bc4a3fc 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -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))); } diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 661c18b..3b6f848 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -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). */} -
+
{toolName === "probe" ? "Probe" diff --git a/components/session-find-dom.ts b/components/session-find-dom.ts new file mode 100644 index 0000000..97a9894 --- /dev/null +++ b/components/session-find-dom.ts @@ -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("[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; +} diff --git a/components/session-find.tsx b/components/session-find.tsx new file mode 100644 index 0000000..a7fd47c --- /dev/null +++ b/components/session-find.tsx @@ -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 "); +(globalThis as Record).window = dom.window; +(globalThis as Record).document = dom.document; +// linkedom ships no Highlight registry — stub the one field the clearer reads. +(globalThis as Record).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(`
+

chain rule lives here

+

chain rule

+
`); + const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "chain rule"); + assert.equal(ranges.length, 2, "both paragraphs match; the first spans its "); + 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(`

needle in the session

+

needle in chrome

+

needle in a dialog

`); + 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(`
+

needle visible

+ +

needle menu

+ needle mathml + +
`); + 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(`

the\n\tchain rule

`); + 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(`
a  b

a b

`); + 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(`

text

`); + assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "").length, 0); + }); + + it("identity survives text appended after the match (streaming)", () => { + mount(`

findme and more to come

`); + 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(`

needle

`); + assert.equal(sessionFindHasForegroundLayer(document), false); + }); + + it("a surfaced menu/dialog/cheat sheet outranks the Find bar", () => { + for (const html of [ + '
', + '
', + '
', + '
', + ]) { + mount(html); + assert.equal(sessionFindHasForegroundLayer(document), true, html); + } + }); + + it("a closed Radix layer is not surfaced", () => { + mount(`
`); + 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(`

needle

`); + const [range] = sessionFindRanges(document.body as unknown as HTMLElement, "needle"); + // linkedom ships no Highlight constructor — the publisher guards on it. + (CSS as { highlights?: Map }).highlights = new Map([["lh-session-find", {}]]); + clearSessionFindHighlights(); + assert.equal((CSS as { highlights?: Map }).highlights!.size, 0); + assert.ok(range!, "a range existed and the DOM beneath it is untouched"); + }); +}); diff --git a/tests/session-navigation.test.ts b/tests/session-navigation.test.ts new file mode 100644 index 0000000..33c329b --- /dev/null +++ b/tests/session-navigation.test.ts @@ -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); + }); +});