samagotchi 0.91.0 → 0.93.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -23,31 +23,23 @@ import {
23
23
  renderText,
24
24
  } from "./data.js";
25
25
  import { createContextChips } from "./context_chips.js";
26
- import {
27
- chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
28
- withChoice,
29
- } from "./llm_context_chip.js";
30
- import {
31
- chipModel as thinkingChipModel, createThinkingControl,
32
- withChoice as withThinkingChoice,
33
- } from "./thinking_chip.js";
26
+ import { createLlmContextControl } from "./llm_context_chip.js";
27
+ import { createThinkingControl } from "./thinking_chip.js";
34
28
  import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
35
29
  import { renderInfoBar } from "./info_bar.js";
36
30
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
37
31
  import { createShownTexts } from "./shown_texts.js";
38
- import { MODEL_KEY } from "./model_pick.js";
39
32
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
- import { createModelPicker } from "./model_picker.js";
41
- import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
- import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
33
+ import { admitImages, chipName, chipsTemplate, imageFiles, thumbsTemplate } from "./images.js";
34
+ import { normalize, failedTurnText, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
35
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
36
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
45
- import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
37
+ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
46
38
  import { createCardsPlace } from "./cards_place.js";
47
39
  import { createStageView, FOLD_MS } from "./stage_view.js";
48
40
  import { createMarkdownPlace } from "./markdown_place.js";
49
41
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
50
- import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
42
+ import { openerText, openTitle } from "./chains.js";
51
43
  import { createTaskStopper } from "./task_stop.js";
52
44
  import { appendQuote } from "./annotations.js";
53
45
  import { parsePresets } from "./annotate_presets.js";
@@ -68,7 +60,7 @@ import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
68
60
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
69
61
  import { createStripGrip } from "./strip_grip.js";
70
62
  import { waitingOn } from "./notify.js";
71
- import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
63
+ import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
72
64
  import { createNotifications } from "./notifications.js";
73
65
  import { tabTitle, topBarTitle } from "./session_title.js";
74
66
  import { restartConfirmText, versionNotice } from "./update_notice.js";
@@ -82,8 +74,7 @@ import { linkRefs, touchedFiles } from "./ref_scan.js";
82
74
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
83
75
  import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
84
76
  import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
85
- import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
86
- import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
77
+ import { createComposerCommands } from "./composer.js";
87
78
  import { createStreamHandlers } from "./stream_handlers.js";
88
79
  import { createListsView } from "./lists_view.js";
89
80
  import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
@@ -93,6 +84,9 @@ import { toolName } from "./activity.js";
93
84
  import { toastBottom } from "./toast_place.js";
94
85
  import { createToasts } from "./toasts.js";
95
86
  import { createUnqueue } from "./unqueue.js";
87
+ import { createStartPage } from "./start_page.js";
88
+ import { createSessionActions } from "./session_actions.js";
89
+ import { createSendTurn } from "./send_turn.js";
96
90
 
97
91
  const $ = (s) => document.querySelector(s);
98
92
  const topStripEl = $("#topStrip");
@@ -122,26 +116,6 @@ const llmContextControl = createLlmContextControl({
122
116
  running: () => liveTurn.running,
123
117
  run: (line) => runSessionCommand(line),
124
118
  });
125
- // The start page's LLM context chip, beside the model picker: the picked
126
- // model's values, and a choice for the next new session only (sent with its
127
- // create, then dropped; never remembered: a reload or opening a session
128
- // drops it too, a failed create keeps it).
129
- const llmContextNewEl = $("#llmContextNew");
130
- let newChatLlmContext = null;
131
- const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
132
- const llmContextNewControl = createLlmContextControl({
133
- el: llmContextNewEl,
134
- summary: newChatLlmSummary,
135
- kind: NEW_SESSION,
136
- choice: () => newChatLlmContext,
137
- choose: (next) => {
138
- newChatLlmContext = next;
139
- renderNewChatLlmContext();
140
- focusPrompt();
141
- },
142
- id: "llmContextNewPopover",
143
- anchor: "#llmContextNew",
144
- });
145
119
  // The open session's thinking chip, beside the llm ctx one: /thinking runs
146
120
  // in the session's worker.
147
121
  const thinkingControl = createThinkingControl({
@@ -160,41 +134,26 @@ const reasoningHistoryControl = createReasoningHistoryControl({
160
134
  running: () => liveTurn.running,
161
135
  run: (line) => runSessionCommand(line),
162
136
  });
163
- // The start page's thinking chip: the picked model's level, and a level
164
- // for the next new session only (sent with its create, then dropped).
165
- const thinkingNewEl = $("#thinkingNew");
166
- let newChatThinking = null;
167
- const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
168
- const thinkingNewControl = createThinkingControl({
169
- el: thinkingNewEl,
170
- summary: newChatThinkingSummary,
171
- kind: NEW_SESSION,
172
- choice: () => newChatThinking,
173
- choose: (next) => {
174
- newChatThinking = next;
175
- renderNewChatThinking();
176
- focusPrompt();
177
- },
178
- id: "thinkingNewPopover",
179
- anchor: "#thinkingNew",
137
+ // The start page's model picker and its LLM context and thinking chips
138
+ // (start_page.js); a fresh chain link's picker too.
139
+ const startPage = createStartPage({
140
+ pickEl: $("#modelPick"),
141
+ panelEl: $("#modelPanel"),
142
+ searchEl: $("#modelSearch"),
143
+ listEl: $("#modelList"),
144
+ composerEl: $("#composer"),
145
+ centerEl: $("#center"),
146
+ llmContextEl: $("#llmContextNew"),
147
+ thinkingEl: $("#thinkingNew"),
148
+ selected: () => selected,
149
+ state: () => current,
150
+ running: () => liveTurn.running,
151
+ sessions: () => allSessions,
152
+ run: (line) => runSessionCommand(line),
153
+ focusPrompt: () => focusPrompt(),
154
+ toast: (text) => showToast(text),
155
+ listModels,
180
156
  });
181
- function renderNewChatThinking() {
182
- const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
183
- thinkingNewEl.hidden = !chip;
184
- if (!chip) return thinkingNewControl.close();
185
- thinkingNewEl.textContent = chip.text;
186
- thinkingNewEl.title = chip.title;
187
- thinkingNewEl.classList.toggle("own", chip.own);
188
- }
189
- // Hidden without the model's values (the list not in, an older server).
190
- function renderNewChatLlmContext() {
191
- const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
192
- llmContextNewEl.hidden = !chip;
193
- if (!chip) return llmContextNewControl.close();
194
- llmContextNewEl.textContent = chip.text;
195
- llmContextNewEl.title = chip.title;
196
- llmContextNewEl.classList.toggle("own", chip.own);
197
- }
198
157
  const markdownWarningEl = $("#markdownWarning");
199
158
  // What the page renders that a notice may stand in for (the server's
200
159
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -210,7 +169,6 @@ const cancelBtn = $("#cancelBtn");
210
169
  const attachBtn = $("#attachBtn");
211
170
  const attachInput = $("#attachInput");
212
171
  const infoCancelBtn = $("#infoCancelBtn");
213
- const modelPickEl = $("#modelPick");
214
172
  const composeHintEl = $("#composeHint");
215
173
 
216
174
  let selected = null;
@@ -506,27 +464,28 @@ const lists = createListsView({
506
464
  leaveAllView: (hash) => leaveAllView(hash),
507
465
  });
508
466
 
509
- // Delete a session (the info bar's button, an all-sessions card's ✕) after
510
- // a confirm; the list drops it without a reload. The server stops a live
511
- // worker first and refuses a session a terminal chi has open.
512
- async function confirmDelete(id) {
513
- const sess = allSessions.find((s) => s.id === id) || { id };
514
- if (sess.owner === "tui") {
515
- alert(`Session ${id.slice(0, 8)} is open in a terminal chi; close it there first.`);
516
- return;
517
- }
518
- if (!confirm(deleteConfirmText(sess))) return;
519
- try {
520
- await deleteSession(id);
521
- } catch (e) {
522
- alert(`Could not delete session ${id.slice(0, 8)}: ${e.message}`);
523
- if (!eventsControl) refresh();
524
- return;
525
- }
526
- allSessions = allSessions.filter((s) => s.id !== id);
527
- if (selected === id) clearSelection();
528
- lists.render();
529
- }
467
+ // Continue, Archive, Delete and Stop, and the start page when the open
468
+ // session is gone (session_actions.js).
469
+ const sessionActions = createSessionActions({
470
+ selected: () => selected,
471
+ state: () => current,
472
+ sessions: () => allSessions,
473
+ setSessions: (list) => { allSessions = list; },
474
+ api: { continueSession, archiveSession, unarchiveSession, deleteSession, stopSession },
475
+ eventsLive: () => !!eventsControl,
476
+ refresh,
477
+ lists,
478
+ select,
479
+ clearSelection,
480
+ showNotice,
481
+ updateInfoBar,
482
+ toast: showToast,
483
+ addCreatedSession,
484
+ fillOpener,
485
+ closeLiveStream: () => streamControl?.close(),
486
+ workerGone,
487
+ });
488
+ const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
530
489
 
531
490
  // All sessions: its own view at #/sessions, so the browser's back button
532
491
  // leaves it. Opened from the page, it closes with history.back().
@@ -616,24 +575,13 @@ function updateComposerMode() {
616
575
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
617
576
  hadSession = !hadSession;
618
577
  // Back on the start page: its remembered model, not the last link's.
619
- if (!hadSession) restoreStartPick();
578
+ if (!hadSession) startPage.restoreStartPick();
620
579
  fitPrompt();
621
580
  placeToast();
622
581
  }
623
- syncModelPick();
624
- if (selected) {
625
- llmContextNewControl.close();
626
- // The start page's choice was for a new session: opening a session drops it.
627
- if (newChatLlmContext) {
628
- newChatLlmContext = null;
629
- renderNewChatLlmContext();
630
- }
631
- thinkingNewControl.close();
632
- if (newChatThinking) {
633
- newChatThinking = null;
634
- renderNewChatThinking();
635
- }
636
- }
582
+ startPage.syncModelPick();
583
+ // The start page's choice was for a new session: opening a session drops it.
584
+ if (selected) startPage.dropChoices();
637
585
  const running = liveTurn.running && !!selected;
638
586
  cancelBtn.style.display = running ? "" : "none";
639
587
  // No images for a session a terminal owns (it takes no message from here);
@@ -768,13 +716,6 @@ function clearNotice() {
768
716
  }
769
717
  promptEl.addEventListener("input", clearNotice);
770
718
 
771
- // The open session no longer exists: back to the start page, saying why.
772
- function leaveGoneSession(id, why) {
773
- if (selected !== id) return;
774
- clearSelection();
775
- showNotice(goneSessionNotice(id, why));
776
- }
777
-
778
719
  async function select(id) {
779
720
  clearNotice();
780
721
  selected = id;
@@ -992,7 +933,10 @@ async function readTurnEnd() {
992
933
  lists.render();
993
934
  lists.paintActive();
994
935
  }
995
- } catch(_){}
936
+ } catch (_) {
937
+ // A failed read (the server or worker gone a moment) leaves the answer
938
+ // as the stream drew it, plain text: the session's next load renders it.
939
+ }
996
940
  }
997
941
 
998
942
  function seedSeenFromDom() {
@@ -1085,7 +1029,7 @@ async function restartWorker() {
1085
1029
  function updateInfoBar() {
1086
1030
  const { buttons, bar } = sessionBar({
1087
1031
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1088
- continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1032
+ continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1089
1033
  now: Date.now(),
1090
1034
  });
1091
1035
  const stopBtn = document.getElementById("infoStopBtn");
@@ -1114,7 +1058,7 @@ function updateInfoBar() {
1114
1058
  updateSessionTitle();
1115
1059
  // A fresh link's picker follows what the bar follows: its model, its
1116
1060
  // owner, a next link continued from elsewhere.
1117
- syncModelPick();
1061
+ startPage.syncModelPick();
1118
1062
  }
1119
1063
 
1120
1064
  // The open session's title, once on screen (session_title.js): its strip
@@ -1211,10 +1155,6 @@ function startStream(id, fromSeq) {
1211
1155
  updateInfoBar();
1212
1156
  }
1213
1157
 
1214
- // The session this tab is stopping: its stream may close before the stop
1215
- // request returns.
1216
- let stoppingId = null;
1217
-
1218
1158
  // +opened+ false: the stream never delivered a frame (the proxy answered
1219
1159
  // 503, or the bridge refused it), so a retry waits a moment rather than
1220
1160
  // spinning through resync → stream → error.
@@ -1223,7 +1163,7 @@ function workerGone(id, { opened = true } = {}) {
1223
1163
  streamControl = null;
1224
1164
  if (id !== selected) return;
1225
1165
  // A turn that was running is over: its worker can't finish it.
1226
- const stopped = stoppingId === id;
1166
+ const stopped = sessionActions.stoppingId() === id;
1227
1167
  const endText = workerGoneText({ turnRunning: liveTurn.running, stopped });
1228
1168
  if (endText) {
1229
1169
  endTurnView("gone", endText, { stopped });
@@ -1284,38 +1224,19 @@ const streamHandlers = createStreamHandlers({
1284
1224
  promptEl.value = text;
1285
1225
  fitPrompt();
1286
1226
  },
1287
- setChips: (list) => setChips(list),
1227
+ setChips,
1288
1228
  },
1289
1229
  getSession,
1290
- resync: () => resync(),
1291
- updateInfoBar: () => updateInfoBar(),
1292
- applyPromptOps: (data) => applyPromptOps(data),
1293
- showSteers: (steers) => showSteers(steers),
1294
- markRecapStale: () => markRecapStale(),
1295
- setTurnRunning: (running) => setTurnRunning(running),
1296
- setLiveStatus: (status) => setLiveStatus(status),
1297
- endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
1298
- appendChunk: (text) => appendChunk(text),
1299
- readTurnEnd: () => readTurnEnd(),
1300
- applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
1301
- refreshTimingFromSession: () => refreshTimingFromSession(),
1302
- makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
1303
- addEndBubble: (text) => addEndBubble(text),
1304
- commandBubble: (id) => commandBubble(id),
1305
- addCommandBubble: (view) => addCommandBubble(view),
1306
- addNoteBubble: (note) => addNoteBubble(note),
1307
- findInTurnRoots: (selector) => findInTurnRoots(selector),
1308
- setUsedMemories: (names) => setUsedMemories(names),
1309
- showRecap: (text) => showRecap(text),
1310
- showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
1311
- showHookNotice: (data) => showHookNotice(data),
1312
- onInitEvent: (type, data) => onInitEvent(type, data),
1313
- upsertCard: (card) => upsertCard(card),
1314
- refreshCardBodies: () => refreshCardBodies(),
1315
- syncModelPick: () => {
1316
- if (pendingPick) pickRanEarly = true;
1317
- syncModelPick();
1318
- },
1230
+ // The page's hooks, passed as they are: function declarations (hoisted)
1231
+ // and the views' methods bound above.
1232
+ resync, updateInfoBar, setTurnRunning, setLiveStatus, endTurnView, readTurnEnd, refreshTimingFromSession,
1233
+ setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
1234
+ // bubbles.js
1235
+ applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
1236
+ addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
1237
+ // cards_place.js
1238
+ upsertCard, refreshCardBodies,
1239
+ syncModelPick: startPage.commandRan,
1319
1240
  });
1320
1241
 
1321
1242
  async function refreshTimingFromSession() {
@@ -1347,149 +1268,6 @@ function addCreatedSession(created) {
1347
1268
  lists.render();
1348
1269
  }
1349
1270
 
1350
- // A first message: an idle session, then the turn (or the command) into it
1351
- // as for any later one (handleSendTurn), so a failed first turn's prompt
1352
- // comes back into the composer too (sentIds).
1353
- function handleCreate() {
1354
- return handleSendTurn();
1355
- }
1356
-
1357
- // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1358
- // a delivery-line link: cut or queue); else the armed one; a typed /cut or
1359
- // /queue wins over both.
1360
- async function handleSendTurn({ delivery: chosen = null } = {}) {
1361
- if (current.owner === "tui") return;
1362
- // A fresh link's pick first: its /model is queued in the worker once
1363
- // acked, and the worker runs queued commands before a turn's input.
1364
- if (pendingPick) await pendingPick;
1365
- let prompt = turnText(promptEl.value, chips);
1366
- if (!prompt) return;
1367
- const armed = deliveryControl.take();
1368
- // An image-only send's text is its placeholders: not for ↑.
1369
- const typed = promptEl.value.trim() !== "";
1370
- // The composer holds the line as typed until the ack (composerAfterAck).
1371
- const line = prompt;
1372
- // Where the line goes (composer.js): the page's own reply, a session to
1373
- // make first (the start page), a command, or a turn.
1374
- let route = routeComposerLine(prompt, {
1375
- session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
1376
- });
1377
- if (route.create) {
1378
- actionBtn.disabled = true;
1379
- try {
1380
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
1381
- llm_context: choiceWords(newChatLlmContext),
1382
- thinking: newChatThinking || undefined });
1383
- // The choice was for this chat only.
1384
- newChatLlmContext = null;
1385
- renderNewChatLlmContext();
1386
- newChatThinking = null;
1387
- renderNewChatThinking();
1388
- addCreatedSession(s);
1389
- await select(s.id);
1390
- } catch (e) {
1391
- alert(e.message);
1392
- return;
1393
- } finally {
1394
- actionBtn.disabled = false;
1395
- }
1396
- // In the new session, by its command list (select read it).
1397
- route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
1398
- }
1399
- if (route.reply) {
1400
- // The start page's reply is a notice: no session is made for it (2.29).
1401
- if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
1402
- else showNotice(route.reply);
1403
- clearComposer();
1404
- return;
1405
- }
1406
- if (route.command) return composerCommands.send(route.command);
1407
- prompt = route.send;
1408
- const running = liveTurn.running && !!selected;
1409
- const delivery = route.delivery || (running ? chosen || armed : null);
1410
- const hint = historyEl.querySelector(".hint");
1411
- if (hint) hint.remove();
1412
- // Sending moves a finished turn out of the stage at once (else the echo
1413
- // would land among its rows); into a running one it is a queued row there.
1414
- turnDom.handOff?.({ immediate: true });
1415
- const sending = chips.slice();
1416
- // Into a running turn the echo says what it waits for (its delivery); a
1417
- // line with images always waits for the turn's end (the worker queues it).
1418
- const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
1419
- const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
1420
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1421
- if (!current.firstPreview || current.firstPreview==="—") {
1422
- current.firstPreview = firstPreview(prompt);
1423
- updateInfoBar();
1424
- }
1425
- actionBtn.disabled = true;
1426
- // The session the line goes to: the page may switch to another while an
1427
- // image uploads or the turn is sent, and what follows stays with this one.
1428
- const sentTo = selected;
1429
- try {
1430
- await uploadChips(sending, sentTo, { upload: uploadImage });
1431
- const ack = await sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1432
- if (ack?.command_id) {
1433
- // The worker knew it as a command (its list was newer than ours):
1434
- // its command_ran shows it, not a prompt bubble.
1435
- echo.remove();
1436
- clearComposer();
1437
- if (sentTo === selected) followWokenWorker();
1438
- return;
1439
- }
1440
- if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1441
- // A cut the worker didn't take (no thinking-only step: cut off) goes in
1442
- // at the next step; a queue whose turn ended meanwhile is the next turn.
1443
- if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
1444
- setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
1445
- }
1446
- refreshHistory();
1447
- // A turn that failed fast was restored before this ack: its prompt
1448
- // (and images) go back into the composer, without replacing text typed
1449
- // since (4.13). The composer still holds the text just sent: it goes.
1450
- const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1451
- // A restored line must be seen: the composer opens for this visit.
1452
- if (restored?.refill && sentTo === selected) openForVisit();
1453
- promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1454
- fitPrompt();
1455
- clearChips();
1456
- if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1457
- // Its turn_enqueued usually tagged the echo already; the ack covers a
1458
- // worker that wasn't live (no turn_enqueued then).
1459
- if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
1460
- echo.dataset.enqueuedId = ack.enqueued_id;
1461
- }
1462
- // A live stream carries this turn already; restarting it would drop the
1463
- // events in between. Without one, POST /turn just woke a worker: its
1464
- // events start after the seq the session was rendered at (0 for a new
1465
- // worker process). A switch since then opened the other session's own.
1466
- if (sentTo === selected) followWokenWorker();
1467
- } catch (e) {
1468
- echo.remove();
1469
- // The turn never reached the worker's queue: an early restore waiting
1470
- // for its ack is never taken back (4.13), so drop it here. The prompt
1471
- // is still in the composer, as the echo was removed.
1472
- dropEarlyRestores(earlyRestores);
1473
- // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1474
- // image refused (bad_image, too_large, bad_images) says why below.
1475
- if (e.code === "owned_by_tui") {
1476
- if (sentTo !== selected) return showToast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
1477
- current.owner = "tui";
1478
- updateComposerMode();
1479
- return;
1480
- }
1481
- // A stale tab sent into a session deleted meanwhile: the start page
1482
- // says so, and the line stays in the composer.
1483
- if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1484
- // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1485
- if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1486
- showToast(cantText("send", e), null, { sticky: true });
1487
- } finally {
1488
- actionBtn.disabled = current.owner === "tui";
1489
- focusPrompt();
1490
- }
1491
- }
1492
-
1493
1271
  // /model, !ls … from the composer, and a control's command (the llm ctx
1494
1272
  // and thinking chips): composer.js.
1495
1273
  const composerCommands = createComposerCommands({
@@ -1499,7 +1277,7 @@ const composerCommands = createComposerCommands({
1499
1277
  followWokenWorker: () => followWokenWorker(),
1500
1278
  refreshHistory: () => refreshHistory(),
1501
1279
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1502
- // As handleSendTurn says a turn's failure after a switch.
1280
+ // As send_turn.js says a turn's failure after a switch.
1503
1281
  failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1504
1282
  ownedByTui: () => {
1505
1283
  current.owner = "tui";
@@ -1517,6 +1295,51 @@ const composerCommands = createComposerCommands({
1517
1295
  },
1518
1296
  });
1519
1297
 
1298
+ // The composer's send (send_turn.js): a line into the open session, or a
1299
+ // new session first on the start page.
1300
+ const sendTurnControl = createSendTurn({
1301
+ session: () => selected,
1302
+ state: () => current,
1303
+ running: () => liveTurn.running,
1304
+ clientId,
1305
+ sentIds,
1306
+ earlyRestores,
1307
+ api: { createIdleSession, uploadImage, sendTurn },
1308
+ dir: scope.dir,
1309
+ startPage,
1310
+ takeDelivery: () => deliveryControl.take(),
1311
+ composer: {
1312
+ el: promptEl,
1313
+ button: actionBtn,
1314
+ chips: () => chips,
1315
+ setChips,
1316
+ fit: fitPrompt,
1317
+ clear: clearComposer,
1318
+ openForVisit: () => openForVisit(),
1319
+ focus: () => focusPrompt(),
1320
+ },
1321
+ historyEl,
1322
+ turnDom,
1323
+ makeUserBubble,
1324
+ bubbleById,
1325
+ addCommandBubble,
1326
+ addCreatedSession,
1327
+ select,
1328
+ showNotice,
1329
+ updateInfoBar,
1330
+ updateComposerMode,
1331
+ followWokenWorker,
1332
+ refreshHistory,
1333
+ leaveGoneSession,
1334
+ runCommand: (command) => composerCommands.send(command),
1335
+ toast: showToast,
1336
+ });
1337
+ // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1338
+ // a delivery-line link: cut or queue); else the armed one.
1339
+ function handleSendTurn({ delivery = null } = {}) {
1340
+ return sendTurnControl.send({ delivery });
1341
+ }
1342
+
1520
1343
  function runSessionCommand(line) {
1521
1344
  return composerCommands.run(line);
1522
1345
  }
@@ -1548,124 +1371,10 @@ async function handleCancel() {
1548
1371
  $("#newBtn").addEventListener("click", () => {
1549
1372
  if (allViewOpen()) leaveAllView("");
1550
1373
  clearSelection();
1551
- loadModels();
1374
+ startPage.loadModels({ asked: true });
1552
1375
  });
1553
1376
 
1554
- // ── The start page's model picker ──────────────────────────────────────────
1555
- // The hosts' models from GET /api/models, the choice this browser made last
1556
- // preselected while it is still offered (else the server's default). Hidden
1557
- // until the list is in, and in a session (its model is in the info bar),
1558
- // but a fresh chain link's (chains.js freshLink): there it shows the link's
1559
- // model and a pick runs /model in the link, remembered neither as this
1560
- // browser's choice nor in Recent. A host that failed is the picker's
1561
- // tooltip; a route that fails leaves the picker hidden and the server's
1562
- // default in charge. The search list itself is model_picker.js'; a pick
1563
- // sends the focus to the message box.
1564
- const START_PICK_TITLE = "The model the new session starts on";
1565
- let startPickTitle = START_PICK_TITLE;
1566
- // The fresh link the picker shows (null: the start page, or a session it
1567
- // is hidden in), and a link pick's /model still in flight (a send waits).
1568
- let pickLinkId = null;
1569
- let pickSelected = null;
1570
- let pendingPick = null;
1571
- let pendingPickId = null;
1572
- // A command_ran came while the pick was in flight (its sync was skipped):
1573
- // the ack syncs then.
1574
- let pickRanEarly = false;
1575
- const modelPicker = createModelPicker({
1576
- button: modelPickEl,
1577
- panel: $("#modelPanel"),
1578
- input: $("#modelSearch"),
1579
- list: $("#modelList"),
1580
- composer: $("#composer"),
1581
- center: $("#center"),
1582
- remember: () => !selected,
1583
- onPick: (name) => {
1584
- if (selected) pickForLink(name);
1585
- else {
1586
- renderNewChatLlmContext();
1587
- renderNewChatThinking();
1588
- }
1589
- focusPrompt();
1590
- },
1591
- });
1592
- // A pick on a fresh link: /model in its worker (its command bubble and the
1593
- // info bar follow from command_ran); refused, the button goes back to the
1594
- // link's model, if the page is still on that link.
1595
- function pickForLink(name) {
1596
- const id = selected;
1597
- const run = runSessionCommand(`/model ${name}`).then((ok) => {
1598
- const ranEarly = pendingPick === run && pickRanEarly;
1599
- if (pendingPick === run) {
1600
- pendingPick = null;
1601
- pendingPickId = null;
1602
- pickRanEarly = false;
1603
- }
1604
- if ((!ok || ranEarly) && selected === id) syncModelPick();
1605
- return ok;
1606
- });
1607
- pendingPick = run;
1608
- pendingPickId = id;
1609
- pickRanEarly = false;
1610
- }
1611
- // The button's name, tooltip and label for what the page shows: a fresh
1612
- // link's model, else the start page's (whose choice stays as it is).
1613
- // Toggles the fresh-link class (the CSS shows the button in a session
1614
- // only with it); the panel closes when the link changes or stops being fresh.
1615
- function syncModelPick() {
1616
- // The list walk only for a link that is fresh otherwise (the info bar
1617
- // calls this every second while a turn runs).
1618
- const fresh = !!selected && freshLink(current, { running: liveTurn.running })
1619
- && !chainNeighbours(allSessions, selected, { continues: current.continues }).next;
1620
- document.body.classList.toggle("fresh-link", fresh);
1621
- const linkId = fresh ? selected : null;
1622
- // Another session (or the start page), or the link stopped being fresh:
1623
- // a panel left open would come back open where it no longer belongs.
1624
- if (selected !== pickSelected || linkId !== pickLinkId) modelPicker.close();
1625
- pickSelected = selected;
1626
- pickLinkId = linkId;
1627
- if (fresh) {
1628
- // A pick in flight keeps its name until its /model is taken (its
1629
- // command_ran syncs again then).
1630
- if (!(pendingPick && pendingPickId === selected)) modelPicker.show(current.model);
1631
- modelPickEl.title = "The model this link starts on";
1632
- modelPickEl.setAttribute("aria-label", "Model this link starts on");
1633
- } else {
1634
- modelPickEl.title = startPickTitle;
1635
- modelPickEl.setAttribute("aria-label", "Model for the new session");
1636
- }
1637
- }
1638
- // Leaving a session: the start page's remembered model again (no fetch).
1639
- function restoreStartPick() {
1640
- modelPicker.restore(readStored(MODEL_KEY));
1641
- renderNewChatLlmContext();
1642
- renderNewChatThinking();
1643
- }
1644
- async function loadModels() {
1645
- let payload;
1646
- try {
1647
- payload = await listModels();
1648
- } catch (_) {
1649
- return;
1650
- }
1651
- const stored = readStored(MODEL_KEY);
1652
- if (!modelPicker.setModels(payload, stored)) return;
1653
- startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1654
- modelPickEl.hidden = false;
1655
- // A fresh link open meanwhile shows its own model again, not the stored one.
1656
- syncModelPick();
1657
- renderNewChatLlmContext();
1658
- renderNewChatThinking();
1659
- }
1660
- // The picker's choice for a create request; nothing (the server's default)
1661
- // while the list is not in.
1662
- function chosenModel() {
1663
- return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
1664
- }
1665
- actionBtn.addEventListener("click", () => {
1666
- if (!selected) handleCreate();
1667
- else handleSendTurn();
1668
- });
1377
+ actionBtn.addEventListener("click", () => handleSendTurn());
1669
1378
 
1670
1379
  // ── Images: paste or drop into the composer, chips until sent ─────────────
1671
1380
  const chipsEl = $("#chips");
@@ -1678,16 +1387,13 @@ function renderChips() {
1678
1387
  drawFoldControls();
1679
1388
  }
1680
1389
 
1390
+ // A chip that goes frees its preview; one kept in +list+ keeps it.
1681
1391
  function setChips(list) {
1682
- for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
1392
+ for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
1683
1393
  chips = list;
1684
1394
  renderChips();
1685
1395
  }
1686
1396
 
1687
- function clearChips() {
1688
- setChips([]);
1689
- }
1690
-
1691
1397
  // A paste's, a drop's or the image button's files: the images the message
1692
1398
  // may still take become chips (images.js admitImages); a toast says why the
1693
1399
  // rest stayed out. Whether any went in.
@@ -1806,7 +1512,8 @@ composerEl.addEventListener("drop", (e) => {
1806
1512
  const unqueue = createUnqueue({
1807
1513
  sessionId: () => selected,
1808
1514
  clientId,
1809
- toast: (text) => showToast(text),
1515
+ toast: (text, action, options) => showToast(text, action, options),
1516
+ copy: (text) => copyText(text),
1810
1517
  // Edit: the removed text back into an empty composer (never over a draft).
1811
1518
  putBack: (text) => {
1812
1519
  if (promptEl.value.trim() || chips.length) return false;
@@ -1891,7 +1598,7 @@ promptEl.addEventListener("keydown", (e) => {
1891
1598
  // An IME's Enter picks the composition, it doesn't send.
1892
1599
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1893
1600
  e.preventDefault();
1894
- if (!selected) handleCreate();
1601
+ if (!selected) handleSendTurn();
1895
1602
  // Alt/Option+Enter: queued for after the running turn (D6), only to a
1896
1603
  // worker that queues (else it would steer): the text stays, a note says why.
1897
1604
  else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
@@ -1899,81 +1606,12 @@ promptEl.addEventListener("keydown", (e) => {
1899
1606
  } else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
1900
1607
  }
1901
1608
  });
1902
- $("#infoStopBtn").addEventListener("click", async () => {
1903
- if (!selected) return;
1904
- if (!confirm(`Stop session ${selected}?`)) return;
1905
- const id = selected;
1906
- stoppingId = id;
1907
- try {
1908
- await stopSession(id);
1909
- if (id === selected) stoppedWorker(id);
1910
- } catch (e) {
1911
- // A refusal (a terminal holds it) or a worker that won't stop: said, as
1912
- // a failed restart is.
1913
- showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
1914
- } finally {
1915
- stoppingId = null;
1916
- }
1917
- // The list hears of the stop from the hub (the stop route rescans it).
1918
- if (!eventsControl) refresh();
1919
- });
1609
+ $("#infoStopBtn").addEventListener("click", () => sessionActions.stopSelected());
1920
1610
  $("#infoDeleteBtn").addEventListener("click", () => {
1921
1611
  if (selected) confirmDelete(selected);
1922
1612
  });
1923
1613
  $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1924
- $("#infoArchiveBtn").addEventListener("click", () => {
1925
- if (!selected) return;
1926
- if (selectedArchived()) setArchived(selected, false);
1927
- else setArchived(selected, true);
1928
- });
1929
-
1930
- // ── Continue (session chains) ───────────────────────────────────────────────
1931
- // Continue → starts the next link of the open session's chain (the server
1932
- // waits up to 30 s for its recap, archives it, starts the new one with the
1933
- // recap as a note) and opens it with the opener in the composer, not sent
1934
- // (fillOpener: the previous link's own first message when it continues one).
1935
- // A link continued already opens that next link; its open delegates move
1936
- // to the new link (the toast counts them); a refusal for open delegates
1937
- // that can't move names them, each a link.
1938
- // One continue per session at a time (the server locks it too); another
1939
- // session's continue may run beside it.
1940
- const continuingIds = new Set();
1941
-
1942
- async function continueSelected() {
1943
- if (!selected || continuingIds.has(selected)) return;
1944
- const id = selected;
1945
- const short = id.slice(0, 8);
1946
- continuingIds.add(id);
1947
- updateInfoBar();
1948
- try {
1949
- const created = await continueSession(id);
1950
- markArchived([id], true);
1951
- addCreatedSession(created);
1952
- const next = created.id;
1953
- // A model fallback's warning stays until closed.
1954
- const sticky = !!created.model_warning;
1955
- if (selected !== id) {
1956
- showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
1957
- return;
1958
- }
1959
- await select(next);
1960
- if (selected === next) fillOpener(created);
1961
- showToast(continuedText(id, created), null, { sticky });
1962
- } catch (e) {
1963
- const refusal = continueRefusal(e, allSessions);
1964
- if (refusal.kind === "open") {
1965
- if (selected === id) await select(refusal.id);
1966
- showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
1967
- } else if (refusal.kind === "children") {
1968
- showToast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
1969
- } else {
1970
- showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
1971
- }
1972
- } finally {
1973
- continuingIds.delete(id);
1974
- updateInfoBar();
1975
- }
1976
- }
1614
+ $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
1977
1615
 
1978
1616
  // The new link's composer: the opener (openerText: the previous link's own,
1979
1617
  // else the constant), unless a draft is there (it stays). +created+: the
@@ -1987,68 +1625,6 @@ function fillOpener(created) {
1987
1625
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1988
1626
  }
1989
1627
 
1990
- // ── Archive ────────────────────────────────────────────────────────────────
1991
- // Archive hides a session (and its delegates) from the strip and the list,
1992
- // kept for good; "include archived" in all sessions finds it. No confirm:
1993
- // the toast's Undo takes it back. Archiving the open session stays on it
1994
- // (the button flips to unarchive); its worker was stopped by the server.
1995
-
1996
- function selectedArchived() {
1997
- return !!allSessions.find((s) => s.id === selected)?.archived;
1998
- }
1999
-
2000
- // The ids in the server's answer flip here at once; the hub's events
2001
- // follow with the same.
2002
- function markArchived(ids, archived) {
2003
- const set = new Set(ids);
2004
- allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
2005
- }
2006
-
2007
- // One archive / unarchive request, the core the info bar, a card's button
2008
- // and a batch share. The ids in the answer flip in the list at once. When
2009
- // the open session's worker was stopped (it, or a delegate of the archived
2010
- // one), its stream ending is a stop, not a lost worker.
2011
- // The open session's worker was stopped from here (the stop button, an
2012
- // archive): don't wait for its stream to notice.
2013
- function stoppedWorker(id) {
2014
- streamControl?.close();
2015
- current.status = "stopped";
2016
- workerGone(id);
2017
- }
2018
-
2019
- // @return {ok: true, result} | {ok: false, error}
2020
- async function archiveOne(id, archive) {
2021
- // A live worker in the tree is stopped by the archive: the open
2022
- // session's stream ending then is a stop.
2023
- const mine = archive && selected && inTree(allSessions, id, selected) ? selected : null;
2024
- if (mine) stoppingId = mine;
2025
- try {
2026
- const result = archive ? await archiveSession(id) : await unarchiveSession(id);
2027
- markArchived(archive ? result.archived : result.unarchived, archive);
2028
- if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
2029
- return { ok: true, result };
2030
- } catch (error) {
2031
- return { ok: false, error };
2032
- } finally {
2033
- // Another call may have taken it meanwhile: clear only our own.
2034
- if (mine && stoppingId === mine) stoppingId = null;
2035
- }
2036
- }
2037
-
2038
- async function setArchived(id, archive, { undo = true } = {}) {
2039
- const short = id.slice(0, 8);
2040
- const answer = await archiveOne(id, archive);
2041
- if (!answer.ok) {
2042
- alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
2043
- return;
2044
- }
2045
- lists.render();
2046
- updateInfoBar();
2047
- if (!eventsControl) refresh();
2048
- const what = archive ? "Archived" : "Unarchived";
2049
- showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2050
- }
2051
-
2052
1628
  // The toast sits above the session's composer card (measured: it grows with
2053
1629
  // a wrapping footer or a taller box), so it never covers the card's
2054
1630
  // controls, and above the stage while it shows and a fresh link's open
@@ -2181,7 +1757,7 @@ if (document.body.dataset.sessionsDir) {
2181
1757
  renderScope();
2182
1758
  updateInfoBar();
2183
1759
  updateComposerMode();
2184
- loadModels();
1760
+ startPage.loadModels();
2185
1761
  // The list: the hub's stream when the browser has EventSource, else fetched.
2186
1762
  if (typeof globalThis.EventSource === "function") openSessionEvents();
2187
1763
  else refresh();