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
+2
View File
@@ -62,6 +62,8 @@ export * from "./settings.ts";
export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts";
export * from "./settings-resolution.ts";
export * from "./search.ts"; // search port (search wave, 2026-09-20)
export * from "./session-find.ts";
export * from "./session-navigation.ts";
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
// shape rules + masking — pure, browser-safe (the auth page binds to the
// /api/keys routes built on it).
+3 -4
View File
@@ -69,10 +69,9 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
{ id: "toggle-user-thinking", label: "Toggle all your thinking", scope: "chat", binding: "mod+t" },
// ⌘F is surface-scoped (owner key map): the graph focuses node search; a
// chat-scoped transcript search rides the same event when it lands (the
// graph's listener simply isn't mounted on chat pages).
{ id: "find", label: "Find (graph: node search)", scope: "graph", binding: "mod+f" },
// Mod+F is surface-scoped: graph node search and session text Find share
// one action/binding. Only the currently mounted surface listens.
{ id: "find", label: "Find", scope: "global", binding: "mod+f" },
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
// The question dock's row machine (wave B) — displayOnly: the dock owns
// the keys while a question is expanded; the registry never dispatches
+20
View File
@@ -0,0 +1,20 @@
/** Literal, case-insensitive session Find (owner, 2026-10-13). The DOM seam
* supplies only displayed text; no transcript/Markdown/TeX indexing lives here. */
export interface SessionTextMatch {
start: number;
end: number;
}
export function findSessionTextMatches(text: string, query: string): SessionTextMatch[] {
if (!query) return [];
// Escape the user's string, never expose regex semantics. RegExp retains
// ORIGINAL UTF-16 offsets, unlike lowercasing text (İ can change length).
const literal = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(literal, "giu");
return Array.from(text.matchAll(pattern), (m) => ({ start: m.index, end: m.index + m[0].length }));
}
export function stepSessionMatch(index: number, count: number, direction: 1 | -1): number {
if (!count) return -1;
return ((index + direction) % count + count) % count;
}
+47
View File
@@ -0,0 +1,47 @@
import type { GraphNode } from "./contracts/graph.ts";
import type { LhViewMessage } from "./view.ts";
/** Message-granular graph links (owner, 2026-10-13). Persisted provenance and
* native IDs already carry everything needed; only the render seam is new. */
export function messageAnchorId(messageId: string): string {
return `message-${messageId}`;
}
export function messageIdFromHash(hash: string): string | null {
try {
const anchor = decodeURIComponent(hash.replace(/^#/, ""));
return anchor.startsWith("message-") && anchor.length > "message-".length
? anchor.slice("message-".length)
: null;
} catch {
return null;
}
}
/** Old empty anchors do not mask a later real one. Ledger order, not date or
* lexical order, selects the FIRST recorded message in this source. */
export function propositionSessionHref(node: Pick<GraphNode, "provenance">, chatId: string): string {
const messageId = node.provenance.find((p) => p.chatId === chatId && p.messageId !== "")?.messageId;
return `/chat/${encodeURIComponent(chatId)}${messageId ? `#${encodeURIComponent(messageAnchorId(messageId))}` : ""}`;
}
/** projectView's text-part identity is <native message ID>-t<text index>.
* Assistant GROUP IDs cannot identify an individual extraction source. */
export function textPartMessageId(partId: string | undefined): string | null {
return partId?.match(/^(.*)-t\d+$/)?.[1] || null;
}
/** One DOM anchor per native teacher message, at its first non-empty prose
* block. Empty text blocks and intervening tools/thinking do not steal it. */
export function firstSourceTextParts(messages: readonly LhViewMessage[]): Map<string, string> {
const first = new Map<string, string>();
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (part.type !== "text" || typeof part.text !== "string" || !part.text.trim() || !part.id) continue;
const messageId = textPartMessageId(part.id);
if (messageId && !first.has(messageId)) first.set(messageId, part.id);
}
}
return first;
}