samagotchi 0.135.0 → 0.136.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 +30 -1
  3. data/docs/cli.md +4 -4
  4. data/docs/configuration.md +10 -7
  5. data/docs/internals/prompt-caching.md +1 -1
  6. data/docs/internals/web-frontend.md +9 -8
  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 +98 -29
  15. data/lib/samagotchi/web/public/app.js +17 -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/history_reveal.js +2 -2
  19. data/lib/samagotchi/web/public/index.html +67 -44
  20. data/lib/samagotchi/web/public/info_bar.js +11 -12
  21. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  22. data/lib/samagotchi/web/public/level_chip.js +33 -95
  23. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  24. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  25. data/lib/samagotchi/web/public/revise_stack.js +9 -3
  26. data/lib/samagotchi/web/public/session_fields.js +4 -4
  27. data/lib/samagotchi/web/public/session_state.js +7 -5
  28. data/lib/samagotchi/web/public/session_store.js +1 -1
  29. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  30. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  31. data/lib/samagotchi/web/public/shell_command.js +26 -0
  32. data/lib/samagotchi/web/public/start_settings.js +25 -88
  33. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  34. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  35. data/lib/samagotchi/web/public/timing.js +13 -3
  36. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  37. data/lib/samagotchi/web/public/turn_block.js +6 -1
  38. data/lib/samagotchi/web/public/turn_events.js +1 -1
  39. data/lib/samagotchi/web/public/turn_html.js +16 -14
  40. data/lib/samagotchi/web/session_create.rb +1 -1
  41. data/lib/samagotchi/web/session_show.rb +6 -3
  42. metadata +4 -1
@@ -3,13 +3,20 @@
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); the row reads its first line and its size, "▸
7
+ // ‹first line› · N lines", folded or not: unfolded it is the bubble's header,
8
+ // flush on top of it, its bar the bubble's.
9
+ //
10
+ // A delegate report (a user line chi brought in, data-source
11
+ // "delegate_report") gets the same row, folded by default (it is written
12
+ // for the model; the reply under it is the user's summary): the row sits
13
+ // where the report sits and reads "delegate report · ‹first line› · N
14
+ // lines". Its key: a merged one's data-line-key, a wake turn's opening
15
+ // "<turn id>:prompt". A live merged report has no key until a reload.
9
16
  //
10
17
  // 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
18
+ // storage.js): one key per session holding the folded answers' keys and
19
+ // the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
13
20
  // (data-answer-turn), a delegate report's reply its own (data-reply-key);
14
21
  // a bubble with neither (an answer before any prompt, a canceled turn's
15
22
  // partial) gets no row. The keys of sessions gone from the session list go
@@ -36,7 +43,9 @@ export const FOLD_PREFIX = "chi_answer_fold:";
36
43
  const LABEL_CHARS = 160;
37
44
 
38
45
  const ROW_CLASS = "answer-fold";
39
- const ANSWERS = ".bubble.output";
46
+ // What gets a row: answers and delegate reports (user lines chi brought in).
47
+ const REPORTS = '.bubble.user[data-source="delegate_report"]';
48
+ const ANSWERS = `.bubble.output, ${REPORTS}`;
40
49
  // Not done yet: still streaming.
41
50
  const UNFINISHED = ".streaming";
42
51
 
@@ -71,12 +80,12 @@ export function foldLabel(text, { display = false } = {}) {
71
80
  return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
72
81
  }
73
82
 
