samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -1
@@ -32,47 +32,49 @@ import {
32
32
  } from "./thinking_chip.js";
33
33
  import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
34
34
  import { renderInfoBar } from "./info_bar.js";
35
+ import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
35
36
  import { createShownTexts } from "./shown_texts.js";
36
37
  import { MODEL_KEY } from "./model_pick.js";
37
38
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
39
  import { createModelPicker } from "./model_picker.js";
39
40
  import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
40
- import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
- import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
- import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
41
+ import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
42
+ import { createTurnView, turnHistoryNodes } from "./turn_view.js";
43
+ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
44
+ import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
45
+ import { createCardsPlace } from "./cards_place.js";
43
46
  import { createStageView, FOLD_MS } from "./stage_view.js";
44
- import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
47
+ import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
45
48
  import { createTaskStopper } from "./task_stop.js";
46
49
  import { appendQuote } from "./annotations.js";
47
50
  import { parsePresets } from "./annotate_presets.js";
48
51
  import { createAnnotateBar } from "./annotate_bar.js";
49
52
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
50
53
  import { copyText, installCopy } from "./copy.js";
51
- import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
52
54
  import { createQuestionCards } from "./question_cards.js";
53
- import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, splitSnapshotCards, turnNoticePlace } from "./card.js";
55
+ import { splitSnapshotCards } from "./card.js";
54
56
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
55
57
  import { createCommandList } from "./command_list.js";
56
58
  import { createPromptHistory } from "./prompt_history.js";
57
59
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
58
60
  import { scopeDir, scopeTemplate } from "./scope.js";
59
61
  import { html, nothing, render } from "./vendor/lit-html.js";
60
- import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
62
+ import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
61
63
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
62
- import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
64
+ import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
63
65
  import { createNotifications } from "./notifications.js";
64
66
  import { tabTitle, topBarTitle } from "./session_title.js";
65
- import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
67
+ import { restartConfirmText, versionNotice } from "./update_notice.js";
66
68
  import {
67
- appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
69
+ appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
70
  } from "./timing.js";
69
71
  import { createLiveTurn } from "./live_turn.js";
70
72
  import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
71
73
  import { installRefClicks } from "./refs.js";
72
74
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
- import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
74
- import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
75
- import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
75
+ import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
76
+ import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
77
+ import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
76
78
  import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
77
79
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
78
80
  import { createStreamHandlers } from "./stream_handlers.js";
@@ -81,6 +83,7 @@ import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
81
83
  import { liveSlots } from "./stage_model.js";
82
84
  import { currentGen } from "./turn_model.js";
83
85
  import { toolName } from "./activity.js";
86
+ import { toastBottom } from "./toast_place.js";
84
87
 
85
88
  const $ = (s) => document.querySelector(s);
86
89
  const topStripEl = $("#topStrip");
@@ -88,7 +91,6 @@ const allListEl = $("#allList");
88
91
  const historyEl = $("#history");
89
92
  const emptyEl = $("#empty");
90
93
  const composerEl = $("#composer");
91
- const infoBarEl = $("#infoBar");
92
94
  // The open session's title in the top bar, when no strip card in sight
93
95
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
94
96
  const sessionTitleEl = $("#sessionTitle");
