samagotchi 0.168.0 → 0.169.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +1 -1
  5. data/docs/guardrails.md +2 -2
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/block-renderers.md +1 -1
  8. data/docs/internals/bridge.md +16 -4
  9. data/docs/internals/inline-widgets.md +1 -1
  10. data/docs/internals/objectives.md +2 -1
  11. data/docs/internals/remote-sessions.md +1 -1
  12. data/docs/internals/web-frontend.md +62 -22
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +10 -6
  15. data/docs/testing.md +12 -0
  16. data/lib/samagotchi/bridge.rb +12 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +3 -0
  19. data/lib/samagotchi/command_intake.rb +7 -7
  20. data/lib/samagotchi/compat.rb +14 -5
  21. data/lib/samagotchi/engine/objectives.rb +10 -1
  22. data/lib/samagotchi/guardrails/approvals.rb +8 -3
  23. data/lib/samagotchi/session_continue.rb +8 -11
  24. data/lib/samagotchi/session_manager/removal.rb +3 -3
  25. data/lib/samagotchi/session_stores.rb +79 -0
  26. data/lib/samagotchi/turn_stages/objectives.rb +6 -8
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/app.js +3 -1
  29. data/lib/samagotchi/web/public/block_watch.js +1 -1
  30. data/lib/samagotchi/web/public/cards_place.js +5 -1
  31. data/lib/samagotchi/web/public/index.html +58 -39
  32. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  33. data/lib/samagotchi/web/public/question_cards.js +20 -6
  34. data/lib/samagotchi/web/public/stage_asking.js +283 -0
  35. data/lib/samagotchi/web/public/stage_frame.js +7 -11
  36. data/lib/samagotchi/web/public/stage_model.js +37 -11
  37. data/lib/samagotchi/web/public/stage_opened.js +28 -18
  38. data/lib/samagotchi/web/public/stage_slots.js +13 -89
  39. data/lib/samagotchi/web/public/stage_view.js +202 -160
  40. data/lib/samagotchi/web/public/stage_waiting.js +180 -0
  41. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  42. data/lib/samagotchi/web/public/turn_model.js +1 -1
  43. data/lib/samagotchi/web/session_show.rb +14 -4
  44. data/lib/samagotchi/worker.rb +3 -4
  45. metadata +4 -1
@@ -10,33 +10,36 @@
10
10
  // newest step first while the turn runs. When the turn has ended and the stage is not
11
11
  // in use, everything moves into the history in the turn block's order (the
12
12
  // hand-off), so a handed-off turn reads like its reload. Slots are patched in place, never rebuilt from HTML: a selection
13
- // and annotate survive. When the model's own question blocks the turn
14
- // after a reply, the turn opens in the history (stage_opened.js): the
15
- // reply becomes a conversation bubble, the prompt, the block and the rows
16
- // so far go there, and the stage shows its asking form (the card alone)
17
- // until the answer. The rules are pure in stage_model.js; the live
18
- // slots are drawn by stage_slots.js, the cap and the held height are
19
- // stage_frame.js's, and the in-use watch that times the hand-off is
20
- // stage_watch.js's.
13
+ // and annotate survive. When a card waits for the user (a question, an
14
+ // approval, a delegate's relayed card, a hook's question, check-in), the
15
+ // turn opens in the history (stage_opened.js): the prompt, the block and the
16
+ // rows so far go there (chi's line before its own question or approval as a
17
+ // lead-in bubble), and the stage docks the card alone (the asking form)
18
+ // until the answer. The step limit's card goes under its ended turn. The
19
+ // rules are pure in stage_model.js; the live slots are drawn by
20
+ // stage_slots.js, the cap and the held height are stage_frame.js's, the
21
+ // in-use watch that times the hand-off is stage_watch.js's, the docked card
22
+ // and the fold are stage_asking.js's, and the waiting cards as data are
23
+ // stage_waiting.js's.
21
24
  import { createTurnBlock } from "./turn_block.js";
22
25
  import { openAfterEnd } from "./block_open.js";
23
- import { chipLabelText, flashOf, handOffDue, handOffOrder, headlineOf, isPlain, liveSlots, MARKS, miniToolText, placeFor, placeKind, QUEUED_CHILDREN, stepSlotText } from "./stage_model.js";
26
+ import { chipLabelText, flashOf, handOffDue, handOffOrder, headlineOf, isPlain, liveSlots, MARKS, foldedRowText, phaseLabel, placeFor, placeKind, QUEUED_CHILDREN, stepSlotText } from "./stage_model.js";
24
27
  import { createStageSlots, node } from "./stage_slots.js";
25
28
  import { createStageFrame, reducedMotion } from "./stage_frame.js";
26
29
  import { createInUseWatch } from "./stage_watch.js";
27
30
  import { createStageStall, GRACE_MS, stallState } from "./stage_stall.js";
28
31
  import { formatDuration } from "./timing.js";
