samagotchi 0.135.0 → 0.136.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/docs/cli.md +7 -6
  4. data/docs/configuration.md +10 -7
  5. data/docs/internals/prompt-caching.md +1 -1
  6. data/docs/internals/web-frontend.md +10 -9
  7. data/docs/internals/working-set.md +2 -2
  8. data/docs/sessions.md +3 -3
  9. data/docs/testing.md +11 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/version.rb +1 -1
  12. data/lib/samagotchi/web/app.rb +4 -4
  13. data/lib/samagotchi/web/message_display.rb +16 -0
  14. data/lib/samagotchi/web/public/answer_fold.js +110 -30
  15. data/lib/samagotchi/web/public/app.js +22 -40
  16. data/lib/samagotchi/web/public/bubbles.js +27 -7
  17. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  18. data/lib/samagotchi/web/public/details_motion.js +74 -0
  19. data/lib/samagotchi/web/public/history_reveal.js +2 -2
  20. data/lib/samagotchi/web/public/index.html +75 -54
  21. data/lib/samagotchi/web/public/info_bar.js +11 -12
  22. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  23. data/lib/samagotchi/web/public/level_chip.js +33 -95
  24. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  25. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  26. data/lib/samagotchi/web/public/revise_stack.js +9 -3
  27. data/lib/samagotchi/web/public/session_fields.js +4 -4
  28. data/lib/samagotchi/web/public/session_state.js +7 -5
  29. data/lib/samagotchi/web/public/session_store.js +1 -1
  30. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  31. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  32. data/lib/samagotchi/web/public/shell_command.js +26 -0
  33. data/lib/samagotchi/web/public/start_settings.js +25 -88
  34. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  35. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  36. data/lib/samagotchi/web/public/timing.js +13 -3
  37. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  38. data/lib/samagotchi/web/public/turn_block.js +6 -1
  39. data/lib/samagotchi/web/public/turn_events.js +1 -1
  40. data/lib/samagotchi/web/public/turn_html.js +16 -14
  41. data/lib/samagotchi/web/session_create.rb +1 -1
  42. data/lib/samagotchi/web/session_show.rb +6 -3
  43. metadata +5 -1
@@ -3,13 +3,22 @@
3
3
  // gets a fold row: a sibling just before the bubble, never inside it (the
4
4
  // bubble's copy text, its claims and a live end's innerHTML stay the
5
5
  // bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
6
- // no inline style) and the row reads its first line
7
- // and its size, "‹first line› · N lines ▸"; unfolded, the row stays as a
8
- // slim "▾ N lines" header.
6
+ // no inline style) and the row is the answer's box closed: "▸ ‹first
7
+ // line› · N lines" in the bubble's shape; unfolded, the row is that box's
8
+ // top edge, "▾ N lines", the bubble its rest (CSS joins the two). The box
9
+ // never changes shape, a click changes its height, animated
10
+ // (details_motion.js animateHeight) unless the viewer prefers no motion.
11
+ //
12
+ // A delegate report (a user line chi brought in, data-source
13
+ // "delegate_report") gets the same row, folded by default (it is written
14
+ // for the model; the reply under it is the user's summary): the row sits
15
+ // where the report sits and reads "delegate report · ‹first line› · N
16
+ // lines". Its key: a merged one's data-line-key, a wake turn's opening
17
+ // "<turn id>:prompt". A live merged report has no key until a reload.
9
18
  //
10
19
  // Which answers are folded is this browser's choice (localStorage through
11
- // storage.js): one key per session holding the folded answers' keys, the
12
- // last FOLD_KEEP of them. An answer's key is its turn's id
20
+ // storage.js): one key per session holding the folded answers' keys and
21
+ // the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
13
22
  // (data-answer-turn), a delegate report's reply its own (data-reply-key);
14
23
  // a bubble with neither (an answer before any prompt, a canceled turn's
15
24
  // partial) gets no row. The keys of sessions gone from the session list go
@@ -23,6 +32,7 @@
23
32
  // A module of its own: static JS is cached at unversioned URLs, and a new
