@symerian/symi 3.5.0 → 3.5.1

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.
Files changed (62) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/bundled/boot-md/handler.js +4 -4
  3. package/dist/bundled/session-memory/handler.js +4 -4
  4. package/dist/canvas-host/a2ui/.bundle.hash +1 -1
  5. package/dist/{chrome-C_I81hbq.js → chrome-B7-rO4i9.js} +4 -4
  6. package/dist/{chrome-BKUACyeO.js → chrome-DPjznJQ-.js} +4 -4
  7. package/dist/control-ui/css/revert-red-theme.md +141 -0
  8. package/dist/control-ui/css/style.css +5843 -0
  9. package/dist/control-ui/css/style.css.backup-2026-03-03-162525 +3546 -0
  10. package/dist/control-ui/css/style.css.backup-before-red-2026-03-03-162525 +3546 -0
  11. package/dist/control-ui/css/style.css.backup-before-red-theme-2026-03-03-162530 +3546 -0
  12. package/dist/control-ui/css/style.css.pre-2row +2165 -0
  13. package/dist/control-ui/css/style.css.pre-brand +1776 -0
  14. package/dist/control-ui/css/style.css.pre-history +1974 -0
  15. package/dist/control-ui/css/style.css.pre-nav +2264 -0
  16. package/dist/control-ui/css/style.css.pre-newsession +1898 -0
  17. package/dist/control-ui/css/style.css.pre-queue +2195 -0
  18. package/dist/control-ui/css/style.css.pre-red-prompt +2524 -0
  19. package/dist/control-ui/css/style.css.pre-stop +2239 -0
  20. package/dist/control-ui/css/style.css.pre-textarea +2184 -0
  21. package/dist/control-ui/css/style.css.pre-watchdog +1848 -0
  22. package/dist/control-ui/css/style.css.red-theme +2999 -0
  23. package/dist/control-ui/index.html +1049 -0
  24. package/dist/control-ui/js/app.js +1304 -0
  25. package/dist/control-ui/js/app.js.pre-2row +463 -0
  26. package/dist/control-ui/js/app.js.pre-heartbeat-filter +595 -0
  27. package/dist/control-ui/js/app.js.pre-newsession +408 -0
  28. package/dist/control-ui/js/app.js.pre-queue +476 -0
  29. package/dist/control-ui/js/app.js.pre-stop +564 -0
  30. package/dist/control-ui/js/app.js.pre-textarea +467 -0
  31. package/dist/control-ui/js/app.js.pre-watchdog +293 -0
  32. package/dist/control-ui/js/connections.js +438 -0
  33. package/dist/control-ui/js/gateway.js +233 -0
  34. package/dist/control-ui/js/gateway.js.pre-stop +110 -0
  35. package/dist/control-ui/js/history.js +732 -0
  36. package/dist/control-ui/js/logs.js +238 -0
  37. package/dist/control-ui/js/menu.js +232 -0
  38. package/dist/control-ui/js/menu.js.pre-nav +66 -0
  39. package/dist/control-ui/js/metrics.js +53 -0
  40. package/dist/control-ui/js/models.js +138 -0
  41. package/dist/control-ui/js/render.js +882 -0
  42. package/dist/control-ui/js/render.test.js +112 -0
  43. package/dist/control-ui/js/scheduling.js +461 -0
  44. package/dist/control-ui/js/settings.js +910 -0
  45. package/dist/control-ui/js/slash-autocomplete.js +168 -0
  46. package/dist/control-ui/js/subagents.js +560 -0
  47. package/dist/control-ui/js/utils.js +29 -0
  48. package/dist/control-ui/vendor/highlight.min.js +2518 -0
  49. package/dist/control-ui/vendor/marked.min.js +69 -0
  50. package/dist/{deliver-DyO3QD8O.js → deliver-DTRkeYm3.js} +4 -4
  51. package/dist/{deliver-Cjyb6h4g.js → deliver-oWGJwzFf.js} +4 -4
  52. package/dist/extensionAPI.js +4 -4
  53. package/dist/llm-slug-generator.js +4 -4
  54. package/dist/{manager-rvtFoeFT.js → manager-CFenq_aO.js} +1 -1
  55. package/dist/{manager-PTSjHNVq.js → manager-CsxTf96V.js} +1 -1
  56. package/dist/{pi-embedded-BPuUM-gD.js → pi-embedded-Cdub5Vs9.js} +10 -10
  57. package/dist/{pw-ai-BFS9ezWe.js → pw-ai-BOOB8qoi.js} +1 -1
  58. package/dist/{pw-ai-Cx-Ko_FL.js → pw-ai-D2pEVS5n.js} +1 -1
  59. package/dist/{synthesis-7UL3pCpj.js → synthesis-Be9nYyDd.js} +4 -4
  60. package/dist/{synthesis-fD8J2vag.js → synthesis-CBIT6Vnk.js} +4 -4
  61. package/dist/{unified-runner-BIiKFnNF.js → unified-runner-BVvvnjXW.js} +10 -10
  62. package/package.json +1 -1