74
- // The row's words: folded, the label and the size; unfolded, the size.
75
- export function foldRowText(text, { display = false, folded = true } = {}) {
83
+ // The row's words, folded or not: the label and the size (an open row is
84
+ // the answer's header, as an open steps block keeps its summary).
85
+ // +label+: the row's own (a report's, reportLabel), else the answer's.
86
+ export function foldRowText(text, { display = false, label = foldLabel(text, { display }) } = {}) {
76
87
  const n = lineCount(text);
77
88
  const size = `${n} line${n === 1 ? "" : "s"}`;
78
- if (!folded) return { label: "", size };
79
- const label = foldLabel(text, { display });
80
89
  return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
81
90
  }
82
91
 
@@ -86,11 +95,17 @@ export function foldStoreKey(sessionId) {
86
95
  return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
87
96
  }
88
97
 
98
+ // A session's entry: {root, turns, open}. `turns` holds the answers folded
99
+ // by hand (and Revise stubs opened, revise_stack.js); `open` the delegate
100
+ // reports unfolded by hand (a report folds by default). An entry written
101
+ // before `open` reads as none unfolded.
102
+ const strings = (list) => (Array.isArray(list) ? list.filter((t) => typeof t === "string") : []);
103
+
89
104
  function readEntry(key) {
90
105
  try {
91
106
  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") };
107
+ if (!entry || (!Array.isArray(entry.turns) && !Array.isArray(entry.open))) return null;
108
+ return { root: typeof entry.root === "string" ? entry.root : null, turns: strings(entry.turns), open: strings(entry.open) };
94
109
  } catch (_) {
95
110
  return null;
96
111
  }
@@ -103,6 +118,12 @@ export function readFolded(sessionId) {
103
118
  return (key && readEntry(key)?.turns) || [];
104
119
  }
105
120
 
121
+ // The session's delegate reports unfolded by hand, oldest first.
122
+ export function readOpened(sessionId) {
123
+ const key = foldStoreKey(sessionId);
124
+ return (key && readEntry(key)?.open) || [];
125
+ }
126
+
106
127
  // +list+ with +answerKey+ folded (moved to the end) or not, at most
107
128
  // FOLD_KEEP long. Pure.
108
129
  export function withFold(list, answerKey, folded) {
@@ -113,11 +134,23 @@ export function withFold(list, answerKey, folded) {
113
134
  // Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
114
135
  // project root, kept for the sweep of a project's list.
115
136
  export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
137
+ writeList(sessionId, "turns", answerKey, folded, root);
138
+ }
139
+
140
+ // Saves the report +reportKey+ unfolded (+open+) or not for +sessionId+.
141
+ export function writeOpened(sessionId, reportKey, open, { root = null } = {}) {
142
+ writeList(sessionId, "open", reportKey, open, root);
143
+ }
144
+
145
+ // +member+ in or out of the entry's +list+ ("turns" or "open"), the other
146
+ // list kept; the key goes when both are empty.
147
+ function writeList(sessionId, list, member, inList, root) {
116
148
  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 }));
149
+ if (!key || !member) return;
150
+ const entry = readEntry(key) || { turns: [], open: [] };
151
+ const next = { turns: entry.turns, open: entry.open, [list]: withFold(entry[list], member, inList) };
152
+ if (!next.turns.length && !next.open.length) removeStored(key);
153
+ else writeStored(key, JSON.stringify({ root, turns: next.turns, ...(next.open.length ? { open: next.open } : {}) }));
121
154
  }
122
155
 
123
156
  // The session list loaded (+sessions+, each {id, project_root}): the fold
@@ -137,8 +170,17 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
137
170
 
138
171
  // ---- The rows ----
139
172
 
140
- // The key an answer bubble is folded by, or null.
173
+ const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
174
+
175
+ // The key an answer bubble is folded by, or null. A delegate report's: a
176
+ // merged one's line key (data-line-key, "<turn id>:input:<place>"), one
177
+ // that opened a wake turn its turn's id and ":prompt" (never the answer's
178
+ // key, the bare id).
141
179
  export function answerKey(bubble) {
180
+ if (isReport(bubble)) {
181
+ const turn = bubble.getAttribute("data-turn-id");
182
+ return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
183
+ }
142
184
  return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
143
185
  }
144
186
 
@@ -146,13 +188,30 @@ function answerText(bubble) {
146
188
  return bubbleCopyText(bubble);
147
189
  }
148
190
 
