samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -23,31 +23,22 @@ 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, modelsFailedText } from "./model_pick.js";
39
32
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
- import { createModelPicker } from "./model_picker.js";
41
- import { admitImages, chipName, chipsAfterSend, 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
- import { createStageView, FOLD_MS } from "./stage_view.js";
39
+ import { createStageView } from "./stage_view.js";
48
40
  import { createMarkdownPlace } from "./markdown_place.js";
49
- import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
50
- import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
41
+ import { openerText, openTitle } from "./chains.js";
51
42
  import { createTaskStopper } from "./task_stop.js";
52
43
  import { appendQuote } from "./annotations.js";
53
44
  import { parsePresets } from "./annotate_presets.js";
@@ -63,12 +54,12 @@ import { createCommandList } from "./command_list.js";
63
54
  import { createPromptHistory } from "./prompt_history.js";
64
55
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
65
56
  import { scopeDir, scopeTemplate } from "./scope.js";
66
- import { html, nothing, render } from "./vendor/lit-html.js";
67
- import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
57
+ import { render } from "./vendor/lit-html.js";
58
+ import { shouldFollowScroll } from "./scroll.js";
68
59
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
69
60
  import { createStripGrip } from "./strip_grip.js";
70
61
  import { waitingOn } from "./notify.js";
71
- import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
62
+ import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
72
63
  import { createNotifications } from "./notifications.js";
73
64
  import { tabTitle, topBarTitle } from "./session_title.js";
74
65
  import { restartConfirmText, versionNotice } from "./update_notice.js";
@@ -82,17 +73,19 @@ import { linkRefs, touchedFiles } from "./ref_scan.js";
82
73
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
83
74
  import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
84
75
  import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
85
- import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
86
- import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
76
+ import { createComposerCommands } from "./composer.js";
87
77
  import { createStreamHandlers } from "./stream_handlers.js";
88
78
  import { createListsView } from "./lists_view.js";
89
79
  import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
90
80
  import { liveSlots } from "./stage_model.js";
91
81
  import { currentGen } from "./turn_model.js";
92
82
  import { toolName } from "./activity.js";
93
- import { toastBottom } from "./toast_place.js";
83
+ import { createDockReserve } from "./dock_reserve.js";
94
84
  import { createToasts } from "./toasts.js";
95
85
  import { createUnqueue } from "./unqueue.js";
86
+ import { createStartPage } from "./start_page.js";
87
+ import { createSessionActions } from "./session_actions.js";
88
+ import { createSendTurn } from "./send_turn.js";
96
89
 
97
90
  const $ = (s) => document.querySelector(s);
98
91
  const topStripEl = $("#topStrip");
@@ -122,26 +115,6 @@ const llmContextControl = createLlmContextControl({
122
115
  running: () => liveTurn.running,
123
116
  run: (line) => runSessionCommand(line),
124
117
  });
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
118
  // The open session's thinking chip, beside the llm ctx one: /thinking runs
146
119
  // in the session's worker.
147
120
  const thinkingControl = createThinkingControl({
@@ -160,41 +133,26 @@ const reasoningHistoryControl = createReasoningHistoryControl({
160
133
  running: () => liveTurn.running,
161
134
  run: (line) => runSessionCommand(line),
162
135
  });
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",
136
+ // The start page's model picker and its LLM context and thinking chips
137
+ // (start_page.js); a fresh chain link's picker too.
138
+ const startPage = createStartPage({
139
+ pickEl: $("#modelPick"),
140
+ panelEl: $("#modelPanel"),
141
+ searchEl: $("#modelSearch"),
142
+ listEl: $("#modelList"),
143
+ composerEl: $("#composer"),
144
+ centerEl: $("#center"),
145
+ llmContextEl: $("#llmContextNew"),
146
+ thinkingEl: $("#thinkingNew"),
147
+ selected: () => selected,
148
+ state: () => current,
149
+ running: () => liveTurn.running,
150
+ sessions: () => allSessions,
151
+ run: (line) => runSessionCommand(line),
152
+ focusPrompt: () => focusPrompt(),
153
+ toast: (text) => showToast(text),
154
+ listModels,
180
155
  });
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
156
  const markdownWarningEl = $("#markdownWarning");
199
157
  // What the page renders that a notice may stand in for (the server's
200
158
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
@@ -210,7 +168,6 @@ const cancelBtn = $("#cancelBtn");
210
168
  const attachBtn = $("#attachBtn");
211
169
  const attachInput = $("#attachInput");
212
170
  const infoCancelBtn = $("#infoCancelBtn");
213
- const modelPickEl = $("#modelPick");
214
171
  const composeHintEl = $("#composeHint");
215
172
 
216
173
  let selected = null;
@@ -260,6 +217,17 @@ const turnDom = view === "stage"
260
217
  renderMarkdown, linkRefs: linkRefsIn,
261
218
  })
262
219
  : createTurnView(viewArgs);
220
+ // The history's bottom reserve and the toast's place follow the composer
221
+ // card and the stage floating over the history (dock_reserve.js).
222
+ const { placeToast } = createDockReserve({
223
+ dockEl: $("#dock"),
224
+ centerEl: $("#center"),
225
+ stageEl: view === "stage" ? turnDom.root : null,
226
+ historyEl,
227
+ toastEl: $("#toast"),
228
+ modelPanelEl: $("#modelPanel"),
229
+ atHistoryEnd,
230
+ });
263
231
  // Where a running turn's rows can be: the history, and the stage while it
264
232
  // holds a turn (lookups by id, copy, annotate, card actions).
265
233
  function turnRoots() {
@@ -506,27 +474,28 @@ const lists = createListsView({
506
474
  leaveAllView: (hash) => leaveAllView(hash),
507
475
  });
508
476
 
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
- }
477
+ // Continue, Archive, Delete and Stop, and the start page when the open
478
+ // session is gone (session_actions.js).
479
+ const sessionActions = createSessionActions({
480
+ selected: () => selected,
481
+ state: () => current,
482
+ sessions: () => allSessions,
483
+ setSessions: (list) => { allSessions = list; },
484
+ api: { continueSession, archiveSession, unarchiveSession, deleteSession, stopSession },
485
+ eventsLive: () => !!eventsControl,
486
+ refresh,
487
+ lists,
488
+ select,
489
+ clearSelection,
490
+ showNotice,
491
+ updateInfoBar,
492
+ toast: showToast,
493
+ addCreatedSession,
494
+ fillOpener,
495
+ closeLiveStream: () => streamControl?.close(),
496
+ workerGone,
497
+ });
498
+ const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
530
499
 
531
500
  // All sessions: its own view at #/sessions, so the browser's back button
532
501
  // leaves it. Opened from the page, it closes with history.back().
@@ -616,24 +585,13 @@ function updateComposerMode() {
616
585
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
617
586
  hadSession = !hadSession;
618
587
  // Back on the start page: its remembered model, not the last link's.
619
- if (!hadSession) restoreStartPick();
588
+ if (!hadSession) startPage.restoreStartPick();
620
589
  fitPrompt();
621
590
  placeToast();
622
591
  }
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
- }
592
+ startPage.syncModelPick();
593
+ // The start page's choice was for a new session: opening a session drops it.
594
+ if (selected) startPage.dropChoices();
637
595
  const running = liveTurn.running && !!selected;
