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
@@ -24,6 +24,7 @@ import { clientLabel, isOwn } from "./turn_events.js";
24
24
  import { continueLine } from "./commands.js";
25
25
  import { plainText } from "./plain_line.js";
26
26
  import { noContactFill } from "./no_contact_fill.js";
27
+ import { CONTINUE_KEY, questionKey } from "./stage_waiting.js";
27
28
 
28
29
  // A card's text fields' name: not a contact field (no_contact_fill.js).
29
30
  const CARD_NOTE = "chi-card-note";
@@ -35,8 +36,9 @@ const CARD_NOTE = "chi-card-note";
35
36
  // @param isNearBottom () => whether the history follows its end
36
37
  // @param scrollToEnd () scrolls the history to its end
37
38
  // @param removeHintIfEmpty () drops the history's placeholder hint
38
- // @param askingText () => the running turn's asking reply (turn_model.js
39
- // askingText via the page's view), kept by a question or own approval card
39
+ // @param askingText (pq) => the running turn's reply that asked +pq+
40
+ // (stage_asking.js askedText via the page's view), kept by a question or
41
+ // own approval card
40
42
  // @param renderMarkdown (text) => Promise<html|null>: the kept reply's
41
43
  // markdown (the page's POST /api/render; null keeps the raw text)
42
44
  // @param linkRefs (el, refs) the page's ref pass on it: a plugin's +refs+
@@ -48,6 +50,9 @@ const CARD_NOTE = "chi-card-note";
48
50
  // @param onPending (pending|null) called whenever the open question changes:
49
51
  // the page touches the redraws that read pendingQuestion() (the session
50
52
  // bar's waiting chip, docs/internals/web-frontend.md write -> redraw)
53
+ // @param onSettled (key) a card stopped waiting (answered, cancelled,
54
+ // replaced or removed; the continue card resolved or dropped), by its
55
+ // stage_waiting.js key: the stage's waiting cards as data
51
56
  // @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
52
57
  // questionCard() (the live card or null), pendingQuestion() (or null),
53
58
  // isResolved(id) (this page already saw that question answered),
