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