24
33
  // export on an old module would blank the page.
25
34
  import { bubbleCopyText } from "./copy.js";
35
+ import { animateHeight, canAnimate } from "./details_motion.js";
26
36
  import { clip } from "./format.js";
27
37
  import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
28
38
 
@@ -36,7 +46,9 @@ export const FOLD_PREFIX = "chi_answer_fold:";
36
46
  const LABEL_CHARS = 160;
37
47
 
38
48
  const ROW_CLASS = "answer-fold";
39
- const ANSWERS = ".bubble.output";
49
+ // What gets a row: answers and delegate reports (user lines chi brought in).
50
+ const REPORTS = '.bubble.user[data-source="delegate_report"]';
51
+ const ANSWERS = `.bubble.output, ${REPORTS}`;
40
52
  // Not done yet: still streaming.
41
53
  const UNFINISHED = ".streaming";
42
54
 
@@ -71,12 +83,13 @@ export function foldLabel(text, { display = false } = {}) {
71
83
  return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
72
84
  }
73
85
 
74
- // The row's words: folded, the label and the size; unfolded, the size.
75
- export function foldRowText(text, { display = false, folded = true } = {}) {
86
+ // The row's words: folded, the label and the size; unfolded, the size
87
+ // alone (the content is right under it). +label+: the row's own (a
88
+ // report's, reportLabel), else the answer's.
89
+ export function foldRowText(text, { display = false, folded = true, label = foldLabel(text, { display }) } = {}) {
76
90
  const n = lineCount(text);
77
91
  const size = `${n} line${n === 1 ? "" : "s"}`;
78
92
  if (!folded) return { label: "", size };
79
- const label = foldLabel(text, { display });
80
93
  return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
81
94
  }
82
95
 
@@ -86,11 +99,17 @@ export function foldStoreKey(sessionId) {
86
99
  return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
87
100
  }
88
101
 
102
+ // A session's entry: {root, turns, open}. `turns` holds the answers folded
103
+ // by hand (and Revise stubs opened, revise_stack.js); `open` the delegate
104
+ // reports unfolded by hand (a report folds by default). An entry written
105
+ // before `open` reads as none unfolded.
106
+ const strings = (list) => (Array.isArray(list) ? list.filter((t) => typeof t === "string") : []);
107
+
89
108
  function readEntry(key) {
90
109
  try {
91
110
  const entry = JSON.parse(readStored(key, "null"));
92
- if (!entry || !Array.isArray(entry.turns)) return null;
93
- return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns.filter((t) => typeof t === "string") };
111
+ if (!entry || (!Array.isArray(entry.turns) && !Array.isArray(entry.open))) return null;
112
+ return { root: typeof entry.root === "string" ? entry.root : null, turns: strings(entry.turns), open: strings(entry.open) };
94
113
  } catch (_) {
95
114
  return null;
96
115
  }
@@ -103,6 +122,12 @@ export function readFolded(sessionId) {
103
122
  return (key && readEntry(key)?.turns) || [];
104
123
  }
105
124
 
125
+ // The session's delegate reports unfolded by hand, oldest first.
126
+ export function readOpened(sessionId) {
127
+ const key = foldStoreKey(sessionId);
128
+ return (key && readEntry(key)?.open) || [];
129
+ }
130
+
106
131
  // +list+ with +answerKey+ folded (moved to the end) or not, at most
107
132
  // FOLD_KEEP long. Pure.
108
133
  export function withFold(list, answerKey, folded) {
@@ -113,11 +138,23 @@ export function withFold(list, answerKey, folded) {
113
138
  // Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
114
139
  // project root, kept for the sweep of a project's list.
115
140
  export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
141
+ writeList(sessionId, "turns", answerKey, folded, root);
142
+ }
143
+
144
+ // Saves the report +reportKey+ unfolded (+open+) or not for +sessionId+.
145
+ export function writeOpened(sessionId, reportKey, open, { root = null } = {}) {
146
+ writeList(sessionId, "open", reportKey, open, root);
147
+ }
148
+
149
+ // +member+ in or out of the entry's +list+ ("turns" or "open"), the other
150
+ // list kept; the key goes when both are empty.
151
+ function writeList(sessionId, list, member, inList, root) {
116
152
  const key = foldStoreKey(sessionId);
117
- if (!key || !answerKey) return;
118
- const turns = withFold(readFolded(sessionId), answerKey, folded);
119
- if (!turns.length) removeStored(key);
120
- else writeStored(key, JSON.stringify({ root, turns }));
153
+ if (!key || !member) return;
154
+ const entry = readEntry(key) || { turns: [], open: [] };
155
+ const next = { turns: entry.turns, open: entry.open, [list]: withFold(entry[list], member, inList) };
156
+ if (!next.turns.length && !next.open.length) removeStored(key);
157
+ else writeStored(key, JSON.stringify({ root, turns: next.turns, ...(next.open.length ? { open: next.open } : {}) }));
121
158
  }
122
159
 
123
160
  // The session list loaded (+sessions+, each {id, project_root}): the fold
@@ -137,8 +174,17 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
137
174
 
138
175
  // ---- The rows ----
139
176
 
140
- // The key an answer bubble is folded by, or null.
177
+ const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
178
+
179
+ // The key an answer bubble is folded by, or null. A delegate report's: a
180
+ // merged one's line key (data-line-key, "<turn id>:input:<place>"), one
181
+ // that opened a wake turn its turn's id and ":prompt" (never the answer's
182
+ // key, the bare id).
141
183
  export function answerKey(bubble) {
184
+ if (isReport(bubble)) {
185
+ const turn = bubble.getAttribute("data-turn-id");
186
+ return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
187
+ }
142
188
  return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
143
189
  }
144
190
 
@@ -146,13 +192,30 @@ function answerText(bubble) {
146
192
  return bubbleCopyText(bubble);
147
193
  }
148
194
 
195
+ // A delegate report's lines before its body: "session: <id>",
196
+ // "status: <s>", "---".
197
+ const REPORT_HEADER = /^(?:(?:session|status):\s|---\s*$)/;
198
+
199
+ // A report row's label: its origin label (the bubble's) and its first
200
+ // heading or first line after the header.
201
+ export function reportLabel(text, origin = "") {
202
+ const body = String(text ?? "").replace(/\r\n?/g, "\n").split("\n").filter((l) => !REPORT_HEADER.test(l.trim())).join("\n");
203
+ const first = foldLabel(body);
204
+ return clip([origin.trim(), first].filter(Boolean).join(" · "), LABEL_CHARS);
205
+ }
206
+
149
207
  function drawRow(row, bubble) {
150
208
  const folded = bubble.classList.contains("folded");
151
209
  const text = answerText(bubble);
152
- const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
210
+ const report = isReport(bubble);
211
+ const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
212
+ const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded, ...own });
213
+ const what = report ? "report" : "answer";
153
214
  row.setAttribute("aria-expanded", folded ? "false" : "true");
154
- row.title = folded ? "Show the answer" : "Fold the answer";
215
+ row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
155
216
  row.classList.toggle("folded", folded);
217
+ row.classList.toggle("report", report);
218
+ row.classList.toggle("steered", report && bubble.classList.contains("steered"));
156
219
  const [labelEl, sizeEl] = row.children;
157
220
  if (labelEl.textContent !== label) labelEl.textContent = label;
158
221
  if (sizeEl.textContent !== size) sizeEl.textContent = size;
@@ -186,8 +249,9 @@ export function keepRowInView(scroller, row, mutate) {
186
249
  // @param historyEl the history (#history), also its scroller
187
250
  // @param sessionId () => the open session's id
188
251
  // @param projectRoot () => the open session's project root, or null
252
+ // @param motion (bubble) => whether a fold or unfold animates (canAnimate)
189
253
  // @return {decorate(root)} (the observer runs it; specs call it)
190
- export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
254
+ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null, motion = canAnimate }) {
191
255
  // Each bubble's row, and each row's bubble.
192
256
  const rows = new WeakMap();
193
257
  const bubbleOf = new WeakMap();
@@ -202,14 +266,18 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
202
266
  rows.set(bubble, row);
203
267
  bubbleOf.set(row, bubble);
204
268
  // The stored choice, once: after that the bubble's class is the state
205
- // (a jump that unfolds it, history_reveal.js, isn't saved).
206
- if (stored().includes(key)) bubble.classList.add("folded");
269
+ // (a jump that unfolds it, history_reveal.js, isn't saved). An answer
270
+ // is open unless folded by hand, a report folded unless opened.
271
+ const folded = isReport(bubble) ? !stored().open.includes(key) : stored().turns.includes(key);
272
+ if (folded) bubble.classList.add("folded");
207
273
  }
