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

This commit is contained in:
amosblomqvist
2026-10-13 08:26:31 +03:00
parent 664c1bfed2
commit cdd8ac047e
14 changed files with 831 additions and 13 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* Session Find — the pure matcher plus the DOM seam (owner, 2026-10-13).
* The DOM seam runs under linkedom (styles resolve to defaults: display
* block/inline behave; the hidden/overlay paths are exercised through the
* selectors and inline styles linkedom DOES evaluate). The highlight/
* scroll lifecycle itself is browser-verified (SessionFind on the live
* page), not re-implemented here.
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseHTML } from "linkedom";
import { findSessionTextMatches, stepSessionMatch } from "../src/core/session-find.ts";
import { sessionFindRanges, sessionRangeIdentity, sameSessionRange } from "../components/session-find-dom.ts";
import { clearSessionFindHighlights, injectFindStyles, sessionFindHasForegroundLayer } from "../components/session-find.tsx";
const dom = parseHTML("<html><head><style></style></head><body></body></html>");
(globalThis as Record<string, unknown>).window = dom.window;
(globalThis as Record<string, unknown>).document = dom.document;
// linkedom ships no Highlight registry — stub the one field the clearer reads.
(globalThis as Record<string, unknown>).CSS = dom.window.CSS ?? { highlights: new Map() };
describe("findSessionTextMatches — literal, case-insensitive, regex-inert", () => {
it("finds every literal occurrence, any case — substrings included", () => {
assert.deepEqual(findSessionTextMatches("Cat and CAT", "cat"), [
{ start: 0, end: 3 }, { start: 8, end: 11 },
]);
assert.deepEqual(findSessionTextMatches("scat", "cat"), [{ start: 1, end: 4 }]);
});
it("regex metacharacters are inert", () => {
assert.deepEqual(findSessionTextMatches("a.b axb ?*+ (x) [y]", "a.b"), [{ start: 0, end: 3 }]);
assert.deepEqual(findSessionTextMatches("cost $5 now", "$5"), [{ start: 5, end: 7 }]);
assert.deepEqual(findSessionTextMatches("a\\b", "a\\b"), [{ start: 0, end: 3 }]);
});
it("empty query matches nothing; no query, no ranges", () => {
assert.deepEqual(findSessionTextMatches("anything", ""), []);
});
it("unicode-aware, and offsets are UTF-16 into the text as-is", () => {
assert.deepEqual(findSessionTextMatches("α αα", "αα"), [{ start: 2, end: 4 }]);
});
});
describe("stepSessionMatch — wraparound navigation", () => {
it("steps forward and wraps; backward and wraps; empty stays empty", () => {
assert.equal(stepSessionMatch(0, 3, 1), 1);
assert.equal(stepSessionMatch(2, 3, 1), 0);
assert.equal(stepSessionMatch(0, 3, -1), 2);
assert.equal(stepSessionMatch(1, 3, -1), 0);
assert.equal(stepSessionMatch(-1, 0, 1), -1);
});
});
describe("sessionFindRanges — the displayed-text DOM seam", () => {
function mount(html: string): void {
document.body.innerHTML = html;
}
it("matches text spanning inline markup, never across blocks", () => {
mount(`<div data-session-find-root="">
<p data-testid="p1">chain <strong>rule</strong> lives here</p>
<p data-testid="p2">chain rule</p>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "chain rule");
assert.equal(ranges.length, 2, "both paragraphs match; the first spans its <strong>");
const first = ranges[0]!;
assert.equal(first.startContainer.nodeName, "#text");
assert.equal(first.toString().length, "chain rule".length);
});
it("searches only marked session roots — chrome, dialogs, and the composer stay out", () => {
mount(`<div data-session-find-root=""><p>needle in the session</p></div>
<div data-testid="chrome"><p>needle in chrome</p></div>
<div role="dialog"><p>needle in a dialog</p></div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);
assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
});
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => {
mount(`<div data-session-find-root="">
<p>needle visible</p>
<p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea>
</div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1);
assert.ok((ranges[0]!.startContainer as Text).data.includes("visible"));
});
it("collapsed whitespace matches: a phrase with spaces/tabs/newlines between words", () => {
mount(`<div data-session-find-root=""><p>the\n\tchain rule</p></div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "the chain rule");
assert.equal(ranges.length, 1);
});
it("code spans keep their literal whitespace (pre): no collapse", () => {
mount(`<div data-session-find-root=""><pre>a b</pre><p>a b</p></div>`);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "a b").length, 1);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "a b").length, 1);
});
it("empty query returns no ranges", () => {
mount(`<div data-session-find-root=""><p>text</p></div>`);
assert.equal(sessionFindRanges(document.body as unknown as HTMLElement, "").length, 0);
});
it("identity survives text appended after the match (streaming)", () => {
mount(`<div data-session-find-root=""><p>findme and more to come</p></div>`);
const [range] = sessionFindRanges(document.body as unknown as HTMLElement, "findme");
const identity = sessionRangeIdentity(range!);
const p = document.querySelector("p")!;
p.firstChild!.textContent = "findme and more text streamed in";
const [again] = sessionFindRanges(document.body as unknown as HTMLElement, "findme");
assert.ok(sameSessionRange(again!, identity), "same node/offsets: refresh holds the active match");
});
});
describe("sessionFindHasForegroundLayer — the one-Esc law", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("no surfaced layer when only session content exists", () => {
mount(`<div data-testid="user-message"><p>needle</p></div>`);
assert.equal(sessionFindHasForegroundLayer(document), false);
});
it("a surfaced menu/dialog/cheat sheet outranks the Find bar", () => {
for (const html of [
'<div data-lh-menu></div>',
'<div data-testid="cheat-sheet"></div>',
'<div role="dialog"></div>',
'<div data-radix-popper-content-wrapper></div>',
]) {
mount(html);
assert.equal(sessionFindHasForegroundLayer(document), true, html);
}
});
it("a closed Radix layer is not surfaced", () => {
mount(`<div role="dialog" data-state="closed"></div>`);
assert.equal(sessionFindHasForegroundLayer(document), false);
});
});
describe("injectFindStyles — ::highlight rules ship outside the PostCSS pipeline", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("injects both rules once, idempotently", () => {
document.head.innerHTML = "";
injectFindStyles();
injectFindStyles();
const styles = [...document.querySelectorAll("style")];
assert.equal(styles.length, 1, "one style element, never duplicated");
const css = styles[0]!.textContent ?? "";
assert.ok(css.includes("::highlight(lh-session-find) {"));
assert.ok(css.includes("::highlight(lh-session-find-active) {"));
});
});
describe("clearSessionFindHighlights — browser-API shaped, linkedom-safe", () => {
const mount = (html: string): void => {
document.body.innerHTML = html;
};
it("clears registered highlights without touching the DOM", () => {
mount(`<div data-session-find-root=""><p>needle</p></div>`);
const [range] = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
// linkedom ships no Highlight constructor — the publisher guards on it.
(CSS as { highlights?: Map<string, unknown> }).highlights = new Map([["lh-session-find", {}]]);
clearSessionFindHighlights();
assert.equal((CSS as { highlights?: Map<string, unknown> }).highlights!.size, 0);
assert.ok(range!, "a range existed and the DOM beneath it is untouched");
});
});
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
firstSourceTextParts, messageAnchorId, messageIdFromHash, propositionSessionHref, textPartMessageId,
} from "../src/core/session-navigation.ts";
import { projectView } from "../src/core/view.ts";
import type { LhMessage } from "../src/core/native-messages.ts";
function teacher(id: string, content: unknown[]): LhMessage {
return { id, role: "assistant", content, stopReason: "stop", timestamp: 0, provider: "openrouter", model: "test" } as LhMessage;
}
describe("proposition source-session links (owner, 2026-10-13)", () => {
it("chooses the first recorded non-empty anchor for that source", () => {
const node = { provenance: [
{ chatId: "other", messageId: "unrelated" },
{ chatId: "chat_a", messageId: "" },
{ chatId: "chat_a", messageId: "msg_first" },
{ chatId: "chat_a", messageId: "msg_latest" },
] };
assert.equal(propositionSessionHref(node, "chat_a"), "/chat/chat_a#message-msg_first");
assert.equal(propositionSessionHref(node, "other"), "/chat/other#message-unrelated");
});
it("legacy chat-only/missing provenance remains an ordinary session link", () => {
assert.equal(propositionSessionHref({ provenance: [{ chatId: "c", messageId: "" }] }, "c"), "/chat/c");
assert.equal(propositionSessionHref({ provenance: [] }, "c"), "/chat/c");
});
it("encodes path/hash values and round-trips the actual message ID", () => {
const node = { provenance: [{ chatId: "chat /a", messageId: "msg /%#a" }] };
const href = propositionSessionHref(node, "chat /a");
assert.equal(href, "/chat/chat%20%2Fa#message-msg%20%2F%25%23a");
assert.equal(messageIdFromHash(href.slice(href.indexOf("#"))), "msg /%#a");
});
it("recognizes only non-empty, well-formed message hashes", () => {
assert.equal(messageAnchorId("msg_a"), "message-msg_a");
assert.equal(messageIdFromHash("#message-msg_a"), "msg_a");
for (const hash of ["", "#", "#message-", "#other-msg_a", "#message-%ZZ"]) {
assert.equal(messageIdFromHash(hash), null, hash);
}
});
});
describe("native message anchors survive assistant grouping", () => {
it("anchors each native teacher message, not only the group's first ID", () => {
const messages = [
teacher("msg_a", [{ type: "text", text: "First passage." }]),
teacher("msg_b", [{ type: "thinking", thinking: "Noise" }, { type: "text", text: "Second passage." }]),
teacher("msg_c", [{ type: "text", text: "Third passage." }]),
];
const view = projectView(messages);
assert.equal(view.length, 1, "three native messages form one assistant group");
assert.deepEqual([...firstSourceTextParts(view)], [
["msg_a", "msg_a-t0"], ["msg_b", "msg_b-t0"], ["msg_c", "msg_c-t0"],
]);
});
it("empty prose and tools do not steal the first non-empty prose anchor", () => {
const view = projectView([teacher("msg_a", [
{ type: "text", text: " \n " },
{ type: "toolCall", id: "tool_a", name: "probe", arguments: {} },
{ type: "text", text: "Actual source passage." },
{ type: "text", text: "Later prose from the same message." },
])]);
assert.deepEqual([...firstSourceTextParts(view)], [["msg_a", "msg_a-t1"]]);
});
it("learner text and teacher messages without prose acquire no source anchor", () => {
const view = projectView([
{ id: "user_a", role: "user", content: "Learner text", timestamp: 1 } as LhMessage,
teacher("msg_a", [{ type: "thinking", thinking: "Private reasoning" }]),
teacher("msg_b", [{ type: "text", text: "\n\t" }]),
]);
assert.equal(firstSourceTextParts(view).size, 0);
});
it("reads the suffix only, preserving IDs containing hyphens or -t themselves", () => {
assert.equal(textPartMessageId("msg_a-t12"), "msg_a");
assert.equal(textPartMessageId("msg-a-t4-t12"), "msg-a-t4");
for (const id of [undefined, "", "-t0", "msg_a-r0", "msg_a-twat"]) assert.equal(textPartMessageId(id), null);
});
});