@@ -56,7 +61,7 @@ export function createQuestionCards({
56
61
  sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
57
62
  renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
58
63
  api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
59
- onPending = () => {},
64
+ onPending = () => {}, onSettled = () => {},
60
65
  }) {
61
66
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
62
67
  // pending_question on session select so a reload mid-question restores it.
@@ -99,7 +104,9 @@ export function createQuestionCards({
99
104
  // stage_view.js): the kind, and a delegate's question relayed here.
100
105
  card.dataset.kind = pq.kind || "question";
101
106
  if (relayView(pq)) card.dataset.relay = "1";
102
- const asked = keepsAskingText(pq) ? String(said ?? askingText() ?? "").trim() : "";
107
+ // An approval says the tool it approves (the stage's replay rule for its lead-in).
108
+ if (approval && pq.approval?.tool) card.dataset.tool = String(pq.approval.tool);
109
+ const asked = keepsAskingText(pq) ? String(said ?? askingText(pq) ?? "").trim() : "";
103
110
  if (asked) saidOf.set(card, asked);
104
111
  // Open while pending: the question must be actionable.
105
112
  card.open = true;
@@ -110,8 +117,10 @@ export function createQuestionCards({
110
117
  summary.textContent = summaryText(pq);
111
118
  card.appendChild(summary);
112
119
  // A hand toggle: the resolve's auto-collapse then leaves it as the user
113
- // left it (rememberToggle-style, as turn_block.js does).
114
- summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
120
+ // left it (rememberToggle-style, as turn_block.js does). Not a click whose
121
+ // toggle was refused: a card that waits in the stage has none of its own
122
+ // (stage_view.js).
123
+ summary.addEventListener("click", (e) => { if (!e.defaultPrevented) card.dataset.toggled = "1"; });
115
124
 
116
125
  // Relayed to the parent: say where else it waits (QuestionDesk#annotate).
117
126
  card.appendChild(renderRelayedTo(pq));
@@ -502,6 +511,7 @@ export function createQuestionCards({
502
511
  // After the collapse: a card left open by hand renders at once.
503
512
  if (full) renderSaidOnOpen(questionEl, full, asked, refs);
504
513
  resolvedId = pending.id;
514
+ onSettled(questionKey(questionEl.dataset.qid));
505
515
  setPending(null);
506
516
  }
507
517
 
@@ -567,8 +577,10 @@ export function createQuestionCards({
567
577
  function replaceQuestionCard() {
568
578
  if (!questionEl) return;
569
579
 
580
+ const key = questionKey(questionEl.dataset.qid);
570
581
  questionEl.remove();
571
582
  questionEl = null;
583
+ onSettled(key);
572
584
  }
573
585
 
574
586
  // ── continue card ──────────────────────────────────────────────────────────
@@ -587,6 +599,7 @@ export function createQuestionCards({
587
599
  // The old card goes: the step-limit question asks the same (a worker that
588
600
  // posts it announces continue_offered too, for older pages).
589
601
  function dropContinueCard() {
602
+ if (continueEl) onSettled(CONTINUE_KEY);
590
603
  continueEl?.remove();
591
604
  continueEl = null;
592
605
  }
@@ -656,6 +669,7 @@ export function createQuestionCards({
656
669
  continueEl = null;
657
670
  if (!card) return;
658
671
  card.classList.add("resolved");
672
+ onSettled(CONTINUE_KEY);
659
673
  card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
660
674
  const note = doc.createElement("div");
661
675
  note.className = "question-result";
@@ -0,0 +1,283 @@
1
+ // The stage's docked card and its fold (stage_view.js wires it): a card that
2
+ // waits for the user (stage_cards.js PENDING but the step limit, or ASKS)
3
+ // opens the running turn in the history (stage_opened.js) and docks alone
4
+ // in the stage; chi's own question or approval after a reply gets that
5
+ // reply as its lead-in bubble. A waiting card has no toggle of its own (the
6
+ // stage's fold is the one); and the fold itself: the user's choice, a ref's
7
+ // reveal, and the docked card's own fold.
8
+ import { handOffOrder, stageCollapsed } from "./stage_model.js";
9
+ import { readStored, writeStored } from "./storage.js";
10
+ import { ASKS, PENDING, waitsInStage } from "./stage_cards.js";
11
+ import { cardIn, waitingKey, waitingKind } from "./stage_waiting.js";
12
+
13
+ const COLLAPSE_KEY = "chi_stage_collapsed";
14
+ // The cards that get a lead-in bubble when their step wrote text: chi's own
15
+ // question and its own approval (question_cards.js data-kind; not a
16
+ // delegate's relayed one, a hook's question or the step limit).
17
+ const LEAD_IN_CARD = '.bubble.question[data-kind="question"]:not([data-relay]), .bubble.question[data-kind="approval"]:not([data-relay])';
18
+ // The step limit: its card goes under its ended turn (stage_view.js place),
19
+ // it never docks.
20
+ const STEP_LIMIT = '.bubble.continue, .bubble.question.step-limit, .bubble.question[data-kind="continue"]';
21
+
22
+ function readCollapsed() {
23
+ return readStored(COLLAPSE_KEY) === "1";
24
+ }
25
+
26
+ // +stage+, +extras+, +tail+, +scroller+ and +fold+: the stage's root, its
27
+ // two card slots, its scroller and its fold button. +view+: the turn block
28
+ // (turn_block.js); +opener+: the opened turn (stage_opened.js); +frame()+:
29
+ // the stage's frame (stage_frame.js, made after this). +isOpened()+ /
30
+ // +setOpened(v)+: whether the turn is opened in the history; +isEnded()+:
31
+ // the turn has ended; +promptEl()+: the turn's prompt bubble; +setCloud(open)+:
32
+ // the cloud; +scheduleRefresh()+; +pendingCard()+: a card waits in the card
33
+ // slots; +waiting+: the stage's waiting cards as data (stage_waiting.js),
34
+ // which say the asking form's card and hold its fold.
35
+ export function createStageAsking({
36
+ stage, extras, tail, scroller, fold, view, opener, frame, isOpened, setOpened, isEnded, promptEl, setCloud,
37
+ scheduleRefresh, pendingCard, waiting,
38
+ }) {
39
+ // The user's own fold (chi_stage_collapsed), in memory: written on a
40
+ // hand toggle, never read back from storage to decide (another tab's
41
+ // write doesn't apply live).
42
+ let choice = readCollapsed();
43
+ // A ref's "show in turn" opened a collapsed stage (not kept).
44
+ let revealed = false;
45
+ // The docked card's fold is +waiting+'s (fold, trackFold): the waiting
46
+ // card the user folded, by its key. For that card only, never saved: the
47
+ // next one arrives open, and when the docked card goes the stage is as the
48
+ // choice and +revealed+ say. One more card waiting since the fold (an
49
+ // approval, a relayed or hook question) drops it, so the stage opens on it.
50
+
51
+ // ── the docked card ──────────────────────────────────────────────────────
52
+
53
+ // The step that asked +what+ ({kind, tool}: the card's data-kind and, for
54
+ // an approval, the tool it approves): live, the step whose call runs
55
+ // (turn_model.js askingText). In a replay (a reload, a resync) the asking
56
+ // call may have completed before its question_requested comes
57
+ // (TurnAccumulator.join_events: a question's always, an approval's once
58
+ // it is answered), so the last step when one of its calls is the one
59
+ // that asks (ask_user_question; the approved tool: a parallel call after
60
+ // it doesn't hide it) and no card of this page took its reply yet. Live or replayed, a step whose reply a card already
61
+ // took (+asked+) gives no other card its reply: a second approval or a
62
+ // question after it in the same step has no lead-in. null: none.
63
+ function askingGen({ kind = "question", tool = null } = {}) {
64
+ const { turn } = view.current();
65
+ const gen = turn && !turn.ended ? turn.gens[turn.gens.length - 1] : null;
66
+ if (!gen?.text || asked.has(gen)) return null;
67
+ if (view.askingText()) return gen;
68
+ const call = kind === "question" ? "ask_user_question" : kind === "approval" ? tool : null;
69
+ return call && gen.tools.some((row) => row.tool === call) ? gen : null;
70
+ }
71
+ // What a card asks, for askingGen.
72
+ const asksOf = (el) => ({ kind: el?.dataset?.kind || "question", tool: el?.dataset?.tool || null });
73
+ // The steps whose reply a question card took (place: question_cards.js
74
+ // reads askingText right before), for askingGen's replay rule.
75
+ const asked = new WeakSet();
76
+ function askedFor(el) {
77
+ const gen = askingGen(asksOf(el));
78
+ if (gen) asked.add(gen);
79
+ }
80
+
81
+ // The reply that asked +pq+ (a pending question: its kind, an approval's
82
+ // tool), "" for none.
83
+ function askedText(pq = null) {
84
+ return String(askingGen({ kind: pq?.kind || "question", tool: pq?.approval?.tool || null })?.text || "").trim();
85
+ }
86
+
87
+ // +el+ docks: a card that waits for the user (a pending question,
88
+ // approval, relayed or hook card; an asking card), not the step limit,
89
+ // placed while the turn runs.
90
+ function docks(el) {
91
+ if (isEnded() || !el?.matches) return false;
92
+ if (el.matches(PENDING)) return !el.matches(STEP_LIMIT);
93
+ return !!el.querySelector(ASKS);
94
+ }
95
+
96
+ // The lead-in for +el+: the reply that asked it when +el+ is chi's own
97
+ // question or approval (LEAD_IN_CARD) and its step wrote text; "" otherwise.
98
+ function leadInText(el) {
99
+ if (!el.matches?.(LEAD_IN_CARD) || !el.matches(PENDING)) return "";
100
+ return String(askingGen(asksOf(el))?.text || "").trim();
101
+ }
102
+
103
+ // The turn opens in the history (once): the prompt, the block (closed;
104
+ // its steps in order from now on) and the rows the extras hold, in the
105
+ // hand-off's order. What waits stays. +follow+: a reader who was at the
106
+ // history's end follows it (a docked card with no lead-in; a lead-in's
107
+ // read follows instead).
108
+ function openTurn({ follow = false } = {}) {
109
+ if (isOpened()) return;
110
+ const { blockEl } = view.current();
111
+ view.inOrder?.();
112
+ setCloud(false);
113
+ if (blockEl) {
114
+ blockEl.open = false;
115
+ // A lone step's block keeps its chrome there (index.html): its summary
116
+ // is the way to the step's text, which no lead-in shows.
117
+ blockEl.dataset.openedTurn = "";
118
+ }
119
+ const rows = [...extras.children].filter((el) => !waitsInStage(el));
120
+ setOpened(true);
121
+ opener.open(handOffOrder({ prompt: promptEl(), block: blockEl, extras: rows }), { follow });
122
+ }
123
+
124
+ // The waiting cards as data, checked against the slots (stage_waiting.js
125
+ // reconcile, its second feed: it only settles): a card gone from the
126
+ // extras and the tail, or no longer waiting, settles, also one answered
127
+ // outside question_cards.js's hook.
128
+ function reconcileWaiting() {
129
+ waiting.reconcile((key) => !!waitingKind(cardIn(extras, key) || cardIn(tail, key)));
130
+ }
131
+
132
+ // An opened turn's cards that stopped waiting go into the history: one
133
+ // with a lead-in under it (also after the turn's end, so a card
134
+ // cancelled by Stop turn sits there), another one at the history's end
135
+ // while the turn runs.
136
+ function settleCards() {
137
+ if (!isOpened()) return;
138
+ for (const el of [...extras.children]) {
139
+ if (waitsInStage(el)) continue;
140
+ // Out of the stage it waits behind nothing (stage_view.js drawDocked).
141
+ el.classList.remove("ts-behind");
142
+ const key = waitingKey(el);
143
+ if (opener.has(key)) opener.settle(key, el);
144
+ else if (!isEnded()) opener.add(el);
145
+ }
146
+ }
147
+
148
+ // The docked card the stage shows, by its key (the asking form: one at a
149
+ // time, stage_waiting.js shown); null: none docked.
150
+ function askingKey() {
151
+ return waiting.shown();
152
+ }
153
+ function askingCard() {
154
+ return cardIn(extras, askingKey());
155
+ }
156
+
157
+ // The folded row's text for the docked +card+ (stage_model.js
158
+ // foldedRowText): a question's text, an approval's summary and its
159
+ // command's first line, a plugin card's title; "" for none.
160
+ function foldedText(card) {
161
+ if (!card) return "";
162
+ if (card.matches(".plugin-card")) return card.querySelector(".card-title")?.textContent || "";
163
+ if (card.dataset.kind !== "approval") return card.querySelector(".question-text")?.textContent || "";
164
+ // The summary's own words, not its "↑ its reason" link.
165
+ const summary = card.querySelector(":scope > summary");
166
+ const head = [...(summary?.childNodes || [])].filter((n) => n.nodeType === 3).map((n) => n.textContent).join("").trim();
167
+ const what = (card.querySelector(".approval-what") || card.querySelector(".approval-tool"))?.textContent || "";
168
+ return [head, what.trim().split("\n")[0].trim()].filter(Boolean).join(" ");
169
+ }
170
+
171
+ // The stage's card element for +key+ (its extras or its tail), null: none.
172
+ function cardOf(key) {
173
+ return cardIn(extras, key) || cardIn(tail, key);
174
+ }
175
+
176
+ // A card that waits in the stage has no toggle of its own: the stage's
177
+ // fold is the one (a closed card left an empty box). A click on its
178
+ // summary (Enter and Space on one arrive as a click) doesn't toggle it,
179
+ // and question_cards.js reads the refusal (defaultPrevented): no
180
+ // data-toggled. Not stopped: the page's outside-click closers still see
181
+ // it. A button or link in the summary ("↑ its reason") works as ever.
182
+ // The test is "waits now", at the click: an answered card toggles.
183
+ stage.addEventListener("click", (e) => {
184
+ const summary = e.target.closest?.("summary");
185
+ if (!summary || summary.contains(e.target.closest("button, a"))) return;
186
+ if (summary.parentElement?.matches(PENDING)) e.preventDefault();
187
+ }, true);
188
+ // Its summary isn't a control meanwhile: out of the tab order (the CSS
189
+ // drops its mark and pointer). Not aria-disabled: that would disable the
190
+ // buttons in it too. +inert+: the ones marked, given back once their card
191
+ // stops waiting or leaves.
192
+ const inert = new Set();
193
+ function markOwnToggles() {
194
+ const waits = new Set();
195
+ for (const key of waiting.blocking()) {
196
+ const summary = cardOf(key)?.querySelector(":scope > summary");
197
+ if (summary) waits.add(summary);
198
+ }
199
+ for (const summary of inert) {
200
+ if (waits.has(summary)) continue;
201
+ summary.removeAttribute("tabindex");
202
+ inert.delete(summary);
203
+ }
204
+ for (const summary of waits) {
205
+ if (inert.has(summary)) continue;
206
+ summary.tabIndex = -1;
207
+ inert.add(summary);
208
+ }
209
+ }
210
+
211
+ // ── the fold ─────────────────────────────────────────────────────────────
212
+
213
+ // Whether the stage is collapsed now (stage_model.js stageCollapsed).
214
+ // The registry is checked against the slots first: a card answered since
215
+ // the last refresh no longer holds the fold.
216
+ function isCollapsed() {
217
+ reconcileWaiting();
218
+ return stageCollapsed({ choice, revealed, asking: askingKey(), askingFold: waiting.folded() });
219
+ }
220
+
221
+ // Its fold is that card's only; the form's start opens a stage saved as
222
+ // collapsed and its end gives the choice back, in this task (the card's
223
+ // focus lands right after the start: question_cards.js focusWhenIdle).
224
+ // Another card that comes to wait meanwhile opens it: nothing waits
225
+ // unseen behind the folded question.
226
+ // A card answered meanwhile lowers the mark, so the next one opens it too.
227
+ function trackFold() {
228
+ waiting.trackFold(askingKey());
229
+ }
230
+
231
+ // The fold as isCollapsed says, only when it changed (+force+: always):
232
+ // idempotent, and no refresh of its own (refresh calls it).
233
+ function drawCollapsed({ force = false } = {}) {
234
+ const collapsed = isCollapsed();
235
+ if (!force && fold.textContent && stage.classList.contains("collapsed") === collapsed) return;
236
+ stage.classList.toggle("collapsed", collapsed);
237
+ scroller.hidden = collapsed;
238
+ fold.textContent = collapsed ? "▴" : "▾";
239
+ fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
240
+ fold.setAttribute("aria-expanded", String(!collapsed));
241
+ frame().fitCap();
242
+ }
243
+
244
+ function applyCollapsed() {
245
+ drawCollapsed({ force: true });
246
+ scheduleRefresh();
247
+ }
248
+
249
+ // A hand toggle (the fold button, a tap on the folded row). In the
250
+ // asking form it folds that question only and writes nothing; else it is
251
+ // the user's choice, kept.
252
+ function setCollapsed(value) {
253
+ reconcileWaiting();
254
+ const asking = askingKey();
255
+ if (asking) {
256
+ waiting.fold(value ? asking : null);
257
+ } else {
258
+ choice = value;
259
+ revealed = false;
260
+ writeStored(COLLAPSE_KEY, value ? "1" : "0");
261
+ }
262
+ applyCollapsed();
263
+ // Opened on a waiting card: it shows as it does on arrival.
264
+ const front = !value && pendingCard() ? cardOf(waiting.front()) : null;
265
+ if (front) frame().afterGrowth(() => frame().reveal(front.parentElement));
266
+ }
267
+
268
+ return {
269
+ askingGen, asksOf, askedFor, askedText, docks, leadInText, openTurn, reconcileWaiting, settleCards, askingCard, cardOf, foldedText, markOwnToggles,
270
+ isCollapsed, trackFold, drawCollapsed, applyCollapsed, setCollapsed,
271
+ // A ref's "show in turn" opens a collapsed stage for this turn only.
272
+ reveal() {
273
+ revealed = true;
274
+ applyCollapsed();
275
+ },
276
+ // The stage cleared: a reveal ends with it.
277
+ unreveal() {
278
+ if (!revealed) return;
279
+ revealed = false;
280
+ applyCollapsed();
281
+ },
282
+ };
283
+ }
@@ -4,8 +4,8 @@
4
4
  // until the next step), and never above the session panel. The frame: held
5
5
  // and grow-only while the stage holds a turn (stage_model.js frameTarget),
6
6
  // its growth eased; it lets go in two places only (forgetContent): the
7
- // asking form (a turn opened in the history: the card alone, stage_view.js
8
- // place) and the next step after an answered card (releaseHeldWait). The stage's state comes
7
+ // asking form (a card docked, its turn opened in the history: the card
8
+ // alone, stage_view.js place) and the next step after an answered card (releaseHeldWait). The stage's state comes
9
9
  // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
10
10
  // what the stage changes of the frame's goes through resetHeld and
11
11
  // forgetContent. +waitingCard+: the card blocking the turn, or null.
@@ -35,7 +35,7 @@ export function reducedMotion() {
35
35
 
36
36
  // +observed+: the elements whose size changes refit the frame (the stage's
37
37
  // slots, in the order they are observed).
38
- export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false, waitingCard = () => null }) {
38
+ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, extras, dockEl, centerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false, waitingCard = () => null }) {
39
39
  // The frame (fitFrame): the height held while the turn runs and the
40
40
  // tallest content seen.
41
41
  let held = null;
@@ -65,16 +65,13 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
65
65
  // would push the stage's top out) ──────────────────────────────────────
66
66
 
67
67
  // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
68
- // its end in sight, and the asking text's top too when it and the slot
69
- // (a waiting card) fit together (the reply reads from its start, the card
70
- // still in sight). A waiting card in +slot+ taller than the
71
- // view reads from its top, never from its options: the question and what
72
- // it approves are what the answer is about; the options are a scroll away.
68
+ // its end in sight. A waiting card in +slot+ taller than the view reads
69
+ // from its top, never from its options: the question and what it
70
+ // approves are what the answer is about; the options are a scroll away.
73
71
  // null: nothing to show.
74
72
  function revealTop(slot) {
75
73
  if (stage.hidden || slot.hidden) return null;
76
74
  let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
77
- if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
78
75
  const card = waitingCard();
79
76
  if (card && slot.contains(card)) top = Math.min(top, Math.max(0, card.offsetTop - CARD_TOP_GAP));
80
77
  return top;
@@ -138,8 +135,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
138
135
  stage.style.setProperty("--stage-max", `${cap}px`);
139
136
  fitFrame();
140
137
  // A smaller cap (a phone's keyboard came up) keeps a waiting card in
141
- // sight: the asking text above it would push it out. A larger one
142
- // leaves the reader's scroll alone.
138
+ // sight. A larger one leaves the reader's scroll alone.
143
139
  if (keep) requestAnimationFrame(keepCardInSight);
144
140
  }
145
141
  // The whole dock, not only its composer card: the info bar under it
@@ -6,7 +6,6 @@ import { toolName } from "./activity.js";
6
6
  import { splitSentences } from "./sentences.js";
7
7
  import { steerSender } from "./format.js";
8
8
  import { hookNoticeLabel } from "./turn_events.js";
9
- import { askingRefs, askingText } from "./turn_model.js";
10
9
  import { plainLine } from "./plain_line.js";
11
10
  import { turnPieces } from "./turn_pieces.js";
12
11
  import { waitsInStage } from "./stage_cards.js";
@@ -120,6 +119,18 @@ export function stepSlotText({ step, ended, chipHidden }) {
120
119
  return !ended ? `step ${step}` : chipHidden ? stepsText(step) : "";
121
120
  }
122
121
 
122
+ // Whether the stage is collapsed to its status row. In the asking form
123
+ // (+asking+: the key of the card that waits there, stage_waiting.js; null:
124
+ // none) only when the user folded that card (+askingFold+: the folded key,
125
+ // null: none), so a
126
+ // question always arrives open and a new one never matches an old fold;
127
+ // else the user's +choice+ (chi_stage_collapsed, as set by hand), unless a
128
+ // ref's "show in turn" opened it for this turn (+revealed+).
129
+ export function stageCollapsed({ choice, revealed, asking = null, askingFold = null }) {
130
+ if (asking) return askingFold === asking;
131
+ return revealed ? false : !!choice;
132
+ }
133
+
123
134
  // The collapsed stage's mini tool in the status row: the running call's
124
135
  // name and title, its full command as the hover; "" for both otherwise.
125
136
  export function miniToolText({ collapsed, tool }) {
@@ -129,6 +140,28 @@ export function miniToolText({ collapsed, tool }) {
129
140
  };
130
141
  }
131
142
 
143
+ // The collapsed stage's status row text: with the asking form folded, what
144
+ // the docked card asks (+question+: a question's text, an approval's
145
+ // summary and command, a plugin card's title; its first line, the whole as
146
+ // the hover, in the UI font: +question+ true), else miniToolText's running
147
+ // call: folded, or with +running+ (check-in docked while nothing blocks:
148
+ // the row says what chi runs now).
149
+ export function foldedRowText({ collapsed, question = "", tool = null, running = false }) {
150
+ const whole = String(question || "").trim();
151
+ if (collapsed && whole) return { text: whole.split("\n")[0].trim(), title: whole, question: true };
152
+ return { ...miniToolText({ collapsed: collapsed || running, tool }), question: false };
153
+ }
154
+
155
+ // The status row's word while check-in's card is docked and nothing blocks
156
+ // (+asksFront+): "writing" while the live step's text streams (the page
157
+ // can't tell narration before a tool call from the answer), else the
158
+ // phase's own word (+phase+: liveSlots's; it stays "waiting for you").
159
+ // +streaming+: the phase the step would have with no card (liveSlots with
160
+ // pending false).
161
+ export function phaseLabel({ phase, asksFront = false, streaming = "" }) {
162
+ return asksFront && (streaming === "writing" || streaming === "writing the answer") ? "writing" : phase;
163
+ }
164
+
132
165
  // While the first step has no thinking and no tool rows the turn is plain:
133
166
  // its text streams in the stage's answer slot, as the turn block's
134
167
  // chrome-less block streams like a bubble.
@@ -156,16 +189,11 @@ const NO_ANSWER = "no answer";
156
189
  // ended (nothing happens now);
157
190
  // trail the last three finished calls, newest last (a file's title
158
191
  // without its dir; command as for tool);
159
- // ticks one per call: {kind, status, step (the gen's index)};
160
- // asking the whole text of the reply whose call a card blocks
161
- // (turn_model.js askingText), "" when none is blocked;
162
- // askingRefs a plugin's refs on it (turn_model.js askingRefs), or null.
163
- // +pending+: a card waits for the user; +blocked+: a card that blocks the
164
- // turn (a question, an approval; not an action card) waits; +ended+: the
165
- // turn's end kind;
192
+ // ticks one per call: {kind, status, step (the gen's index)}.
193
+ // +pending+: a card waits for the user; +ended+: the turn's end kind;
166
194
  // +emptyAnswer+: a completed turn ended with no answer
167
195
  // (turn_summary.empty_answer), so it reads "no answer", not "answered".
168
- export function liveSlots(turn, { narration = "", thinking = "", pending = false, blocked = false, ended = null, emptyAnswer = false } = {}) {
196
+ export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
169
197
  const gens = turn?.gens || [];
170
198
  const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
171
199
  const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
@@ -193,8 +221,6 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
193
221
  tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
194
222
  trail,
195
223
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
196
- asking: blocked && !ended ? askingText(turn) : "",
197
- askingRefs: blocked && !ended ? askingRefs(turn) : null,
198
224
  };
199
225
  }
200
226
 
@@ -1,9 +1,10 @@
1
- // A running turn opened in the history (stage_view.js). When the model's
2
- // own question blocks the turn after a reply, the reply that asked it (the
3
- // reason) becomes a conversation bubble at the history's end, and from then
4
- // to the turn's end the turn's prompt, its steps block and the rows that
5
- // arrive are the history's; only what waits for the user (the card), the
6
- // live slots and the answer stay in the stage. This module draws the reason
1
+ // A running turn opened in the history (stage_view.js). When a card waits
2
+ // for the user (stage_asking.js docks), the turn's prompt, its steps block
3
+ // and the rows that arrive are the history's from then to the turn's end;
4
+ // only what waits for the user (the card), the live slots and the answer
5
+ // stay in the stage. chi's own question or approval after a reply gets that
6
+ // reply (the reason, its lead-in) as a conversation bubble at the history's
7
+ // end. This module draws the reason
7
8
  // bubble and moves things into the history (history_end.js), following a
8
9
  // reader who was at the history's end (a reader scrolled up is never
9
10
  // moved). At the hand-off the reasons go (end): each answered card's own
@@ -32,7 +33,8 @@ export function createOpenedTurn({
32
33
  historyEl, atHistoryEnd, renderMarkdown = () => null, linkRefs = () => {}, reduce = () => false,
33
34
  afterSettle = (fn) => requestAnimationFrame(() => requestAnimationFrame(fn)),
34
35
  }) {
35
- // card → its reason bubble, for the turn in the stage.
36
+ // card key (stage_waiting.js waitingKey) → its reason bubble, for the turn
37
+ // in the stage.
36
38
  const reasons = new Map();
37
39
  // The asking steps whose text a reason shows (data-said: their text is
38
40
  // hidden in the block meanwhile, index.html), to clear at the end.
@@ -141,18 +143,20 @@ export function createOpenedTurn({
141
143
  }
142
144
 
143
145
  return {
144
- // The turn's pieces so far (handOffOrder's order) into the history; the
145
- // reason's read (ask) does the follow.
146
- open: (pieces) => add(pieces, { follow: false }),
146
+ // The turn's pieces so far (handOffOrder's order) into the history; a
147
+ // reason's read (ask) does the follow, else +follow+ (a reader who was at
148
+ // the end before the move).
149
+ open: (pieces, { follow = false } = {}) => add(pieces, { follow }),
147
150
  // A row of the opened turn: to the history's end.
148
151
  add: (el) => add([el]),
149
152
  // +card+ blocks the turn: its reason's bubble at the history's end, in
150
153
  // the card's task; a reader at the end reads it from its first line.
151
154
  // +follow+: the reader was at the end before the turn opened.
152
- ask({ card, text, refs = null, step = 0, turnId = null, questionId = null, follow = atHistoryEnd() }) {
155
+ // +key+: the card's key, the bubble's for has and settle.
156
+ ask({ card, key, text, refs = null, step = 0, turnId = null, questionId = null, follow = atHistoryEnd() }) {
153
157
  const bubble = reasonBubble({ text, refs, step, turnId, questionId });
154
158
  moveToHistoryEnd(historyEl, [bubble]);
155
- reasons.set(card, bubble);
159
+ reasons.set(key, bubble);
156
160
  // The step's own text is the reason: not twice (and raw while the
157
161
  // step runs). data-said, not a class: lit owns the step's class.
158
162
  const stepEl = askingStep();
@@ -174,12 +178,18 @@ export function createOpenedTurn({
174
178
  }
175
179
  return bubble;
176
180
  },
177
- has: (card) => reasons.has(card),
178
- // +card+ no longer waits (answered, cancelled): it goes right under its
179
- // reason, which stops saying "asking you"; another card of the turn to
180
- // the history's end. A reader at the end follows.
181
- settle(card) {
182
- const bubble = reasons.get(card);
181
+ has: (key) => key != null && reasons.has(key),
182
+ // +card+ drawn again with +key+ (the same question, a new element): its
183
+ // way back to the reason it already has.
184
+ relink(key, card) {
185
+ const bubble = reasons.get(key);
186
+ if (bubble && !card.querySelector(":scope > summary > .question-reason-link")) reasonLink(card, bubble);
187
+ },
188
+ // +card+ (its +key+) no longer waits (answered, cancelled): it goes right
189
+ // under its reason, which stops saying "asking you"; another card of the
190
+ // turn to the history's end. A reader at the end follows.
191
+ settle(key, card) {
192
+ const bubble = reasons.get(key);
183
193
  if (!bubble?.isConnected) return add([card]);
184
194
  const follow = atHistoryEnd();
185
195
  bubble.classList.remove("pending");