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
@@ -0,0 +1,130 @@
1
+ // The stage view's live slots (stage_view.js): the headline, the asking
2
+ // text, the "now" row, the trail and the chip's ticks, each drawn from
3
+ // liveSlots (stage_model.js) and patched only when what it shows changes
4
+ // (+shown+), so an unchanged slot keeps its nodes: a selection in it
5
+ // survives, and its animations never restart (the ticks' blink; the
6
+ // headline's fade restarts on purpose at a new text).
7
+ import { FLASH_MARKS, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
8
+ import { taskCommand, taskStopButton } from "./task_stop.js";
9
+
10
+ // The stage's one element helper (stage_view.js builds its skeleton with it).
11
+ export function node(tag, cls, parent = null) {
12
+ const el = document.createElement(tag);
13
+ if (cls) el.className = cls;
14
+ if (parent) parent.appendChild(el);
15
+ return el;
16
+ }
17
+
18
+ // +headline+, +asking+, +now+, +trail+, +ticks+: the slots' elements.
19
+ // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
20
+ // on a running task_wait; +gens()+ the running turn's gens (the button's
21
+ // command, read at its click); +onChange+ redraws (the stage's
22
+ // scheduleRefresh).
23
+ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop = null, gens, onChange }) {
24
+ // What each slot shows, so an unchanged slot is left alone.
25
+ const shown = { headline: null, now: null, trail: null, asking: null };
26
+
27
+ function drawHeadline({ text, thinking }) {
28
+ const key = `${thinking ? "t" : "n"}|${text}`;
29
+ if (shown.headline === key) return;
30
+ shown.headline = key;
31
+ headline.replaceChildren();
32
+ headline.classList.toggle("thinking", thinking);
33
+ if (thinking) {
34
+ const tag = node("span", "ts-think-tag", headline);
35
+ tag.textContent = "thinking";
36
+ }
37
+ headline.appendChild(document.createTextNode(text));
38
+ headline.classList.remove("fresh");
39
+ void headline.offsetWidth;
40
+ headline.classList.add("fresh");
41
+ }
42
+
43
+ // Patched only when the text changes (a late chunk to the closed gen
44
+ // rewrites the same text): a selection in it survives. A new text reads
45
+ // from its top.
46
+ function drawAsking(text) {
47
+ asking.hidden = !text;
48
+ if (shown.asking === text) return;
49
+ shown.asking = text;
50
+ asking.textContent = text;
51
+ asking.scrollTop = 0;
52
+ }
53
+
54
+ function drawNow(tool, quiet) {
55
+ // A task_wait's stop button, as the row's (task_stop.js): in the key
56
+ // with its in-flight state, so a redraw keeps a clicked one disabled.
57
+ const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
58
+ const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
59
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
60
+ if (shown.now === key) return;
61
+ shown.now = key;
62
+ now.replaceChildren();
63
+ now.classList.toggle("running", !!tool);
64
+ node("span", tool ? "ts-spin" : "ts-idle", now);
65
+ if (tool) {
66
+ const name = node("span", `ts-tool ${tool.kind}`, now);
67
+ name.textContent = tool.name;
68
+ const title = node("span", "ts-title", now);
69
+ title.textContent = tool.title;
70
+ // The full command when the call has one (the title is its first step +N).
71
+ title.title = tool.command || tool.title;
72
+ if (stopId) {
73
+ const command = () => taskCommand(gens(), stopId);
74
+ now.appendChild(taskStopButton(taskStop, stopId, { command, onChange }));
75
+ }
76
+ } else if (quiet) {
77
+ const word = node("span", "ts-title quiet", now);
78
+ word.textContent = quiet;
79
+ }
80
+ }
81
+
82
+ // +flash+: the trail's flash ({ kind, text, until }), shown ahead of the
83
+ // calls until its time is up.
84
+ function drawTrail(items, flash) {
85
+ const flashing = flash && flash.until > Date.now() ? flash : null;
86
+ const key = JSON.stringify([flashing?.kind, flashing?.text, items]);
87
+ if (shown.trail === key) return;
88
+ shown.trail = key;
89
+ trail.replaceChildren();
90
+ if (flashing) {
91
+ const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
92
+ const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
93
+ el.textContent = flash.text;
94
+ el.title = flash.hover;
95
+ }
96
+ for (const item of items) {
97
+ const tone = trailTone(item.status);
98
+ const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
99
+ const { text, hover } = trailItemText(item);
100
+ el.textContent = text;
101
+ el.title = hover;
102
+ }
103
+ if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
104
+ }
105
+
106
+ function drawTicks(list) {
107
+ list.forEach((tick, i) => {
108
+ let el = ticks.children[i];
109
+ if (!el) {
110
+ el = node("span", "", ticks);
111
+ el.setAttribute("role", "button");
112
+ }
113
+ const cls = `ts-tick ${tick.kind} ${tick.status}`;
114
+ if (el.className !== cls) el.className = cls;
115
+ el.dataset.step = String(tick.step);
116
+ el.title = `step ${tick.step + 1}`;
117
+ });
118
+ while (ticks.children.length > list.length) ticks.lastChild.remove();
119
+ }
120
+
121
+ // A new turn (or none): the slots empty, the asking text hidden, the next
122
+ // draw of each slot a fresh one.
123
+ function reset() {
124
+ for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
125
+ asking.hidden = true;
126
+ shown.headline = shown.now = shown.trail = shown.asking = null;
127
+ }
128
+
129
+ return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset };
130
+ }
@@ -11,25 +11,23 @@
11
11
  // in use, everything moves into the history in the turn view's order (the