638
596
  cancelBtn.style.display = running ? "" : "none";
639
597
  // No images for a session a terminal owns (it takes no message from here);
@@ -768,13 +726,6 @@ function clearNotice() {
768
726
  }
769
727
  promptEl.addEventListener("input", clearNotice);
770
728
 
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
729
  async function select(id) {
779
730
  clearNotice();
780
731
  selected = id;
@@ -1088,7 +1039,7 @@ async function restartWorker() {
1088
1039
  function updateInfoBar() {
1089
1040
  const { buttons, bar } = sessionBar({
1090
1041
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1091
- continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1042
+ continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1092
1043
  now: Date.now(),
1093
1044
  });
1094
1045
  const stopBtn = document.getElementById("infoStopBtn");
@@ -1117,7 +1068,7 @@ function updateInfoBar() {
1117
1068
  updateSessionTitle();
1118
1069
  // A fresh link's picker follows what the bar follows: its model, its
1119
1070
  // owner, a next link continued from elsewhere.
1120
- syncModelPick();
1071
+ startPage.syncModelPick();
1121
1072
  }
1122
1073
 
1123
1074
  // The open session's title, once on screen (session_title.js): its strip
@@ -1214,10 +1165,6 @@ function startStream(id, fromSeq) {
1214
1165
  updateInfoBar();
1215
1166
  }
1216
1167
 
1217
- // The session this tab is stopping: its stream may close before the stop
1218
- // request returns.
1219
- let stoppingId = null;
1220
-
1221
1168
  // +opened+ false: the stream never delivered a frame (the proxy answered
1222
1169
  // 503, or the bridge refused it), so a retry waits a moment rather than
1223
1170
  // spinning through resync → stream → error.
@@ -1226,7 +1173,7 @@ function workerGone(id, { opened = true } = {}) {
1226
1173
  streamControl = null;
1227
1174
  if (id !== selected) return;
1228
1175
  // A turn that was running is over: its worker can't finish it.
1229
- const stopped = stoppingId === id;
1176
+ const stopped = sessionActions.stoppingId() === id;
1230
1177
  const endText = workerGoneText({ turnRunning: liveTurn.running, stopped });
1231
1178
  if (endText) {
1232
1179
  endTurnView("gone", endText, { stopped });
@@ -1287,38 +1234,19 @@ const streamHandlers = createStreamHandlers({
1287
1234
  promptEl.value = text;
1288
1235
  fitPrompt();
1289
1236
  },
1290
- setChips: (list) => setChips(list),
1237
+ setChips,
1291
1238
  },
1292
1239
  getSession,
1293
- resync: () => resync(),
1294
- updateInfoBar: () => updateInfoBar(),
1295
- applyPromptOps: (data) => applyPromptOps(data),
1296
- showSteers: (steers) => showSteers(steers),
1297
- markRecapStale: () => markRecapStale(),
1298
- setTurnRunning: (running) => setTurnRunning(running),
1299
- setLiveStatus: (status) => setLiveStatus(status),
1300
- endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
1301
- appendChunk: (text) => appendChunk(text),
1302
- readTurnEnd: () => readTurnEnd(),
1303
- applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
1304
- refreshTimingFromSession: () => refreshTimingFromSession(),
1305
- makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
1306
- addEndBubble: (text) => addEndBubble(text),
1307
- commandBubble: (id) => commandBubble(id),
1308
- addCommandBubble: (view) => addCommandBubble(view),
1309
- addNoteBubble: (note) => addNoteBubble(note),
1310
- findInTurnRoots: (selector) => findInTurnRoots(selector),
1311
- setUsedMemories: (names) => setUsedMemories(names),
1312
- showRecap: (text) => showRecap(text),
1313
- showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
1314
- showHookNotice: (data) => showHookNotice(data),
1315
- onInitEvent: (type, data) => onInitEvent(type, data),
1316
- upsertCard: (card) => upsertCard(card),
1317
- refreshCardBodies: () => refreshCardBodies(),
1318
- syncModelPick: () => {
1319
- if (pendingPick) pickRanEarly = true;
1320
- syncModelPick();
1321
- },
1240
+ // The page's hooks, passed as they are: function declarations (hoisted)
1241
+ // and the views' methods bound above.
1242
+ resync, updateInfoBar, setTurnRunning, setLiveStatus, endTurnView, readTurnEnd, refreshTimingFromSession,
1243
+ setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
1244
+ // bubbles.js
1245
+ applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
1246
+ addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
1247
+ // cards_place.js
1248
+ upsertCard, refreshCardBodies,
1249
+ syncModelPick: startPage.commandRan,
1322
1250
  });
1323
1251
 
1324
1252
  async function refreshTimingFromSession() {
@@ -1350,150 +1278,6 @@ function addCreatedSession(created) {
1350
1278
  lists.render();
1351
1279
  }
1352
1280
 
1353
- // A first message: an idle session, then the turn (or the command) into it
1354
- // as for any later one (handleSendTurn), so a failed first turn's prompt
1355
- // comes back into the composer too (sentIds).
1356
- function handleCreate() {
1357
- return handleSendTurn();
1358
- }
1359
-
1360
- // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1361
- // a delivery-line link: cut or queue); else the armed one; a typed /cut or
1362
- // /queue wins over both.
1363
- async function handleSendTurn({ delivery: chosen = null } = {}) {
1364
- if (current.owner === "tui") return;
1365
- // A fresh link's pick first: its /model is queued in the worker once
1366
- // acked, and the worker runs queued commands before a turn's input.
1367
- if (pendingPick) await pendingPick;
1368
- let prompt = turnText(promptEl.value, chips);
1369
- if (!prompt) return;
1370
- const armed = deliveryControl.take();
1371
- // An image-only send's text is its placeholders: not for ↑.
1372
- const typed = promptEl.value.trim() !== "";
1373
- // The composer holds the line as typed until the ack (composerAfterAck).
1374
- const line = prompt;
1375
- // Where the line goes (composer.js): the page's own reply, a session to
1376
- // make first (the start page), a command, or a turn.
1377
- let route = routeComposerLine(prompt, {
1378
- session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
1379
- });
1380
- if (route.create) {
1381
- actionBtn.disabled = true;
1382
- try {
1383
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
1384
- llm_context: choiceWords(newChatLlmContext),
1385
- thinking: newChatThinking || undefined });
1386
- // The choice was for this chat only.
1387
- newChatLlmContext = null;
1388
- renderNewChatLlmContext();
1389
- newChatThinking = null;
1390
- renderNewChatThinking();
1391
- addCreatedSession(s);
1392
- await select(s.id);
1393
- } catch (e) {
1394
- alert(e.message);
1395
- return;
1396
- } finally {
1397
- actionBtn.disabled = false;
1398
- }
1399
- // In the new session, by its command list (select read it).
1400
- route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
1401
- }
1402
- if (route.reply) {
1403
- // The start page's reply is a notice: no session is made for it (2.29).
1404
- if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
1405
- else showNotice(route.reply);
1406
- clearComposer();
1407
- return;
1408
- }
1409
- if (route.command) return composerCommands.send(route.command);
1410
- prompt = route.send;
1411
- const running = liveTurn.running && !!selected;
1412
- const delivery = route.delivery || (running ? chosen || armed : null);
1413
- const hint = historyEl.querySelector(".hint");
1414
- if (hint) hint.remove();
1415
- // Sending moves a finished turn out of the stage at once (else the echo
1416
- // would land among its rows); into a running one it is a queued row there.
1417
- turnDom.handOff?.({ immediate: true });
1418
- const sending = chips.slice();
1419
- // Into a running turn the echo says what it waits for (its delivery); a
1420
- // line with images always waits for the turn's end (the worker queues it).
1421
- const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
1422
- const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
1423
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1424
- if (!current.firstPreview || current.firstPreview==="—") {
1425
- current.firstPreview = firstPreview(prompt);
1426
- updateInfoBar();
1427
- }
1428
- actionBtn.disabled = true;
1429
- // The session the line goes to: the page may switch to another while an
1430
- // image uploads or the turn is sent, and what follows stays with this one.
1431
- const sentTo = selected;
1432
- try {
1433
- await uploadChips(sending, sentTo, { upload: uploadImage });
1434
- const ack = await sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1435
- if (ack?.command_id) {
1436
- // The worker knew it as a command (its list was newer than ours):
1437
- // its command_ran shows it, not a prompt bubble.
1438
- echo.remove();
1439
- clearComposer();
1440
- if (sentTo === selected) followWokenWorker();
1441
- return;
1442
- }
1443
- if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1444
- // A cut the worker didn't take (no thinking-only step: cut off) goes in
1445
- // at the next step; a queue whose turn ended meanwhile is the next turn.
1446
- if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
1447
- setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
1448
- }
1449
- refreshHistory();
1450
- // A turn that failed fast was restored before this ack: its prompt
1451
- // (and images) go back into the composer, without replacing text typed
1452
- // since (4.13). The composer still holds the text just sent: it goes.
1453
- const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1454
- // A restored line must be seen: the composer opens for this visit.
1455
- if (restored?.refill && sentTo === selected) openForVisit();
1456
- promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1457
- fitPrompt();
1458
- // Only the sent chips go: images added since stay (they go up at their own send).
1459
- const kept = chipsAfterSend(chips, sending);
1460
- setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
1461
- // Its turn_enqueued usually tagged the echo already; the ack covers a
1462
- // worker that wasn't live (no turn_enqueued then).
1463
- if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
1464
- echo.dataset.enqueuedId = ack.enqueued_id;
1465
- }
1466
- // A live stream carries this turn already; restarting it would drop the
1467
- // events in between. Without one, POST /turn just woke a worker: its
1468
- // events start after the seq the session was rendered at (0 for a new
1469
- // worker process). A switch since then opened the other session's own.
1470
- if (sentTo === selected) followWokenWorker();
1471
- } catch (e) {
1472
- echo.remove();
1473
- // The turn never reached the worker's queue: an early restore waiting
1474
- // for its ack is never taken back (4.13), so drop it here. The prompt
1475
- // is still in the composer, as the echo was removed.
1476
- dropEarlyRestores(earlyRestores);
1477
- // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1478
- // image refused (bad_image, too_large, bad_images) says why below.
1479
- if (e.code === "owned_by_tui") {
1480
- if (sentTo !== selected) return showToast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
1481
- current.owner = "tui";
1482
- updateComposerMode();
1483
- return;
1484
- }
1485
- // A stale tab sent into a session deleted meanwhile: the start page
1486
- // says so, and the line stays in the composer.
1487
- if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1488
- // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1489
- if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1490
- showToast(cantText("send", e), null, { sticky: true });
1491
- } finally {
1492
- actionBtn.disabled = current.owner === "tui";
1493
- focusPrompt();
1494
- }
1495
- }
1496
-
1497
1281
  // /model, !ls … from the composer, and a control's command (the llm ctx