29
32
  import { easeScrollTo } from "./scroll.js";
30
- import { readStored, writeStored } from "./storage.js";
31
33
  import { createWorkingSetRail } from "./working_set_rail.js";
32
34
  import { moveToHistoryEnd } from "./history_end.js";
33
35
  import { turnElements } from "./turn_range.js";
34
36
  import { createOpenedTurn } from "./stage_opened.js";
35
37
  import { askingRefs } from "./turn_model.js";
36
- import { ASKS, PENDING, waitsInStage } from "./stage_cards.js";
38
+ import { ASKS, PENDING } from "./stage_cards.js";
39
+ import { createStageAsking } from "./stage_asking.js";
40
+ import { createStageWaiting, isApprovalCard, isBlocking, waitingKey, waitingKind } from "./stage_waiting.js";
37
41
  import { stampRows } from "./working_set_view.js";
38
42
 
39
- const COLLAPSE_KEY = "chi_stage_collapsed";
40
43
  // The turn-end read (markdown, answer_display) settles within this, or the
41
44
  // hand-off stops waiting for it.
42
45
  const SETTLE_CAP_MS = 8000;
@@ -51,26 +54,15 @@ const ANSWER_CUT = 12;
51
54
  const ANSWERED_LINE = 40;
52
55
  // That line brought back sits this far under the scroller's edge.
53
56
  const ANSWERED_GAP = 8;
54
- // The model's own question (question_cards.js data-kind; not an approval,
55
- // the step limit, a hook's question or a delegate's relayed one): the one
56
- // card whose asking text opens the turn in the history (stage_opened.js).
57
- const ASK_CARD = '.bubble.question[data-kind="question"]:not([data-relay])';
58
- // A blocking approval, a delegate's relayed one too: too tall for the stage's
59
- // view, it gets a raised cap (raiseForApproval).
60
- const APPROVAL_CARD = '.bubble.question[data-kind="approval"]';
61
57
  // What opens a turn in the history (reload_check.js's OPENING).
62
58
  const OPENING = "[data-turn-id], [data-opening-id]";
63
59
 
64
- function readCollapsed() {
65
- return readStored(COLLAPSE_KEY) === "1";
66
- }
67
-
68
60
  // +historyNearBottom+: the history's own follow check (the hand-off scrolls
69
61
  // the history only for a reader at its end). +dockEl+ and +centerEl+ size
70
62
  // the stage's cap.
71
63
  // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
72
- // on a running task_wait. +renderMarkdown+ and +linkRefs+: the asking
73
- // slot's markdown read and ref pass (stage_slots.js). +workingSet+: the
64
+ // on a running task_wait. +renderMarkdown+ and +linkRefs+: a lead-in
65
+ // bubble's markdown read and ref pass (stage_opened.js). +workingSet+: the
74
66
  // page's working set (working_set_view.js, web.working_set; null: off): its
75
67
  // rail in the live slots, its footprint at the hand-off. +onCancel+: the
76
68
  // status row's Stop turn (a promise of the cancel request; the button is
