@mlx-bun/module-chat 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ // Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
2
+ //
3
+ // Recent-chats sidebar (list/search/fork/delete), the last-user-message
4
+ // sibling `< i/n >` toggle (edit-and-resend branching), and the
5
+ // per-message action row (copy/regenerate/edit). Extracted from the
6
+ // original monolithic controllers.chat closure in app.html — behavior
7
+ // identical, but state that used to be closed-over local variables is now
8
+ // passed explicitly (SidebarState/SiblingState) so this module has no
9
+ // hidden coupling to chat.ts beyond what's passed in.
10
+ //
11
+ // Later additions (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
12
+ // beat-matrix Axis 10/11): full-text
13
+ // "in messages" search fallback below the title filter (GET
14
+ // /api/sessions/search, backed by packages/module-chat/src/session-search.ts), an export
15
+ // action per session row (Markdown/JSON, via GET /api/sessions/export —
16
+ // path-validated server-side under sessionDir exactly like pi-web's
17
+ // isUnderSessionDir), and openSessionRowByPath(), the DOM-click bridge the
18
+ // command palette (palette.ts) uses to open a session without this module
19
+ // needing any reference back into chat.ts's WebSocket (chat.ts already
20
+ // wires each rendered `.sess` row's click to `onOpen` below — this just
21
+ // finds and clicks the right row instead of duplicating that send()).
22
+
23
+ import type { SessionListItem } from "../protocol";
24
+ import { $, api, el, injectStyles } from "./dom";
25
+
26
+ const SESSIONS_STYLE_ID = "mlxbun-sessions-style";
27
+
28
+ /** Injected once (shell.ts's injectStyles is idempotent per id): the small
29
+ * set of new classes this module's later additions need
30
+ * (full-text-search fallback rows + the export micro-menu) that have no
31
+ * home in app.html's own <style> block, since this module doesn't touch
32
+ * that file. References the existing :root design tokens so it matches
33
+ * the current theme automatically. */
34
+ function injectSessionsStyles(): void {
35
+ injectStyles(SESSIONS_STYLE_ID, `
36
+ .sess-msg-search{margin-top:6px;padding-top:6px;border-top:1px solid var(--hairline,rgba(255,255,255,.14));}
37
+ .sess-msg-search-hdr{font-size:11px;color:var(--dim,#86868b);text-transform:uppercase;
38
+ letter-spacing:.04em;padding:4px 10px;}
39
+ .sess-msg-search-loading{color:var(--dim,#86868b);font-size:12px;padding:6px 10px;}
40
+ .sess-msg-hit{padding:7px 10px;border-radius:8px;cursor:pointer;}
41
+ .sess-msg-hit:hover{background:var(--card-hover,rgba(255,255,255,.08));}
42
+ .sess-msg-hit .stitle{font-size:12.5px;}
43
+ .sess-msg-snippet{color:var(--dim,#86868b);font-size:11px;overflow:hidden;text-overflow:ellipsis;
44
+ white-space:nowrap;margin-top:2px;}
45
+ .sess-msg-snippet mark{background:rgba(255,214,10,.35);color:inherit;border-radius:2px;}
46
+ .sess-export-menu{position:fixed;z-index:950;background:var(--bg,#000);
47
+ border:1px solid var(--hairline-strong,rgba(255,255,255,.28));border-radius:10px;
48
+ box-shadow:0 12px 30px rgba(0,0,0,.4);overflow:hidden;min-width:150px;}
49
+ .sess-export-item{display:block;width:100%;box-sizing:border-box;appearance:none;background:none;
50
+ border:0;text-align:left;color:var(--ink,#fff);font-size:12.5px;padding:9px 12px;cursor:pointer;}
51
+ .sess-export-item:hover{background:var(--card-hover,rgba(255,255,255,.08));}
52
+ `);
53
+ }
54
+
55
+ /* ────────────────────────────────────────────────────────────────────
56
+ Sidebar (session list + search)
57
+ ──────────────────────────────────────────────────────────────────── */
58
+
59
+ export interface SidebarCallbacks {
60
+ onOpen(path: string): void;
61
+ onFork(path: string): void;
62
+ onDelete(path: string): void;
63
+ }
64
+
65
+ /** Mutable box so renderSessions/applySessSearch share the last-rendered
66
+ * item list without a module-level global (kept per chat-controller
67
+ * instance — there's only ever one, but this avoids reintroducing a
68
+ * bare module-level `let` for state that's really the controller's). */
69
+ export interface SidebarState {
70
+ lastSessionItems: SessionListItem[];
71
+ }
72
+
73
+ export function newSidebarState(): SidebarState {
74
+ return { lastSessionItems: [] };
75
+ }
76
+
77
+ function relTime(ms: number | undefined): string {
78
+ if (!ms) return "";
79
+ const s = (Date.now() - ms) / 1000;
80
+ if (s < 60) return "just now";
81
+ if (s < 3600) return Math.floor(s / 60) + "m ago";
82
+ if (s < 86400) return Math.floor(s / 3600) + "h ago";
83
+ if (s < 604800) return Math.floor(s / 86400) + "d ago";
84
+ try { return new Date(ms).toLocaleDateString(); } catch { return ""; }
85
+ }
86
+
87
+ // Local esc() to avoid importing markdown.ts's whole surface just for
88
+ // escaping — identical logic.
89
+ function esc(s: unknown): string {
90
+ return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" } as Record<string, string>)[c]!);
91
+ }
92
+
93
+ export function renderSessions(state: SidebarState, items: SessionListItem[] | undefined, activePath: string | undefined, cb: SidebarCallbacks): void {
94
+ injectSessionsStyles();
95
+ state.lastSessionItems = items || [];
96
+ const box = $("chat-sessions");
97
+ if (!items || !items.length) { box.innerHTML = '<div class="sessempty">No saved chats yet. Your conversations are saved locally here.</div>'; return; }
98
+ box.innerHTML = "";
99
+ for (const s of items) {
100
+ const row = el("div", "sess" + (s.path === activePath ? " active" : ""), box);
101
+ row.dataset.title = (s.title || "New chat").toLowerCase();
102
+ row.dataset.path = s.path;
103
+ row.innerHTML =
104
+ '<div class="stitle">' + esc(s.title || "New chat") + '</div>' +
105
+ '<div class="smeta"><span>' + esc(relTime(s.modified)) + '</span>' +
106
+ (s.messageCount ? '<span>· ' + s.messageCount + ' msg' + (s.messageCount === 1 ? '' : 's') + '</span>' : '') +
107
+ (s.forked ? '<span>· forked</span>' : '') + '</div>' +
108
+ '<button class="sbtn sexport" title="Export this chat">⇩</button>' +
109
+ '<button class="sbtn sfork" title="New chat from here">⑂</button>' +
110
+ '<button class="sbtn sdel" title="Delete chat">✕</button>';
111
+ row.addEventListener("click", () => { cb.onOpen(s.path); });
112
+ row.querySelector(".sexport")!.addEventListener("click", (e) => { e.stopPropagation(); openExportMenu(row.querySelector(".sexport") as HTMLElement, s.path, s.title); });
113
+ row.querySelector(".sfork")!.addEventListener("click", (e) => { e.stopPropagation(); cb.onFork(s.path); });
114
+ row.querySelector(".sdel")!.addEventListener("click", (e) => { e.stopPropagation(); cb.onDelete(s.path); });
115
+ }
116
+ applySessSearch(state);
117
+ }
118
+
119
+ /** Find the rendered `.sess` row for `path` (present for every session —
120
+ * renderSessions draws the full list, title-search only hides rows via
121
+ * `.sess-hidden`) and click it, reusing chat.ts's existing onOpen wiring
122
+ * instead of this module needing its own WebSocket reference. Un-hides the
123
+ * row first so a click reaches an element that isn't `display:none`-adjacent
124
+ * (sess-hidden only dims/collapses via CSS, but clearing it keeps the
125
+ * sidebar visibly consistent with "this is the chat you're now in").
126
+ * Returns false (no-op) if the row isn't in the DOM yet (e.g. sessions
127
+ * frame hasn't arrived) — callers (palette.ts) treat that as "can't open
128
+ * yet", never a throw. */
129
+ export function openSessionRowByPath(path: string): boolean {
130
+ const box = document.getElementById("chat-sessions");
131
+ if (!box) return false;
132
+ const row = box.querySelector<HTMLElement>('.sess[data-path="' + cssEscape(path) + '"]');
133
+ if (!row) return false;
134
+ row.classList.remove("sess-hidden");
135
+ row.click();
136
+ return true;
137
+ }
138
+
139
+ /** Minimal CSS.escape fallback (attribute-selector value escaping) — avoids
140
+ * depending on the global CSS.escape existing (it does in every real
141
+ * browser, but happy-dom's test environment is the reason this is spelled
142
+ * out defensively rather than assumed). */
143
+ function cssEscape(s: string): string {
144
+ if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(s);
145
+ return s.replace(/["\\]/g, "\\$&");
146
+ }
147
+
148
+ /** Client-side substring filter over session titles (no server round
149
+ * trip). Re-applied after every re-render since #chat-sessions is
150
+ * rebuilt wholesale on each `sessions` frame. When the title filter yields
151
+ * few/no hits, debounced-fetches a full-text "in messages" fallback
152
+ * section (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
153
+ * beat-matrix Axis 10/11's full-text BEAT) below the title-filtered rows. */
154
+ export function applySessSearch(state: SidebarState): void {
155
+ const input = $("chat-sess-search") as HTMLInputElement | null;
156
+ const q = input ? input.value.trim().toLowerCase() : "";
157
+ const rows = $("chat-sessions").querySelectorAll<HTMLElement>(".sess");
158
+ let visibleCount = 0;
159
+ rows.forEach((row) => {
160
+ const match = !q || (row.dataset.title || "").includes(q);
161
+ row.classList.toggle("sess-hidden", !match);
162
+ if (match) visibleCount++;
163
+ });
164
+ let empty = $("chat-sessions").querySelector(".sess-search-empty");
165
+ if (q && !visibleCount && state.lastSessionItems.length) {
166
+ if (!empty) { empty = el("div", "sessempty sess-search-empty", $("chat-sessions")); }
167
+ empty.textContent = "No chats match “" + q + "”.";
168
+ } else if (empty) {
169
+ empty.remove();
170
+ }
171
+ scheduleMessageSearch(state, q, visibleCount);
172
+ }
173
+
174
+ /* ────────────────────────────────────────────────────────────────────
175
+ Full-text "in messages" fallback (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
176
+ beat-matrix Axis 10/11): when the
177
+ title filter yields few/no hits, debounce a GET /api/sessions/search
178
+ and render title+snippet rows below the sidebar's own list. Clicking a
179
+ row opens that session via the existing sidebar click flow (the row is
180
+ already rendered by renderSessions above — a full-text hit is always
181
+ for a session that's IN the sidebar, since the sidebar lists every
182
+ session on disk, just some hidden by the title filter).
183
+ ──────────────────────────────────────────────────────────────────── */
184
+
185
+ const MESSAGE_SEARCH_DEBOUNCE_MS = 250;
186
+ /** Only bother with the server round trip once the title filter has mostly
187
+ * given up — a query that already matches plenty of titles doesn't need a
188
+ * second, slower full-text pass under it. */
189
+ const FEW_HITS_THRESHOLD = 3;
190
+
191
+ let messageSearchTimer: ReturnType<typeof setTimeout> | undefined;
192
+ let messageSearchSeq = 0;
193
+
194
+ function scheduleMessageSearch(state: SidebarState, q: string, titleHits: number): void {
195
+ if (messageSearchTimer) clearTimeout(messageSearchTimer);
196
+ const section = document.getElementById("chat-sessions")?.querySelector(".sess-msg-search");
197
+ if (!q || q.length < 2 || titleHits > FEW_HITS_THRESHOLD) {
198
+ if (section) section.remove();
199
+ return;
200
+ }
201
+ const seq = ++messageSearchSeq;
202
+ messageSearchTimer = setTimeout(() => runMessageSearch(state, q, seq), MESSAGE_SEARCH_DEBOUNCE_MS);
203
+ }
204
+
205
+ async function runMessageSearch(state: SidebarState, q: string, seq: number): Promise<void> {
206
+ const box = $("chat-sessions");
207
+ let section = box.querySelector(".sess-msg-search") as HTMLElement | null;
208
+ if (!section) section = el("div", "sess-msg-search", box);
209
+ section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div><div class="sess-msg-search-loading">Searching…</div>';
210
+
211
+ const d = await api("/api/sessions/search?q=" + encodeURIComponent(q)).catch(() => ({ ok: false } as { ok: boolean }));
212
+ if (seq !== messageSearchSeq) return; // superseded by a newer keystroke
213
+ // The input may have been cleared/changed while the request was in flight.
214
+ const input = $("chat-sess-search") as HTMLInputElement | null;
215
+ if (!input || input.value.trim().toLowerCase() !== q) return;
216
+
217
+ const body = d as { ok: boolean; results?: Array<{ sessionPath: string; sessionTitle: string; matches: Array<{ snippet: string; ranges: Array<[number, number]>; role: string }> }> };
218
+ if (!body.ok || !body.results || !body.results.length) {
219
+ section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div><div class="sessempty">No message matches for “' + esc(q) + '”.</div>';
220
+ return;
221
+ }
222
+ section.innerHTML = '<div class="sess-msg-search-hdr">In messages</div>';
223
+ for (const r of body.results) {
224
+ const item = el("div", "sess-msg-hit", section);
225
+ item.dataset.path = r.sessionPath;
226
+ const first = r.matches[0];
227
+ item.innerHTML =
228
+ '<div class="stitle">' + esc(r.sessionTitle) + '</div>' +
229
+ (first ? '<div class="sess-msg-snippet">' + highlightSnippetHtml(first.snippet, first.ranges) + '</div>' : "");
230
+ item.addEventListener("click", () => { openSessionRowByPath(r.sessionPath); });
231
+ }
232
+ }
233
+
234
+ /** Escape a plain-text snippet then re-insert `<mark>` around the reported
235
+ * [start,end) ranges — same escape-then-restore discipline markdown.ts
236
+ * uses for code spans, applied here to search highlighting instead. Ranges
237
+ * are assumed non-overlapping and sorted (buildSnippet in
238
+ * packages/module-chat/src/session-search.ts only ever emits one range per snippet
239
+ * today, but this loop handles more without change). */
240
+ export function highlightSnippetHtml(snippet: string, ranges: Array<[number, number]>): string {
241
+ if (!ranges.length) return esc(snippet);
242
+ let out = "";
243
+ let cursor = 0;
244
+ for (const [start, end] of ranges) {
245
+ if (start < cursor || end <= start || end > snippet.length) continue;
246
+ out += esc(snippet.slice(cursor, start));
247
+ out += "<mark>" + esc(snippet.slice(start, end)) + "</mark>";
248
+ cursor = end;
249
+ }
250
+ out += esc(snippet.slice(cursor));
251
+ return out;
252
+ }
253
+
254
+ /* ────────────────────────────────────────────────────────────────────
255
+ Message actions (copy / regenerate / edit) + sibling toggle
256
+ ──────────────────────────────────────────────────────────────────── */
257
+
258
+ export interface SiblingInfo {
259
+ entryId: string | null;
260
+ index: number;
261
+ count: number;
262
+ siblingIds: string[];
263
+ }
264
+
265
+ export function newSiblingInfo(): SiblingInfo {
266
+ return { entryId: null, index: 0, count: 0, siblingIds: [] };
267
+ }
268
+
269
+ export interface MsgActionOpts {
270
+ regenerate?: boolean;
271
+ edit?: boolean;
272
+ }
273
+
274
+ /** Append a hover/focus action row to a message element. `text` is the
275
+ * RAW SOURCE (markdown for assistant, plain for user) — never innerHTML —
276
+ * so Copy puts back exactly what the model said / the user typed.
277
+ * `onRegenerate`/`onEdit` are the click handlers (owned by chat.ts, which
278
+ * knows about the WS connection and turnActive state this module doesn't). */
279
+ export function addMsgActions(
280
+ m: HTMLElement,
281
+ text: string,
282
+ opts: MsgActionOpts,
283
+ handlers: { onRegenerate?: () => void; onEdit?: (m: HTMLElement) => void },
284
+ ): HTMLElement {
285
+ const row = el("div", "msg-actions", m);
286
+ const copyBtn = el("button", "maction", row);
287
+ copyBtn.type = "button"; copyBtn.textContent = "Copy";
288
+ copyBtn.addEventListener("click", () => {
289
+ if (!navigator.clipboard) return;
290
+ navigator.clipboard.writeText(text || "").then(() => {
291
+ copyBtn.textContent = "Copied"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
292
+ }).catch(() => {});
293
+ });
294
+ if (opts.regenerate) {
295
+ const btn = el("button", "maction", row);
296
+ btn.type = "button"; btn.textContent = "Regenerate";
297
+ btn.title = "Re-run this reply (keeps the old one on disk as a branch)";
298
+ btn.addEventListener("click", () => { handlers.onRegenerate && handlers.onRegenerate(); });
299
+ }
300
+ if (opts.edit) {
301
+ const btn = el("button", "maction", row);
302
+ btn.type = "button"; btn.textContent = "Edit";
303
+ btn.title = "Edit and resend — keeps the original as a numbered branch";
304
+ btn.addEventListener("click", () => { handlers.onEdit && handlers.onEdit(m); });
305
+ }
306
+ return row;
307
+ }
308
+
309
+ /** Render (or clear) the `< i/n >` sibling toggle above the last user
310
+ * message. Rebuilt whenever a `siblings` frame arrives or the thread is
311
+ * rebuilt. Hidden entirely when count <= 1 (the common no-edits case). */
312
+ export function renderSiblingToggle(
313
+ lastUserMsgEl: HTMLElement | null,
314
+ lastUserEntryId: string | null,
315
+ siblingInfo: SiblingInfo,
316
+ onSwitch: (delta: number) => void,
317
+ ): void {
318
+ if (!lastUserMsgEl) return;
319
+ let box = lastUserMsgEl.querySelector(".sib-toggle") as HTMLElement | null;
320
+ if (siblingInfo.count <= 1 || siblingInfo.entryId !== lastUserEntryId) {
321
+ if (box) box.remove();
322
+ return;
323
+ }
324
+ if (!box) {
325
+ box = el("div", "sib-toggle", null);
326
+ lastUserMsgEl.insertBefore(box, lastUserMsgEl.firstChild);
327
+ }
328
+ const { index, count } = siblingInfo;
329
+ box.innerHTML =
330
+ '<button class="sib-prev" type="button" ' + (index <= 1 ? "disabled" : "") + '>&lt;</button>' +
331
+ '<span>' + index + '/' + count + '</span>' +
332
+ '<button class="sib-next" type="button" ' + (index >= count ? "disabled" : "") + '>&gt;</button>';
333
+ box.querySelector(".sib-prev")!.addEventListener("click", () => onSwitch(-1));
334
+ box.querySelector(".sib-next")!.addEventListener("click", () => onSwitch(1));
335
+ }
336
+
337
+ /** Resolve the sibling target id for a +-1 direction move from siblingInfo,
338
+ * or null if there's no such neighbor (caller should no-op). Pure — split
339
+ * out from the button wiring above so it's independently testable. */
340
+ export function switchSiblingTarget(siblingInfo: SiblingInfo, delta: number): string | null {
341
+ const ids = siblingInfo.siblingIds || [];
342
+ return ids[siblingInfo.index - 1 + delta] ?? null;
343
+ }
344
+
345
+ /* ────────────────────────────────────────────────────────────────────
346
+ Export (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
347
+ beat-matrix Axis 10 "Chat export" + Axis 11 "Portable JSON export / import"):
348
+ Markdown or raw JSON, built from
349
+ GET /api/sessions/export?path= (server-validated under sessionDir) — used
350
+ uniformly for the open session and any other session in the sidebar,
351
+ so this module needs no reference into chat.ts's in-memory thread state.
352
+ ──────────────────────────────────────────────────────────────────── */
353
+
354
+ /** Slugify a session title into a filesystem-safe download filename stem
355
+ * (lowercase, spaces/punctuation -> hyphens, collapsed, trimmed, capped —
356
+ * same shape as a typical static-site slug). Pure — independently
357
+ * testable and reused by palette.ts's export action. */
358
+ export function slugTitle(title: string): string {
359
+ const slug = (title || "chat")
360
+ .toLowerCase()
361
+ .replace(/[^a-z0-9]+/g, "-")
362
+ .replace(/^-+|-+$/g, "");
363
+ return (slug || "chat").slice(0, 60);
364
+ }
365
+
366
+ interface ExportEntry {
367
+ type?: string;
368
+ message?: { role?: string; content?: unknown };
369
+ }
370
+
371
+ function entryContentText(content: unknown): string {
372
+ if (typeof content === "string") return content;
373
+ if (Array.isArray(content)) {
374
+ return content
375
+ .filter((p): p is { type: string; text: string } =>
376
+ !!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
377
+ .map((p) => p.text)
378
+ .join(" ");
379
+ }
380
+ return "";
381
+ }
382
+
383
+ /** Render a session's raw JSONL entries (as returned by GET
384
+ * /api/sessions/export) into plain Markdown: one `### User`/`### Assistant`
385
+ * heading per turn plus its text. Pure — takes the already-parsed entry
386
+ * array, not a fetch, so it's testable without a network stub and reusable
387
+ * for both "export the open session" and "export any sidebar session"
388
+ * (both go through the same server endpoint in this module, see
389
+ * exportSession below). */
390
+ export function sessionEntriesToMarkdown(entries: unknown[], title: string): string {
391
+ const lines: string[] = ["# " + (title || "Chat"), ""];
392
+ for (const raw of entries) {
393
+ const e = raw as ExportEntry;
394
+ if (e.type !== "message" || !e.message) continue;
395
+ const role = e.message.role;
396
+ if (role !== "user" && role !== "assistant") continue;
397
+ const text = entryContentText(e.message.content);
398
+ if (!text) continue;
399
+ lines.push("### " + (role === "user" ? "User" : "Assistant"), "", text, "");
400
+ }
401
+ return lines.join("\n");
402
+ }
403
+
404
+ function downloadBlob(filename: string, content: string, mime: string): void {
405
+ const blob = new Blob([content], { type: mime });
406
+ const url = URL.createObjectURL(blob);
407
+ const a = document.createElement("a");
408
+ a.href = url; a.download = filename;
409
+ document.body.appendChild(a);
410
+ a.click();
411
+ a.remove();
412
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
413
+ }
414
+
415
+ /** Fetch a session's raw entries + title and trigger a browser download in
416
+ * the requested format. Always goes through GET /api/sessions/export
417
+ * (works uniformly whether or not this is the currently-open session —
418
+ * SessionManager flushes each turn to disk as it happens, so the file is
419
+ * never meaningfully stale relative to what's on screen). */
420
+ export async function exportSession(path: string, title: string, format: "md" | "json"): Promise<void> {
421
+ const d = await api("/api/sessions/export?path=" + encodeURIComponent(path))
422
+ .catch(() => ({ ok: false } as { ok: boolean }));
423
+ const body = d as { ok: boolean; entries?: unknown[] };
424
+ if (!body.ok || !body.entries) return;
425
+ const stem = slugTitle(title);
426
+ if (format === "json") {
427
+ downloadBlob(stem + ".json", JSON.stringify(body.entries, null, 2), "application/json");
428
+ } else {
429
+ downloadBlob(stem + ".md", sessionEntriesToMarkdown(body.entries, title), "text/markdown");
430
+ }
431
+ }
432
+
433
+ let exportMenuEl: HTMLElement | null = null;
434
+
435
+ function closeExportMenu(): void {
436
+ if (exportMenuEl) { exportMenuEl.remove(); exportMenuEl = null; }
437
+ }
438
+
439
+ /** Tiny two-item popover (Markdown / JSON) anchored under the export
440
+ * button. Not a FocusTrap-managed overlay (it's a two-button micro-menu,
441
+ * not a modal) — a document-level click-outside listener closes it, and
442
+ * Escape closes it via the same listener path since Escape isn't
443
+ * special-cased elsewhere for this menu. */
444
+ function openExportMenu(anchor: HTMLElement, path: string, title: string): void {
445
+ closeExportMenu();
446
+ const menu = el("div", "sess-export-menu", document.body);
447
+ const rect = anchor.getBoundingClientRect();
448
+ menu.style.position = "fixed";
449
+ menu.style.top = (rect.bottom + 4) + "px";
450
+ menu.style.left = Math.max(4, rect.right - 140) + "px";
451
+ menu.innerHTML =
452
+ '<button class="sess-export-item" data-fmt="md">Export as Markdown</button>' +
453
+ '<button class="sess-export-item" data-fmt="json">Export as JSON</button>';
454
+ menu.querySelectorAll<HTMLButtonElement>(".sess-export-item").forEach((btn) => {
455
+ btn.addEventListener("click", (e) => {
456
+ e.stopPropagation();
457
+ exportSession(path, title, (btn.dataset.fmt as "md" | "json"));
458
+ closeExportMenu();
459
+ });
460
+ });
461
+ exportMenuEl = menu;
462
+ setTimeout(() => {
463
+ document.addEventListener("click", closeExportMenu, { once: true });
464
+ document.addEventListener("keydown", onExportMenuKeydown);
465
+ }, 0);
466
+ }
467
+
468
+ function onExportMenuKeydown(e: KeyboardEvent): void {
469
+ if (e.key === "Escape") { closeExportMenu(); document.removeEventListener("keydown", onExportMenuKeydown); }
470
+ }