@@ -117,7 +119,7 @@ const llmContextControl = createLlmContextControl({
117
119
  // drops it too, a failed create keeps it).
118
120
  const llmContextNewEl = $("#llmContextNew");
119
121
  let newChatLlmContext = null;
120
- const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
122
+ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
121
123
  const llmContextNewControl = createLlmContextControl({
122
124
  el: llmContextNewEl,
123
125
  summary: newChatLlmSummary,
@@ -153,7 +155,7 @@ const reasoningHistoryControl = createReasoningHistoryControl({
153
155
  // for the next new chat only (sent with its create, then dropped).
154
156
  const thinkingNewEl = $("#thinkingNew");
155
157
  let newChatThinking = null;
156
- const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
158
+ const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
157
159
  const thinkingNewControl = createThinkingControl({
158
160
  el: thinkingNewEl,
159
161
  summary: newChatThinkingSummary,
@@ -291,6 +293,38 @@ const questionCards = createQuestionCards({
291
293
  removeHintIfEmpty: () => removeHintIfEmpty(),
292
294
  askingText: () => turnDom.askingText(),
293
295
  });
296
+ // The history's bubbles (bubbles.js): output, end lines, every client's
297
+ // prompts, the recap, warnings and notices, command and note bubbles.
298
+ const bubbles = createBubbles({
299
+ historyEl,
300
+ appendToHistory: (el) => appendToHistory(el),
301
+ isNearBottom: () => isNearBottom(),
302
+ turnDom,
303
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
304
+ shownTexts,
305
+ liveTurn,
306
+ selected: () => selected,
307
+ clientId,
308
+ });
309
+ const {
310
+ removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
311
+ showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
312
+ } = bubbles;
313
+ // The plugin cards and a snapshot's notices in the history (cards_place.js).
314
+ const cards = createCardsPlace({
315
+ historyEl,
316
+ appendToHistory: (el) => appendToHistory(el),
317
+ isNearBottom: () => isNearBottom(),
318
+ turnDom,
319
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
320
+ selected: () => selected,
321
+ clientId,
322
+ questionCards,
323
+ bubbles,
324
+ followWokenWorker: () => followWokenWorker(),
325
+ });
326
+ const { upsertCard, placeSnapshotCards, refreshCardBodies } = cards;
327
+ turnRoots().forEach((root) => root.addEventListener("click", cards.onCardAction));
294
328
  // The enqueued_ids this page sent (from the /turn acks): only these come back
295
329
  // into the composer when their turn fails.
296
330
  const sentIds = new Set();
@@ -554,11 +588,13 @@ function updateComposerMode() {
554
588
  // In a session the box is one line: its minimum follows the class.
555
589
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
556
590
  hadSession = !hadSession;
591
+ // Back on the start page: its remembered model, not the last link's.
592
+ if (!hadSession) restoreStartPick();
557
593
  fitPrompt();
558
594
  placeToast();
559
595
  }
596
+ syncModelPick();
560
597
  if (selected) {
561
- modelPicker.close();
562
598
  llmContextNewControl.close();
563
599
  // The start page's choice was for a new chat: opening a session drops it.
564
600
  if (newChatLlmContext) {
@@ -615,6 +651,8 @@ function syncDeliveryLine() {
615
651
 
616
652
  function setTurnRunning(running) {
617
653
  liveTurn.running = !!running;
654
+ // A fresh link's picker goes now, not at the timing re-read after the turn.
655
+ if (running && selected) current.turnStarted = true;
618
656
  updateComposerMode();
619
657
  updateInfoBar();
620
658
  }
@@ -639,7 +677,7 @@ function setLiveStatus(status) {
639
677
  const initRowEl = $("#initRow");
640
678
  const initTasks = newInitTasks();
641
679
  function renderInitRow() {
642
- initRowEl.innerHTML = initRowHtml(initTasks);
680
+ render(initRowTemplate(initTasks), initRowEl);
643
681
  initRowEl.hidden = initTasks.size === 0;
644
682
  }
645
683
  function onInitEvent(type, data) {
@@ -649,7 +687,8 @@ function onInitEvent(type, data) {
649
687
  const session = selected;
650
688
  setTimeout(() => {
651
689
  if (session !== selected) return;
652
- initRowEl.querySelector(`.init-task.done[data-id="${CSS.escape(done)}"]`)?.classList.add("fading");
690
+ fadeDone(initTasks, done);
691
+ renderInitRow();
653
692
  setTimeout(() => {
654
693
  if (session !== selected) return;
655
694
  dropDone(initTasks, done);
@@ -847,15 +886,6 @@ function renderSessionView(data, { kept = [] } = {}) {
847
886
  updateComposerMode();
848
887
  }
849
888
 
850
- // The history's one line while it has nothing else (loading, an error,
851
- // no output yet).
852
- function hintEl(text) {
853
- const el = document.createElement("div");
854
- el.className = "hint";
855
- el.textContent = text;
856
- return el;
857
- }
858
-
859
889
  function renderHistory(items) {
860
890
  if (!items.length) {
861
891
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
@@ -865,12 +895,7 @@ function renderHistory(items) {
865
895
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
866
896
  linkTextRefs();
867
897
  } else {
868
- historyEl.replaceChildren(...items.map((c) => {
869
- const el = document.createElement("div");
870
- el.className = "bubble output";
871
- el.textContent = String(c);
872
- return el;
873
- }));
898
+ historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
874
899
  }
875
900
  historyEl.scrollTop = historyEl.scrollHeight;
876
901
  }
@@ -978,30 +1003,6 @@ function atHistoryEnd() {
978
1003
  return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
979
1004
  }
980
1005
 
981
- function appendChunk(text) {
982
- if (!text) return;
983
- const k = normalize(text);
984
- if (!k || shownTexts.shows(k)) return;
985
- const follow = isNearBottom();
986
- const hint = historyEl.querySelector(".hint");
987
- if (hint) hint.remove();
988
- const div = document.createElement("div");
989
- div.className = "bubble output";
990
- div.textContent = text;
991
- appendToHistory(div);
992
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
993
- }
994
-
995
- // +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
996
- // the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
997
- function addEndBubble(text, { stopped = false } = {}) {
998
- const div = document.createElement("div");
999
- div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
1000
- div.textContent = text;
1001
- appendToHistory(div);
1002
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1003
- }
1004
-
1005
1006
  // Everything a turn's end (completed, canceled, failed) closes: then its
1006
1007
  // end line (+endText+, the cancel or failed line), then the step cards that
1007
1008
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
@@ -1025,349 +1026,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1025
1026
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
1026
1027
  }
1027
1028
 
1028
- // ── Prompt bubbles ─────────────────────────────────────────────────────────
1029
- // Every client's prompts, keyed by enqueued_id: queued (badge) until their
1030
- // turn starts, or steered when merged into a running turn. This tab's own
1031
- // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
1032
-
1033
- // The bubble a reload draws (turn_html.js), with this page's own fields.
1034
- // +delivery+: how it went into a running turn (next_step, cut, queue): its
1035
- // queued badge says what it waits for.
1036
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
1037
- const hint = historyEl.querySelector(".hint");
1038
- if (hint) hint.remove();
1039
- const div = elementOf(userBubbleTemplate({ content: prompt }, {
1040
- label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1041
- }));
1042
- const follow = isNearBottom();
1043
- appendToHistory(div);
1044
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1045
- return div;
1046
- }
1047
-
1048
- function setBubbleState(bubble, state) {
1049
- bubble.classList.remove("queued", "steered", "failed");
1050
- bubble.querySelector(".state-badge")?.remove();
1051
- if (!BADGES[state]) {
1052
- delete bubble.dataset.userState;
1053
- return;
1054
- }
1055
- bubble.classList.add(state);
1056
- bubble.dataset.userState = state;
1057
- bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
1058
- }
1059
-
1060
- // A bubble's delivery changed (the ack's cut: off goes in at the next
1061
- // step; a queue to a turn that ended meanwhile is just the next turn): its
1062
- // badge follows.
1063
- function setBubbleDelivery(bubble, delivery) {
1064
- if ((bubble.dataset.delivery || null) === (delivery || null)) return;
1065
- if (delivery) bubble.dataset.delivery = delivery;
1066
- else delete bubble.dataset.delivery;
1067
- if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
1068
- }
1069
-
1070
- function bubbleById(enqueuedId) {
1071
- if (!enqueuedId) return null;
1072
- return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
1073
- (el) => el.dataset.enqueuedId === enqueuedId,
1074
- ) || null;
1075
- }
1076
-
1077
- // This tab's oldest untagged echo, preferring one with the same text.
1078
- function untaggedOwnBubble(prompt) {
1079
- const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
1080
- const text = normalize(prompt || "");
1081
- return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
1082
- }
1083
-
1084
- // Returns the bubble of the turn a turn_started starts (the stage takes it
1085
- // into its prompt line), else null.
1086
- function applyPromptOps(event) {
1087
- if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1088
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
1089
- if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
1090
- if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
1091
- let started = null;
1092
- for (const op of ops) {
1093
- if (op.op === "report") {
1094
- liveTurn.unmatchedMerge = "report";
1095
- } else if (op.op === "add") {
1096
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1097
- if (op.state === "started") started = bubble;
1098
- if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
1099
- } else if (op.op === "tag") {
1100
- if (bubbleById(op.enqueuedId)) continue;
1101
- const echo = untaggedOwnBubble(op.prompt);
1102
- if (echo) {
1103
- echo.dataset.enqueuedId = op.enqueuedId;
1104
- if (op.delivery) setBubbleDelivery(echo, op.delivery);
1105
- }
1106
- // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
1107
- else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1108
- } else if (op.op === "start") {
1109
- const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
1110
- // A resync (a new worker woken by this page's send) re-rendered the
1111
- // history and took the echo with it: draw the prompt again.
1112
- if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
1113
- else {
1114
- if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
1115
- setBubbleState(bubble, null);
1116
- started = bubble;
1117
- }
1118
- } else if (op.op === "steer") {
1119
- const bubble = bubbleById(op.enqueuedId);
1120
- if (bubble) setBubbleState(bubble, "steered");
1121
- else liveTurn.unmatchedMerge = true;
1122
- }
1123
- }
1124
- return started;
1125
- }
1126
-
1127
- // ── Idle recap ─────────────────────────────────────────────────────────────
1128
- // A summary the worker writes after a quiet stretch or as it leaves: shown at
1129
- // the end of the history. A turn starting leaves it in place, marked
1130
- // earlier; only a new recap replaces it.
1131
-
1132
- function showRecap(text, turnsSince = 0) {
1133
- if (!text) return;
1134
- removeRecap();
1135
- const details = document.createElement("details");
1136
- details.className = "bubble recap";
1137
- if (turnsSince > 0) details.classList.add("stale");
1138
- details.open = true;
1139
- const summary = document.createElement("summary");
1140
- summary.textContent = recapLabel(turnsSince);
1141
- const body = document.createElement("div");
1142
- body.className = "recap-body";
1143
- body.textContent = text;
1144
- details.appendChild(summary);
1145
- details.appendChild(body);
1146
- // A stale one sits where it was written, above the turns it doesn't cover.
1147
- // Never in the stage: a recap is between turns.
1148
- turnDom.settleFold?.();
1149
- const before = recapPlace(turnPromptBubbles(), turnsSince);
1150
- keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1151
- }
1152
-
1153
- // A turn started: the recap describes the chat before it.
1154
- function markRecapStale() {
1155
- historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
1156
- el.classList.add("stale");
1157
- const summary = el.querySelector("summary");
1158
- if (summary) summary.textContent = recapLabel(0, true);
1159
- });
1160
- }
1161
-
1162
- // A guardrail hook or rule file that failed to load (once per worker), or
1163
- // a bundle's plugin (+label+ "plugins").
1164
- // +before+: the element it goes above (a snapshot's), else the end.
1165
- function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
1166
- if (!text) return;
1167
- const el = document.createElement("div");
1168
- el.className = "bubble guardrail-warning";
1169
- el.textContent = `${label}: ${text}`;
1170
- const follow = isNearBottom();
1171
- if (before) historyEl.insertBefore(el, before);
1172
- else appendToHistory(el);
1173
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1174
- }
1175
-
1176
- // A hook's line to the user (event[:notify]): "<bundle>: text", in the
1177
- // warning colour for level warn.
1178
- // +before+: the element it goes above (a snapshot's notice), else the end.
1179
- // A plugin's steers (pending_input_merged's steers): a row of the running
1180
- // turn's step, else (no turn runs) a bubble.
1181
- function showSteers(steers) {
1182
- for (const steer of steers || []) {
1183
- if (turnDom.steer(steer)) continue;
1184
- removeHintIfEmpty();
1185
- const row = elementOf(steerRowTemplate(steer, { bubble: true }));
1186
- const follow = isNearBottom();
1187
- appendToHistory(row);
1188
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1189
- }
1190
- }
1191
-
1192
- function showHookNotice(data, { before = null } = {}) {
1193
- removeHintIfEmpty();
1194
- const el = document.createElement("div");
1195
- el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
1196
- el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1197
- if (data.title) el.title = data.title;
1198
- const follow = isNearBottom();
1199
- if (before) historyEl.insertBefore(el, before);
1200
- else appendToHistory(el);
1201
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1202
- }
1203
-
1204
- // ── plugin cards ───────────────────────────────────────────────────────────
1205
- // A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
1206
- // a row of the running turn's step. The same id updates the card in place.
1207
- // An action button runs its command line in the session (sendCommand), as
1208
- // if typed.
1209
-
1210
- function cardElement(id) {
1211
- if (!id) return null;
1212
- return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1213
- }
1214
-
1215
- // Show +card+: update the one with its id where it is, else add it (a
1216
- // running turn's card as a row of its step; else before +before+ or at the
1217
- // end of the history). A running turn's card with actions asks the user
1218
- // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
1219
- // block, not into a step that collapses when the next one starts.
1220
- function upsertCard(card, { before = null } = {}) {
1221
- if (!card || !card.id) return;
1222
- const existing = cardElement(card.id);
1223
- if (existing) {
1224
- const drawn = drawCard(existing, card);
1225
- drawn.classList.add("card-updated");
1226
- setTimeout(() => drawn.classList.remove("card-updated"), 900);
1227
- return;
1228
- }
1229
- removeHintIfEmpty();
1230
- const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
1231
- const asks = (card.actions || []).some((a) => a && a.command);
1232
- if (card.in_turn && !before && !asks && turnDom.card(el)) return;
1233
- const follow = isNearBottom();
1234
- if (before) historyEl.insertBefore(el, before);
1235
- else appendToHistory(el);
1236
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1237
- }
1238
-
1239
- // A snapshot's cards and notices, each above the first turn after it; a
1240
- // turn's own notice where it showed live (placeTurnNotice).
1241
- function placeSnapshotCards(entries) {
1242
- const users = turnPromptBubbles();
1243
- for (const entry of entries) {
1244
- const before = cardPlace(users, entry);
1245
- if (isCard(entry)) upsertCard(entry, { before });
1246
- else if (entry.type === "question") placeResolvedQuestion(entry, before);
1247
- else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
1248
- else if (entry.in_turn) placeTurnNotice(entry, before);
1249
- else if (entry.type === "hook_notice") showHookNotice(entry, { before });
1250
- }
1251
- }
1252
-
1253
- // A finished turn's row, a hook's notice or the loop's asking again
1254
- // (+before+: the next turn's prompt, or null for the last turn): a row of
1255
- // its step, above the row of the call it came before, as live; else (a
1256
- // plain answer, a step the history doesn't have) a bubble before the
1257
- // turn's answer, or before its timing line when it has none.
1258
- function placeTurnNotice(entry, before) {
1259
- const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
1260
- const line = noticeLine(entry);
1261
- const turnEls = reloadedTurnEls(before);
1262
- const place = turnNoticePlace(entry);
1263
- const block = turnEls.find((el) => el.matches(".turn-work"));
1264
- const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1265
- if (!step) {
1266
- showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
1267
- return;
1268
- }
1269
- const el = document.createElement("div");
1270
- el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
1271
- el.textContent = line;
1272
- if (extra.title) el.title = extra.title;
1273
- let body = step.querySelector(":scope > .activity-body");
1274
- if (!body) {
1275
- body = document.createElement("div");
1276
- body.className = "activity-body";
1277
- step.appendChild(body);
1278
- }
1279
- const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
1280
- body.insertBefore(el, row || null);
1281
- }
1282
-
1283
- // A prompt that started a turn: not a line steered into a running one.
1284
- const TURN_PROMPT = ".bubble.user:not(.steered)";
1285
-
1286
- // The history's turn prompts, oldest first (what turns_since counts).
1287
- function turnPromptBubbles() {
1288
- return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
1289
- }
1290
-
1291
- // The elements of the reloaded turn that ends before +before+ (the next
1292
- // turn's prompt, or null for the last turn), its prompt first.
1293
- function reloadedTurnEls(before) {
1294
- const turnEls = [];
1295
- for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1296
- turnEls.unshift(el);
1297
- if (el.matches(TURN_PROMPT)) break;
1298
- }
1299
- return turnEls;
1300
- }
1301
-
1302
- // What goes after a reloaded turn's own rows and cards: its answer, else
1303
- // its timing line; null when it has neither.
1304
- function turnAnswerEl(turnEls) {
1305
- return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1306
- }
1307
-
1308
- // A finished turn's question (the snapshot's cards keep it with its
1309
- // answer): the card as it was left live, resolved, before the lines the
1310
- // user sent into the turn after it (a steered bubble's data-step: the step
1311
- // that read it) and the turn's answer. A pending one is the running turn's
1312
- // (pending_question draws it).
1313
- function placeResolvedQuestion(entry, before) {
1314
- const pq = entry.pending_question;
1315
- if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1316
- const turnEls = reloadedTurnEls(before);
1317
- const asked = Number(entry.iteration) || 1;
1318
- // The reply that asked: its step's text in the reloaded block (the card
1319
- // keeps it, as live), never the running turn's.
1320
- const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
1321
- questionCards.renderQuestion(pq, { said });
1322
- const card = questionCards.questionCard();
1323
- if (!card || card.dataset.qid !== String(pq.id)) return;
1324
- questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1325
- const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1326
- historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1327
- }
1328
-
1329
- // A live card's body comes as text; the worker's snapshot (through the
1330
- // server) has it rendered. One re-read for a burst of cards.
1331
- let cardBodiesTimer = null;
1332
- function refreshCardBodies() {
1333
- clearTimeout(cardBodiesTimer);
1334
- const id = selected;
1335
- cardBodiesTimer = setTimeout(async () => {
1336
- try {
1337
- const data = await getSession(id, { cards: true });
1338
- if (id !== selected) return;
1339
- for (const card of data.cards || []) {
1340
- const el = card.type === "card" && cardElement(card.id);
1341
- if (el) refreshCardBody(el, card);
1342
- }
1343
- } catch (_) {}
1344
- }, 150);
1345
- }
1346
-
1347
- function onCardAction(e) {
1348
- const button = e.target.closest(".plugin-card .card-action");
1349
- if (!button || !selected) return;
1350
- const card = button.closest(".plugin-card");
1351
- const err = card.querySelector(".card-error");
1352
- err.classList.add("hidden");
1353
- button.disabled = true;
1354
- sendCommand(selected, button.dataset.command, { clientId, card: true })
1355
- .then(() => {
1356
- // The command woke the worker: follow its stream if none is open.
1357
- followWokenWorker();
1358
- })
1359
- .catch((error) => {
1360
- err.textContent = error.message;
1361
- err.classList.remove("hidden");
1362
- })
1363
- .finally(() => { button.disabled = false; });
1364
- }
1365
- turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
1366
-
1367
- function removeRecap() {
1368
- historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
1369
- }
1370
-
1371
1029
  // The info bar's "copy chi --attach": the full id, which the page shows
1372
1030
  // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1373
1031
  infoTextEl.addEventListener("click", (e) => {
@@ -1387,12 +1045,9 @@ infoTextEl.addEventListener("click", async (e) => {
1387
1045
  setTimeout(updateInfoBar, 2000);
1388
1046
  });
1389
1047
 
1390
- // The open session's worker runs an older chi than the newest installed:
1391
- // a badge, with Restart when the worker can (else the stop command).
1048
+ // The open session's worker badge (info_bar_model.js workerBadgeFor).
1392
1049
  function currentWorkerBadge() {
1393
- if (!selected || current.owner !== "worker") return null;
1394
- return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
1395
- installed: versions.installed, served: versions.served, sessionId: selected });
1050
+ return workerBadgeFor(selected, current, versions);
1396
1051
  }
1397
1052
 
1398
1053
  // Restart: the session moves to a new worker on the newest chi; the page
@@ -1415,76 +1070,40 @@ async function restartWorker() {
1415
1070
  }
1416
1071
  }
1417
1072
 
1073
+ // The session bar (info_bar_model.js sessionBar): its buttons, and the
1074
+ // info line rendered by info_bar.js.
1418
1075
  function updateInfoBar() {
1076
+ const { buttons, bar } = sessionBar({
1077
+ id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1078
+ continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1079
+ now: Date.now(),
1080
+ });
1419
1081
  const stopBtn = document.getElementById("infoStopBtn");
1420
1082
  const deleteBtn = document.getElementById("infoDeleteBtn");
1421
1083
  const archiveBtn = document.getElementById("infoArchiveBtn");
1422
1084
  const continueBtn = document.getElementById("infoContinueBtn");
1423
- const chain = selected ? chainNeighbours(allSessions, selected, { continues: current.continues }) : { prev: null, next: null };
1424
1085
  if (continueBtn) {
1425
- // A link continued already has its → link instead; a terminal's
1426
- // session is continued there.
1427
- continueBtn.style.display = selected && !chain.next && current.owner !== "tui" ? "" : "none";
1428
- const busy = !!selected && continuingIds.has(selected);
1429
- continueBtn.disabled = busy;
1430
- continueBtn.textContent = busy ? "continuing…" : "continue →";
1086
+ continueBtn.style.display = buttons.continue.shown ? "" : "none";
1087
+ continueBtn.disabled = buttons.continue.disabled;
1088
+ continueBtn.textContent = buttons.continue.text;
1431
1089
  }
1432
- if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
1090
+ if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
1433
1091
  if (archiveBtn) {
1434
- archiveBtn.style.display = selected ? "" : "none";
1435
- const archived = selectedArchived();
1436
- archiveBtn.textContent = archived ? "unarchive" : "archive";
1437
- archiveBtn.classList.toggle("archived", archived);
1438
- archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
1092
+ archiveBtn.style.display = buttons.archive.shown ? "" : "none";
1093
+ archiveBtn.textContent = buttons.archive.text;
1094
+ archiveBtn.classList.toggle("archived", buttons.archive.archived);
1095
+ archiveBtn.title = buttons.archive.title;
1439
1096
  }
1440
- if (!selected) {
1441
- // The start page hides the info bar (the storage path is on #/sessions).
1442
- renderInfoBar(infoTextEl, null);
1443
- if (stopBtn) stopBtn.style.display = "none";
1444
- updateSessionTitle();
1445
- return;
1446
- }
1447
- if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1448
- const tokens = current.timing.tokens;
1449
- const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
1450
- { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
1451
- budget: current.llmContext?.budget_tokens });
1452
- const speedTextNow = lastSpeedText(tokens);
1453
- const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1454
- const sessionDuration = currentSessionDuration();
1455
- // A delegated session names its parent, as a link to it.
1456
- const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
1457
- const badge = currentWorkerBadge();
1458
- // The session clock ticks every second while a turn runs: lit writes its
1459
- // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1460
- renderInfoBar(infoTextEl, {
1461
- id: selected,
1462
- copied: Date.now() - current.attachCopiedAt < 2000,
1463
- status: current.status,
1464
- model: modelLabel(current.model, ...(current.served || [])),
1465
- // A link of a session chain: ← the previous link, → the next one (each
1466
- // only while the list holds it).
1467
- chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
1468
- parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
1469
- // A parent names its delegates; the link opens all sessions.
1470
- kids: childrenSummary({ id: selected }, allSessions),
1471
- llmContext: current.llmContext,
1472
- thinking: current.thinking,
1473
- reasoningHistory: current.reasoningHistory,
1474
- // The tools chip (toolkit_chip.js): null on full.
1475
- toolkit: current.toolkit,
1476
- // The model notes the session's prompt carried.
1477
- notes: promptNotesChip(current.promptNotes),
1478
- memories: current.usedMemories,
1479
- preloaded: current.preloadedMemories,
1480
- muted: current.mutedMemories,
1481
- dir: current.dir,
1482
- ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1483
- speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1484
- clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1485
- worker: badge ? { badge, busy: restartingIds.has(selected) } : null,
1486
- });
1097
+ if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1098
+ // No session: the start page hides the info bar (the storage path is on
1099
+ // #/sessions). The session clock ticks every second while a turn runs:
1100
+ // lit writes its text alone, the bar's other nodes (a tooltip, a button
1101
+ // mid-press) stay.
1102
+ renderInfoBar(infoTextEl, bar);
1487
1103
  updateSessionTitle();
1104
+ // A fresh link's picker follows what the bar follows: its model, its
1105
+ // owner, a next link continued from elsewhere.
1106
+ syncModelPick();
1488
1107
  }
1489
1108
 
1490
1109
  // The open session's title, once on screen (session_title.js): its strip
@@ -1513,18 +1132,6 @@ function updateSessionTitle() {
1513
1132
  }
1514
1133
  }
1515
1134
 
1516
- // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
1517
- // shows it (info_bar.js chainLinkTemplate), or null.
1518
- function chainLink(link) {
1519
- if (!link) return null;
1520
- return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
1521
- archived: !!link.session?.archived };
1522
- }
1523
-
1524
- function currentSessionDuration() {
1525
- return sessionDurationNow(current.timing, liveTurn.running);
1526
- }
1527
-
1528
1135
  // The stage takes a running turn's rows (its extras); else the history.
1529
1136
  function appendToHistory(el) {
1530
1137
  // (The stage's place first moves a turn still sliding away into the history.)
@@ -1690,6 +1297,10 @@ const streamHandlers = createStreamHandlers({
1690
1297
  onInitEvent: (type, data) => onInitEvent(type, data),
1691
1298
  upsertCard: (card) => upsertCard(card),
1692
1299
  refreshCardBodies: () => refreshCardBodies(),
1300
+ syncModelPick: () => {
1301
+ if (pendingPick) pickRanEarly = true;
1302
+ syncModelPick();
1303
+ },
1693
1304
  });
1694
1305
 
1695
1306
  async function refreshTimingFromSession() {
@@ -1705,12 +1316,6 @@ async function refreshTimingFromSession() {
1705
1316
  }
1706
1317
 
1707
1318
 
1708
- function removeHintIfEmpty() {
1709
- const hint = historyEl.querySelector(".hint");
1710
- if (!hint) return;
1711
- // Remove the placeholder when the card will be the first content in history.
1712
- if (!historyEl.querySelector(".bubble")) hint.remove();
1713
- }
1714
1319
  function closeStream() {
1715
1320
  turnDom.reset();
1716
1321
  if (streamControl) {
@@ -1739,6 +1344,9 @@ function handleCreate() {
1739
1344
  // /queue wins over both.
1740
1345
  async function handleSendTurn({ delivery: chosen = null } = {}) {
1741
1346
  if (current.owner === "tui") return;
1347
+ // A fresh link's pick first: its /model is queued in the worker once
1348
+ // acked, and the worker runs queued commands before a turn's input.
1349
+ if (pendingPick) await pendingPick;
1742
1350
  let prompt = turnText(promptEl.value, chips);
1743
1351
  if (!prompt) return;
1744
1352
  const armed = deliveryControl.take();
@@ -1897,30 +1505,6 @@ function clearComposer() {
1897
1505
  fitPrompt();
1898
1506
  }
1899
1507
 
1900
- function addCommandBubble(view) {
1901
- removeHintIfEmpty();
1902
- const div = document.createElement("div");
1903
- // Page-only: kept across a resync of this session (keptCommandBubbles).
1904
- if (selected) div.dataset.keptFor = selected;
1905
- drawCommandBubble(div, view);
1906
- const follow = isNearBottom();
1907
- appendToHistory(div);
1908
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1909
- }
1910
-
1911
- function commandBubble(commandId) {
1912
- if (!commandId) return null;
1913
- return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
1914
- }
1915
-
1916
- function addNoteBubble(note) {
1917
- removeHintIfEmpty();
1918
- const div = elementOf(noteBubbleTemplate(note));
1919
- const follow = isNearBottom();
1920
- appendToHistory(div);
1921
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1922
- }
1923
-
1924
1508
  async function handleCancel() {
1925
1509
  if (!selected) return;
1926
1510
  cancelBtn.disabled = true;
@@ -1947,10 +1531,24 @@ $("#newBtn").addEventListener("click", () => {
1947
1531
  // ── The start page's model picker ──────────────────────────────────────────
1948
1532
  // The hosts' models from GET /api/models, the choice this browser made last
1949
1533
  // preselected while it is still offered (else the server's default). Hidden
1950
- // until the list is in, and in a session (its model is in the info bar). A
1951
- // host that failed is the picker's tooltip; a route that fails leaves the
1952
- // picker hidden and the server's default in charge. The search list itself
1953
- // is model_picker.js'; a pick sends the focus to the message box.
1534
+ // until the list is in, and in a session (its model is in the info bar),
1535
+ // but a fresh chain link's (chains.js freshLink): there it shows the link's
1536
+ // model and a pick runs /model in the link, remembered neither as this
1537
+ // browser's choice nor in Recent. A host that failed is the picker's
1538
+ // tooltip; a route that fails leaves the picker hidden and the server's
1539
+ // default in charge. The search list itself is model_picker.js'; a pick
1540
+ // sends the focus to the message box.
1541
+ const START_PICK_TITLE = "The model the new chat starts on";
1542
+ let startPickTitle = START_PICK_TITLE;
1543
+ // The fresh link the picker shows (null: the start page, or a session it
1544
+ // is hidden in), and a link pick's /model still in flight (a send waits).
1545
+ let pickLinkId = null;
1546
+ let pickSelected = null;
1547
+ let pendingPick = null;
1548
+ let pendingPickId = null;
1549
+ // A command_ran came while the pick was in flight (its sync was skipped):
1550
+ // the ack syncs then.
1551
+ let pickRanEarly = false;
1954
1552
  const modelPicker = createModelPicker({
1955
1553
  button: modelPickEl,
1956
1554
  panel: $("#modelPanel"),
@@ -1958,12 +1556,70 @@ const modelPicker = createModelPicker({
1958
1556
  list: $("#modelList"),
1959
1557
  composer: $("#composer"),
1960
1558
  center: $("#center"),
1961
- onPick: () => {
1962
- renderNewChatLlmContext();
1963
- renderNewChatThinking();
1559
+ remember: () => !selected,
1560
+ onPick: (name) => {
1561
+ if (selected) pickForLink(name);
1562
+ else {
1563
+ renderNewChatLlmContext();
1564
+ renderNewChatThinking();
1565
+ }
1964
1566
  promptEl.focus();
1965
1567
  },
1966
1568
  });
1569
+ // A pick on a fresh link: /model in its worker (its command bubble and the
1570
+ // info bar follow from command_ran); refused, the button goes back to the
1571
+ // link's model, if the page is still on that link.
1572
+ function pickForLink(name) {
1573
+ const id = selected;
1574
+ const run = runSessionCommand(`/model ${name}`).then((ok) => {
1575
+ const ranEarly = pendingPick === run && pickRanEarly;
1576
+ if (pendingPick === run) {
1577
+ pendingPick = null;
1578
+ pendingPickId = null;
1579
+ pickRanEarly = false;
1580
+ }
1581
+ if ((!ok || ranEarly) && selected === id) syncModelPick();
1582
+ return ok;
1583
+ });
1584
+ pendingPick = run;
1585
+ pendingPickId = id;
1586
+ pickRanEarly = false;
1587
+ }
1588
+ // The button's name, tooltip and label for what the page shows: a fresh
1589
+ // link's model, else the start page's (whose choice stays as it is).
1590
+ // Toggles the fresh-link class (the CSS shows the button in a session
1591
+ // only with it); the panel closes when the link changes or stops being fresh.
1592
+ function syncModelPick() {
1593
+ // The list walk only for a link that is fresh otherwise (the info bar
1594
+ // calls this every second while a turn runs).
1595
+ const fresh = !!selected && freshLink(current, { running: liveTurn.running })
1596
+ && !chainNeighbours(allSessions, selected, { continues: current.continues }).next;
1597
+ document.body.classList.toggle("fresh-link", fresh);
1598
+ const linkId = fresh ? selected : null;
1599
+ // Another session (or the start page), or the link stopped being fresh:
1600
+ // a panel left open would come back open where it no longer belongs.
1601
+ if (selected !== pickSelected || linkId !== pickLinkId) modelPicker.close();
1602
+ pickSelected = selected;
1603
+ pickLinkId = linkId;
1604
+ if (fresh) {
1605
+ // A pick in flight keeps its name until its /model is taken (its
1606
+ // command_ran syncs again then).
1607
+ if (!(pendingPick && pendingPickId === selected)) modelPicker.show(current.model);
1608
+ modelPickEl.title = "The model this link starts on";
1609
+ modelPickEl.setAttribute("aria-label", "Model this link starts on");
1610
+ } else {
1611
+ modelPickEl.title = startPickTitle;
1612
+ modelPickEl.setAttribute("aria-label", "Model for the new chat");
1613
+ }
1614
+ }
1615
+ // Leaving a session: the start page's remembered model again (no fetch).
1616
+ function restoreStartPick() {
1617
+ let stored = null;
1618
+ try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1619
+ modelPicker.restore(stored);
1620
+ renderNewChatLlmContext();
1621
+ renderNewChatThinking();
1622
+ }
1967
1623
  async function loadModels() {
1968
1624
  let payload;
1969
1625
  try {
@@ -1974,8 +1630,10 @@ async function loadModels() {
1974
1630
  let stored = null;
1975
1631
  try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1976
1632
  if (!modelPicker.setModels(payload, stored)) return;
1977
- modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
1633
+ startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1978
1634
  modelPickEl.hidden = false;
1635
+ // A fresh link open meanwhile shows its own model again, not the stored one.
1636
+ syncModelPick();
1979
1637
  renderNewChatLlmContext();
1980
1638
  renderNewChatThinking();
1981
1639
  }
@@ -2250,13 +1908,15 @@ async function continueSelected() {
2250
1908
  markArchived([id], true);
2251
1909
  addCreatedSession(created);
2252
1910
  const next = created.id;
1911
+ // A model fallback's warning stays until closed.
1912
+ const sticky = !!created.model_warning;
2253
1913
  if (selected !== id) {
2254
- showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
1914
+ showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
2255
1915
  return;
2256
1916
  }
2257
1917
  await select(next);
2258
1918
  if (selected === next) fillOpener();
2259
- showToast(continuedText(id, created));
1919
+ showToast(continuedText(id, created), null, { sticky });
2260
1920
  } catch (e) {
2261
1921
  const refusal = continueRefusal(e, allSessions);
2262
1922
  if (refusal.kind === "open") {
@@ -2343,22 +2003,32 @@ async function setArchived(id, archive, { undo = true } = {}) {
2343
2003
 
2344
2004
  // The toast sits above the session's composer card (measured: it grows with
2345
2005
  // a wrapping footer or a taller box), so it never covers the card's
2346
- // controls, and above the stage while it shows; with no card in sight (the
2347
- // start page, all sessions) the CSS's 24 px from the bottom.
2006
+ // controls, and above the stage while it shows and a fresh link's open
2007
+ // model panel (toast_place.js); with no card in sight (the start page, all
2008
+ // sessions) the CSS's 24 px from the bottom.
2348
2009
  function placeToast() {
2349
- const gap = 12;
2350
2010
  const dock = document.getElementById("dock");
2351
2011
  const r = dock.getBoundingClientRect();
2352
- const over = document.body.classList.contains("has-session") && r.height > 0;
2353
- const stage = document.getElementById("turnStage");
2354
- const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
2355
- const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
2012
+ const shownTop = (el) => {
2013
+ const box = el && !el.hidden ? el.getBoundingClientRect() : null;
2014
+ return box && box.height > 0 ? box.top : null;
2015
+ };
2016
+ const px = toastBottom({
2017
+ inSession: document.body.classList.contains("has-session") && r.height > 0,
2018
+ viewportHeight: window.innerHeight,
2019
+ dockTop: r.top,
2020
+ tops: [shownTop(document.getElementById("turnStage")), shownTop(document.getElementById("modelPanel"))],
2021
+ toastHeight: document.getElementById("toast").offsetHeight,
2022
+ });
2023
+ const bottom = px === null ? "" : `${px}px`;
2356
2024
  const root = document.documentElement.style;
2357
2025
  if (root.getPropertyValue("--toast-bottom") !== bottom) {
2358
2026
  if (bottom) root.setProperty("--toast-bottom", bottom);
2359
2027
  else root.removeProperty("--toast-bottom");
2360
2028
  }
2361
2029
  }
2030
+ // The model panel opening, closing or changing height (its list filters).
2031
+ new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
2362
2032
 
2363
2033
  // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2364
2034
  // words, or a template (chains.js openChildrenTemplate: a link in it, a
@@ -2370,8 +2040,8 @@ function showToast(text, action = null, { sticky = false } = {}) {
2370
2040
  render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2371
2041
  ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2372
2042
  : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2373
- placeToast();
2374
2043
  el.hidden = false;
2044
+ placeToast();
2375
2045
  clearTimeout(toastTimer);
2376
2046
  if (!sticky) toastTimer = setTimeout(hide, 6000);
2377
2047
  }