208
274
  if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
209
275
  drawRow(row, bubble);
210
276
  }
211
277
 
212
- // Rows whose bubble left (a re-render, a replaced bubble) go with it.
278
+ // Rows whose bubble left (a re-render, a replaced bubble) go with it; a
279
+ // row something was placed after (a card or a recap placed before a
280
+ // report that opens its turn) goes back just before its bubble.
213
281
  function dropOrphans() {
214
282
  for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
215
283
  const bubble = bubbleOf.get(row);
@@ -219,8 +287,8 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
219
287
  }
220
288
 
221
289
  function decorate(root) {
222
- let folded = null;
223
- const stored = () => (folded ??= readFolded(sessionId()));
290
+ let entry = null;
291
+ const stored = () => (entry ??= { turns: readFolded(sessionId()), open: readOpened(sessionId()) });
224
292
  if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
225
293
  for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
226
294
  }
@@ -229,18 +297,22 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
229
297
  // the same frame as its bubble, so nothing below it shifts after a paint.
230
298
  new MutationObserver((records) => {
231
299
  const roots = new Set();
232
- let removed = false;
300
+ let settle = false;
233
301
  for (const r of records) {
234
302
  if (r.type === "attributes") {
235
303
  if (r.target.matches?.(ANSWERS)) roots.add(r.target);
236
304
  continue;
237
305
  }
238
- if (r.removedNodes.length) removed = true;
306
+ if (r.removedNodes.length) settle = true;
239
307
  const answer = r.target.closest?.(ANSWERS);
240
308
  if (answer) roots.add(answer);
241
- for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
309
+ for (const n of r.addedNodes) {
310
+ if (n.nodeType !== 1 || n.classList.contains(ROW_CLASS)) continue;
311
+ roots.add(n);
312
+ if (r.target === historyEl) settle = true;
313
+ }
242
314
  }
243
- if (removed) dropOrphans();
315
+ if (settle) dropOrphans();
244
316
  for (const el of roots) if (el.isConnected) decorate(el);
245
317
  }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
246
318
  decorate(historyEl);
@@ -251,11 +323,19 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
251
323
  if (!bubble) return;
252
324
  e.preventDefault();
253
325
  const folded = !bubble.classList.contains("folded");
254
- keepRowInView(historyEl, row, () => {
326
+ const settle = () => keepRowInView(historyEl, row, () => {
255
327
  bubble.classList.toggle("folded", folded);
256
328
  drawRow(row, bubble);
257
329
  });
258
- writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
330
+ if (!motion(bubble)) settle();
331
+ else if (folded) animateHeight(bubble, bubble.offsetHeight, 0, { fade: true }).then(settle);
332
+ else {
333
+ settle();
334
+ animateHeight(bubble, 0, bubble.offsetHeight, { fade: true });
335
+ }
336
+ // An answer's choice is its fold, a report's its unfold (D2: the default differs).
337
+ if (isReport(bubble)) writeOpened(sessionId(), answerKey(bubble), !folded, { root: projectRoot() });
338
+ else writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
259
339
  });
260
340
 
261
341
  return { decorate };
@@ -35,10 +35,8 @@ import {
35
35
  import { authProbe, createAuthLost } from "./auth_lost.js";
36
36
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
37
37
  import { createContextChips } from "./context_chips.js";
38
- import { createLlmContextControl } from "./llm_context_chip.js";
39
38
  import { createParticipantsControl } from "./participants_panel.js";
40
- import { createThinkingControl } from "./thinking_chip.js";
41
- import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
39
+ import { createSettingsControl } from "./settings_chip.js";
42
40
  import { renderInfoBar } from "./info_bar.js";
43
41
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
44
42
  import { createShownTexts } from "./shown_texts.js";
@@ -74,6 +72,7 @@ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
74
72
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
75
73
  import { copyText, installCopy } from "./copy.js";
76
74
  import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
75
+ import { installDetailsMotion } from "./details_motion.js";
77
76
  import { answerLabel, installReviseStacks, reviseChipTemplate, reviseWorkerUnavailable } from "./revise_stack.js";
78
77
  import { createQuestionCards } from "./question_cards.js";
79
78
  import { splitSnapshotCards } from "./card.js";
@@ -155,29 +154,13 @@ const contextChips = createContextChips({
155
154
  api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
156
155
  toast: (text) => showToast(text),
157
156
  });
158
- // The open session's LLM context chip in the info bar, and its popover:
159
- // /llm-context runs in the session's worker.
160
- const llmContextControl = createLlmContextControl({
157
+ // The open session's ⚙ chip in the info bar, and its panel (thinking,
158
+ // reasoning history, LLM context): a pick runs its command in the session's
159
+ // worker.
160
+ const settingsControl = createSettingsControl({
161
161
  el: infoTextEl,
162
- summary: () => (store.selected ? store.current.llmContext : null),
163
- session: () => store.selected,
164
- running: () => liveTurn.running,
165
- run: (line) => runSessionCommand(line),
166
- });
167
- // The open session's thinking chip, beside the llm ctx one: /thinking runs
168
- // in the session's worker.
169
- const thinkingControl = createThinkingControl({
170
- el: infoTextEl,
171
- summary: () => (store.selected ? store.current.thinking : null),
172
- session: () => store.selected,
173
- running: () => liveTurn.running,
174
- run: (line) => runSessionCommand(line),
175
- });
176
- // The open session's reasoning history chip, after the thinking one (a
177
- // chat host's only): /reasoning-history runs in the session's worker.
178
- const reasoningHistoryControl = createReasoningHistoryControl({
179
- el: infoTextEl,
180
- summary: () => (store.selected ? store.current.reasoningHistory : null),
162
+ summaries: () => (store.selected ? { thinking: store.current.thinking, reasoningHistory: store.current.reasoningHistory,
163
+ llmContext: store.current.llmContext } : null),
181
164
  session: () => store.selected,
182
165
  running: () => liveTurn.running,
183
166
  run: (line) => runSessionCommand(line),
@@ -1191,14 +1174,15 @@ function renderSessionView(data, { kept = [] } = {}) {
1191
1174
  const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
1192
1175
  if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
1193
1176
  if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
1194
- // The recap saved with the session (also a stopped one's), else a live
1195
- // worker's from before saved_recap existed.
1196
- if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
1197
- else showRecap(data.recap);
1198
1177
  // A live worker's last cards and between-turns notices, where they
1199
1178
  // arrived; the running turn's go into its step once it is drawn.
1200
1179
  const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
1201
1180
  placeSnapshotCards(snapshotCards.placed);
1181
+ // The recap saved with the session (also a stopped one's), else a live
1182
+ // worker's from before saved_recap existed. After the cards: the last
1183
+ // turn's check-in notice goes to the end, and the recap closes the page.
1184
+ if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
1185
+ else showRecap(data.recap);
1202
1186
  const replay = snapshotEvents({
1203
1187
  current_turn: data.current_turn,
1204
1188
  queued: data.queued,
@@ -1373,8 +1357,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1373
1357
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
1374
1358
  }
1375
1359
 
1376
- // The info bar's "copy chi --attach": the full id, which the page shows
1377
- // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1378
1360
  infoTextEl.addEventListener("click", (e) => {
1379
1361
  if (e.target.closest(".worker-restart")) restartWorker();
1380
1362
  // Pushed as openAllView does, so the view's back returns here.
@@ -1383,12 +1365,10 @@ infoTextEl.addEventListener("click", (e) => {
1383
1365
  openAllView();
1384
1366
  }
1385
1367
  });
1368
+ // The info bar's id: a click copies the full id, which the bar shows only short.
1386
1369
  infoTextEl.addEventListener("click", async (e) => {
1387
- if (!e.target.closest(".copy-attach") || !store.selected) return;
1388
- const id = store.selected;
1389
- if (!(await copyText(`chi --attach ${id}`)) || id !== store.selected) return;
1390
- store.update({ current: { attachCopiedAt: Date.now() } });
1391
- setTimeout(() => store.touch("infoBar"), 2000);
1370
+ if (!e.target.closest(".id") || !store.selected) return;
1371
+ showToast((await copyText(store.selected)) ? "Session id copied" : "Couldn't copy the session id");
1392
1372
  });
1393
1373
 
1394
1374
  // Apply (review_apply.js) under a participant's review turn that just
@@ -1503,7 +1483,7 @@ function updateInfoBar() {
1503
1483
  const { buttons, bar } = sessionBar({
1504
1484
  id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
1505
1485
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1506
- now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1486
+ workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1507
1487
  });
1508
1488
  const stopBtn = document.getElementById("infoStopBtn");
1509
1489
  const deleteBtn = document.getElementById("infoDeleteBtn");
@@ -2106,6 +2086,10 @@ historyEl.addEventListener("click", (e) => {
2106
2086
  });
2107
2087
  turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
2108
2088
  turnRoots().forEach((root) => installCopy(root));
2089
+ // A steps block, a thinking block and the recap open and close by hand
2090
+ // with their height animated (details_motion.js), in the history and on
2091
+ // the stage.
2092
+ installDetailsMotion({ root: document.body, selector: ".turn-work, .bubble.thinking, .bubble.recap" });
2109
2093
  // Long answers fold by hand (answer_fold.js), per viewer and session.
2110
2094
  installAnswerFold({
2111
2095
  historyEl,
@@ -2193,9 +2177,7 @@ document.addEventListener("keydown", (e) => {
2193
2177
  // before it (none at load) would redraw nothing.
2194
2178
  store.on("lists", () => lists.render());
2195
2179
  store.on("paintActive", () => lists.paintActive());
2196
- store.on("llmContextChip", () => llmContextControl.refresh());
2197
- store.on("thinkingChip", () => thinkingControl.refresh());
2198
- store.on("reasoningChip", () => reasoningHistoryControl.refresh());
2180
+ store.on("settingsPanel", () => settingsControl.refresh());
2199
2181
  // The working set's list offers pin only under forget.
2200
2182
  store.on("workingSetStrategy", () => workingSet?.strategyChanged());
2201
2183
  store.on("featureGates", () => featureGates.check(store.current));
@@ -14,6 +14,11 @@ import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
14
14
  import { drawCommandBubble } from "./commands.js";
15
15
  import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
16
16
 
17
+ // What a session's load places at a turn's prompt from the snapshot
18
+ // (cards_place.js placeSnapshotCards): its cards, answered questions,
19
+ // notices and warnings.
20
+ const SNAPSHOT_PLACED = ".plugin-card, .bubble.question, .bubble.hook-notice, .bubble.guardrail-warning";
21
+
17
22
  export function removeQueuedBubble(bubble) {
18
23
  if (!bubble) return;
19
24
  const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
@@ -159,10 +164,11 @@ export function createBubbles({
159
164
 
160
165
  // The bubble a reload draws (turn_html.js), with this page's own fields.
161
166
  // +delivery+: how it went into a running turn (next_step, cut, queue): its
162
- // queued badge says what it waits for.
163
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null, author = null, turnId = null } = {}) {
167
+ // queued badge says what it waits for. +source+: a delegate report's
168
+ // "delegate_report" (its fold row, answer_fold.js).
169
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null, author = null, turnId = null, source = null } = {}) {
164
170
  removeHint();
165
- const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId }, {
171
+ const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId, source }, {
166
172
  label, state, own, enqueuedId, delivery, author, key: normalize(prompt), thumbs: thumbsTemplate(selected(), images),
167
173
  }));
168
174
  return placeFollowing(div);
@@ -182,10 +188,16 @@ export function createBubbles({
182
188
  return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
183
189
  }
184
190
 
191
+ // The running wake turn's id (a turn chi ran for reports), for its report's bubble.
192
+ let wakeTurnId = null;
193
+
185
194
  // Returns the bubble of the turn a turn_started starts (the stage takes it
186
195
  // into its prompt line), else null.
187
196
  function applyPromptOps(event) {
188
- if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
197
+ if (event.type === "turn_started") {
198
+ liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
199
+ wakeTurnId = liveTurn.wakeStart ? event.turn_id || null : null;
200
+ }
189
201
  const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
190
202
  if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
191
203
  if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
@@ -194,7 +206,10 @@ export function createBubbles({
194
206
  if (op.op === "report") {
195
207
  liveTurn.unmatchedMerge = "report";
196
208
  } else if (op.op === "add") {
197
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery, author: op.author });
209
+ // A report that opens a wake turn carries that turn's id, as a
210
+ // reload's does (its fold key); a merged one has none until reload.
211
+ const turnId = op.source && !op.state && liveTurn.wakeStart ? wakeTurnId : null;
212
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery, author: op.author, source: op.source, turnId });
198
213
  if (op.state === "started") started = bubble;
199
214
  if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
200
215
  } else if (op.op === "tag") {
@@ -241,8 +256,13 @@ export function createBubbles({
241
256
  // Never in the stage: a recap is between turns.
242
257
  turnDom.settleFold();
243
258
  const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
244
- // Above that turn's gap divider too: the recap was written before the quiet stretch ended.
245
- const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
259
+ // Above that turn's gap divider too: the recap was written before the quiet stretch ended. The
260
+ // snapshot's cards placed at that prompt (placed first) sit between the divider and the prompt.
261
+ // A delegate report's fold row (answer_fold.js) heads its prompt.
262
+ const head = prompt?.previousElementSibling?.matches(".answer-fold") ? prompt.previousElementSibling : prompt;
263
+ let back = head?.previousElementSibling;
264
+ while (back?.matches(SNAPSHOT_PLACED)) back = back.previousElementSibling;
265
+ const before = back?.matches(`.${TIME_GAP_CLASS}`) ? back : head;
246
266
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
247
267
  }
248
268
 
@@ -1,5 +1,5 @@
1
- // The popover shell a session-bar chip opens (llm_context_chip.js,
2
- // level_chip.js, the working set's): a div.ctx-popover on the body, placed
1
+ // The popover shell a session-bar chip opens (settings_chip.js' ⚙ panel,
2
+ // the working set's, the start page's panels): a div.ctx-popover on the body, placed
3
3
  // at the chip, opened by a click on it (delegated on +el+) or by open()
4
4
  // (another opener: the working set's rail and footprint), closed by a second
5
5
  // click, its × (data-act="close"), a click outside or Esc; focus goes back
@@ -0,0 +1,74 @@
1
+ // Collapsibles keep their shape: a steps block, a thinking block, the recap
2
+ // and the answer fold are the same box open or closed (the bar, the border,
3
+ // the width), and a click only changes the height. This module is the
4
+ // motion: a click on one of their summaries animates the height between the
5
+ // two states (Web Animations, MOTION_MS) instead of snapping, and the
6
+ // answer fold borrows animateHeight for its bubble. Nothing moves when the
7
+ // viewer prefers reduced motion or the element cannot animate (an old
8
+ // engine, a spec's DOM): the click is the native toggle then.
9
+ //
10
+ // Only a hand's click animates: the page's own opens and closes (a turn's
11
+ // end closing a block, a reload drawing the outline) stay instant, as they
12
+ // are not a change the viewer watches.
13
+ //
14
+ // A module of its own: static JS is cached at unversioned URLs, and a new
15
+ // export on an old module would blank the page.
16
+
17
+ export const MOTION_MS = 220;
18
+
19
+ // Whether +el+ can animate for this viewer.
20
+ export function canAnimate(el) {
21
+ if (typeof el?.animate !== "function") return false;
22
+ const win = el.ownerDocument?.defaultView;
23
+ return !win?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
24
+ }
25
+
26
+ // Animates +el+'s height from +from+ to +to+ (px), its overflow clipped
27
+ // meanwhile; +fade+ fades it with the height. Resolves when the animation
28
+ // is over (or canceled).
29
+ export async function animateHeight(el, from, to, { fade = false, ms = MOTION_MS } = {}) {
30
+ const at = (h, o) => (fade ? { height: `${h}px`, opacity: String(o) } : { height: `${h}px` });
31
+ const anim = el.animate([at(from, from ? 1 : 0), at(to, to ? 1 : 0)], { duration: ms, easing: "ease" });
32
+ el.style.overflow = "hidden";
33
+ try {
34
+ await anim.finished;
35
+ } catch {
36
+ // canceled: the element is where its class puts it
37
+ } finally {
38
+ el.style.overflow = "";
39
+ }
40
+ }
41
+
42
+ // A <details>' height closed: its summary and its own padding and borders.
43
+ function closedHeight(details, summary) {
44
+ const win = details.ownerDocument?.defaultView;
45
+ const cs = win?.getComputedStyle?.(details);
46
+ const px = (v) => Number.parseFloat(v) || 0;
47
+ const frame = cs ? px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth) : 0;
48
+ return summary.offsetHeight + frame;
49
+ }
50
+
51
+ // Installs the motion on +root+ for the <details> matching +selector+ whose
52
+ // own summary is clicked. +motion+: whether to animate +el+ (canAnimate).
53
+ export function installDetailsMotion({ root, selector, motion = canAnimate }) {
54
+ const busy = new WeakSet();
55
+ root.addEventListener("click", (e) => {
56
+ const summary = e.target.closest?.("summary");
57
+ const details = summary?.parentElement;
58
+ if (!details || !details.matches?.(selector) || summary.parentElement !== details) return;
59
+ if (e.target.closest("a, button, input, label") !== null && e.target !== summary) return;
60
+ if (!motion(details)) return;
61
+ e.preventDefault();
62
+ if (busy.has(details)) return;
63
+ busy.add(details);
64
+ const done = () => busy.delete(details);
65
+ if (details.open) {
66
+ const from = details.offsetHeight;
67
+ animateHeight(details, from, closedHeight(details, summary)).then(() => { details.open = false; done(); });
68
+ } else {
69
+ const from = closedHeight(details, summary);
70
+ details.open = true;
71
+ animateHeight(details, from, details.offsetHeight).then(done);
72
+ }
73
+ });
74
+ }
@@ -1,6 +1,6 @@
1
1
  // The one way a jump into the history shows what it lands on: whatever
2
2
  // hides +el+ unfolds: a closed <details> around it (a turn's steps block, a
3
- // step, a diff), an answer folded by hand (answer_fold.js: its row follows
3
+ // step, a diff), an answer or a delegate report folded (answer_fold.js: its row follows
4
4
  // the bubble's class; the jump's unfold isn't saved), a superseded version
5
5
  // of a Revise stack (revise_stack.js: its stub opens, not saved). Every
6
6
  // jump goes through it (claim_link.js showClaim, refs.js showRow and so the
@@ -19,7 +19,7 @@ import { openStubOver } from "./revise_stack.js";
19
19
  export function revealInHistory(el) {
20
20
  if (!el) return null;
21
21
  openStubOver(el);
22
- el.closest?.(".bubble.output.folded")?.classList.remove("folded");
22
+ el.closest?.(".bubble.folded")?.classList.remove("folded");
23
23
  for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
24
24
  details.open = true;
25
25
  }