12
12
  // hand-off), so a handed-off turn reads like the turn view's and like its
13
13
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
14
- // and annotate survive. The rules are pure in stage_model.js.
14
+ // and annotate survive. The rules are pure in stage_model.js; the live
15
+ // slots are drawn by stage_slots.js, the cap and the held height are
16
+ // stage_frame.js's, and the in-use watch that times the hand-off is
17
+ // stage_watch.js's.
15
18
  import { createTurnView } from "./turn_view.js";
16
- import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, placeKind, QUEUED_CHILDREN, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
19
+ import { chipLabelText, flashOf, handOffDue, handOffOrder, headlineOf, isPlain, liveSlots, MARKS, miniToolText, placeFor, placeKind, QUEUED_CHILDREN, stepSlotText } from "./stage_model.js";
20
+ import { createStageSlots, node } from "./stage_slots.js";
21
+ import { createStageFrame, reducedMotion } from "./stage_frame.js";
22
+ import { createInUseWatch } from "./stage_watch.js";
17
23
  import { formatDuration } from "./timing.js";
18
24
  import { easeScrollTo } from "./scroll.js";
19
- import { taskCommand, taskStopButton } from "./task_stop.js";
20
25
 
21
26
  const COLLAPSE_KEY = "chi_stage_collapsed";
22
- const WATCH_MS = 200;
23
27
  // The turn-end read (markdown, answer_display) settles within this, or the
24
28
  // hand-off stops waiting for it.
25
29
  const SETTLE_CAP_MS = 8000;
26
30
  export const FOLD_MS = 420;
27
- // The stage grows (a card, the long answer, the cloud opening) with a short
28
- // ease: deliberate, never a snap.
29
- const GROW_MS = 180;
30
- const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
31
- // The room between the stage and the composer card (index.html's margin-bottom).
32
- const STAGE_GAP = 10;
33
31
  const FLASH_MS = 4000;
34
32
  // The answer reads from its top: at the end it is scrolled to unless this
35
33
  // much of it (about three lines) shows already.
@@ -41,21 +39,6 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
41
39
  // asks the user too, but the turn goes on: it waits only while the turn runs
42
40
  // and never holds the hand-off (a turn cut short can leave it open).
43
41
  const ASKS = ".plugin-card[data-in-turn] .card-action";
44
- const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
45
- // The mark of a flash in the trail: a warn notice, a hook's info notice,
46
- // an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
47
- const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
48
-
49
- function node(tag, cls, parent = null) {
50
- const el = document.createElement(tag);
51
- if (cls) el.className = cls;
52
- if (parent) parent.appendChild(el);
53
- return el;
54
- }
55
-
56
- function reducedMotion() {
57
- return matchMedia("(prefers-reduced-motion: reduce)").matches;
58
- }
59
42
 