1498
1282
  // and thinking chips): composer.js.
1499
1283
  const composerCommands = createComposerCommands({
@@ -1503,7 +1287,7 @@ const composerCommands = createComposerCommands({
1503
1287
  followWokenWorker: () => followWokenWorker(),
1504
1288
  refreshHistory: () => refreshHistory(),
1505
1289
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1506
- // As handleSendTurn says a turn's failure after a switch.
1290
+ // As send_turn.js says a turn's failure after a switch.
1507
1291
  failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1508
1292
  ownedByTui: () => {
1509
1293
  current.owner = "tui";
@@ -1521,6 +1305,51 @@ const composerCommands = createComposerCommands({
1521
1305
  },
1522
1306
  });
1523
1307
 
1308
+ // The composer's send (send_turn.js): a line into the open session, or a
1309
+ // new session first on the start page.
1310
+ const sendTurnControl = createSendTurn({
1311
+ session: () => selected,
1312
+ state: () => current,
1313
+ running: () => liveTurn.running,
1314
+ clientId,
1315
+ sentIds,
1316
+ earlyRestores,
1317
+ api: { createIdleSession, uploadImage, sendTurn },
1318
+ dir: scope.dir,
1319
+ startPage,
1320
+ takeDelivery: () => deliveryControl.take(),
1321
+ composer: {
1322
+ el: promptEl,
1323
+ button: actionBtn,
1324
+ chips: () => chips,
1325
+ setChips,
1326
+ fit: fitPrompt,
1327
+ clear: clearComposer,
1328
+ openForVisit: () => openForVisit(),
1329
+ focus: () => focusPrompt(),
1330
+ },
1331
+ historyEl,
1332
+ turnDom,
1333
+ makeUserBubble,
1334
+ bubbleById,
1335
+ addCommandBubble,
1336
+ addCreatedSession,
1337
+ select,
1338
+ showNotice,
1339
+ updateInfoBar,
1340
+ updateComposerMode,
1341
+ followWokenWorker,
1342
+ refreshHistory,
1343
+ leaveGoneSession,
1344
+ runCommand: (command) => composerCommands.send(command),
1345
+ toast: showToast,
1346
+ });
1347
+ // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
1348
+ // a delivery-line link: cut or queue); else the armed one.
1349
+ function handleSendTurn({ delivery = null } = {}) {
1350
+ return sendTurnControl.send({ delivery });
1351
+ }
1352
+
1524
1353
  function runSessionCommand(line) {
1525
1354
  return composerCommands.run(line);
1526
1355
  }
@@ -1552,132 +1381,10 @@ async function handleCancel() {
1552
1381
  $("#newBtn").addEventListener("click", () => {
1553
1382
  if (allViewOpen()) leaveAllView("");
1554
1383
  clearSelection();
1555
- loadModels({ asked: true });
1384
+ startPage.loadModels({ asked: true });
1556
1385
  });
1557
1386
 
1558
- // ── The start page's model picker ──────────────────────────────────────────
1559
- // The hosts' models from GET /api/models, the choice this browser made last
1560
- // preselected while it is still offered (else the server's default). Hidden
1561
- // until the list is in, and in a session (its model is in the info bar),
1562
- // but a fresh chain link's (chains.js freshLink): there it shows the link's
1563
- // model and a pick runs /model in the link, remembered neither as this
1564
- // browser's choice nor in Recent. A host that failed is the picker's
1565
- // tooltip; a route that fails leaves the picker hidden and the server's
1566
- // default in charge. The search list itself is model_picker.js'; a pick
1567
- // sends the focus to the message box.
1568
- const START_PICK_TITLE = "The model the new session starts on";
1569
- let startPickTitle = START_PICK_TITLE;
1570
- // The fresh link the picker shows (null: the start page, or a session it
1571
- // is hidden in), and a link pick's /model still in flight (a send waits).
1572
- let pickLinkId = null;
1573
- let pickSelected = null;
1574
- let pendingPick = null;
1575
- let pendingPickId = null;
1576
- // A command_ran came while the pick was in flight (its sync was skipped):
1577
- // the ack syncs then.
1578
- let pickRanEarly = false;
1579
- const modelPicker = createModelPicker({
1580
- button: modelPickEl,
1581
- panel: $("#modelPanel"),
1582
- input: $("#modelSearch"),
1583
- list: $("#modelList"),
1584
- composer: $("#composer"),
1585
- center: $("#center"),
1586
- remember: () => !selected,
1587
- onPick: (name) => {
1588
- if (selected) pickForLink(name);
1589
- else {
1590
- renderNewChatLlmContext();
1591
- renderNewChatThinking();
1592
- }
1593
- focusPrompt();
1594
- },
1595
- });
1596
- // A pick on a fresh link: /model in its worker (its command bubble and the
1597
- // info bar follow from command_ran); refused, the button goes back to the
1598
- // link's model, if the page is still on that link.
1599
- function pickForLink(name) {
1600
- const id = selected;
1601
- const run = runSessionCommand(`/model ${name}`).then((ok) => {
1602
- const ranEarly = pendingPick === run && pickRanEarly;
1603
- if (pendingPick === run) {
1604
- pendingPick = null;
1605
- pendingPickId = null;
1606
- pickRanEarly = false;
1607
- }
1608
- if ((!ok || ranEarly) && selected === id) syncModelPick();
1609
- return ok;
1610
- });
1611
- pendingPick = run;
1612
- pendingPickId = id;
1613
- pickRanEarly = false;
1614
- }
1615
- // The button's name, tooltip and label for what the page shows: a fresh
1616
- // link's model, else the start page's (whose choice stays as it is).
1617
- // Toggles the fresh-link class (the CSS shows the button in a session
1618
- // only with it); the panel closes when the link changes or stops being fresh.
1619
- function syncModelPick() {
1620
- // The list walk only for a link that is fresh otherwise (the info bar
1621
- // calls this every second while a turn runs).
1622
- const fresh = !!selected && freshLink(current, { running: liveTurn.running })
1623
- && !chainNeighbours(allSessions, selected, { continues: current.continues }).next;
1624
- document.body.classList.toggle("fresh-link", fresh);
1625
- const linkId = fresh ? selected : null;
1626
- // Another session (or the start page), or the link stopped being fresh:
1627
- // a panel left open would come back open where it no longer belongs.
1628
- if (selected !== pickSelected || linkId !== pickLinkId) modelPicker.close();
1629
- pickSelected = selected;
1630
- pickLinkId = linkId;
1631
- if (fresh) {
1632
- // A pick in flight keeps its name until its /model is taken (its
1633
- // command_ran syncs again then).
1634
- if (!(pendingPick && pendingPickId === selected)) modelPicker.show(current.model);
1635
- modelPickEl.title = "The model this link starts on";
1636
- modelPickEl.setAttribute("aria-label", "Model this link starts on");
1637
- } else {
1638
- modelPickEl.title = startPickTitle;
1639
- modelPickEl.setAttribute("aria-label", "Model for the new session");
1640
- }
1641
- }
1642
- // Leaving a session: the start page's remembered model again (no fetch).
1643
- function restoreStartPick() {
1644
- modelPicker.restore(readStored(MODEL_KEY));
1645
- renderNewChatLlmContext();
1646
- renderNewChatThinking();
1647
- }
1648
- // A reload cuts the fetch: its error is no news (modelsFailedText).
1649
- let pageLeaving = false;
1650
- addEventListener("beforeunload", () => { pageLeaving = true; });
1651
- addEventListener("pagehide", () => { pageLeaving = true; });
1652
- addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
1653
- // +asked+: + new session's load, for the start page it opens.
1654
- async function loadModels({ asked = false } = {}) {
1655
- let payload;
1656
- try {
1657
- payload = await listModels();
1658
- } catch (e) {
1659
- const text = modelsFailedText(e, { startPage: !selected, asked, leaving: pageLeaving });
1660
- if (text) showToast(text);
1661
- return;
1662
- }
1663
- const stored = readStored(MODEL_KEY);
1664
- if (!modelPicker.setModels(payload, stored)) return;
1665
- startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1666
- modelPickEl.hidden = false;
1667
- // A fresh link open meanwhile shows its own model again, not the stored one.
1668
- syncModelPick();
1669
- renderNewChatLlmContext();
1670
- renderNewChatThinking();
1671
- }
1672
- // The picker's choice for a create request; nothing (the server's default)
1673
- // while the list is not in.
1674
- function chosenModel() {
1675
- return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
1676
- }
1677
- actionBtn.addEventListener("click", () => {
1678
- if (!selected) handleCreate();
1679
- else handleSendTurn();
1680
- });
1387
+ actionBtn.addEventListener("click", () => handleSendTurn());
1681
1388
 
1682
1389
  // ── Images: paste or drop into the composer, chips until sent ─────────────
1683
1390
  const chipsEl = $("#chips");
@@ -1744,55 +1451,6 @@ promptEl.addEventListener("input", fitPrompt);
1744
1451
  window.addEventListener("resize", fitPrompt);
1745
1452
  fitPrompt();
1746
1453
 
1747
- // The card floats over the history: keep the history's bottom padding at the
1748
- // card's height, and stay pinned to the end if the reader was there, since the
1749
- // card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
1750
- // In the stage view the stage floats above the card, out of the dock's flow
1751
- // (stage_view.js): the reserve counts it too, with its 10 px gap, while it
1752
- // shows.
1753
- const dockEl = $("#dock");
1754
- const centerEl = $("#center");
1755
- // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
1756
- // down over the slide (index.html, #center.reserve-easing).
1757
- const stageEl = view === "stage" ? turnDom.root : null;
1758
- function dockReserve() {
1759
- const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
1760
- return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
1761
- }
1762
- const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
1763
- // A reader at the end stays there when the reserve changes: at once for the
1764
- // composer card, eased for the stage (+fromStage+: it appeared, grew or
1765
- // leaves), so the history glides rather than jumps. An eased follow running
1766
- // counts as at the end.
1767
- function updateReserve({ fromStage = false } = {}) {
1768
- const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
1769
- const leaving = !!stageEl?.classList.contains("leaving");
1770
- centerEl.classList.toggle("reserve-easing", leaving);
1771
- const reserve = `${dockReserve()}px`;
1772
- const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
1773
- if (changed) centerEl.style.setProperty("--dock-h", reserve);
1774
- if (changed && follow) {
1775
- if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
1776
- else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
1777
- }
1778
- placeToast();
1779
- }
1780
- const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
1781
- dockObserver.observe(dockEl);
1782
- if (stageEl) {
1783
- dockObserver.observe(stageEl);
1784
- // Only showing, hiding and leaving change the reserve (not the stage's
1785
- // other classes: waiting, in-use…).
1786
- let stageShown = "";
1787
- new MutationObserver(() => {
1788
- const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
1789
- if (shown === stageShown) return;
1790
- stageShown = shown;
1791
- updateReserve({ fromStage: true });
1792
- }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
1793
- }
1794
- window.addEventListener("resize", () => placeToast());
1795
-
1796
1454
  promptEl.addEventListener("paste", (e) => {
1797
1455
  const files = imageFiles(e.clipboardData);
1798
1456
  // Text pasted along with an image still lands in the textarea.
@@ -1901,7 +1559,7 @@ promptEl.addEventListener("keydown", (e) => {
1901
1559
  // An IME's Enter picks the composition, it doesn't send.
1902
1560
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
1903
1561
  e.preventDefault();
1904
- if (!selected) handleCreate();
1562
+ if (!selected) handleSendTurn();
1905
1563
  // Alt/Option+Enter: queued for after the running turn (D6), only to a
1906
1564
  // worker that queues (else it would steer): the text stays, a note says why.
1907
1565
  else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
@@ -1909,81 +1567,12 @@ promptEl.addEventListener("keydown", (e) => {
1909
1567
  } else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
1910
1568
  }
1911
1569
  });
1912
- $("#infoStopBtn").addEventListener("click", async () => {
1913
- if (!selected) return;
1914
- if (!confirm(`Stop session ${selected}?`)) return;
1915
- const id = selected;
1916
- stoppingId = id;
1917
- try {
1918
- await stopSession(id);
1919
- if (id === selected) stoppedWorker(id);
1920
- } catch (e) {
1921
- // A refusal (a terminal holds it) or a worker that won't stop: said, as
1922
- // a failed restart is.
1923
- showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
1924
- } finally {
1925
- stoppingId = null;
1926
- }
1927
- // The list hears of the stop from the hub (the stop route rescans it).
1928
- if (!eventsControl) refresh();
1929
- });
1570
+ $("#infoStopBtn").addEventListener("click", () => sessionActions.stopSelected());
1930
1571
  $("#infoDeleteBtn").addEventListener("click", () => {
1931
1572
  if (selected) confirmDelete(selected);
1932
1573
  });
1933
1574
  $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1934
- $("#infoArchiveBtn").addEventListener("click", () => {
1935
- if (!selected) return;
1936
- if (selectedArchived()) setArchived(selected, false);
1937
- else setArchived(selected, true);
1938
- });
1939
-
1940
- // ── Continue (session chains) ───────────────────────────────────────────────
1941
- // Continue → starts the next link of the open session's chain (the server
1942
- // waits up to 30 s for its recap, archives it, starts the new one with the
1943
- // recap as a note) and opens it with the opener in the composer, not sent
1944
- // (fillOpener: the previous link's own first message when it continues one).
1945
- // A link continued already opens that next link; its open delegates move
1946
- // to the new link (the toast counts them); a refusal for open delegates
1947
- // that can't move names them, each a link.
1948
- // One continue per session at a time (the server locks it too); another
1949
- // session's continue may run beside it.
1950
- const continuingIds = new Set();
1951
-
1952
- async function continueSelected() {
1953
- if (!selected || continuingIds.has(selected)) return;
1954
- const id = selected;
1955
- const short = id.slice(0, 8);
1956
- continuingIds.add(id);
1957
- updateInfoBar();
1958
- try {
1959
- const created = await continueSession(id);
1960
- markArchived([id], true);
1961
- addCreatedSession(created);
1962
- const next = created.id;
1963
- // A model fallback's warning stays until closed.
1964
- const sticky = !!created.model_warning;
1965
- if (selected !== id) {
1966
- showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
1967
- return;
1968
- }
1969
- await select(next);
1970
- if (selected === next) fillOpener(created);
1971
- showToast(continuedText(id, created), null, { sticky });
1972
- } catch (e) {
1973
- const refusal = continueRefusal(e, allSessions);
1974
- if (refusal.kind === "open") {
1975
- if (selected === id) await select(refusal.id);
1976
- showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
1977
- } else if (refusal.kind === "children") {
1978
- showToast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
1979
- } else {
1980
- showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
1981
- }
1982
- } finally {
1983
- continuingIds.delete(id);
1984
- updateInfoBar();
1985
- }
1986
- }
1575
+ $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
1987
1576
 
1988
1577
  // The new link's composer: the opener (openerText: the previous link's own,
1989
1578
  // else the constant), unless a draft is there (it stays). +created+: the
@@ -1997,97 +1586,7 @@ function fillOpener(created) {
1997
1586
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1998
1587
  }
1999
1588
 
2000
- // ── Archive ────────────────────────────────────────────────────────────────
2001
- // Archive hides a session (and its delegates) from the strip and the list,
2002
- // kept for good; "include archived" in all sessions finds it. No confirm:
2003
- // the toast's Undo takes it back. Archiving the open session stays on it
2004
- // (the button flips to unarchive); its worker was stopped by the server.
2005
-
2006
- function selectedArchived() {
2007
- return !!allSessions.find((s) => s.id === selected)?.archived;
2008
- }
2009
-
2010
- // The ids in the server's answer flip here at once; the hub's events
2011
- // follow with the same.
2012
- function markArchived(ids, archived) {
2013
- const set = new Set(ids);
2014
- allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
2015
- }
2016
-
2017
- // One archive / unarchive request, the core the info bar, a card's button
2018
- // and a batch share. The ids in the answer flip in the list at once. When
2019
- // the open session's worker was stopped (it, or a delegate of the archived
2020
- // one), its stream ending is a stop, not a lost worker.
2021
- // The open session's worker was stopped from here (the stop button, an
2022
- // archive): don't wait for its stream to notice.
2023
- function stoppedWorker(id) {
2024
- streamControl?.close();
2025
- current.status = "stopped";
2026
- workerGone(id);
2027
- }
2028
-
2029
- // @return {ok: true, result} | {ok: false, error}
2030
- async function archiveOne(id, archive) {
2031
- // A live worker in the tree is stopped by the archive: the open
2032
- // session's stream ending then is a stop.
2033
- const mine = archive && selected && inTree(allSessions, id, selected) ? selected : null;
2034
- if (mine) stoppingId = mine;
2035
- try {
2036
- const result = archive ? await archiveSession(id) : await unarchiveSession(id);
2037
- markArchived(archive ? result.archived : result.unarchived, archive);
2038
- if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
2039
- return { ok: true, result };
2040
- } catch (error) {
2041
- return { ok: false, error };
2042
- } finally {
2043
- // Another call may have taken it meanwhile: clear only our own.
2044
- if (mine && stoppingId === mine) stoppingId = null;
2045
- }
2046
- }
2047
-
2048
- async function setArchived(id, archive, { undo = true } = {}) {
2049
- const short = id.slice(0, 8);
2050
- const answer = await archiveOne(id, archive);
2051
- if (!answer.ok) {
2052
- alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
2053
- return;
2054
- }
2055
- lists.render();
2056
- updateInfoBar();
2057
- if (!eventsControl) refresh();
2058
- const what = archive ? "Archived" : "Unarchived";
2059
- showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2060
- }
2061
-
2062
- // The toast sits above the session's composer card (measured: it grows with
2063
- // a wrapping footer or a taller box), so it never covers the card's
2064
- // controls, and above the stage while it shows and a fresh link's open
2065
- // model panel (toast_place.js); with no card in sight (the start page, all
2066
- // sessions) the CSS's 24 px from the bottom.
2067
- function placeToast() {
2068
- const dock = document.getElementById("dock");
2069
- const r = dock.getBoundingClientRect();
2070
- const shownTop = (el) => {
2071
- const box = el && !el.hidden ? el.getBoundingClientRect() : null;
2072
- return box && box.height > 0 ? box.top : null;
2073
- };
2074
- const px = toastBottom({
2075
- inSession: document.body.classList.contains("has-session") && r.height > 0,
2076
- viewportHeight: window.innerHeight,
2077
- dockTop: r.top,
2078
- tops: [shownTop(document.getElementById("turnStage")), shownTop(document.getElementById("modelPanel"))],
2079
- toastHeight: document.getElementById("toast").offsetHeight,
2080
- });
2081
- const bottom = px === null ? "" : `${px}px`;
2082
- const root = document.documentElement.style;
2083
- if (root.getPropertyValue("--toast-bottom") !== bottom) {
2084
- if (bottom) root.setProperty("--toast-bottom", bottom);
2085
- else root.removeProperty("--toast-bottom");
2086
- }
2087
- }
2088
1589
  const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
2089
- // The model panel opening, closing or changing height (its list filters).
2090
- new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
2091
1590
 
2092
1591
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
2093
1592
 
@@ -2191,7 +1690,7 @@ if (document.body.dataset.sessionsDir) {
2191
1690
  renderScope();
2192
1691
  updateInfoBar();
2193
1692
  updateComposerMode();
2194
- loadModels();
1693
+ startPage.loadModels();
2195
1694
  // The list: the hub's stream when the browser has EventSource, else fetched.
2196
1695
  if (typeof globalThis.EventSource === "function") openSessionEvents();
2197
1696
  else refresh();