@@ -99,6 +91,18 @@ export function createStageView({
99
91
  const status = node("div", "ts-status", stage);
100
92
  const mark = node("span", "ts-mark", status);
101
93
  const phaseEl = node("span", "ts-phase", status);
94
+ // Two cards docked: one at a time, "‹ 1 of 2 ›" (stage_waiting.js shown).
95
+ const pager = node("span", "ts-pager", status);
96
+ pager.hidden = true;
97
+ const pagePrev = node("button", "ts-page", pager);
98
+ pagePrev.type = "button";
99
+ pagePrev.textContent = "‹";
100
+ pagePrev.setAttribute("aria-label", "The card before");
101
+ const pageLabel = node("span", "ts-page-label", pager);
102
+ const pageNext = node("button", "ts-page", pager);
103
+ pageNext.type = "button";
104
+ pageNext.textContent = "›";
105
+ pageNext.setAttribute("aria-label", "The next card");
102
106
  const stepEl = node("span", "ts-step", status);
103
107
  const timingSlot = node("span", "ts-timing", status);
104
108
  const miniTool = node("span", "ts-mini", status);
@@ -131,11 +135,6 @@ export function createStageView({
131
135
  headline.tabIndex = 0;
132
136
  headline.setAttribute("role", "button");
133
137
  headline.title = "Show this step's reasoning";
134
- // While a question or approval waits: the whole reply that asked it, in
135
- // the headline's place (the card says what to answer, this says why),
136
- // as markdown when web.markdown is on.
137
- const asking = node("div", "ts-asking", live);
138
- asking.hidden = true;
139
138
  const now = node("div", "ts-now", live);
140
139
  const trail = node("div", "ts-trail", live);
141
140
  // The working set's rail: out of the live slots' flow on their right (a
@@ -149,6 +148,11 @@ export function createStageView({
149
148
  workingSet.onServer(() => scheduleRefresh());
150
149
  }
151
150
  const extras = node("div", "ts-extras", scroller);
151
+ // While check-in's card is docked: the live step's text, streamed under
152
+ // the card (the steps are in the history then). The answer's own slot
153
+ // takes it over at the turn's end (placeAnswer, in one task).
154
+ const asksText = node("div", "ts-asks-text", scroller);
155
+ asksText.hidden = true;
152
156
  const answer = node("div", "ts-answer", scroller);
153
157
  const tail = node("div", "ts-tail", scroller);
154
158
  // The cloud's steps, the scroller's last slot, right above the chip.
@@ -174,7 +178,7 @@ export function createStageView({
174
178
  // The quiet signal: its span in the now row, its head after the timing line.
175
179
  const stall = createStageStall({ stage, nowEl: now, timingSlot });
176
180
  const slotsView = createStageSlots({
177
- headline, asking, now, trail, ticks, taskStop, renderMarkdown, linkRefs,
181
+ headline, now, trail, ticks, taskStop,
178
182
  gens: () => view.current().turn?.gens,
179
183
  onChange: () => scheduleRefresh(),
180
184
  });
@@ -191,10 +195,7 @@ export function createStageView({
191
195
  let thinkingLine = null; // { step, text } from the live step's ticker
192
196
  let flash = null; // { kind, text, until } a notice or nudge in the trail
193
197
  let flashTimer = null; // its end's redraw
194
- let collapsed = readCollapsed();
195
198
  let cloudOpen = false;
196
- // A ref's "show in turn" opened a collapsed stage (not kept).
197
- let revealed = false;
198
199
  let rafPending = false;
199
200
  // A turn the page knows runs (the session says running) with none in the
200
201
  // stage to show (a join without its current_turn): the status row alone,
@@ -244,6 +245,7 @@ export function createStageView({
244
245
  blockHost: (el) => cloudBody.appendChild(el),
245
246
  placeAnswer: (pieces, { plain, blockEl }) => {
246
247
  if (plain) blockEl.remove();
248
+ drawAsksText("");
247
249
  answer.append(...pieces);
248
250
  showAnswerTop();
249
251
  },
@@ -256,6 +258,19 @@ export function createStageView({
256
258
  },
257
259
  });
258
260
 
261
+ // The cards that wait in the stage, as data (stage_waiting.js): the
262
+ // docked card, its fold, what blocks, the approval's cap read them.
263
+ const waiting = createStageWaiting();
264
+ // The asking form and the stage's fold (stage_asking.js).
265
+ const ask = createStageAsking({
266
+ stage, extras, tail, scroller, fold, view, opener, frame: () => frame, isOpened: () => opened, setOpened,
267
+ isEnded: () => !!endKind, promptEl: () => promptEl, setCloud, scheduleRefresh, pendingCard, waiting,
268
+ });
269
+ const {
270
+ askingGen, asksOf, askedFor, askedText, docks, leadInText, openTurn, settleCards, askingCard, cardOf, foldedText, markOwnToggles,
271
+ isCollapsed, drawCollapsed, applyCollapsed, setCollapsed,
272
+ } = ask;
273
+
259
274
  // ── slots ────────────────────────────────────────────────────────────────
260
275
 
261
276
  // "history" (an opened turn's), "cloud", "answer" (a plain turn's in
@@ -272,8 +287,12 @@ export function createStageView({
272
287
  requestAnimationFrame(refresh);
273
288
  }
274
289
 
290
+ // A card that blocks the turn waits (stage_waiting.js, checked against
291
+ // the slots first: a replay answers a card and starts the next step in one
292
+ // task, before the refresh).
275
293
  function pendingCard() {
276
- return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
294
+ ask.reconcileWaiting();
295
+ return waiting.count() > 0;
277
296
  }
278
297
 
279
298
  // A blocking approval too tall for the stage's view: a raised cap, the
@@ -282,75 +301,64 @@ export function createStageView({
282
301
  // fits never gets here.
283
302
  function raiseForApproval(blocked) {
284
303
  if (!blocked || roomCap) return;
285
- const card = extras.querySelector(PENDING) || tail.querySelector(PENDING);
286
- if (!card?.matches(APPROVAL_CARD) || frame.fitsCap(card)) return;
304
+ const front = waiting.get(waiting.front());
305
+ const card = front?.approval ? cardOf(front.key) : null;
306
+ if (!card || frame.fitsCap(card)) return;
287
307
  roomCap = true;
288
308
  frame.fitCap();
289
309
  }
290
310
 
291
- // The step that asked the turn's question: live, the step whose call
292
- // runs (turn_model.js askingText); in a replay (a reload, a resync) the
293
- // asking call has already completed when its question_requested comes
294
- // (TurnAccumulator.join_events), so the last step when its last call is
295
- // ask_user_question and no card of this page took its reply yet (+asked+:
296
- // a later card, another kind's, never reuses it). null: none.
297
- function askingGen() {
298
- const { turn } = view.current();
299
- const gen = turn && !turn.ended ? turn.gens[turn.gens.length - 1] : null;
300
- if (!gen?.text) return null;
301
- if (view.askingText()) return gen;
302
- return gen.tools[gen.tools.length - 1]?.tool === "ask_user_question" && !asked.has(gen) ? gen : null;
303
- }
304
- // The steps whose reply a question card took (place: question_cards.js
305
- // reads askingText right before), for askingGen's replay rule.
306
- const asked = new WeakSet();
307
- function askedFor() {
308
- const gen = askingGen();
309
- if (gen) asked.add(gen);
310
- }
311
-
312
- // The reply that asked the question, "" for none.
313
- function askedText() {
314
- return String(askingGen()?.text || "").trim();
311
+ // An asking card (check-in) waits while the turn runs.
312
+ function asksCard() {
313
+ return !endKind && waiting.count({ asks: true }) > waiting.count();
315
314
  }
316
315
 
317
- // The reply that asked +el+ when +el+ is the model's own question and
318
- // there is one (the promotion rule: ASK_CARD, in the extras, a reply
319
- // before it); "" otherwise.
320
- function promotedText(el) {
321
- if (!el.matches?.(ASK_CARD) || !el.matches(PENDING)) return "";
322
- return askedText();
316
+ // One docked card at a time: the others wait behind it (.ts-behind) and
317
+ // the pager says where the shown one is.
318
+ function drawDocked(shownCard) {
319
+ const keys = waiting.docked();
320
+ for (const key of keys) cardOf(key)?.classList.toggle("ts-behind", !!shownCard && cardOf(key) !== shownCard);
321
+ for (const el of extras.querySelectorAll(":scope > .ts-behind")) if (!keys.includes(waitingKey(el))) el.classList.remove("ts-behind");
322
+ // Focus in a card that went behind (a new card came in front of it):
323
+ // to the card that shows now.
324
+ const focused = document.activeElement;
325
+ if (shownCard && focused && [...extras.querySelectorAll(":scope > .ts-behind")].some((el) => el.contains(focused))) focusIn(shownCard);
326
+ const at = shownCard ? keys.indexOf(waitingKey(shownCard)) : -1;
327
+ pager.hidden = keys.length < 2 || at < 0;
328
+ if (pager.hidden) return;
329
+ const label = `${at + 1} of ${keys.length}`;
330
+ if (pageLabel.textContent !== label) pageLabel.textContent = label;
331
+ pagePrev.disabled = at === 0;
332
+ pageNext.disabled = at === keys.length - 1;
333
+ // A pager button the page just disabled keeps no focus: the other one has it.
334
+ if (document.activeElement === pagePrev && pagePrev.disabled) pageNext.focus();
335
+ else if (document.activeElement === pageNext && pageNext.disabled) pagePrev.focus();
323
336
  }
324
337
 
325
- // The turn opens in the history (once): the prompt, the block (closed;
326
- // its steps in order from now on) and the rows the extras hold, in the
327
- // hand-off's order. What waits stays.
328
- function openTurn() {
329
- if (opened) return;
330
- const { blockEl } = view.current();
331
- view.inOrder?.();
332
- setCloud(false);
333
- if (blockEl) blockEl.open = false;
334
- const rows = [...extras.children].filter((el) => !waitsInStage(el));
335
- setOpened(true);
336
- opener.open(handOffOrder({ prompt: promptEl, block: blockEl, extras: rows }));
337
- }
338
-
339
- // An opened turn's cards that stopped waiting go into the history: a
340
- // promoted one under its reason (also after the turn's end, so a card
341
- // cancelled by Stop turn sits there), another one at the history's end
342
- // while the turn runs.
343
- function settleCards() {
344
- if (!opened) return;
345
- for (const el of [...extras.children]) {
346
- if (waitsInStage(el)) continue;
347
- if (opener.has(el)) opener.settle(el);
348
- else if (!endKind) opener.add(el);
338
+ // +card+'s first control takes the focus (its summary is out of the tab
339
+ // order while it waits), else the card.
340
+ function focusIn(card) {
341
+ const control = card.querySelector('input:not([type="hidden"]), textarea, select, button:not([disabled]), a[href]');
342
+ if (control) control.focus();
343
+ else {
344
+ if (!card.hasAttribute("tabindex")) card.tabIndex = -1;
345
+ card.focus();
349
346
  }
350
347
  }
348
+ for (const [button, by] of [[pagePrev, -1], [pageNext, 1]]) {
349
+ button.addEventListener("click", (e) => {
350
+ e.stopPropagation();
351
+ waiting.page(by);
352
+ refresh();
353
+ const shown = askingCard();
354
+ if (shown) frame.afterGrowth(() => frame.reveal(shown.parentElement));
355
+ });
356
+ }
351
357
 
352
- function asksCard() {
353
- return !endKind && !!extras.querySelector(ASKS);
358
+ // The text under a docked check-in card, patched only when it changes.
359
+ function drawAsksText(text) {
360
+ if (asksText.textContent !== text) asksText.textContent = text;
361
+ asksText.hidden = !text;
354
362
  }
355
363
  // A card answered or redrawn in place (its buttons gone) changes the
356
364
  // status without a turn event: at once (a microtask), so the slots swap in
@@ -377,13 +385,21 @@ export function createStageView({
377
385
  const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
378
386
  // A question blocks the turn: its card takes the live slots' room. An
379
387
  // asking card's turn goes on: the status waits, the slots stay.
388
+ ask.reconcileWaiting();
380
389
  settleCards();
390
+ markOwnToggles();
381
391
  const blocked = pendingCard();
382
- // The asking form: a promoted card waits (the card alone in the stage;
383
- // its reply is the reason bubble in the history, not the asking slot).
384
- const askingForm = [...extras.querySelectorAll(PENDING)].some((el) => opener.has(el));
392
+ // The asking form: a docked card waits (the card alone in the stage;
393
+ // its turn is in the history, a reply before it the lead-in bubble).
394
+ const asking = askingCard();
395
+ const askingForm = !!asking;
385
396
  stage.classList.toggle("asking-form", askingForm);
386
- const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), blocked, ended: endKind, emptyAnswer });
397
+ // Its fold (stage_asking.js trackFold): that card's only.
398
+ ask.trackFold();
399
+ drawDocked(asking);
400
+ const collapsed = isCollapsed();
401
+ drawCollapsed();
402
+ const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
387
403
 
388
404
  // The live slots keep their height while the turn waits and until its
389
405
  // next step (held-wait, set with waiting): the card under them never
@@ -398,13 +414,18 @@ export function createStageView({
398
414
  cancel.hidden = !onCancel || !!endKind;
399
415
  stage.classList.toggle("waiting", blocked);
400
416
  if (!blocked && wasWaiting) settleAnsweredCard();
401
- stage.classList.toggle("asking", !!slots.asking);
402
417
  stage.dataset.phase = slots.phase;
403
418
  mark.textContent = MARKS[slots.phase] || "";
404
- phaseEl.textContent = slots.phase;
419
+ // check-in docked, nothing blocking: the row says what chi does now (the
420
+ // running call, "writing" while text streams) and the text streams
421
+ // under the card.
422
+ const asksFront = !!asking && !blocked && !endKind && waiting.get(waiting.front())?.kind === "asks";
423
+ const streaming = asksFront ? liveSlots(turn).phase : "";
424
+ phaseEl.textContent = phaseLabel({ phase: slots.phase, asksFront, streaming });
425
+ stage.classList.toggle("asks-live", asksFront);
426
+ drawAsksText(asksFront && gen?.text && !gen.answer ? String(gen.text).trim() : "");
405
427
 
406
428
  slotsView.drawHeadline(slots.headline);
407
- slotsView.drawAsking(askingForm ? "" : slots.asking, slots.askingRefs);
408
429
  raiseForApproval(blocked);
409
430
  slotsView.drawNow(slots.tool, slots.quiet);
410
431
  drawStall(clock(), blocked);
@@ -422,15 +443,19 @@ export function createStageView({
422
443
  // that line is elsewhere (a turn the stage didn't hold from its start).
423
444
  const elapsed = endKind && !timingSlot.firstChild ? formatDuration(endedAt - startedAt) : "";
424
445
  chipLabel.textContent = chipLabelText({ steps: slots.step, calls, elapsed });
425
- const mini = miniToolText({ collapsed, tool: slots.tool });
446
+ // Folded, the row says what the docked card asks (live and after a
447
+ // reload alike: from the card), else the running call.
448
+ const mini = foldedRowText({ collapsed, question: foldedText(asking), tool: slots.tool, running: asksFront });
426
449
  miniTool.textContent = mini.text;
427
450
  miniTool.title = mini.title;
451
+ miniTool.classList.toggle("ts-mini-question", mini.question);
428
452
 
429
453
  // An ended turn's live slots go, unless something under them would move
430
454
  // (a card or a row in the extras): then they stay as they were last.
431
455
  live.hidden = plain || (!!endKind && !extras.firstChild);
432
- // The chip: a block in the cloud, or in the history (it reveals it there).
433
- cloud.hidden = place !== "cloud" && place !== "history";
456
+ // The chip: a block in the cloud. An opened turn's steps line in the
457
+ // history says the count: none here.
458
+ cloud.hidden = place !== "cloud";
434
459
  cloudBody.hidden = !cloudOpen || cloud.hidden;
435
460
  // Running: the step it is on. Ended: the count, unless the chip says it.
436
461
  stepEl.textContent = stepSlotText({ step: slots.step, ended: !!endKind, chipHidden: cloud.hidden });
@@ -529,25 +554,6 @@ export function createStageView({
529
554
 
530
555
  // ── collapse, cloud, peek ────────────────────────────────────────────────
531
556
 
532
- function applyCollapsed() {
533
- stage.classList.toggle("collapsed", collapsed);
534
- scroller.hidden = collapsed;
535
- fold.textContent = collapsed ? "▴" : "▾";
536
- fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
537
- fold.setAttribute("aria-expanded", String(!collapsed));
538
- frame.fitCap();
539
- scheduleRefresh();
540
- }
541
-
542
- function setCollapsed(value) {
543
- collapsed = value;
544
- revealed = false;
545
- writeStored(COLLAPSE_KEY, value ? "1" : "0");
546
- applyCollapsed();
547
- // Opened on a waiting card: it shows as it does on arrival.
548
- if (!value && pendingCard()) frame.afterGrowth(() => frame.reveal(tail.querySelector(PENDING) ? tail : extras));
549
- }
550
-
551
557
  function setCloud(open) {
552
558
  cloudOpen = open;
553
559
  cloudBody.hidden = !open || cloud.hidden;
@@ -574,7 +580,7 @@ export function createStageView({
574
580
  opener.reveal(step.el);
575
581
  return;
576
582
  }
577
- if (collapsed) setCollapsed(false);
583
+ if (isCollapsed()) setCollapsed(false);
578
584
  setCloud(true);
579
585
  if (!step.el.open) step.summary.click();
580
586
  const wrap = step.thinkingWrap;
@@ -589,16 +595,14 @@ export function createStageView({
589
595
 
590
596
  fold.addEventListener("click", (e) => {
591
597
  e.stopPropagation();
592
- setCollapsed(!collapsed);
598
+ setCollapsed(!isCollapsed());
593
599
  });
594
600
  status.addEventListener("click", (e) => {
595
- if (collapsed && !e.target.closest("button, a")) setCollapsed(false);
601
+ if (isCollapsed() && !e.target.closest("button, a")) setCollapsed(false);
596
602
  });
597
603
  chip.addEventListener("click", (e) => {
598
604
  const tick = e.target.closest(".ts-tick");
599
605
  if (tick) openStep(Number(tick.dataset.step));
600
- // An opened turn's block is in the history: the chip shows it there.
601
- else if (opened) opener.reveal(view.current().blockEl);
602
606
  else setCloud(!cloudOpen);
603
607
  });
604
608
  headline.addEventListener("click", peek);
@@ -613,11 +617,7 @@ export function createStageView({
613
617
  if (!cloudBody.contains(e.target)) return;
614
618
  if (folding) return finishFold();
615
619
  if (!holding) return;
616
- if (collapsed) {
617
- collapsed = false;
618
- revealed = true;
619
- applyCollapsed();
620
- }
620
+ if (isCollapsed()) ask.reveal();
621
621
  setCloud(true);
622
622
  if (e.detail) e.detail.until = new Promise((resolve) => frame.afterGrowth(resolve));
623
623
  });
@@ -636,13 +636,13 @@ export function createStageView({
636
636
  // The cap and the frame (stage_frame.js): the stage's height, held while
637
637
  // it holds a turn, and its own scrolls.
638
638
  const frame = createStageFrame({
639
- stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl,
640
- observed: [status, promptSlot, live, extras, answer, tail, cloudBody, cloud, queuedSlot],
639
+ stage, scroller, status, cloud, queuedSlot, holdEl, extras, dockEl, centerEl,
640
+ observed: [status, promptSlot, live, extras, asksText, answer, tail, cloudBody, cloud, queuedSlot],
641
641
  isHolding: () => holding,
642
- isCollapsed: () => collapsed,
642
+ isCollapsed,
643
643
  isCloudOpen: () => cloudOpen,
644
644
  isRoomCap: () => roomCap,
645
- waitingCard: () => (stage.classList.contains("waiting") ? extras.querySelector(PENDING) || tail.querySelector(PENDING) : null),
645
+ waitingCard: () => (stage.classList.contains("waiting") ? cardOf(waiting.front()) : null),
646
646
  });
647
647
 
648
648
  // The in-use watch (stage_watch.js): an ended turn hands off once the
@@ -683,6 +683,7 @@ export function createStageView({
683
683
 
684
684
  function emptySlots() {
685
685
  for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
686
+ drawAsksText("");
686
687
  setOpened(false);
687
688
  promptEl = null;
688
689
  // The turn left the stage: the status row and the chip say nothing until
@@ -703,9 +704,13 @@ export function createStageView({
703
704
  flash = null;
704
705
  clearTimeout(flashTimer);
705
706
  roomCap = false;
707
+ markOwnToggles();
706
708
  opener.reset();
709
+ waiting.reset();
710
+ miniTool.classList.remove("ts-mini-question");
707
711
  setCloud(false);
708
- stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use", "asking-form");
712
+ stage.classList.remove("plain", "ended", "waiting", "held-wait", "in-use", "asking-form", "asks-live");
713
+ pager.hidden = true;
709
714
  stage.style.removeProperty("--live-held");
710
715
  }
711
716
 
@@ -731,11 +736,7 @@ export function createStageView({
731
736
  emptySlots();
732
737
  stage.hidden = true;
733
738
  frame.frameFree();
734
- if (revealed) {
735
- revealed = false;
736
- collapsed = readCollapsed();
737
- applyCollapsed();
738
- }
739
+ ask.unreveal();
739
740
  }
740
741
 
741
742
  function release() {
@@ -760,6 +761,8 @@ export function createStageView({
760
761
  const { blockEl } = view.current();
761
762
  view.chronological();
762
763
  const place = blockPlace(blockEl);
764
+ // A finished turn's block draws as a reload draws it.
765
+ if (blockEl) delete blockEl.dataset.openedTurn;
763
766
  // The block moves with the pieces from the cloud; an opened turn's is
764
767
  // in the history already.
765
768
  const block = place === "cloud" ? blockEl : null;
@@ -772,13 +775,13 @@ export function createStageView({
772
775
  // The prompt still in its slot (an opened turn's is in the history).
773
776
  prompt: !opened && promptEl?.parentNode === promptSlot ? promptEl : null,
774
777
  block,
775
- extras: [...extras.children],
778
+ extras: [...extras.children].map((el) => { el.classList.remove("ts-behind"); return el; }),
776
779
  answer: [...answer.children],
777
780
  timing: timingSlot.firstElementChild,
778
781
  kept: [...tail.children],
779
782
  queued: [...queuedSlot.children],
780
783
  });
781
- const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
784
+ const animate = !immediate && !reducedMotion() && !document.hidden && !isCollapsed();
782
785
  folding = { pieces, block: blockEl, endKind, wasAtEnd: historyNearBottom(), animate, immediate, opened, prompt: promptEl };
783
786
  releaseTurn();
784
787
  if (!animate) return finishFold();
@@ -795,6 +798,9 @@ export function createStageView({
795
798
  // The stage held it open; here it takes its place in the history:
796
799
  // closed, unless the turn was cut short with an outline (block_open.js).
797
800
  if (fold.block) fold.block.open = openAfterEnd(fold.endKind, fold.block.classList.contains("outline"));
801
+ // A piece removed while it slid (the old continue card the step-limit
802
+ // question replaces) stays gone.
803
+ fold.pieces = fold.pieces.filter((el) => el.isConnected);
798
804
  moveToHistoryEnd(historyEl, fold.pieces, { animate: fold.animate });
799
805
  // An opened turn's reasons go in the pieces' task (after them: the
800
806
  // history is as tall as it gets, room to keep the reader's rows in
@@ -903,7 +909,10 @@ export function createStageView({
903
909
  // The warn cards the extras kept in sight leave the turn with the
904
910
  // block's (card.js leavesBlock): after the end line, as a reload has them.
905
911
  const warn = [...extras.querySelectorAll(":scope > .plugin-card.warn")];
906
- warn.forEach((el) => el.remove());
912
+ warn.forEach((el) => {
913
+ el.remove();
914
+ waiting.settle(waitingKey(el), { only: "warn" });
915
+ });
907
916
  kept.push(...warn);
908
917
  view.chronological();
909
918
  endKind = args.kind;
@@ -946,8 +955,8 @@ export function createStageView({
946
955
  containsLive: (el) => view.containsLive(el),
947
956
  // The reply that asked (question_cards.js keeps it for the answered
948
957
  // card's two lines), a replayed question's too.
949
- askingText: () => askedText(),
950
- // The asking slot pairs them on its next draw.
958
+ askingText: (pq) => askedText(pq),
959
+ // A lead-in bubble drawn after this pairs them (askingRefs).
951
960
  questionRefs(refs) {
952
961
  alive();
953
962
  view.questionRefs(refs);
@@ -956,7 +965,9 @@ export function createStageView({
956
965
  // The turn model and its live phase as the status row reads it (the
957
966
  // composer's delivery line).
958
967
  current: () => view.current(),
959
- phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
968
+ // Only a card that blocks says "goes in once the card above is
969
+ // answered": check-in leaves the composer free.
970
+ phase: () => liveSlots(view.current().turn, { pending: pendingCard() }).phase,
960
971
  // Another session opens, or this one renders again: a turn still
961
972
  // sliding away is dropped (its history is about to be drawn anew),
962
973
  // never moved into whatever history shows now.
@@ -989,37 +1000,62 @@ export function createStageView({
989
1000
  scheduleRefresh();
990
1001
  return true;
991
1002
  }
992
- // An opened turn's row: the history's end, in arrival order.
1003
+ // An opened turn's row: the history's end, in arrival order; a card
1004
+ // that stopped waiting before it (a replay answers it and steers in
1005
+ // one task, before the refresh) goes there first.
993
1006
  if (where === "opened") {
1007
+ ask.reconcileWaiting();
1008
+ settleCards();
994
1009
  opener.add(el);
995
1010
  scheduleRefresh();
996
1011
  return true;
997
1012
  }
998
- // The model's own question after a reply: the turn opens in the
999
- // history, the reason lands there in this task, the card stays here.
1000
- const reason = waits && !endKind ? promotedText(el) : "";
1001
- // Read before the turn's pieces make the history taller.
1002
- const atEnd = reason ? historyNearBottom() : false;
1003
- if (reason) openTurn();
1013
+ // A card that waits for the user docks: the turn opens in the
1014
+ // history, a lead-in lands there in this task, the card stays here.
1015
+ const docked = docks(el);
1016
+ const key = waitingKey(el);
1017
+ // A card drawn again with its key (the same question) keeps its lead-in.
1018
+ const kept = docked && opener.has(key);
1019
+ const reason = docked && !kept ? leadInText(el) : "";
1020
+ // Read before the turn's pieces make the history taller: with no
1021
+ // lead-in to read, a reader at the end follows the turn's move.
1022
+ const atEnd = docked ? historyNearBottom() : false;
1023
+ if (docked) openTurn({ follow: atEnd && !reason });
1004
1024
  const slot = endKind ? tail : extras;
1005
1025
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
1006
- slot.appendChild(el);
1026
+ // The stage's order (stage_waiting.js): a card that blocks goes before
1027
+ // the asking ones, as a reload places them.
1028
+ const behind = isBlocking(waitingKind(el)) ? [...slot.children].find((x) => waitingKind(x) === "asks") : null;
1029
+ slot.insertBefore(el, behind || null);
1007
1030
  slot.hidden = false;
1031
+ // The card is the stage's to keep (stage_waiting.js): docked when it
1032
+ // waits for the user (its lead-in is the opener's, by the same key).
1033
+ const waitKind = waitingKind(el);
1034
+ if (waitKind) waiting.arrive(key, { kind: waitKind, docked, approval: isApprovalCard(el) });
1008
1035
  if (stick || waits) slot.scrollTop = slot.scrollHeight;
1036
+ if (kept) opener.relink(key, el);
1009
1037
  if (reason) {
1010
1038
  const { turn } = view.current();
1011
1039
  opener.ask({
1012
- card: el, text: reason, refs: askingRefs(turn) || askingGen()?.refs || null, step: turn?.gens.length || 0,
1040
+ card: el, key, text: reason, refs: askingRefs(turn) || askingGen(asksOf(el))?.refs || null, step: turn?.gens.length || 0,
1013
1041
  turnId: promptEl?.dataset.turnId || null, questionId: el.dataset.qid ?? null, follow: atEnd,
1014
1042
  });
1015
1043
  }
1016
- // A waiting card and the asking text (refresh) land in one task.
1044
+ // The step limit: its card goes under its ended turn in the
1045
+ // conversation at once, as a reload draws it (the hand-off: its fold,
1046
+ // the history's follow and the reload check as for any finished turn;
1047
+ // the in-use watch and the end read don't hold it).
1048
+ if (endKind && waitKind === "continue") {
1049
+ handOff();
1050
+ return true;
1051
+ }
1052
+ // A waiting card and the asking form (refresh) land in one task.
1017
1053
  if (waits) refresh();
1018
1054
  else scheduleRefresh();
1019
1055
  // The asking form: the stage shrinks to the card (one of its two
1020
1056
  // allowed shrinks, stage_frame.js; the other is releaseHeldWait).
1021
- if (reason) frame.forgetContent();
1022
- if (waits && el.matches?.(".bubble.question")) askedFor();
1057
+ if (docked) frame.forgetContent();
1058
+ if (waits && el.matches?.(".bubble.question")) askedFor(el);
1023
1059
  // A card that waits for the user is brought into the stage's view: in
1024
1060
  // a short window the prompt and headline fill it and the card sat
1025
1061
  // below, out of sight (once the stage has grown for it).
@@ -1059,6 +1095,12 @@ export function createStageView({
1059
1095
  drawStall(nowMs);
1060
1096
  },
1061
1097
  handOff,
1098
+ // A card stopped waiting (stage_waiting.js keys; +only+: when it is that
1099
+ // kind): question_cards.js's resolve path, cards_place.js upsertCard.
1100
+ // The stage's redraw comes from the card's own change.
1101
+ settleWaiting: (key, opts) => waiting.settle(key, opts),
1102
+ // The waiting cards as data (the tests' and the harness's read).
1103
+ waiting: { list: () => waiting.list(), front: () => waiting.front(), docked: () => waiting.docked(), folded: () => waiting.folded(), count: (o) => waiting.count(o) },
1062
1104
  root: stage,
1063
1105
  scrollEl: scroller,
1064
1106
  };