60
43
  function readCollapsed() {
61
44
  try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
@@ -127,6 +110,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
127
110
  // Below the turn's card: the lines queued for after it (placeKind
128
111
  // "queued"), each to run as a turn of its own; they hand off last.
129
112
  const queuedSlot = node("div", "ts-queued", stage);
113
+ const slotsView = createStageSlots({
114
+ headline, asking, now, trail, ticks, taskStop,
115
+ gens: () => view.current().turn?.gens,
116
+ onChange: () => scheduleRefresh(),
117
+ });
130
118
 
131
119
  // The turn in the stage, from turnStarted until the hand-off (or a reset).
132
120
  let holding = false;
@@ -145,18 +133,6 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
145
133
  let revealed = false;
146
134
  let rafPending = false;
147
135
  let foldTimer = null;
148
- let watchTimer = null;
149
- let quietSince = null;
150
- let pointerIn = false;
151
- let lastTouch = 0;
152
- // The frame (fitFrame): the height held while the turn runs and the
153
- // tallest content seen.
154
- let held = null;
155
- let maxContent = 0;
156
- let growth = null; // the running ease of the frame's growth (an Animation)
157
- let growthTo = 0; // its end height
158
- // What each slot shows, so an unchanged slot is left alone.
159
- const shown = { headline: null, now: null, trail: null, asking: null };
160
136
 
161
137
  const view = createTurnView({
162
138
  historyEl,
@@ -240,22 +216,22 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
240
216
  stage.dataset.phase = slots.phase;
241
217
  mark.textContent = MARKS[slots.phase] || "";
242
218
  phaseEl.textContent = slots.phase;
243
- const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
244
219
 
245
- drawHeadline(slots.headline);
246
- drawAsking(slots.asking);
247
- drawNow(slots.tool, slots.quiet);
248
- drawTrail(slots.trail);
249
- drawTicks(slots.ticks);
220
+ slotsView.drawHeadline(slots.headline);
221
+ slotsView.drawAsking(slots.asking);
222
+ slotsView.drawNow(slots.tool, slots.quiet);
223
+ slotsView.drawTrail(slots.trail, flash);
224
+ slotsView.drawTicks(slots.ticks);
250
225
  const calls = slots.ticks.length;
251
226
  // An ended turn says each thing once: its steps in the chip (the label
252
227
  // of what the cloud holds), its time in the status row's timing line
253
228
  // (the one that moves into the history). The chip keeps the time when
254
229
  // that line is elsewhere (a turn the stage didn't hold from its start).
255
230
  const elapsed = endKind && !timingSlot.firstChild ? formatDuration(endedAt - startedAt) : "";
256
- chipLabel.textContent = `${steps} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
257
- miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
258
- miniTool.title = (collapsed && slots.tool?.command) || "";
231
+ chipLabel.textContent = chipLabelText({ steps: slots.step, calls, elapsed });
232
+ const mini = miniToolText({ collapsed, tool: slots.tool });
233
+ miniTool.textContent = mini.text;
234
+ miniTool.title = mini.title;
259
235
 
260
236
  // An ended turn's live slots go, unless something under them would move
261
237
  // (a card or a row in the extras): then they stay as they were last.
@@ -263,90 +239,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
263
239
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
264
240
  cloudBody.hidden = !cloudOpen || cloud.hidden;
265
241
  // Running: the step it is on. Ended: the count, unless the chip says it.
266
- stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
242
+ stepEl.textContent = stepSlotText({ step: slots.step, ended: !!endKind, chipHidden: cloud.hidden });
267
243
  promptSlot.hidden = !promptSlot.firstChild;
268
244
  extras.hidden = !extras.firstChild;
269
245
  answer.hidden = !answer.firstChild;
270
246
  tail.hidden = !tail.firstChild;
271
247
  }
272
248
 
273
- function drawHeadline({ text, thinking }) {
274
- const key = `${thinking ? "t" : "n"}|${text}`;
275
- if (shown.headline === key) return;
276
- shown.headline = key;
277
- headline.replaceChildren();
278
- headline.classList.toggle("thinking", thinking);
279
- if (thinking) {
280
- const tag = node("span", "ts-think-tag", headline);
281
- tag.textContent = "thinking";
282
- }
283
- headline.appendChild(document.createTextNode(text));
284
- headline.classList.remove("fresh");
285
- void headline.offsetWidth;
286
- headline.classList.add("fresh");
287
- }
288
-
289
- // Patched only when the text changes (a late chunk to the closed gen
290
- // rewrites the same text): a selection in it survives. A new text reads
291
- // from its top.
292
- function drawAsking(text) {
293
- asking.hidden = !text;
294
- if (shown.asking === text) return;
295
- shown.asking = text;
296
- asking.textContent = text;
297
- asking.scrollTop = 0;
298
- }
299
-
300
- function drawNow(tool, quiet) {
301
- // A task_wait's stop button, as the row's (task_stop.js): in the key
302
- // with its in-flight state, so a redraw keeps a clicked one disabled.
303
- const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
304
- const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
305
- const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
306
- if (shown.now === key) return;
307
- shown.now = key;
308
- now.replaceChildren();
309
- now.classList.toggle("running", !!tool);
310
- node("span", tool ? "ts-spin" : "ts-idle", now);
311
- if (tool) {
312
- const name = node("span", `ts-tool ${tool.kind}`, now);
313
- name.textContent = tool.name;
314
- const title = node("span", "ts-title", now);
315
- title.textContent = tool.title;
316
- // The full command when the call has one (the title is its first step +N).
317
- title.title = tool.command || tool.title;
318
- if (stopId) {
319
- const command = () => taskCommand(view.current().turn?.gens, stopId);
320
- now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
321
- }
322
- } else if (quiet) {
323
- const word = node("span", "ts-title quiet", now);
324
- word.textContent = quiet;
325
- }
326
- }
327
-
328
- function drawTrail(items) {
329
- const flashing = flash && flash.until > Date.now() ? flash : null;
330
- const key = JSON.stringify([flashing?.kind, flashing?.text, items]);
331
- if (shown.trail === key) return;
332
- shown.trail = key;
333
- trail.replaceChildren();
334
- if (flashing) {
335
- const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
336
- const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
337
- el.textContent = flash.text;
338
- el.title = flash.hover;
339
- }
340
- for (const item of items) {
341
- const tone = trailTone(item.status);
342
- const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
343
- const { text, hover } = trailItemText(item);
344
- el.textContent = text;
345
- el.title = hover;
346
- }
347
- if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
348
- }
349
-
350
249
  // One line in the trail for FLASH_MS, ahead of the calls (a newer flash
351
250
  // takes its place).
352
251
  function flashTrail(what) {
@@ -356,49 +255,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
356
255
  setTimeout(scheduleRefresh, FLASH_MS + 50);
357
256
  }
358
257
 
359
- function drawTicks(list) {
360
- list.forEach((tick, i) => {
361
- let el = ticks.children[i];
362
- if (!el) {
363
- el = node("span", "", ticks);
364
- el.setAttribute("role", "button");
365
- }
366
- const cls = `ts-tick ${tick.kind} ${tick.status}`;
367
- if (el.className !== cls) el.className = cls;
368
- el.dataset.step = String(tick.step);
369
- el.title = `step ${tick.step + 1}`;
370
- });
371
- while (ticks.children.length > list.length) ticks.lastChild.remove();
372
- }
373
-
374
258
  // The answer reads from its top (no tail pin in the stage): scrolled to
375
259
  // (eased, once the stage has grown for it) when its top is out of sight
376
260
  // or only its first line shows.
377
261
  function showAnswerTop() {
378
- afterGrowth(() => {
262
+ frame.afterGrowth(() => {
379
263
  if (!answer.firstChild || answer.hidden || stage.hidden) return;
380
264
  const top = answer.offsetTop;
381
265
  const seen = scroller.scrollTop + scroller.clientHeight - top;
382
- if (top < scroller.scrollTop || seen < Math.min(answer.offsetHeight - ANSWER_CUT, ANSWER_SEEN)) easeStage(Math.max(0, top - 8));
266
+ if (top < scroller.scrollTop || seen < Math.min(answer.offsetHeight - ANSWER_CUT, ANSWER_SEEN)) frame.easeStage(Math.max(0, top - 8));
383
267
  });
384
268
  }
385
269
 
386
- // The stage's own scrolls, eased (instant with reduced motion).
387
- function easeStage(top) {
388
- if (Math.abs(top - scroller.scrollTop) < 1) return;
389
- easeScrollTo(scroller, top, { reduce: reducedMotion() });
390
- }
391
-
392
- // +fn+ once the frame has grown for a change made now: two frames on (the
393
- // ResizeObserver starts the ease in the frame after the change), then at
394
- // the ease's end.
395
- function afterGrowth(fn) {
396
- requestAnimationFrame(() => requestAnimationFrame(() => {
397
- if (growth?.playState === "running") growth.finished.then(fn, fn);
398
- else fn();
399
- }));
400
- }
401
-
402
270
  // ── collapse, cloud, peek ────────────────────────────────────────────────
403
271
 
404
272
  function applyCollapsed() {
@@ -407,7 +275,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
407
275
  fold.textContent = collapsed ? "▴" : "▾";
408
276
  fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
409
277
  fold.setAttribute("aria-expanded", String(!collapsed));
410
- fitCap();
278
+ frame.fitCap();
411
279
  scheduleRefresh();
412
280
  }
413
281
 
@@ -422,14 +290,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
422
290
  cloudOpen = open;
423
291
  cloudBody.hidden = !open || cloud.hidden;
424
292
  // Closed by hand: its room isn't held at the scroller's end.
425
- if (!open) maxContent = 0;
293
+ if (!open) frame.forgetContent();
426
294
  cloud.classList.toggle("open", open);
427
295
  stage.classList.toggle("cloud-open", open);
428
296
  chip.setAttribute("aria-expanded", String(open));
429
- fitCap();
297
+ frame.fitCap();
430
298
  // The steps open right above the chip: in sight (the answer and the
431
299
  // cards above them scroll up), once the stage has grown for them.
432
- if (open) afterGrowth(() => reveal(cloudBody));
300
+ if (open) frame.afterGrowth(() => frame.reveal(cloudBody));
433
301
  }
434
302
 
435
303
  // Open the cloud at step +i+ (a hand toggle, as a click on its row), its
@@ -480,7 +348,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
480
348
  applyCollapsed();
481
349
  }
482
350
  setCloud(true);
483
- if (e.detail) e.detail.until = new Promise((resolve) => afterGrowth(resolve));
351
+ if (e.detail) e.detail.until = new Promise((resolve) => frame.afterGrowth(resolve));
484
352
  });
485
353
  headline.addEventListener("keydown", (e) => {
486
354
  if (e.key === "Enter" || e.key === " ") {
@@ -494,170 +362,24 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
494
362
  promptSlot.classList.toggle("full");
495
363
  });
496
364
 
497
- // ── the cap: at most ~60% of the page (~80% with the cloud open), and
498
- // never above the session panel (the dock is absolute: a tall composer or
499
- // a phone's keyboard would push the stage's top out) ────────────────────
500
-
501
- // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
502
- // its end in sight, and the asking text's top too when it and the slot
503
- // (a waiting card) fit together (the reply reads from its start, the card
504
- // still in sight). null: nothing to show.
505
- function revealTop(slot) {
506
- if (stage.hidden || slot.hidden) return null;
507
- let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
508
- if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
509
- return top;
510
- }
511
-
512
- // Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
513
- // dropped under it).
514
- function reveal(slot, { now = false } = {}) {
515
- const top = revealTop(slot);
516
- if (top === null) return;
517
- if (now) scroller.scrollTop = top;
518
- else easeStage(top);
519
- }
520
-
521
- function fitCap() {
522
- if (stage.hidden) return;
523
- // The composer card and the gap above it (the stage floats over them,
524
- // out of the dock's flow).
525
- const rest = dockEl.offsetHeight + STAGE_GAP;
526
- const room = centerEl.clientHeight - rest - 32;
527
- const share = window.innerHeight * (cloudOpen ? 0.8 : 0.6);
528
- const cap = Math.max(96, Math.round(Math.min(share, room)));
529
- const was = capPx();
530
- if (cap === was) return;
531
- stage.style.setProperty("--stage-max", `${cap}px`);
532
- fitFrame();
533
- // A smaller cap (a phone's keyboard came up) keeps a waiting card in
534
- // sight: the asking text above it would push it out. A larger one
535
- // leaves the reader's scroll alone.
536
- if (cap < was && stage.classList.contains("waiting")) {
537
- requestAnimationFrame(() => reveal(extras, { now: true }));
538
- }
539
- }
540
- new ResizeObserver(() => fitCap()).observe(centerEl);
541
- new ResizeObserver(() => fitCap()).observe(composerEl);
542
- window.addEventListener("resize", fitCap);
543
-
544
- // ── the frame: the one place that writes the stage's height ────────────
545
- // Nothing in the stage view jumps. The rules: the stage is out of the
546
- // dock's flow (index.html), so its height never moves the composer; while
547
- // it holds a turn its height is held and grow-only (frameTarget), so a
548
- // content change happens inside it; growth eases (frameTo); the chip is a
549
- // footer no content can push; the hand-off releases the turn at once
550
- // (releaseTurn) and empties the stage only after it slid away under the
551
- // composer (a transform, then finishFold/clearStage).
552
-
553
- // The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
554
- // Animations: the running ease is +growth+), unless motion is reduced or
555
- // the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
556
- function frameTo(px, { animate = false } = {}) {
557
- const from = stage.offsetHeight;
558
- stage.style.height = `${px}px`;
559
- if (px === from) return;
560
- const ease = animate && px > from && !collapsed && !stage.hidden && !document.hidden && !reducedMotion();
561
- if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
562
- growth.cancel();
563
- growth = null;
564
- }
565
- if (!ease || growth) return;
566
- growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
567
- growthTo = px;
568
- const run = growth;
569
- run.finished.then(() => { if (growth === run) growth = null; }, () => {});
570
- }
571
-
572
- // Back to the content's own height (under the cap).
573
- function frameFree() {
574
- growth?.cancel();
575
- growth = null;
576
- stage.style.height = "";
577
- }
578
-
579
- function capPx() {
580
- return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
581
- }
582
-
583
- // The scroller's content height: its last shown slot's end (offsetTop is
584
- // the scroller's own, whatever its scroll) and its bottom padding.
585
- function contentHeight() {
586
- const css = getComputedStyle(scroller);
587
- let end = parseFloat(css.paddingTop) || 0;
588
- for (const el of scroller.children) {
589
- if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
590
- end = el.offsetTop + el.offsetHeight;
591
- }
592
- return end + (parseFloat(css.paddingBottom) || 0);
593
- }
594
-
595
- // Holds the frame while the stage holds a turn (stage_model.js
596
- // frameTarget: grow-only, clamped by the cap; free when collapsed or
597
- // released). Run from a ResizeObserver on the slots (after layout, before
598
- // paint: no frame shows the content's own height) and when the cap moves.
599
- function fitFrame() {
600
- if (stage.hidden || stage.classList.contains("leaving")) return;
601
- const content = scroller.hidden ? 0 : contentHeight();
602
- const holds = holding && !collapsed;
603
- maxContent = holds ? Math.max(maxContent, content) : 0;
604
- const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
605
- if (holdEl.style.top !== mark) holdEl.style.top = mark;
606
- const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
607
- + queuedSlot.offsetHeight;
608
- held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
609
- if (held === null) frameFree();
610
- else frameTo(held, { animate: true });
611
- }
612
- const frameObserver = new ResizeObserver(() => fitFrame());
613
- for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud, queuedSlot]) frameObserver.observe(el);
614
-
615
- // ── in use: pointer over it (tracked on the stage itself: :hover goes
616
- // stale when children change), a touch or wheel in the last 4 s (phones
617
- // have no hover), keyboard focus or a selection in it ─────────────────
618
-
619
- stage.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
620
- stage.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
621
- stage.addEventListener("pointerleave", () => { pointerIn = false; });
622
- // Scrolling needs the pointer over it, a touch or keyboard focus; the
623
- // stage's own scrolls (follow, the answer's top) are no use.
624
- for (const type of ["touchstart", "touchmove", "wheel"]) {
625
- stage.addEventListener(type, () => { lastTouch = Date.now(); }, { passive: true, capture: true });
626
- }
627
-
628
- function inUse(nowMs) {
629
- const active = document.activeElement;
630
- const sel = window.getSelection();
631
- return inUseFrom({
632
- pointerIn,
633
- lastTouchMs: lastTouch,
634
- // A clicked button keeps the focus in Chrome: only keyboard focus and
635
- // a field being typed in count.
636
- focusInside: !!active && stage.contains(active) && active.matches(":focus-visible"),
637
- selectionInside: !!sel && !sel.isCollapsed && stage.contains(sel.anchorNode),
638
- nowMs,
639
- });
640
- }
641
-
642
- function stopWatch() {
643
- clearInterval(watchTimer);
644
- watchTimer = null;
645
- quietSince = null;
646
- stage.classList.remove("in-use");
647
- }
365
+ // The cap and the frame (stage_frame.js): the stage's height, held while
366
+ // it holds a turn, and its own scrolls.
367
+ const frame = createStageFrame({
368
+ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl,
369
+ observed: [status, promptSlot, live, extras, answer, tail, cloudBody, cloud, queuedSlot],
370
+ isHolding: () => holding,
371
+ isCollapsed: () => collapsed,
372
+ isCloudOpen: () => cloudOpen,
373
+ });
648
374
 
649
- function startWatch() {
650
- stopWatch();
651
- watchTimer = setInterval(() => {
652
- if (!holding || !endKind) return stopWatch();
653
- const nowMs = Date.now();
654
- const busy = inUse(nowMs);
655
- stage.classList.toggle("in-use", busy);
656
- const r = handOffDue({ ended: true, pending: !settled || pendingCard(), inUse: busy, quietSinceMs: quietSince, nowMs });
657
- quietSince = r.quietSinceMs;
658
- if (r.due) handOff();
659
- }, WATCH_MS);
660
- }
375
+ // The in-use watch (stage_watch.js): an ended turn hands off once the
376
+ // stage has not been in use for a while and nothing is pending.
377
+ const watch = createInUseWatch({
378
+ stage,
379
+ watching: () => holding && !!endKind,
380
+ onTick: ({ inUse, quietSinceMs, nowMs }) => handOffDue({ ended: true, pending: !settled || pendingCard(), inUse, quietSinceMs, nowMs }),
381
+ onDue: () => handOff(),
382
+ });
661
383
 
662
384
  // ── show, clear, hand off ────────────────────────────────────────────────
663
385
 
@@ -681,10 +403,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
681
403
  }
682
404
 
683
405
  function emptySlots() {
684
- for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
406
+ for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
407
+ slotsView.reset();
685
408
  promptSlot.classList.remove("full");
686
- asking.hidden = true;
687
- shown.headline = shown.now = shown.trail = shown.asking = null;
688
409
  thinkingLine = null;
689
410
  flash = null;
690
411
  setCloud(false);
@@ -697,10 +418,10 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
697
418
  // scroll are the history's (place, timingHost, holds). The slots keep
698
419
  // what they show until clearStage.
699
420
  function releaseTurn() {
700
- stopWatch();
421
+ watch.stop();
701
422
  clearTimeout(settleTimer);
702
423
  holding = false;
703
- held = null;
424
+ frame.resetHeld();
704
425
  endKind = null;
705
426
  emptyAnswer = false;
706
427
  settled = true;
@@ -712,7 +433,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
712
433
  cancelFold();
713
434
  emptySlots();
714
435
  stage.hidden = true;
715
- frameFree();
436
+ frame.frameFree();
716
437
  if (revealed) {
717
438
  revealed = false;
718
439
  collapsed = readCollapsed();
@@ -792,8 +513,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
792
513
  else release();
793
514
  }
794
515
  holding = true;
795
- held = null;
796
- maxContent = 0;
516
+ frame.resetHeld();
517
+ frame.forgetContent();
797
518
  endKind = null;
798
519
  emptyAnswer = false;
799
520
  settled = true;
@@ -839,7 +560,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
839
560
  clearTimeout(settleTimer);
840
561
  if (!settled) settleTimer = setTimeout(() => { settled = true; }, SETTLE_CAP_MS);
841
562
  refresh();
842
- startWatch();
563
+ watch.start();
843
564
  return kept;
844
565
  },
845
566
  // A hook's notice and a plugin's nudge go into their step inside the
@@ -899,7 +620,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
899
620
  // A card that waits for the user is brought into the stage's view: in
900
621
  // a short window the prompt and headline fill it and the card sat
901
622
  // below, out of sight (once the stage has grown for it).
902
- if (waits) afterGrowth(() => reveal(slot));
623
+ if (waits) frame.afterGrowth(() => frame.reveal(slot));
903
624
  return true;
904
625
  },
905
626
  // Where the live timing line goes (the status row), null: the history.