samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -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, isOpener, continueRefusal, continuedText, 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";
@@ -88,7 +90,6 @@ const allListEl = $("#allList");
88
90
  const historyEl = $("#history");
89
91
  const emptyEl = $("#empty");
90
92
  const composerEl = $("#composer");
91
- const infoBarEl = $("#infoBar");
92
93
  // The open session's title in the top bar, when no strip card in sight
93
94
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
94
95
  const sessionTitleEl = $("#sessionTitle");
@@ -291,6 +292,38 @@ const questionCards = createQuestionCards({
291
292
  removeHintIfEmpty: () => removeHintIfEmpty(),
292
293
  askingText: () => turnDom.askingText(),
293
294
  });
295
+ // The history's bubbles (bubbles.js): output, end lines, every client's
296
+ // prompts, the recap, warnings and notices, command and note bubbles.
297
+ const bubbles = createBubbles({
298
+ historyEl,
299
+ appendToHistory: (el) => appendToHistory(el),
300
+ isNearBottom: () => isNearBottom(),
301
+ turnDom,
302
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
303
+ shownTexts,
304
+ liveTurn,
305
+ selected: () => selected,
306
+ clientId,
307
+ });
308
+ const {
309
+ removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
310
+ showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
311
+ } = bubbles;
312
+ // The plugin cards and a snapshot's notices in the history (cards_place.js).
313
+ const cards = createCardsPlace({
314
+ historyEl,
315
+ appendToHistory: (el) => appendToHistory(el),
316
+ isNearBottom: () => isNearBottom(),
317
+ turnDom,
318
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
319
+ selected: () => selected,
320
+ clientId,
321
+ questionCards,
322
+ bubbles,
323
+ followWokenWorker: () => followWokenWorker(),
324
+ });
325
+ const { upsertCard, placeSnapshotCards, refreshCardBodies } = cards;
326
+ turnRoots().forEach((root) => root.addEventListener("click", cards.onCardAction));
294
327
  // The enqueued_ids this page sent (from the /turn acks): only these come back
295
328
  // into the composer when their turn fails.
296
329
  const sentIds = new Set();
@@ -639,7 +672,7 @@ function setLiveStatus(status) {
639
672
  const initRowEl = $("#initRow");
640
673
  const initTasks = newInitTasks();
641
674
  function renderInitRow() {
642
- initRowEl.innerHTML = initRowHtml(initTasks);
675
+ render(initRowTemplate(initTasks), initRowEl);
643
676
  initRowEl.hidden = initTasks.size === 0;
644
677
  }
645
678
  function onInitEvent(type, data) {
@@ -649,7 +682,8 @@ function onInitEvent(type, data) {
649
682
  const session = selected;
650
683
  setTimeout(() => {
651
684
  if (session !== selected) return;
652
- initRowEl.querySelector(`.init-task.done[data-id="${CSS.escape(done)}"]`)?.classList.add("fading");
685
+ fadeDone(initTasks, done);
686
+ renderInitRow();
653
687
  setTimeout(() => {
654
688
  if (session !== selected) return;
655
689
  dropDone(initTasks, done);
@@ -847,15 +881,6 @@ function renderSessionView(data, { kept = [] } = {}) {
847
881
  updateComposerMode();
848
882
  }
849
883
 
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
884
  function renderHistory(items) {
860
885
  if (!items.length) {
861
886
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
@@ -865,12 +890,7 @@ function renderHistory(items) {
865
890
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
866
891
  linkTextRefs();
867
892
  } 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
- }));
893
+ historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
874
894
  }
875
895
  historyEl.scrollTop = historyEl.scrollHeight;
876
896
  }
@@ -978,30 +998,6 @@ function atHistoryEnd() {
978
998
  return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
979
999
  }
980
1000
 
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
1001
  // Everything a turn's end (completed, canceled, failed) closes: then its
1006
1002
  // end line (+endText+, the cancel or failed line), then the step cards that
1007
1003
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
@@ -1025,349 +1021,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1025
1021
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
1026
1022
  }
1027
1023
 
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
1024
  // The info bar's "copy chi --attach": the full id, which the page shows
1372
1025
  // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1373
1026
  infoTextEl.addEventListener("click", (e) => {
@@ -1387,12 +1040,9 @@ infoTextEl.addEventListener("click", async (e) => {
1387
1040
  setTimeout(updateInfoBar, 2000);
1388
1041
  });
1389
1042
 
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).
1043
+ // The open session's worker badge (info_bar_model.js workerBadgeFor).
1392
1044
  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 });
1045
+ return workerBadgeFor(selected, current, versions);
1396
1046
  }
1397
1047
 
1398
1048
  // Restart: the session moves to a new worker on the newest chi; the page
@@ -1415,75 +1065,36 @@ async function restartWorker() {
1415
1065
  }
1416
1066
  }
1417
1067
 
1068
+ // The session bar (info_bar_model.js sessionBar): its buttons, and the
1069
+ // info line rendered by info_bar.js.
1418
1070
  function updateInfoBar() {
1071
+ const { buttons, bar } = sessionBar({
1072
+ id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1073
+ continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1074
+ now: Date.now(),
1075
+ });
1419
1076
  const stopBtn = document.getElementById("infoStopBtn");
1420
1077
  const deleteBtn = document.getElementById("infoDeleteBtn");
1421
1078
  const archiveBtn = document.getElementById("infoArchiveBtn");
1422
1079
  const continueBtn = document.getElementById("infoContinueBtn");
1423
- const chain = selected ? chainNeighbours(allSessions, selected, { continues: current.continues }) : { prev: null, next: null };
1424
1080
  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 →";
1081
+ continueBtn.style.display = buttons.continue.shown ? "" : "none";
1082
+ continueBtn.disabled = buttons.continue.disabled;
1083
+ continueBtn.textContent = buttons.continue.text;
1431
1084
  }
1432
- if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
1085
+ if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
1433
1086
  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";
1439
- }
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;
1087
+ archiveBtn.style.display = buttons.archive.shown ? "" : "none";
1088
+ archiveBtn.textContent = buttons.archive.text;
1089
+ archiveBtn.classList.toggle("archived", buttons.archive.archived);
1090
+ archiveBtn.title = buttons.archive.title;
1446
1091
  }
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
- });
1092
+ if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1093
+ // No session: the start page hides the info bar (the storage path is on
1094
+ // #/sessions). The session clock ticks every second while a turn runs:
1095
+ // lit writes its text alone, the bar's other nodes (a tooltip, a button
1096
+ // mid-press) stay.
1097
+ renderInfoBar(infoTextEl, bar);
1487
1098
  updateSessionTitle();
1488
1099
  }
1489
1100
 
@@ -1513,18 +1124,6 @@ function updateSessionTitle() {
1513
1124
  }
1514
1125
  }
1515
1126
 
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
1127
  // The stage takes a running turn's rows (its extras); else the history.
1529
1128
  function appendToHistory(el) {
1530
1129
  // (The stage's place first moves a turn still sliding away into the history.)
@@ -1705,12 +1304,6 @@ async function refreshTimingFromSession() {
1705
1304
  }
1706
1305
 
1707
1306
 
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
1307
  function closeStream() {
1715
1308
  turnDom.reset();
1716
1309
  if (streamControl) {
@@ -1897,30 +1490,6 @@ function clearComposer() {
1897
1490
  fitPrompt();
1898
1491
  }
1899
1492
 
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
1493
  async function handleCancel() {
1925
1494
  if (!selected) return;
1926
1495
  cancelBtn.disabled = true;