samagotchi 0.2.0 → 0.3.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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -6,6 +6,8 @@ import {
6
6
  cancelTurn,
7
7
  stopSession,
8
8
  deleteSession,
9
+ archiveSession,
10
+ unarchiveSession,
9
11
  sendAnswer,
10
12
  dismissQuestion,
11
13
  sendCommand,
@@ -17,13 +19,14 @@ import {
17
19
  } from "./data.js";
18
20
  import { MODEL_KEY, modelNames, pickModel } from "./model_pick.js";
19
21
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
20
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
22
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
21
23
  import { chatHistoryHtml, createChatView } from "./chat_view.js";
22
24
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
23
- import { annotationSource, appendQuote, quoteBlock } from "./annotations.js";
24
- import { COPY_SOURCE_ATTR, copySourceAttr, copyText, installCopy } from "./copy.js";
25
+ import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
26
+ import { parsePresets, presetNote } from "./annotate_presets.js";
27
+ import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
25
28
  import { routeChunk } from "./chunk_router.js";
26
- import { extractCtxPct } from "./ctx.js";
29
+ import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
27
30
  import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
28
31
  import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
29
32
  import { gripFloor, promptHeight } from "./composer_size.js";
@@ -32,12 +35,13 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
32
35
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
33
36
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
34
37
  import { stripColumns, stripShowParts } from "./strip.js";
35
- import { applySessionEvent, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
38
+ import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
39
+ import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
36
40
  import {
37
41
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
38
42
  } from "./timing.js";
39
43
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
40
- import { clientLabel, commandView, continueLine, hookNoticeLabel, isCommandLine, isOwn, newClientId, promptOps, reminderText, restoreAction, snapshotEvents, turnOutput, workerGoneText } from "./turn_events.js";
44
+ import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
41
45
 
42
46
  const $ = (s) => document.querySelector(s);
43
47
  const topStripEl = $("#topStrip");
@@ -121,6 +125,9 @@ let unmatchedMerge = false;
121
125
  // The enqueued_ids this page sent (from the /turn acks): only these come back
122
126
  // into the composer when their turn fails.
123
127
  const sentIds = new Set();
128
+ // Own prompt_restored events that came before their /turn ack, by
129
+ // enqueued_id: the ack puts that prompt back instead of clearing it.
130
+ const earlyRestores = new Map();
124
131
  // The last turn_failed's line, shown again after prompt_restored's resync.
125
132
  let lastFailedText = null;
126
133
 
@@ -158,17 +165,20 @@ async function refresh() {
158
165
  function openSessionEvents() {
159
166
  eventsControl = openEvents({
160
167
  snapshot: (data) => {
168
+ noticeSessions("snapshot", data);
161
169
  allSessions = applySessionEvent(allSessions, "snapshot", data);
162
170
  render();
163
171
  const sess = allSessions.find((s) => s.id === selected);
164
172
  if (sess) followSelectedSession(sess);
165
173
  },
166
174
  session: (data) => {
175
+ noticeSessions("session", data);
167
176
  allSessions = applySessionEvent(allSessions, "session", data);
168
177
  render();
169
178
  if (data.session && data.session.id === selected) followSelectedSession(data.session);
170
179
  },
171
180
  session_gone: (data) => {
181
+ noticeSessions("session_gone", data);
172
182
  const next = applySessionEvent(allSessions, "session_gone", data);
173
183
  if (next !== allSessions) {
174
184
  allSessions = next;
@@ -217,7 +227,8 @@ function renderScope() {
217
227
  el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
218
228
  }
219
229
  el.hidden = false;
220
- document.title = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
230
+ baseTitle = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
231
+ setTitle();
221
232
  // The links follow the hash (an open session, the all-sessions view).
222
233
  el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
223
234
  e.currentTarget.href = allScopeHref(location.hash, scope.dir);
@@ -227,10 +238,21 @@ function renderScope() {
227
238
  });
228
239
  }
229
240
 
230
- function filteredSessions() {
241
+ // The strip, its count and the hidden strip's pill leave archived sessions
242
+ // out; the all view shows them with "include archived". allSessions keeps
243
+ // them all (parent chips, the open session).
244
+ function shownSessions() {
245
+ return listedSessions(allSessions, false);
246
+ }
247
+
248
+ function allViewSessions() {
249
+ return listedSessions(allSessions, $("#includeArchived").checked);
250
+ }
251
+
252
+ function filteredSessions(list) {
231
253
  const q = $("#filter").value.trim().toLowerCase();
232
- if (!q) return allSessions;
233
- return allSessions.filter(
254
+ if (!q) return list;
255
+ return list.filter(
234
256
  (s) =>
235
257
  previewForCard(s).toLowerCase().includes(q) ||
236
258
  (s.first_preview || "").toLowerCase().includes(q) ||
@@ -252,7 +274,10 @@ function cardHtml(s, { deletable = false } = {}) {
252
274
  const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
253
275
  const folder = cardFolder(s.working_directory, scope.projectName);
254
276
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
255
- return `<div class="card" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
277
+ const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
278
+ const ctx = cardCtxText(s.ctx_pct);
279
+ const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
280
+ return `<div class="card${s.archived ? " archived" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
256
281
  }
257
282
 
258
283
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -277,7 +302,7 @@ function fitStripColumns() {
277
302
 
278
303
  function render() {
279
304
  fitStripColumns();
280
- renderTop(allSessions.slice(0, stripCols));
305
+ renderTop(shownSessions().slice(0, stripCols));
281
306
  if (allViewOpen()) renderAll();
282
307
  }
283
308
 
@@ -287,7 +312,7 @@ window.addEventListener("resize", () => {
287
312
  stripResizeTimer = setTimeout(() => {
288
313
  const before = stripCols;
289
314
  fitStripColumns();
290
- if (stripCols !== before && allSessions.length) renderTop(allSessions.slice(0, stripCols));
315
+ if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
291
316
  }, 150);
292
317
  });
293
318
 
@@ -314,11 +339,15 @@ function renderTop(sessions) {
314
339
  topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
315
340
  return;
316
341
  }
317
- if (!sessions.length) {
342
+ // Only archived sessions: no cards, but the tile stays, the way to them.
343
+ if (!allSessions.length) {
318
344
  topStripEl.innerHTML = "";
319
345
  return;
320
346
  }
321
- const count = `${allSessions.length} ${allSessions.length === 1 ? "session" : "sessions"}`;
347
+ const n = shownSessions().length;
348
+ const count = n || allSessions.length === n
349
+ ? `${n} ${n === 1 ? "session" : "sessions"}`
350
+ : `${allSessions.length - n} archived`;
322
351
  const tile = `<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
323
352
  topStripEl.innerHTML = sessions.map((s) => cardHtml(s)).join("") + tile;
324
353
  bindCards(topStripEl, select);
@@ -328,7 +357,7 @@ function renderTop(sessions) {
328
357
 
329
358
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
330
359
  function renderStripShow() {
331
- const { count, live } = stripShowParts(allSessions);
360
+ const { count, live } = stripShowParts(shownSessions());
332
361
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
333
362
  $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
334
363
  }
@@ -336,12 +365,13 @@ function renderStripShow() {
336
365
  // A delegated session sits right after its parent: applied on every
337
366
  // render, since a hub snapshot hands the list back in the server's order.
338
367
  function renderAll() {
339
- const sessions = withChildrenAfterParents(filteredSessions());
340
- $("#count").textContent = sessions.length === allSessions.length
341
- ? `${allSessions.length}`
342
- : `${sessions.length} / ${allSessions.length}`;
368
+ const listed = allViewSessions();
369
+ const sessions = withChildrenAfterParents(filteredSessions(listed));
370
+ $("#count").textContent = sessions.length === listed.length
371
+ ? `${listed.length}`
372
+ : `${sessions.length} / ${listed.length}`;
343
373
  if (!sessions.length) {
344
- allListEl.innerHTML = `<div class="hint">${allSessions.length ? "No session matches." : "No sessions yet."}</div>`;
374
+ allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
345
375
  return;
346
376
  }
347
377
  allListEl.innerHTML = sessions.map((s) => cardHtml(s, { deletable: true })).join("");
@@ -667,6 +697,11 @@ function renderSessionView(data) {
667
697
  currentDir = s.working_directory;
668
698
  currentOwner = s.owner || null;
669
699
  currentTiming = normalizeTiming(data.timing);
700
+ // The saved fill, so a reloaded or stopped session shows it before its
701
+ // next turn (a running turn's replay below streams over it).
702
+ const savedPct = savedCtxPct(data.timing?.context);
703
+ if (savedPct !== null) currentCtxPct = savedPct;
704
+ if (data.timing?.context?.window_tokens) currentCtxWindow = data.timing.context.window_tokens;
670
705
  currentFirstPreview = s.first_preview || previewForCard(s);
671
706
  currentUsedMemories = s.used_memory_names || [];
672
707
  currentPreloadedMemories = s.preloaded_memory_names || [];
@@ -684,6 +719,12 @@ function renderSessionView(data) {
684
719
  if(txt) currentFirstPreview = txt;
685
720
  }
686
721
  }
722
+ // A failed turn's prompt went back to the user: shown after the
723
+ // conversation, marked failed, as it was live (prompt_restored).
724
+ if (data.failed_turn) {
725
+ makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
726
+ addEndBubble(failedTurnText(data.failed_turn));
727
+ }
687
728
  seedSeenFromDom();
688
729
  seedInitTasks(initTasks, data.init_tasks);
689
730
  renderInitRow();
@@ -747,6 +788,36 @@ function setMarkdownWarning(warning) {
747
788
  markdownWarningEl.hidden = false;
748
789
  }
749
790
 
791
+ // The turn-end re-read (turn_completed): the final answer's rendered
792
+ // markdown, the status and the timing. Kept as turnEndRead, so an
793
+ // answer_display re-read waits for it.
794
+ let turnEndRead = Promise.resolve();
795
+ // Resolves the wait of a turn_completed with display_pending (its
796
+ // answer_display landed).
797
+ let displayLanded = null;
798
+ async function readTurnEnd() {
799
+ if (!selected) return;
800
+ try {
801
+ const d = await getSession(selected, { tail: true });
802
+ if (d.session) {
803
+ if (Array.isArray(d.session.used_memory_names)) currentUsedMemories = d.session.used_memory_names;
804
+ if (d.session.status) currentStatus = d.session.status;
805
+ applyTiming(d.timing);
806
+ finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
807
+ setMarkdownWarning(d.markdown_warning);
808
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
809
+ updateInfoBar();
810
+ const sess = allSessions.find(s=>s.id===selected);
811
+ if (sess) {
812
+ sess.used_memory_names = currentUsedMemories.slice();
813
+ sess.status = currentStatus;
814
+ }
815
+ render();
816
+ document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
817
+ }
818
+ } catch(_){}
819
+ }
820
+
750
821
  function applyFinalMarkdown(messages) {
751
822
  const message = [...messages].reverse().find(
752
823
  (item) => item?.role === "assistant" && typeof item.html === "string",
@@ -755,11 +826,11 @@ function applyFinalMarkdown(messages) {
755
826
 
756
827
  const bubble = [...historyEl.querySelectorAll(".bubble.output")]
757
828
  .reverse()
758
- .find((item) => normalize(item.textContent) === normalize(message.content));
829
+ .find((item) => showsAnswer(item, message, normalize));
759
830
  if (!bubble) return;
760
831
 
761
832
  bubble.classList.add("markdown");
762
- bubble.setAttribute(COPY_SOURCE_ATTR, message.content);
833
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
763
834
  bubble.innerHTML = message.html;
764
835
  }
765
836
 
@@ -958,6 +1029,20 @@ function showGuardrailWarning(text, label = "guardrails") {
958
1029
  // A hook's line to the user (event[:notify]): "<bundle>: text", in the
959
1030
  // warning colour for level warn.
960
1031
  // +before+: the element it goes above (a snapshot's notice), else the end.
1032
+ // A plugin's steers (pending_input_merged's steers): in the turn view a row
1033
+ // of the running turn's step, else a bubble.
1034
+ function showSteers(steers) {
1035
+ for (const steer of steers || []) {
1036
+ if (turnDom.steer(steer)) continue;
1037
+ removeHintIfEmpty();
1038
+ const holder = document.createElement("div");
1039
+ holder.innerHTML = steerRowHtml(steer, { bubble: true });
1040
+ const follow = isNearBottom();
1041
+ appendToHistory(holder.firstChild);
1042
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1043
+ }
1044
+ }
1045
+
961
1046
  function showHookNotice(data, { before = null } = {}) {
962
1047
  removeHintIfEmpty();
963
1048
  const el = document.createElement("div");
@@ -995,7 +1080,9 @@ function drawCard(el, card) {
995
1080
 
996
1081
  // Show +card+: update the one with its id where it is, else add it (a
997
1082
  // running turn's card as a row of its step; else before +before+ or at the
998
- // end of the history).
1083
+ // end of the history). A running turn's card with actions asks the user
1084
+ // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
1085
+ // block, not into a step that collapses when the next one starts.
999
1086
  function upsertCard(card, { before = null } = {}) {
1000
1087
  if (!card || !card.id) return;
1001
1088
  const existing = cardElement(card.id);
@@ -1008,7 +1095,8 @@ function upsertCard(card, { before = null } = {}) {
1008
1095
  removeHintIfEmpty();
1009
1096
  const el = document.createElement("div");
1010
1097
  drawCard(el, card);
1011
- if (card.in_turn && !before && turnDom.card(el)) return;
1098
+ const asks = (card.actions || []).some((a) => a && a.command);
1099
+ if (card.in_turn && !before && !asks && turnDom.card(el)) return;
1012
1100
  const follow = isNearBottom();
1013
1101
  if (before) historyEl.insertBefore(el, before);
1014
1102
  else appendToHistory(el);
@@ -1089,7 +1177,7 @@ historyEl.addEventListener("click", (e) => {
1089
1177
  const err = card.querySelector(".card-error");
1090
1178
  err.classList.add("hidden");
1091
1179
  button.disabled = true;
1092
- sendCommand(selected, button.dataset.command, { clientId })
1180
+ sendCommand(selected, button.dataset.command, { clientId, card: true })
1093
1181
  .then(() => {
1094
1182
  // The command woke the worker: follow its stream if none is open.
1095
1183
  if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
@@ -1119,7 +1207,14 @@ infoTextEl.addEventListener("click", async (e) => {
1119
1207
  function updateInfoBar() {
1120
1208
  const stopBtn = document.getElementById("infoStopBtn");
1121
1209
  const deleteBtn = document.getElementById("infoDeleteBtn");
1210
+ const archiveBtn = document.getElementById("infoArchiveBtn");
1122
1211
  if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
1212
+ if (archiveBtn) {
1213
+ archiveBtn.style.display = selected ? "" : "none";
1214
+ const archived = selectedArchived();
1215
+ archiveBtn.textContent = archived ? "unarchive" : "archive";
1216
+ archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
1217
+ }
1123
1218
  if (!selected) {
1124
1219
  // The start page hides the info bar (the storage path is on #/sessions).
1125
1220
  infoTextEl.innerHTML = "";
@@ -1323,7 +1418,10 @@ const streamHandlers = {
1323
1418
  // pending_input_merged carries the merged text, shown only for prompts
1324
1419
  // this tab has no bubble for.
1325
1420
  input_merged: (data) => applyPromptOps(data),
1326
- pending_input_merged: (data) => applyPromptOps(data),
1421
+ pending_input_merged: (data) => {
1422
+ applyPromptOps(data);
1423
+ showSteers(data.steers);
1424
+ },
1327
1425
  turn_enqueued: (data) => applyPromptOps(data),
1328
1426
  turn_started: (data) => keepFollowing(historyEl, () => {
1329
1427
  markRecapStale();
@@ -1339,32 +1437,33 @@ const streamHandlers = {
1339
1437
  endTurnView("completed");
1340
1438
  const out = turnOutput(data);
1341
1439
  if (out) appendChunk(out);
1342
- if (selected) {
1343
- try {
1344
- const d = await getSession(selected, { tail: true });
1345
- if (d.session) {
1346
- if (Array.isArray(d.session.used_memory_names)) currentUsedMemories = d.session.used_memory_names;
1347
- if (d.session.status) currentStatus = d.session.status;
1348
- applyTiming(d.timing);
1349
- finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
1350
- setMarkdownWarning(d.markdown_warning);
1351
- if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1352
- updateInfoBar();
1353
- const sess = allSessions.find(s=>s.id===selected);
1354
- if (sess) {
1355
- sess.used_memory_names = currentUsedMemories.slice();
1356
- sess.status = currentStatus;
1357
- }
1358
- render();
1359
- document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1360
- }
1361
- } catch(_){}
1362
- }
1440
+ // after_turn hooks are running and may present the answer: its
1441
+ // answer_display (display: null when they didn't) comes next.
1442
+ const display = data.display_pending ? new Promise((resolve) => { displayLanded = resolve; }) : null;
1443
+ turnEndRead = readTurnEnd();
1444
+ await turnEndRead;
1363
1445
  // The answer bubble the turn view held at its box's height pops now
1364
1446
  // that the rendered markdown is in it (or the re-read failed: then the
1365
- // plain text pops). The chat view has no hold.
1447
+ // plain text pops), and the display if one was coming, so the links
1448
+ // never swap in after the pop. The hold reveals by itself after
1449
+ // HOLD_MS whatever comes. The chat view has no hold.
1450
+ if (display) await display;
1366
1451
  turnDom.answerReady?.();
1367
1452
  },
1453
+ // An after_turn hook presented the answer (AnswerDisplay): it comes after
1454
+ // turn_completed, so re-read the answer once that read is done.
1455
+ answer_display: async (data) => {
1456
+ try {
1457
+ await turnEndRead;
1458
+ if (!selected || !data.display) return;
1459
+ const d = await getSession(selected, { tail: true });
1460
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1461
+ } catch (_) {
1462
+ } finally {
1463
+ displayLanded?.();
1464
+ displayLanded = null;
1465
+ }
1466
+ },
1368
1467
  turn_canceled: (data) => {
1369
1468
  endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
1370
1469
  setLiveStatus("idle");
@@ -1382,6 +1481,7 @@ const streamHandlers = {
1382
1481
  // then show the prompt once more, marked failed (with why). This tab's own
1383
1482
  // prompt goes back into an empty composer.
1384
1483
  prompt_restored: async (data) => {
1484
+ keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
1385
1485
  const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
1386
1486
  if (refill) {
1387
1487
  promptEl.value = refill;
@@ -1391,6 +1491,13 @@ const streamHandlers = {
1391
1491
  const failedText = lastFailedText;
1392
1492
  lastFailedText = null;
1393
1493
  await resync();
1494
+ // The re-render drew it from the session (failed_turn): drawn again
1495
+ // below with this event's label and images.
1496
+ const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
1497
+ if (drawn) {
1498
+ if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
1499
+ drawn.remove();
1500
+ }
1394
1501
  // Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
1395
1502
  // a resend's echo isn't matched to it.
1396
1503
  makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
@@ -1407,13 +1514,15 @@ const streamHandlers = {
1407
1514
  }
1408
1515
  if (view.resync) await resync();
1409
1516
  const queued = view.anytime && commandBubble(view.commandId);
1410
- if (queued) drawCommandBubble(queued, view);
1517
+ if (view.hidden) queued?.remove();
1518
+ else if (queued) drawCommandBubble(queued, view);
1411
1519
  else addCommandBubble(view);
1412
1520
  },
1413
1521
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
1414
1522
  // turn: its bubble goes here, so the cards it shows come after it.
1415
1523
  command_queued: (data) => {
1416
- if (data.anytime && !commandBubble(data.command_id)) addCommandBubble({ ...commandView(data, clientId), text: "" });
1524
+ // A card's action waits for its command_ran: shown only if it answers.
1525
+ if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble({ ...commandView(data, clientId), text: "" });
1417
1526
  },
1418
1527
  // Due reminders went into the turn (a reminder turn has no prompt bubble).
1419
1528
  reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
@@ -1463,6 +1572,9 @@ const streamHandlers = {
1463
1572
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1464
1573
  // In the turn view, a notice during a turn is a row of its current step.
1465
1574
  hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
1575
+ // The loop asks again after an empty answer: a row of the empty step (the
1576
+ // chat view shows nothing; after a reload neither does the turn view).
1577
+ empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1466
1578
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
1467
1579
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
1468
1580
  // A plugin's card, or a new version of one (the same id).
@@ -1623,7 +1735,8 @@ function renderQuestionCard(pq) {
1623
1735
  if (isNearBottom()) historyEl.scrollTop = historyEl.scrollHeight;
1624
1736
  }
1625
1737
 
1626
- // The tool, the command (or paths) as code, where and why.
1738
+ // The tool, the command (or paths, or args) as code, where and why; no
1739
+ // code box when there is nothing to show.
1627
1740
  function renderApprovalDetails(view) {
1628
1741
  const box = document.createElement("div");
1629
1742
  box.className = "approval-details";
@@ -1631,12 +1744,14 @@ function renderApprovalDetails(view) {
1631
1744
  tool.className = "approval-tool";
1632
1745
  tool.textContent = view.tool;
1633
1746
  box.appendChild(tool);
1634
- const what = document.createElement("pre");
1635
- what.className = view.isCommand ? "approval-what command" : "approval-what paths";
1636
- const code = document.createElement("code");
1637
- code.textContent = view.what;
1638
- what.appendChild(code);
1639
- box.appendChild(what);
1747
+ if (view.what) {
1748
+ const what = document.createElement("pre");
1749
+ what.className = view.isCommand ? "approval-what command" : "approval-what paths";
1750
+ const code = document.createElement("code");
1751
+ code.textContent = view.what;
1752
+ what.appendChild(code);
1753
+ box.appendChild(what);
1754
+ }
1640
1755
  for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
1641
1756
  if (!text) continue;
1642
1757
  const row = document.createElement("div");
@@ -1728,10 +1843,13 @@ function addCreatedSession(created) {
1728
1843
  render();
1729
1844
  }
1730
1845
 
1846
+ // A first message: an idle session, then the turn into it as for any
1847
+ // later one (handleSendTurn), so a failed first turn's prompt comes back
1848
+ // into the composer too (sentIds). A first command line starts the session
1849
+ // with it.
1731
1850
  async function handleCreate() {
1732
- // A first message with images: an idle session, then the turn into it.
1733
- if (chips.length) return handleSendTurn();
1734
1851
  const prompt = promptEl.value.trim();
1852
+ if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
1735
1853
  if (!prompt) return;
1736
1854
  actionBtn.disabled = true;
1737
1855
  try {
@@ -1748,7 +1866,7 @@ async function handleCreate() {
1748
1866
  }
1749
1867
 
1750
1868
  async function handleSendTurn() {
1751
- if (!selected && !chips.length) return handleCreate();
1869
+ if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
1752
1870
  if (currentOwner === "tui") return;
1753
1871
  const prompt = turnText(promptEl.value, chips);
1754
1872
  if (!prompt) return;
@@ -1756,7 +1874,7 @@ async function handleSendTurn() {
1756
1874
  if (!selected) {
1757
1875
  actionBtn.disabled = true;
1758
1876
  try {
1759
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel() });
1877
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
1760
1878
  addCreatedSession(s);
1761
1879
  await select(s.id);
1762
1880
  } catch (e) {
@@ -1782,12 +1900,16 @@ async function handleSendTurn() {
1782
1900
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1783
1901
  }
1784
1902
  const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending) });
1785
- promptEl.value = "";
1903
+ if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1904
+ // A turn that failed fast was restored before this ack: its prompt
1905
+ // (and images) go back into the composer.
1906
+ const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1907
+ promptEl.value = restored?.refill || "";
1786
1908
  fitPrompt();
1787
1909
  clearChips();
1910
+ if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
1788
1911
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1789
1912
  // worker that wasn't live (no turn_enqueued then).
1790
- if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1791
1913
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
1792
1914
  echo.dataset.enqueuedId = ack.enqueued_id;
1793
1915
  }
@@ -1818,6 +1940,13 @@ async function handleSendTurn() {
1818
1940
  // /model, !ls, /continue no … run in the session's worker; every client
1819
1941
  // shows the command_ran, this one too.
1820
1942
  async function handleSendCommand(line) {
1943
+ const local = webLocalReply(line);
1944
+ if (local) {
1945
+ addCommandBubble({ label: null, line, text: local });
1946
+ promptEl.value = "";
1947
+ fitPrompt();
1948
+ return;
1949
+ }
1821
1950
  actionBtn.disabled = true;
1822
1951
  try {
1823
1952
  await sendCommand(selected, line, { clientId });
@@ -2262,25 +2391,108 @@ $("#infoStopBtn").addEventListener("click", async () => {
2262
2391
  $("#infoDeleteBtn").addEventListener("click", () => {
2263
2392
  if (selected) confirmDelete(selected);
2264
2393
  });
2394
+ $("#infoArchiveBtn").addEventListener("click", () => {
2395
+ if (!selected) return;
2396
+ if (selectedArchived()) setArchived(selected, false);
2397
+ else setArchived(selected, true);
2398
+ });
2399
+
2400
+ // ── Archive ────────────────────────────────────────────────────────────────
2401
+ // Archive hides a session (and its delegates) from the strip and the list,
2402
+ // kept for good; "include archived" in all sessions finds it. No confirm:
2403
+ // the toast's Undo takes it back. Archiving the open session stays on it
2404
+ // (the button flips to unarchive); its worker was stopped by the server.
2405
+
2406
+ function selectedArchived() {
2407
+ return !!allSessions.find((s) => s.id === selected)?.archived;
2408
+ }
2409
+
2410
+ // The ids in the server's answer flip here at once; the hub's events
2411
+ // follow with the same.
2412
+ function markArchived(ids, archived) {
2413
+ const set = new Set(ids);
2414
+ allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
2415
+ }
2416
+
2417
+ async function setArchived(id, archive, { undo = true } = {}) {
2418
+ const short = id.slice(0, 8);
2419
+ let result;
2420
+ // A live worker is stopped by the archive: its stream ending is a stop.
2421
+ if (archive) stoppingId = id;
2422
+ try {
2423
+ result = archive ? await archiveSession(id) : await unarchiveSession(id);
2424
+ markArchived(archive ? result.archived : result.unarchived, archive);
2425
+ if (archive && id === selected && (result.stopped || []).includes(id)) {
2426
+ // Its worker is gone: don't wait for its stream to notice.
2427
+ streamControl?.close();
2428
+ currentStatus = "stopped";
2429
+ workerGone(id);
2430
+ }
2431
+ } catch (e) {
2432
+ alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${e.message}`);
2433
+ return;
2434
+ } finally {
2435
+ if (archive) stoppingId = null;
2436
+ }
2437
+ render();
2438
+ updateInfoBar();
2439
+ if (!eventsControl) refresh();
2440
+ const what = archive ? "Archived" : "Unarchived";
2441
+ showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2442
+ }
2443
+
2444
+ let toastTimer = null;
2445
+ function showToast(text, action = null) {
2446
+ const el = $("#toast");
2447
+ el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}`;
2448
+ el.hidden = false;
2449
+ el.querySelector(".toast-action")?.addEventListener("click", () => {
2450
+ el.hidden = true;
2451
+ action.run();
2452
+ });
2453
+ clearTimeout(toastTimer);
2454
+ toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2455
+ }
2456
+
2457
+ $("#includeArchived").addEventListener("change", () => {
2458
+ if (allViewOpen()) renderAll();
2459
+ });
2265
2460
 
2266
2461
  // ── Annotate ───────────────────────────────────────────────────────────────
2267
2462
  // Selecting text in an answer, thinking, a tool row or an own message shows
2268
- // "Annotate"; it appends the quote (with where it came from) to the
2269
- // composer. The button lives outside #history, which is re-rendered whole.
2270
- // The selection is read when it changes: a live bubble rewrites its text
2271
- // every frame, which would collapse it by the time the button is clicked.
2272
-
2273
- const annotateBtn = document.createElement("button");
2274
- annotateBtn.id = "annotateBtn";
2275
- annotateBtn.type = "button";
2276
- annotateBtn.textContent = "Annotate";
2277
- annotateBtn.hidden = true;
2278
- document.body.appendChild(annotateBtn);
2463
+ // "Annotate" and the presets (web.annotate_presets); Annotate appends the
2464
+ // quote (with where it came from) to the composer, a preset the quote with
2465
+ // its text as the note. Nothing is sent. The bar lives outside #history,
2466
+ // which is re-rendered whole. The selection is read when it changes: a live
2467
+ // bubble rewrites its text every frame, which would collapse it by the time
2468
+ // a button is clicked.
2469
+
2470
+ const annotatePresets = parsePresets(document.body.dataset.annotatePresets);
2471
+ const annotateBar = document.createElement("div");
2472
+ annotateBar.id = "annotateBar";
2473
+ annotateBar.hidden = true;
2474
+ annotateBar.setAttribute("role", "toolbar");
2475
+ annotateBar.setAttribute("aria-label", "Annotate");
2476
+ function annotateButton(text, cls) {
2477
+ const btn = document.createElement("button");
2478
+ btn.type = "button";
2479
+ btn.className = cls;
2480
+ btn.textContent = text;
2481
+ return btn;
2482
+ }
2483
+ annotateBar.appendChild(annotateButton("Annotate", "annotate-main"));
2484
+ annotatePresets.forEach((text, i) => {
2485
+ const btn = annotateButton(text, "annotate-preset");
2486
+ btn.dataset.index = String(i);
2487
+ btn.title = text;
2488
+ annotateBar.appendChild(btn);
2489
+ });
2490
+ document.body.appendChild(annotateBar);
2279
2491
  let pendingQuote = null;
2280
2492
 
2281
2493
  function hideAnnotate() {
2282
2494
  pendingQuote = null;
2283
- annotateBtn.hidden = true;
2495
+ annotateBar.hidden = true;
2284
2496
  }
2285
2497
 
2286
2498
  function updateAnnotate() {
@@ -2301,20 +2513,26 @@ function updateAnnotate() {
2301
2513
  pendingQuote = block;
2302
2514
  const rects = clipped.getClientRects();
2303
2515
  const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
2304
- annotateBtn.hidden = false;
2305
- const top = Math.min(rect.bottom + 6, window.innerHeight - annotateBtn.offsetHeight - 8);
2306
- const left = Math.min(Math.max(rect.right - annotateBtn.offsetWidth, 8), window.innerWidth - annotateBtn.offsetWidth - 8);
2307
- annotateBtn.style.top = `${Math.max(top, 8)}px`;
2308
- annotateBtn.style.left = `${left}px`;
2516
+ annotateBar.hidden = false;
2517
+ // Measured at the left edge: a fixed element's shrink-to-fit width is
2518
+ // capped by what's right of it, so at the last (right) spot it would
2519
+ // measure wrapped.
2520
+ annotateBar.style.left = "8px";
2521
+ const w = annotateBar.offsetWidth;
2522
+ const h = annotateBar.offsetHeight;
2523
+ const maxTop = window.innerHeight - h - 8;
2524
+ const top = Math.min(rect.bottom + 6, maxTop);
2525
+ annotateBar.style.top = `${Math.max(top, 8)}px`;
2526
+ annotateBar.style.left = `${annotateBarLeft(rect, w, window.innerWidth)}px`;
2309
2527
  // Not over a copy button (this bubble's, one inside it, the next
2310
2528
  // bubble's).
2311
- const box = annotateBtn.getBoundingClientRect();
2529
+ const box = annotateBar.getBoundingClientRect();
2312
2530
  const bubble = source.root.closest(".bubble");
2313
2531
  const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
2314
2532
  for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
2315
2533
  const r = btn.getBoundingClientRect();
2316
2534
  if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
2317
- annotateBtn.style.top = `${r.bottom + 4}px`;
2535
+ annotateBar.style.top = `${Math.max(Math.min(r.bottom + 4, maxTop), 8)}px`;
2318
2536
  break;
2319
2537
  }
2320
2538
  }
@@ -2329,11 +2547,13 @@ document.addEventListener("selectionchange", scheduleAnnotate);
2329
2547
  historyEl.addEventListener("scroll", scheduleAnnotate);
2330
2548
  window.addEventListener("resize", scheduleAnnotate);
2331
2549
  installCopy(historyEl);
2332
- // Keep the selection when the button is pressed.
2333
- annotateBtn.addEventListener("mousedown", (e) => e.preventDefault());
2334
- annotateBtn.addEventListener("click", () => {
2335
- if (!pendingQuote) return;
2336
- promptEl.value = appendQuote(promptEl.value, pendingQuote);
2550
+ // Keep the selection when a button is pressed.
2551
+ annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
2552
+ annotateBar.addEventListener("click", (e) => {
2553
+ const btn = e.target.closest("button");
2554
+ if (!btn || !pendingQuote) return;
2555
+ const preset = btn.dataset.index === undefined ? null : annotatePresets[Number(btn.dataset.index)];
2556
+ promptEl.value = appendQuote(promptEl.value, preset ? presetNote(pendingQuote, preset) : pendingQuote);
2337
2557
  fitPrompt();
2338
2558
  window.getSelection()?.removeAllRanges();
2339
2559
  hideAnnotate();
@@ -2342,6 +2562,135 @@ annotateBtn.addEventListener("click", () => {
2342
2562
  promptEl.scrollTop = promptEl.scrollHeight;
2343
2563
  });
2344
2564
 
2565
+ // ── Notifications (notify.js): when a session needs the user (a question,
2566
+ // an approval, a failed turn, a long turn done) and this tab is not in
2567
+ // front, an OS notification (the bell turns them on) and a count in the
2568
+ // title (always). One tag per event, so several tabs show one, and none
2569
+ // while another chi tab is in front with it (notify.js, Several tabs).
2570
+ const NOTIFY_KEY = "chi_notify";
2571
+ const notifyBtn = $("#notifyBtn");
2572
+ let attention = initialAttentionState();
2573
+ const badgeKeys = new Set();
2574
+ let baseTitle = "Chi";
2575
+
2576
+ function setTitle() {
2577
+ document.title = badgeKeys.size ? `(${badgeKeys.size}) ${baseTitle}` : baseTitle;
2578
+ }
2579
+
2580
+ // The user is looking at this tab: visible and focused (a window behind
2581
+ // the terminal is visible but not focused).
2582
+ function tabInFront() {
2583
+ return document.visibilityState === "visible" && document.hasFocus();
2584
+ }
2585
+
2586
+ function notifySupported() {
2587
+ return typeof globalThis.Notification === "function";
2588
+ }
2589
+
2590
+ function notifyWanted() {
2591
+ try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
2592
+ }
2593
+
2594
+ // "on" | "off" | "denied" | "unsupported"
2595
+ function notifyState() {
2596
+ if (!notifySupported()) return "unsupported";
2597
+ if (Notification.permission === "denied") return "denied";
2598
+ return notifyWanted() && Notification.permission === "granted" ? "on" : "off";
2599
+ }
2600
+
2601
+ const NOTIFY_TITLES = {
2602
+ on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
2603
+ off: "Notify me when a session needs me while this tab is in the background",
2604
+ denied: "Notifications are blocked for this site in the browser's settings",
2605
+ };
2606
+
2607
+ function renderNotifyBtn() {
2608
+ const state = notifyState();
2609
+ notifyBtn.hidden = state === "unsupported";
2610
+ notifyBtn.dataset.state = state;
2611
+ notifyBtn.title = NOTIFY_TITLES[state] || "";
2612
+ notifyBtn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
2613
+ }
2614
+
2615
+ async function toggleNotify() {
2616
+ const state = notifyState();
2617
+ if (state === "unsupported" || state === "denied") return;
2618
+ let wanted = state !== "on";
2619
+ if (wanted && Notification.permission !== "granted") {
2620
+ wanted = (await Notification.requestPermission()) === "granted";
2621
+ }
2622
+ try { localStorage.setItem(NOTIFY_KEY, wanted ? "1" : "0"); } catch (_) {}
2623
+ renderNotifyBtn();
2624
+ }
2625
+
2626
+ function showNotification(session, a) {
2627
+ if (notifyState() !== "on") return;
2628
+ const { title, body } = attentionText(session, a);
2629
+ try {
2630
+ const n = new Notification(title, { body, tag: a.key });
2631
+ n.onclick = () => {
2632
+ window.focus();
2633
+ location.hash = sessionHash(a.sessionId);
2634
+ n.close();
2635
+ };
2636
+ } catch (_) {}
2637
+ }
2638
+
2639
+ // Another chi tab in front has these (notify.js, Several tabs).
2640
+ function seenElsewhere(keys) {
2641
+ const size = badgeKeys.size;
2642
+ for (const key of keys) badgeKeys.delete(key);
2643
+ if (badgeKeys.size !== size) setTitle();
2644
+ }
2645
+ let notifyChannel = null;
2646
+ try {
2647
+ if (typeof globalThis.BroadcastChannel === "function") notifyChannel = new BroadcastChannel(NOTIFY_CHANNEL);
2648
+ } catch (_) {}
2649
+ const notifyGate = createNotifyGate({ channel: notifyChannel, onSeen: seenElsewhere });
2650
+
2651
+ // Every hub frame goes through here before the list takes it.
2652
+ function noticeSessions(type, data) {
2653
+ const next = trackAttention(attention, type, data);
2654
+ attention = next.state;
2655
+ const size = badgeKeys.size;
2656
+ for (const key of next.closed) badgeKeys.delete(key);
2657
+ notifyGate.drop(next.closed);
2658
+ if (next.attentions.length > 0 && tabInFront()) {
2659
+ notifyGate.seenInFront(next.attentions.map((a) => a.key));
2660
+ } else {
2661
+ for (const a of next.attentions) {
2662
+ notifyGate.offer(a, () => {
2663
+ if (tabInFront()) return;
2664
+ badgeKeys.add(a.key);
2665
+ setTitle();
2666
+ showNotification(attention.byId[a.sessionId], a);
2667
+ });
2668
+ }
2669
+ }
2670
+ if (badgeKeys.size !== size) setTitle();
2671
+ }
2672
+
2673
+ // The user is here: the badge goes, and the other tabs drop what it had.
2674
+ function clearBadge() {
2675
+ if (badgeKeys.size === 0) return;
2676
+ notifyGate.seenInFront([...badgeKeys]);
2677
+ badgeKeys.clear();
2678
+ setTitle();
2679
+ }
2680
+
2681
+ // The user is back: the page turned visible (Safari makes it visible
2682
+ // before it has focus, and a tab switch inside a focused window sends no
2683
+ // focus event), the window got focus, or they touched the page.
2684
+ function backInFront() {
2685
+ if (document.visibilityState === "visible") clearBadge();
2686
+ }
2687
+ document.addEventListener("visibilitychange", backInFront);
2688
+ window.addEventListener("focus", backInFront);
2689
+ document.addEventListener("pointerdown", clearBadge, true);
2690
+ document.addEventListener("keydown", clearBadge, true);
2691
+ notifyBtn.addEventListener("click", toggleNotify);
2692
+ renderNotifyBtn();
2693
+
2345
2694
  // The latest-sessions strip can be hidden for more chat room; the choice
2346
2695
  // stays per browser (it replaced zen mode, so a zen user starts hidden).
2347
2696
  function stripHiddenSaved() {