191
+ // A delegate report's lines before its body: "session: <id>",
192
+ // "status: <s>", "---".
193
+ const REPORT_HEADER = /^(?:(?:session|status):\s|---\s*$)/;
194
+
195
+ // A report row's label: its origin label (the bubble's) and its first
196
+ // heading or first line after the header.
197
+ export function reportLabel(text, origin = "") {
198
+ const body = String(text ?? "").replace(/\r\n?/g, "\n").split("\n").filter((l) => !REPORT_HEADER.test(l.trim())).join("\n");
199
+ const first = foldLabel(body);
200
+ return clip([origin.trim(), first].filter(Boolean).join(" · "), LABEL_CHARS);
201
+ }
202
+
149
203
  function drawRow(row, bubble) {
150
204
  const folded = bubble.classList.contains("folded");
151
205
  const text = answerText(bubble);
152
- const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
206
+ const report = isReport(bubble);
207
+ const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
208
+ const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), ...own });
209
+ const what = report ? "report" : "answer";
153
210
  row.setAttribute("aria-expanded", folded ? "false" : "true");
154
- row.title = folded ? "Show the answer" : "Fold the answer";
211
+ row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
155
212
  row.classList.toggle("folded", folded);
213
+ row.classList.toggle("report", report);
214
+ row.classList.toggle("steered", report && bubble.classList.contains("steered"));
156
215
  const [labelEl, sizeEl] = row.children;
157
216
  if (labelEl.textContent !== label) labelEl.textContent = label;
158
217
  if (sizeEl.textContent !== size) sizeEl.textContent = size;
@@ -202,14 +261,18 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
202
261
  rows.set(bubble, row);
203
262
  bubbleOf.set(row, bubble);
204
263
  // 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");
264
+ // (a jump that unfolds it, history_reveal.js, isn't saved). An answer
265
+ // is open unless folded by hand, a report folded unless opened.
266
+ const folded = isReport(bubble) ? !stored().open.includes(key) : stored().turns.includes(key);
267
+ if (folded) bubble.classList.add("folded");
207
268
  }
208
269
  if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
209
270
  drawRow(row, bubble);
210
271
  }
211
272
 
212
- // Rows whose bubble left (a re-render, a replaced bubble) go with it.
273
+ // Rows whose bubble left (a re-render, a replaced bubble) go with it; a
274
+ // row something was placed after (a card or a recap placed before a
275
+ // report that opens its turn) goes back just before its bubble.
213
276
  function dropOrphans() {
214
277
  for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
215
278
  const bubble = bubbleOf.get(row);
@@ -219,8 +282,8 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
219
282
  }
220
283
 
221
284
  function decorate(root) {
222
- let folded = null;
223
- const stored = () => (folded ??= readFolded(sessionId()));
285
+ let entry = null;
286
+ const stored = () => (entry ??= { turns: readFolded(sessionId()), open: readOpened(sessionId()) });
224
287
  if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
225
288
  for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
226
289
  }
@@ -229,18 +292,22 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
229
292
  // the same frame as its bubble, so nothing below it shifts after a paint.
230
293
  new MutationObserver((records) => {
231
294
  const roots = new Set();
232
- let removed = false;
295
+ let settle = false;
233
296
  for (const r of records) {
234
297
  if (r.type === "attributes") {
235
298
  if (r.target.matches?.(ANSWERS)) roots.add(r.target);
236
299
  continue;
237
300
  }
238
- if (r.removedNodes.length) removed = true;
301
+ if (r.removedNodes.length) settle = true;
239
302
  const answer = r.target.closest?.(ANSWERS);
240
303
  if (answer) roots.add(answer);
241
- for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
304
+ for (const n of r.addedNodes) {
305
+ if (n.nodeType !== 1 || n.classList.contains(ROW_CLASS)) continue;
306
+ roots.add(n);
307
+ if (r.target === historyEl) settle = true;
308
+ }
242
309
  }
243
- if (removed) dropOrphans();
310
+ if (settle) dropOrphans();
244
311
  for (const el of roots) if (el.isConnected) decorate(el);
245
312
  }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
246
313
  decorate(historyEl);
@@ -255,7 +322,9 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
255
322
  bubble.classList.toggle("folded", folded);
256
323
  drawRow(row, bubble);
257
324
  });