@@ -0,0 +1,732 @@
1
+ // ── Session History Browser ────────────────────────────────────────────
2
+ // Provides the history drawer: list past sessions, browse archived
3
+ // transcripts, favourite/delete/load. Exactly one row is ever CURRENT;
4
+ // all others are loadable past sessions.
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ "use strict";
8
+
9
+ (function () {
10
+ const drawer = document.getElementById("history-drawer");
11
+ const overlay = document.getElementById("history-overlay");
12
+ const drawerBody = document.getElementById("history-drawer-body");
13
+ const closeBtn = document.getElementById("history-close-btn");
14
+ const historyBtn = document.getElementById("history-btn");
15
+ const archiveToast = document.getElementById("session-archive-toast");
16
+ const archiveToastLink = document.getElementById("session-archive-link");
17
+ const searchInput = document.getElementById("history-search-input");
18
+ const searchClear = document.getElementById("history-search-clear");
19
+
20
+ let isOpen = false;
21
+ let toastTimer = null;
22
+ let __viewingFile = null;
23
+ const DELETE_CONFIRM_MS = 5000;
24
+ const SEARCH_DEBOUNCE_MS = 250;
25
+ let searchDebounceTimer = null;
26
+ let activeSearchToken = 0;
27
+
28
+ // ── Open / close ──────────────────────────────────────────────────────
29
+ function openDrawer() {
30
+ isOpen = true;
31
+ drawer.classList.add("open");
32
+ overlay.classList.add("open");
33
+ drawer.setAttribute("aria-hidden", "false");
34
+ void loadSessionList();
35
+ }
36
+
37
+ function closeDrawer() {
38
+ isOpen = false;
39
+ drawer.classList.remove("open");
40
+ overlay.classList.remove("open");
41
+ drawer.setAttribute("aria-hidden", "true");
42
+ __viewingFile = null;
43
+ showToolCalls = false;
44
+ if (searchInput && searchInput.value) {
45
+ searchInput.value = "";
46
+ if (searchClear) {
47
+ searchClear.hidden = true;
48
+ }
49
+ }
50
+ }
51
+
52
+ historyBtn.addEventListener("click", () => (isOpen ? closeDrawer() : openDrawer()));
53
+ closeBtn.addEventListener("click", closeDrawer);
54
+ overlay.addEventListener("click", closeDrawer);
55
+ document.addEventListener("keydown", (e) => {
56
+ if (e.key === "Escape" && isOpen) {
57
+ closeDrawer();
58
+ }
59
+ });
60
+
61
+ // ── Cross-session search ─────────────────────────────────────────────
62
+ if (searchInput) {
63
+ searchInput.addEventListener("input", () => {
64
+ const value = searchInput.value;
65
+ if (searchClear) {
66
+ searchClear.hidden = !value;
67
+ }
68
+ if (searchDebounceTimer) {
69
+ clearTimeout(searchDebounceTimer);
70
+ }
71
+ searchDebounceTimer = setTimeout(() => {
72
+ const trimmed = value.trim();
73
+ if (!trimmed) {
74
+ void loadSessionList();
75
+ } else {
76
+ void runSessionSearch(trimmed);
77
+ }
78
+ }, SEARCH_DEBOUNCE_MS);
79
+ });
80
+ searchInput.addEventListener("keydown", (e) => {
81
+ if (e.key === "Escape" && searchInput.value) {
82
+ e.stopPropagation();
83
+ searchInput.value = "";
84
+ if (searchClear) {
85
+ searchClear.hidden = true;
86
+ }
87
+ void loadSessionList();
88
+ }
89
+ });
90
+ }
91
+ if (searchClear) {
92
+ searchClear.addEventListener("click", () => {
93
+ if (!searchInput) {
94
+ return;
95
+ }
96
+ searchInput.value = "";
97
+ searchClear.hidden = true;
98
+ searchInput.focus();
99
+ void loadSessionList();
100
+ });
101
+ }
102
+
103
+ // ── Session list ──────────────────────────────────────────────────────
104
+ async function loadSessionList() {
105
+ drawerBody.innerHTML = '<div class="history-loading">Loading sessions…</div>';
106
+ __viewingFile = null;
107
+
108
+ const currentKey = window.SESSION_KEY ?? "agent:main:main";
109
+ const url = "/api/sessions?currentKey=" + encodeURIComponent(currentKey);
110
+
111
+ let sessions;
112
+ try {
113
+ const res = await fetch(url);
114
+ const payload = await res.json();
115
+ // Response shape: { sessions: [...] } (2.7.1+)
116
+ sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
117
+ } catch {
118
+ drawerBody.innerHTML = '<div class="history-empty">Failed to load sessions.</div>';
119
+ return;
120
+ }
121
+
122
+ if (!sessions.length) {
123
+ drawerBody.innerHTML = '<div class="history-empty">No session history found.</div>';
124
+ return;
125
+ }
126
+
127
+ // Group rows: CURRENT / FAVOURITES / RECENT
128
+ const current = sessions.filter((s) => s.kind === "current");
129
+ const favourites = sessions.filter((s) => s.kind !== "current" && s.favorited);
130
+ const recent = sessions.filter((s) => s.kind !== "current" && !s.favorited);
131
+
132
+ // Newest `.reset.*` archive with content — this becomes the target of
133
+ // the Load Session button on the CURRENT row, giving the user a
134
+ // one-click path back to the session they just left.
135
+ const returnCandidate = findReturnCandidate(sessions);
136
+
137
+ drawerBody.innerHTML = "";
138
+
139
+ if (current.length > 0) {
140
+ drawerBody.appendChild(renderSection("CURRENT", current, returnCandidate));
141
+ }
142
+ if (favourites.length > 0) {
143
+ drawerBody.appendChild(renderSection("★ FAVOURITES", favourites, null));
144
+ }
145
+ if (recent.length > 0) {
146
+ drawerBody.appendChild(renderSection("RECENT", recent, null));
147
+ }
148
+ }
149
+
150
+ function findReturnCandidate(sessions) {
151
+ const now = Date.now();
152
+ const windowMs = 24 * 60 * 60 * 1000; // 24 hours
153
+ const candidates = sessions.filter((s) => {
154
+ if (s.kind !== "archived") {
155
+ return false;
156
+ }
157
+ if (s.archivedReason !== "reset") {
158
+ return false;
159
+ }
160
+ if (!s.msgCount || s.msgCount <= 0) {
161
+ return false;
162
+ }
163
+ if (!s.archivedAt) {
164
+ return false;
165
+ }
166
+ const archivedMs = new Date(s.archivedAt).getTime();
167
+ if (Number.isNaN(archivedMs)) {
168
+ return false;
169
+ }
170
+ return now - archivedMs < windowMs;
171
+ });
172
+ if (!candidates.length) {
173
+ return null;
174
+ }
175
+ // Newest first
176
+ candidates.sort((a, b) => (b.archivedAt ?? "").localeCompare(a.archivedAt ?? ""));
177
+ return candidates[0];
178
+ }
179
+
180
+ function renderSection(title, rows, returnCandidate) {
181
+ const section = document.createElement("section");
182
+ section.className = "history-section";
183
+ const heading = document.createElement("div");
184
+ heading.className = "history-section-heading";
185
+ heading.textContent = title;
186
+ section.appendChild(heading);
187
+ for (const row of rows) {
188
+ // Pass the return candidate through only for the CURRENT row — it
189
+ // drives the row's Load Session button (which returns the user to
190
+ // their previous session). Other rows use their own row data.
191
+ section.appendChild(renderRow(row, row.kind === "current" ? returnCandidate : null));
192
+ }
193
+ return section;
194
+ }
195
+
196
+ function renderRow(row, returnCandidate) {
197
+ const item = document.createElement("div");
198
+ item.className = "history-session-item history-session-" + row.kind;
199
+
200
+ const badge = BADGE[row.kind] ?? BADGE.past;
201
+ const when = formatTimestamp(row.lastActivity);
202
+ const preview = row.preview
203
+ ? escHtml(row.preview) + (row.preview.length >= 80 ? "…" : "")
204
+ : '<em style="opacity:0.5">No messages</em>';
205
+
206
+ // CURRENT row's Load Session button targets the most-recent .reset.*
207
+ // archive (returnCandidate). Non-current rows use their own file.
208
+ let footerHtml;
209
+ if (row.kind === "current") {
210
+ footerHtml = returnCandidate
211
+ ? `<footer class="history-session-actions">
212
+ <button class="history-load-btn" data-return="1">Load Session</button>
213
+ </footer>`
214
+ : "";
215
+ } else {
216
+ footerHtml = `<footer class="history-session-actions">
217
+ ${row.canLoad ? `<button class="history-load-btn">Load Session</button>` : ""}
218
+ ${row.canDelete ? `<button class="history-delete-btn">Delete</button>` : ""}
219
+ </footer>`;
220
+ }
221
+
222
+ item.innerHTML = `
223
+ <header class="history-session-header">
224
+ <button class="history-fav-btn ${row.favorited ? "active" : ""}"
225
+ aria-pressed="${row.favorited ? "true" : "false"}"
226
+ aria-label="${row.favorited ? "Unfavourite" : "Favourite"} session"
227
+ title="${row.favorited ? "Unfavourite" : "Favourite"}">★</button>
228
+ <span class="history-session-badge ${badge.className}">${badge.label}</span>
229
+ <span class="history-session-date">${escHtml(when)}</span>
230
+ </header>
231
+ <div class="history-session-meta">
232
+ <span class="history-session-count">${row.msgCount} msgs · ${fmtSize(row.size)}</span>
233
+ </div>
234
+ <div class="history-session-preview">${preview}</div>
235
+ ${footerHtml}
236
+ `;
237
+
238
+ // Body-click behaviour varies by kind:
239
+ // - CURRENT: close the drawer (user is already viewing this session
240
+ // in the main feed — no point opening a transcript preview).
241
+ // - PAST / ARCHIVED: open the transcript viewer.
242
+ item.addEventListener("click", (e) => {
243
+ const target = e.target;
244
+ if (target instanceof Element && target.closest("button")) {
245
+ return; // button click handled below
246
+ }
247
+ if (row.kind === "current") {
248
+ closeDrawer();
249
+ return;
250
+ }
251
+ void loadTranscript(row.file, when);
252
+ });
253
+
254
+ const favBtn = item.querySelector(".history-fav-btn");
255
+ favBtn?.addEventListener("click", async (e) => {
256
+ e.stopPropagation();
257
+ await toggleFavorite(row, favBtn);
258
+ });
259
+
260
+ const loadBtn = item.querySelector(".history-load-btn");
261
+ loadBtn?.addEventListener("click", async (e) => {
262
+ e.stopPropagation();
263
+ // On the CURRENT row, Load Session loads the most-recent archive
264
+ // (returnCandidate). On PAST/ARCHIVED rows, it loads that row.
265
+ const target = loadBtn.getAttribute("data-return") === "1" ? returnCandidate : row;
266
+ if (target) {
267
+ await loadRow(target);
268
+ }
269
+ });
270
+
271
+ const deleteBtn = item.querySelector(".history-delete-btn");
272
+ if (deleteBtn instanceof HTMLButtonElement) {
273
+ wireTwoStageDelete(deleteBtn, () => deleteRow(row));
274
+ }
275
+
276
+ return item;
277
+ }
278
+
279
+ const BADGE = {
280
+ current: { label: "CURRENT", className: "badge-current" },
281
+ past: { label: "PAST", className: "badge-past" },
282
+ archived: { label: "ARCHIVED", className: "badge-archived" },
283
+ };
284
+
285
+ // ── Cross-session search ─────────────────────────────────────────────
286
+ async function runSessionSearch(query) {
287
+ if (!window.gateway?.connected) {
288
+ drawerBody.innerHTML = '<div class="history-empty">Gateway not connected.</div>';
289
+ return;
290
+ }
291
+ const token = ++activeSearchToken;
292
+ drawerBody.innerHTML = '<div class="history-loading">Searching…</div>';
293
+
294
+ let result;
295
+ try {
296
+ result = await window.gateway.rpc("sessions.search", { query, limit: 24 });
297
+ } catch (err) {
298
+ if (token !== activeSearchToken) {
299
+ return;
300
+ }
301
+ drawerBody.innerHTML =
302
+ '<div class="history-empty">Search failed: ' +
303
+ escHtml(err?.message ?? String(err)) +
304
+ "</div>";
305
+ return;
306
+ }
307
+
308
+ // Drop stale responses if a newer search was kicked off mid-flight.
309
+ if (token !== activeSearchToken) {
310
+ return;
311
+ }
312
+
313
+ const matches = Array.isArray(result?.matches) ? result.matches : [];
314
+ if (!matches.length) {
315
+ drawerBody.innerHTML = `<div class="history-search-status">No matches for ${escHtml(
316
+ query,
317
+ )}.</div>`;
318
+ return;
319
+ }
320
+
321
+ drawerBody.innerHTML = "";
322
+ const status = document.createElement("div");
323
+ status.className = "history-search-status";
324
+ status.textContent = `${matches.length} match${matches.length === 1 ? "" : "es"} for "${query}"`;
325
+ drawerBody.appendChild(status);
326
+
327
+ const section = document.createElement("section");
328
+ section.className = "history-section";
329
+ for (const match of matches) {
330
+ section.appendChild(renderSearchMatch(match, query));
331
+ }
332
+ drawerBody.appendChild(section);
333
+ }
334
+
335
+ function renderSearchMatch(match, query) {
336
+ const item = document.createElement("div");
337
+ item.className = "history-session-item history-session-search-match";
338
+
339
+ const title =
340
+ match.label ||
341
+ match.displayName ||
342
+ match.sessionKey ||
343
+ `session ${match.sessionId.slice(0, 8)}`;
344
+ const when = match.updatedAt ? formatTimestamp(new Date(match.updatedAt).toISOString()) : "—";
345
+ const lineRange =
346
+ match.lineStart === match.lineEnd
347
+ ? `line ${match.lineStart}`
348
+ : `lines ${match.lineStart}–${match.lineEnd}`;
349
+ const score = typeof match.score === "number" ? match.score.toFixed(2) : "—";
350
+ const snippetHtml = highlightSnippet(match.snippet ?? "", query);
351
+
352
+ const canLoad = Boolean(match.sessionKey);
353
+ const footerHtml = canLoad
354
+ ? `<footer class="history-session-actions">
355
+ <button class="history-load-btn">Load Session</button>
356
+ </footer>`
357
+ : "";
358
+
359
+ item.innerHTML = `
360
+ <header class="history-session-header">
361
+ <span class="history-session-badge badge-past">MATCH</span>
362
+ <span class="history-session-date">${escHtml(title)}</span>
363
+ </header>
364
+ <div class="history-match-snippet">${snippetHtml}</div>
365
+ <div class="history-match-meta">
366
+ <span>${escHtml(when)}</span>
367
+ <span>${escHtml(lineRange)}</span>
368
+ <span>score ${escHtml(score)}</span>
369
+ </div>
370
+ ${footerHtml}
371
+ `;
372
+
373
+ const loadBtn = item.querySelector(".history-load-btn");
374
+ loadBtn?.addEventListener("click", async (e) => {
375
+ e.stopPropagation();
376
+ if (!match.sessionKey) {
377
+ return;
378
+ }
379
+ // Adopt-by-file using the session's current transcript file (basename).
380
+ await loadRow({ file: match.sessionId + ".jsonl" });
381
+ });
382
+
383
+ item.addEventListener("click", (e) => {
384
+ const target = e.target;
385
+ if (target instanceof Element && target.closest("button")) {
386
+ return;
387
+ }
388
+ void loadTranscript(match.sessionId + ".jsonl", title);
389
+ });
390
+
391
+ return item;
392
+ }
393
+
394
+ function highlightSnippet(text, query) {
395
+ if (!text) {
396
+ return "";
397
+ }
398
+ if (!query) {
399
+ return escHtml(text);
400
+ }
401
+ const safeText = escHtml(text);
402
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
403
+ try {
404
+ const re = new RegExp(escapedQuery, "gi");
405
+ return safeText.replace(re, (m) => `<mark>${m}</mark>`);
406
+ } catch {
407
+ return safeText;
408
+ }
409
+ }
410
+
411
+ // ── Actions ───────────────────────────────────────────────────────────
412
+
413
+ async function toggleFavorite(row, favBtn) {
414
+ if (!window.gateway?.connected) {
415
+ return;
416
+ }
417
+ const next = !row.favorited;
418
+ try {
419
+ await window.gateway.rpc("sessions.favoriteFile", {
420
+ file: row.file,
421
+ favorited: next,
422
+ });
423
+ row.favorited = next;
424
+ favBtn.classList.toggle("active", next);
425
+ favBtn.setAttribute("aria-pressed", next ? "true" : "false");
426
+ // Re-render to move the row between FAVOURITES and RECENT sections.
427
+ void loadSessionList();
428
+ } catch (err) {
429
+ console.warn("[history] favorite toggle failed", err);
430
+ }
431
+ }
432
+
433
+ async function loadRow(row) {
434
+ if (!window.gateway?.connected) {
435
+ alert("Gateway not connected.");
436
+ return;
437
+ }
438
+ drawerBody.innerHTML = '<div class="history-loading">Loading session…</div>';
439
+ try {
440
+ await window.gateway.rpc("sessions.adoptFile", {
441
+ key: window.SESSION_KEY,
442
+ targetFile: row.file,
443
+ });
444
+ closeDrawer();
445
+ if (typeof window.reloadSession === "function") {
446
+ window.reloadSession();
447
+ }
448
+ } catch (err) {
449
+ drawerBody.innerHTML =
450
+ '<div class="history-empty">Load failed: ' +
451
+ escHtml(err?.message ?? String(err)) +
452
+ "</div>";
453
+ }
454
+ }
455
+
456
+ async function deleteRow(row) {
457
+ if (!window.gateway?.connected) {
458
+ return;
459
+ }
460
+ try {
461
+ if (row.kind === "past" && row.sessionKey) {
462
+ // Archives the transcript via `sessions.delete` — the file becomes
463
+ // an ARCHIVED row in the next list refresh. Reversible.
464
+ await window.gateway.rpc("sessions.delete", {
465
+ key: row.sessionKey,
466
+ deleteTranscript: true,
467
+ });
468
+ } else {
469
+ // Archived files (or orphan live files): permanent deletion.
470
+ await window.gateway.rpc("sessions.deleteFile", { file: row.file });
471
+ }
472
+ await loadSessionList();
473
+ } catch (err) {
474
+ console.warn("[history] delete failed", err);
475
+ alert("Delete failed: " + (err?.message ?? String(err)));
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Wire a Delete button as a two-stage confirm. First click arms it with
481
+ * "Really delete?" for DELETE_CONFIRM_MS; second click within the window
482
+ * runs the action. Auto-reverts if the user walks away.
483
+ */
484
+ function wireTwoStageDelete(btn, run) {
485
+ let armed = false;
486
+ let armTimer = null;
487
+ const originalLabel = btn.textContent;
488
+ btn.addEventListener("click", (e) => {
489
+ e.stopPropagation();
490
+ if (!armed) {
491
+ armed = true;
492
+ btn.textContent = "Really delete?";
493
+ btn.classList.add("armed");
494
+ if (armTimer) {
495
+ clearTimeout(armTimer);
496
+ }
497
+ armTimer = setTimeout(() => {
498
+ armed = false;
499
+ btn.textContent = originalLabel;
500
+ btn.classList.remove("armed");
501
+ }, DELETE_CONFIRM_MS);
502
+ return;
503
+ }
504
+ clearTimeout(armTimer);
505
+ armed = false;
506
+ btn.disabled = true;
507
+ btn.textContent = "Deleting…";
508
+ btn.classList.remove("armed");
509
+ void run();
510
+ });
511
+ }
512
+
513
+ // ── Transcript viewer ─────────────────────────────────────────────────
514
+ // Per-drawer-session preference: whether the transcript modal includes
515
+ // tool_use / tool_result content blocks alongside chat text. Defaults
516
+ // off (matches the historical view); the toggle button in the banner
517
+ // flips it for the current viewing session, then resets on close.
518
+ let showToolCalls = false;
519
+
520
+ async function loadTranscript(file, label) {
521
+ __viewingFile = file;
522
+ drawerBody.innerHTML = '<div class="history-loading">Loading transcript…</div>';
523
+
524
+ let messages;
525
+ try {
526
+ const res = await fetch("/api/transcript?" + new URLSearchParams({ file }));
527
+ const data = await res.json();
528
+ messages = data.messages ?? [];
529
+ } catch {
530
+ drawerBody.innerHTML = '<div class="history-empty">Failed to load transcript.</div>';
531
+ return;
532
+ }
533
+
534
+ renderTranscript(messages, label);
535
+ }
536
+
537
+ function renderTranscript(messages, label) {
538
+ drawerBody.innerHTML = "";
539
+
540
+ const banner = document.createElement("div");
541
+ banner.className = "archive-banner";
542
+ banner.innerHTML = `
543
+ <span class="archive-banner-label">◈ ${escHtml(label)}</span>
544
+ <div class="archive-banner-actions">
545
+ <button class="archive-banner-toggle ${showToolCalls ? "active" : ""}"
546
+ id="archive-tools-toggle"
547
+ aria-pressed="${showToolCalls ? "true" : "false"}">
548
+ ${showToolCalls ? "Hide tool calls" : "Show tool calls"}
549
+ </button>
550
+ <button class="archive-banner-back" id="archive-back-btn">← All Sessions</button>
551
+ </div>
552
+ `;
553
+ drawerBody.appendChild(banner);
554
+ drawerBody.querySelector("#archive-back-btn").addEventListener("click", loadSessionList);
555
+ drawerBody.querySelector("#archive-tools-toggle").addEventListener("click", () => {
556
+ showToolCalls = !showToolCalls;
557
+ renderTranscript(messages, label);
558
+ });
559
+
560
+ const container = document.createElement("div");
561
+ container.className = "history-transcript";
562
+
563
+ const relevant = messages.filter((m) => m.role === "user" || m.role === "assistant");
564
+ if (!relevant.length) {
565
+ container.innerHTML = '<div class="history-empty">No chat messages in this session.</div>';
566
+ } else {
567
+ let renderedAny = false;
568
+ for (const msg of relevant) {
569
+ const text = extractMsgText(msg);
570
+ const tools = showToolCalls ? extractToolBlocks(msg) : [];
571
+ if (!text.trim() && tools.length === 0) {
572
+ continue;
573
+ }
574
+ renderedAny = true;
575
+ const el = document.createElement("div");
576
+ el.className = `history-msg ${msg.role}`;
577
+ const roleLabel = msg.role === "user" ? "YOU" : "SYMI";
578
+
579
+ let html = `<div class="history-msg-role ${msg.role}">${roleLabel}</div>`;
580
+ if (text.trim()) {
581
+ html += `<div class="history-msg-text">${escHtml(text)}</div>`;
582
+ }
583
+ for (const block of tools) {
584
+ html += renderToolBlock(block);
585
+ }
586
+ el.innerHTML = html;
587
+ container.appendChild(el);
588
+ }
589
+ if (!renderedAny) {
590
+ container.innerHTML = '<div class="history-empty">No chat messages in this session.</div>';
591
+ }
592
+ }
593
+
594
+ drawerBody.appendChild(container);
595
+ }
596
+
597
+ function extractToolBlocks(msg) {
598
+ if (!Array.isArray(msg.content)) {
599
+ return [];
600
+ }
601
+ const out = [];
602
+ for (const block of msg.content) {
603
+ if (!block || typeof block !== "object") {
604
+ continue;
605
+ }
606
+ if (block.type === "tool_use") {
607
+ out.push({
608
+ kind: "use",
609
+ name: typeof block.name === "string" ? block.name : "tool",
610
+ input: block.input ?? null,
611
+ });
612
+ } else if (block.type === "tool_result") {
613
+ let resultText = "";
614
+ if (typeof block.content === "string") {
615
+ resultText = block.content;
616
+ } else if (Array.isArray(block.content)) {
617
+ resultText = block.content
618
+ .filter((c) => c && (c.type === "text" || typeof c === "string"))
619
+ .map((c) => (typeof c === "string" ? c : (c.text ?? "")))
620
+ .join("\n");
621
+ }
622
+ out.push({
623
+ kind: "result",
624
+ isError: block.is_error === true,
625
+ text: resultText,
626
+ });
627
+ }
628
+ }
629
+ return out;
630
+ }
631
+
632
+ function renderToolBlock(block) {
633
+ const MAX_INPUT_CHARS = 400;
634
+ const MAX_OUTPUT_CHARS = 600;
635
+ if (block.kind === "use") {
636
+ let body = "";
637
+ try {
638
+ body = block.input == null ? "" : JSON.stringify(block.input, null, 2);
639
+ } catch {
640
+ body = String(block.input);
641
+ }
642
+ if (body.length > MAX_INPUT_CHARS) {
643
+ body = body.slice(0, MAX_INPUT_CHARS) + "\n… [truncated]";
644
+ }
645
+ return `
646
+ <div class="history-tool-block history-tool-use">
647
+ <div class="history-tool-head">⚙ ${escHtml(block.name)}</div>
648
+ ${body ? `<pre class="history-tool-body">${escHtml(body)}</pre>` : ""}
649
+ </div>
650
+ `;
651
+ }
652
+ let text = block.text ?? "";
653
+ if (text.length > MAX_OUTPUT_CHARS) {
654
+ text = text.slice(0, MAX_OUTPUT_CHARS) + "\n… [truncated]";
655
+ }
656
+ const cls = block.isError ? "history-tool-result-error" : "history-tool-result";
657
+ return `
658
+ <div class="history-tool-block ${cls}">
659
+ <div class="history-tool-head">${block.isError ? "✗ tool error" : "✓ tool result"}</div>
660
+ ${text ? `<pre class="history-tool-body">${escHtml(text)}</pre>` : ""}
661
+ </div>
662
+ `;
663
+ }
664
+
665
+ // ── Archive toast (shown after /new) ──────────────────────────────────
666
+ window.showArchiveToast = function () {
667
+ if (toastTimer) {
668
+ clearTimeout(toastTimer);
669
+ }
670
+ archiveToast.classList.add("show");
671
+ toastTimer = setTimeout(() => archiveToast.classList.remove("show"), 8000);
672
+ };
673
+
674
+ archiveToastLink.addEventListener("click", () => {
675
+ archiveToast.classList.remove("show");
676
+ openDrawer();
677
+ });
678
+
679
+ // ── Helpers ───────────────────────────────────────────────────────────
680
+ function extractMsgText(msg) {
681
+ if (typeof msg.content === "string") {
682
+ return msg.content;
683
+ }
684
+ if (!Array.isArray(msg.content)) {
685
+ return "";
686
+ }
687
+ return msg.content
688
+ .filter((b) => b.type === "text" || b.type === "thinking")
689
+ .map((b) => b.text ?? b.thinking ?? "")
690
+ .join("\n")
691
+ .trim();
692
+ }
693
+
694
+ function escHtml(str) {
695
+ return String(str)
696
+ .replace(/&/g, "&amp;")
697
+ .replace(/</g, "&lt;")
698
+ .replace(/>/g, "&gt;")
699
+ .replace(/"/g, "&quot;");
700
+ }
701
+
702
+ function formatTimestamp(iso) {
703
+ if (!iso) {
704
+ return "Unknown";
705
+ }
706
+ const d = new Date(iso);
707
+ if (Number.isNaN(d.getTime())) {
708
+ return "Unknown";
709
+ }
710
+ return d.toLocaleString("en-US", {
711
+ month: "short",
712
+ day: "numeric",
713
+ year: "numeric",
714
+ hour: "2-digit",
715
+ minute: "2-digit",
716
+ hour12: true,
717
+ });
718
+ }
719
+
720
+ function fmtSize(bytes) {
721
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) {
722
+ return "0B";
723
+ }
724
+ if (bytes < 1024) {
725
+ return `${bytes}B`;
726
+ }
727
+ if (bytes < 1024 * 1024) {
728
+ return `${(bytes / 1024).toFixed(0)}KB`;
729
+ }
730
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
731
+ }
732
+ })();