258
- writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
325
+ // An answer's choice is its fold, a report's its unfold (D2: the default differs).
326
+ if (isReport(bubble)) writeOpened(sessionId(), answerKey(bubble), !folded, { root: projectRoot() });
327
+ else writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
259
328
  });
260
329
 
261
330
  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";
@@ -155,29 +153,13 @@ const contextChips = createContextChips({
155
153
  api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
156
154
  toast: (text) => showToast(text),
157
155
  });
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({
156
+ // The open session's ⚙ chip in the info bar, and its panel (thinking,
157
+ // reasoning history, LLM context): a pick runs its command in the session's
158
+ // worker.
159
+ const settingsControl = createSettingsControl({
161
160
  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),
161
+ summaries: () => (store.selected ? { thinking: store.current.thinking, reasoningHistory: store.current.reasoningHistory,
162
+ llmContext: store.current.llmContext } : null),
181
163
  session: () => store.selected,
182
164
  running: () => liveTurn.running,
183
165
  run: (line) => runSessionCommand(line),
@@ -1191,14 +1173,15 @@ function renderSessionView(data, { kept = [] } = {}) {
1191
1173
  const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
1192
1174
  if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
1193
1175
  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
1176
  // A live worker's last cards and between-turns notices, where they
1199
1177
  // arrived; the running turn's go into its step once it is drawn.
1200
1178
  const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
1201
1179
  placeSnapshotCards(snapshotCards.placed);
1180
+ // The recap saved with the session (also a stopped one's), else a live
1181
+ // worker's from before saved_recap existed. After the cards: the last
1182
+ // turn's check-in notice goes to the end, and the recap closes the page.
1183
+ if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
1184
+ else showRecap(data.recap);
1202
1185
  const replay = snapshotEvents({
1203
1186
  current_turn: data.current_turn,
1204
1187
  queued: data.queued,
@@ -1373,8 +1356,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1373
1356
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
1374
1357
  }
1375
1358
 
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
1359
  infoTextEl.addEventListener("click", (e) => {
1379
1360
  if (e.target.closest(".worker-restart")) restartWorker();
1380
1361
  // Pushed as openAllView does, so the view's back returns here.
@@ -1383,12 +1364,10 @@ infoTextEl.addEventListener("click", (e) => {
1383
1364
  openAllView();
1384
1365
  }
1385
1366
  });
1367
+ // The info bar's id: a click copies the full id, which the bar shows only short.
1386
1368
  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);
1369
+ if (!e.target.closest(".id") || !store.selected) return;
1370
+ showToast((await copyText(store.selected)) ? "Session id copied" : "Couldn't copy the session id");
1392
1371
  });
1393
1372
 
1394
1373
  // Apply (review_apply.js) under a participant's review turn that just
@@ -1503,7 +1482,7 @@ function updateInfoBar() {
1503
1482
  const { buttons, bar } = sessionBar({
1504
1483
  id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
1505
1484
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1506
- now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1485
+ workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1507
1486
  });
1508
1487
  const stopBtn = document.getElementById("infoStopBtn");
1509
1488
  const deleteBtn = document.getElementById("infoDeleteBtn");
@@ -2193,9 +2172,7 @@ document.addEventListener("keydown", (e) => {
2193
2172
  // before it (none at load) would redraw nothing.
2194
2173
  store.on("lists", () => lists.render());
2195
2174
  store.on("paintActive", () => lists.paintActive());
2196
- store.on("llmContextChip", () => llmContextControl.refresh());
2197
- store.on("thinkingChip", () => thinkingControl.refresh());
2198
- store.on("reasoningChip", () => reasoningHistoryControl.refresh());
2175
+ store.on("settingsPanel", () => settingsControl.refresh());
2199
2176
  // The working set's list offers pin only under forget.
2200
2177
  store.on("workingSetStrategy", () => workingSet?.strategyChanged());
2201
2178
  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
@@ -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
  }