samagotchi 0.60.0 → 0.62.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The stage view of a running turn (web.view: stage): the active turn pinned
|
|
2
|
-
// above the composer,
|
|
2
|
+
// above the composer, its own card floating over the dock's (out of the
|
|
3
|
+
// dock's flow: its height never moves the composer). It shows only
|
|
3
4
|
// what happens now in fixed slots (a status row, the prompt, a two-line
|
|
4
5
|
// headline, the running tool, a one-line trail of the last calls), the
|
|
5
6
|
// cards and rows that arrive meanwhile in their own capped scroller, the
|
|
6
|
-
// answer, and a chip with one tick per tool call
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// answer, and a chip with one tick per tool call, the stage's footer (no
|
|
8
|
+
// content above it can push it away): the "cloud", the turn view's block
|
|
9
|
+
// (turn_view.js) reused as is, opens above it as the scroller's last slot,
|
|
10
|
+
// newest step first while the turn runs. When the turn has ended and the stage is not
|
|
9
11
|
// in use, everything moves into the history in the turn view's order (the
|
|
10
12
|
// hand-off), so a handed-off turn reads like the turn view's and like its
|
|
11
13
|
// reload. Slots are patched in place, never rebuilt from HTML: a selection
|
|
12
14
|
// and annotate survive. The rules are pure in stage_model.js.
|
|
13
15
|
import { createTurnView } from "./turn_view.js";
|
|
14
|
-
import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
|
|
16
|
+
import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
|
|
15
17
|
import { formatDuration } from "./timing.js";
|
|
18
|
+
import { easeScrollTo } from "./scroll.js";
|
|
16
19
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
17
20
|
|
|
18
21
|
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
@@ -20,8 +23,19 @@ const WATCH_MS = 200;
|
|
|
20
23
|
// The turn-end read (markdown, answer_display) settles within this, or the
|
|
21
24
|
// hand-off stops waiting for it.
|
|
22
25
|
const SETTLE_CAP_MS = 8000;
|
|
23
|
-
const FOLD_MS = 420;
|
|
26
|
+
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;
|
|
24
33
|
const FLASH_MS = 4000;
|
|
34
|
+
// The answer reads from its top: at the end it is scrolled to unless this
|
|
35
|
+
// much of it (about three lines) shows already.
|
|
36
|
+
const ANSWER_SEEN = 72;
|
|
37
|
+
// A short answer cut by less than this at its foot isn't scrolled to.
|
|
38
|
+
const ANSWER_CUT = 12;
|
|
25
39
|
const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
|
|
26
40
|
// A running turn's card with actions (check-in's Nudge / Keep going / Stop)
|
|
27
41
|
// asks the user too, but the turn goes on: it waits only while the turn runs
|
|
@@ -39,6 +53,10 @@ function node(tag, cls, parent = null) {
|
|
|
39
53
|
return el;
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
function reducedMotion() {
|
|
57
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function readCollapsed() {
|
|
43
61
|
try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
|
|
44
62
|
}
|
|
@@ -80,18 +98,32 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
80
98
|
headline.tabIndex = 0;
|
|
81
99
|
headline.setAttribute("role", "button");
|
|
82
100
|
headline.title = "Show this step's reasoning";
|
|
101
|
+
// While a question or approval waits: the whole reply that asked it, in
|
|
102
|
+
// the headline's place (the card says what to answer, this says why).
|
|
103
|
+
const asking = node("div", "ts-asking", live);
|
|
104
|
+
asking.hidden = true;
|
|
83
105
|
const now = node("div", "ts-now", live);
|
|
84
106
|
const trail = node("div", "ts-trail", live);
|
|
85
107
|
const extras = node("div", "ts-extras", scroller);
|
|
86
108
|
const answer = node("div", "ts-answer", scroller);
|
|
87
109
|
const tail = node("div", "ts-tail", scroller);
|
|
88
|
-
|
|
110
|
+
// The cloud's steps, the scroller's last slot, right above the chip.
|
|
111
|
+
const cloudBody = node("div", "ts-cloud-body", scroller);
|
|
112
|
+
cloudBody.hidden = true;
|
|
113
|
+
// A 1 px mark at the tallest content's end (absolute: out of the flow),
|
|
114
|
+
// so the scroll extent never drops while the turn runs: content that
|
|
115
|
+
// shrinks never lowers a scrolled reader's scrollTop (the browser would
|
|
116
|
+
// clamp it, moving everything in sight), even in the layout before
|
|
117
|
+
// fitFrame runs.
|
|
118
|
+
const holdEl = node("div", "ts-hold", scroller);
|
|
119
|
+
holdEl.setAttribute("aria-hidden", "true");
|
|
120
|
+
// The footer: the chip, pinned to the stage's bottom edge as the status
|
|
121
|
+
// row is to its top.
|
|
122
|
+
const cloud = node("div", "ts-foot", stage);
|
|
89
123
|
const chip = node("button", "ts-chip", cloud);
|
|
90
124
|
chip.type = "button";
|
|
91
125
|
const chipLabel = node("span", "ts-chip-label", chip);
|
|
92
126
|
const ticks = node("span", "ts-ticks", chip);
|
|
93
|
-
const cloudBody = node("div", "ts-cloud-body", cloud);
|
|
94
|
-
cloudBody.hidden = true;
|
|
95
127
|
|
|
96
128
|
// The turn in the stage, from turnStarted until the hand-off (or a reset).
|
|
97
129
|
let holding = false;
|
|
@@ -114,8 +146,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
114
146
|
let quietSince = null;
|
|
115
147
|
let pointerIn = false;
|
|
116
148
|
let lastTouch = 0;
|
|
149
|
+
// The frame (fitFrame): the height held while the turn runs and the
|
|
150
|
+
// tallest content seen.
|
|
151
|
+
let held = null;
|
|
152
|
+
let maxContent = 0;
|
|
153
|
+
let growth = null; // the running ease of the frame's growth (an Animation)
|
|
154
|
+
let growthTo = 0; // its end height
|
|
117
155
|
// What each slot shows, so an unchanged slot is left alone.
|
|
118
|
-
const shown = { headline: null, now: null, trail: null };
|
|
156
|
+
const shown = { headline: null, now: null, trail: null, asking: null };
|
|
119
157
|
|
|
120
158
|
const view = createTurnView({
|
|
121
159
|
historyEl,
|
|
@@ -161,8 +199,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
161
199
|
return !endKind && !!extras.querySelector(ASKS);
|
|
162
200
|
}
|
|
163
201
|
// A card answered or redrawn in place (its buttons gone) changes the
|
|
164
|
-
// status without a turn event
|
|
165
|
-
|
|
202
|
+
// status without a turn event: at once (a microtask), so the slots swap in
|
|
203
|
+
// the card's own task, never a frame apart.
|
|
204
|
+
new MutationObserver(() => refresh()).observe(extras, { childList: true, subtree: true });
|
|
166
205
|
|
|
167
206
|
function refresh() {
|
|
168
207
|
rafPending = false;
|
|
@@ -183,17 +222,25 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
183
222
|
// A question blocks the turn: its card takes the live slots' room. An
|
|
184
223
|
// asking card's turn goes on: the status waits, the slots stay.
|
|
185
224
|
const blocked = pendingCard();
|
|
186
|
-
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
|
|
187
|
-
|
|
225
|
+
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), blocked, ended: endKind, emptyAnswer });
|
|
226
|
+
|
|
227
|
+
// The live slots keep their height while the turn waits and until its
|
|
228
|
+
// next step (held-wait, set with waiting): the card under them never
|
|
229
|
+
// moves at the answer. The waiting height is measured before the swap.
|
|
230
|
+
const wasWaiting = stage.classList.contains("waiting");
|
|
231
|
+
if (blocked && !wasWaiting) stage.classList.add("held-wait");
|
|
232
|
+
if (!blocked && wasWaiting) stage.style.setProperty("--live-held", `${live.offsetHeight}px`);
|
|
188
233
|
stage.classList.toggle("plain", plain);
|
|
189
234
|
stage.classList.toggle("ended", !!endKind);
|
|
190
235
|
stage.classList.toggle("waiting", blocked);
|
|
236
|
+
stage.classList.toggle("asking", !!slots.asking);
|
|
191
237
|
stage.dataset.phase = slots.phase;
|
|
192
238
|
mark.textContent = MARKS[slots.phase] || "";
|
|
193
239
|
phaseEl.textContent = slots.phase;
|
|
194
240
|
const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
|
|
195
241
|
|
|
196
242
|
drawHeadline(slots.headline);
|
|
243
|
+
drawAsking(slots.asking);
|
|
197
244
|
drawNow(slots.tool, slots.quiet);
|
|
198
245
|
drawTrail(slots.trail);
|
|
199
246
|
drawTicks(slots.ticks);
|
|
@@ -207,8 +254,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
207
254
|
miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
|
|
208
255
|
miniTool.title = (collapsed && slots.tool?.command) || "";
|
|
209
256
|
|
|
210
|
-
live
|
|
257
|
+
// An ended turn's live slots go, unless something under them would move
|
|
258
|
+
// (a card or a row in the extras): then they stay as they were last.
|
|
259
|
+
live.hidden = plain || (!!endKind && !extras.firstChild);
|
|
211
260
|
cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
|
|
261
|
+
cloudBody.hidden = !cloudOpen || cloud.hidden;
|
|
212
262
|
// Running: the step it is on. Ended: the count, unless the chip says it.
|
|
213
263
|
stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
|
|
214
264
|
promptSlot.hidden = !promptSlot.firstChild;
|
|
@@ -233,6 +283,17 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
233
283
|
headline.classList.add("fresh");
|
|
234
284
|
}
|
|
235
285
|
|
|
286
|
+
// Patched only when the text changes (a late chunk to the closed gen
|
|
287
|
+
// rewrites the same text): a selection in it survives. A new text reads
|
|
288
|
+
// from its top.
|
|
289
|
+
function drawAsking(text) {
|
|
290
|
+
asking.hidden = !text;
|
|
291
|
+
if (shown.asking === text) return;
|
|
292
|
+
shown.asking = text;
|
|
293
|
+
asking.textContent = text;
|
|
294
|
+
asking.scrollTop = 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
236
297
|
function drawNow(tool, quiet) {
|
|
237
298
|
// A task_wait's stop button, as the row's (task_stop.js): in the key
|
|
238
299
|
// with its in-flight state, so a redraw keeps a clicked one disabled.
|
|
@@ -307,13 +368,34 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
307
368
|
while (ticks.children.length > list.length) ticks.lastChild.remove();
|
|
308
369
|
}
|
|
309
370
|
|
|
310
|
-
// The answer reads from its top (no tail pin in the stage)
|
|
371
|
+
// The answer reads from its top (no tail pin in the stage): scrolled to
|
|
372
|
+
// (eased, once the stage has grown for it) when its top is out of sight
|
|
373
|
+
// or only its first line shows.
|
|
311
374
|
function showAnswerTop() {
|
|
312
|
-
|
|
313
|
-
if (answer.firstChild
|
|
375
|
+
afterGrowth(() => {
|
|
376
|
+
if (!answer.firstChild || answer.hidden || stage.hidden) return;
|
|
377
|
+
const top = answer.offsetTop;
|
|
378
|
+
const seen = scroller.scrollTop + scroller.clientHeight - top;
|
|
379
|
+
if (top < scroller.scrollTop || seen < Math.min(answer.offsetHeight - ANSWER_CUT, ANSWER_SEEN)) easeStage(Math.max(0, top - 8));
|
|
314
380
|
});
|
|
315
381
|
}
|
|
316
382
|
|
|
383
|
+
// The stage's own scrolls, eased (instant with reduced motion).
|
|
384
|
+
function easeStage(top) {
|
|
385
|
+
if (Math.abs(top - scroller.scrollTop) < 1) return;
|
|
386
|
+
easeScrollTo(scroller, top, { reduce: reducedMotion() });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// +fn+ once the frame has grown for a change made now: two frames on (the
|
|
390
|
+
// ResizeObserver starts the ease in the frame after the change), then at
|
|
391
|
+
// the ease's end.
|
|
392
|
+
function afterGrowth(fn) {
|
|
393
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
394
|
+
if (growth?.playState === "running") growth.finished.then(fn, fn);
|
|
395
|
+
else fn();
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
|
|
317
399
|
// ── collapse, cloud, peek ────────────────────────────────────────────────
|
|
318
400
|
|
|
319
401
|
function applyCollapsed() {
|
|
@@ -335,11 +417,16 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
335
417
|
|
|
336
418
|
function setCloud(open) {
|
|
337
419
|
cloudOpen = open;
|
|
338
|
-
cloudBody.hidden = !open;
|
|
420
|
+
cloudBody.hidden = !open || cloud.hidden;
|
|
421
|
+
// Closed by hand: its room isn't held at the scroller's end.
|
|
422
|
+
if (!open) maxContent = 0;
|
|
339
423
|
cloud.classList.toggle("open", open);
|
|
340
424
|
stage.classList.toggle("cloud-open", open);
|
|
341
425
|
chip.setAttribute("aria-expanded", String(open));
|
|
342
426
|
fitCap();
|
|
427
|
+
// The steps open right above the chip: in sight (the answer and the
|
|
428
|
+
// cards above them scroll up), once the stage has grown for them.
|
|
429
|
+
if (open) afterGrowth(() => reveal(cloudBody));
|
|
343
430
|
}
|
|
344
431
|
|
|
345
432
|
// Open the cloud at step +i+ (a hand toggle, as a click on its row), its
|
|
@@ -377,14 +464,20 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
377
464
|
// stage and the cloud to it, as a hand toggle would.
|
|
378
465
|
// A collapsed stage opens for this turn only: the kept choice
|
|
379
466
|
// (chi_stage_collapsed) is left alone and comes back at the release.
|
|
467
|
+
// The row's scroll into view (refs.js) waits for the stage's growth.
|
|
468
|
+
// A row of a turn sliding away is no longer the stage's: it goes into the
|
|
469
|
+
// history first (finishFold), and refs.js finds it there.
|
|
380
470
|
stage.addEventListener("ref:reveal", (e) => {
|
|
381
471
|
if (!cloudBody.contains(e.target)) return;
|
|
472
|
+
if (folding) return finishFold();
|
|
473
|
+
if (!holding) return;
|
|
382
474
|
if (collapsed) {
|
|
383
475
|
collapsed = false;
|
|
384
476
|
revealed = true;
|
|
385
477
|
applyCollapsed();
|
|
386
478
|
}
|
|
387
479
|
setCloud(true);
|
|
480
|
+
if (e.detail) e.detail.until = new Promise((resolve) => afterGrowth(resolve));
|
|
388
481
|
});
|
|
389
482
|
headline.addEventListener("keydown", (e) => {
|
|
390
483
|
if (e.key === "Enter" || e.key === " ") {
|
|
@@ -402,24 +495,119 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
402
495
|
// never above the session panel (the dock is absolute: a tall composer or
|
|
403
496
|
// a phone's keyboard would push the stage's top out) ────────────────────
|
|
404
497
|
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
498
|
+
// Where the stage's scroll goes so +slot+ (the scroller's child) shows:
|
|
499
|
+
// its end in sight, and the asking text's top too when it and the slot
|
|
500
|
+
// (a waiting card) fit together (the reply reads from its start, the card
|
|
501
|
+
// still in sight). null: nothing to show.
|
|
502
|
+
function revealTop(slot) {
|
|
503
|
+
if (stage.hidden || slot.hidden) return null;
|
|
504
|
+
let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
|
|
505
|
+
if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
|
|
506
|
+
return top;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
|
|
510
|
+
// dropped under it).
|
|
511
|
+
function reveal(slot, { now = false } = {}) {
|
|
512
|
+
const top = revealTop(slot);
|
|
513
|
+
if (top === null) return;
|
|
514
|
+
if (now) scroller.scrollTop = top;
|
|
515
|
+
else easeStage(top);
|
|
410
516
|
}
|
|
411
517
|
|
|
412
518
|
function fitCap() {
|
|
413
519
|
if (stage.hidden) return;
|
|
414
|
-
|
|
520
|
+
// The composer card and the gap above it (the stage floats over them,
|
|
521
|
+
// out of the dock's flow).
|
|
522
|
+
const rest = dockEl.offsetHeight + STAGE_GAP;
|
|
415
523
|
const room = centerEl.clientHeight - rest - 32;
|
|
416
524
|
const share = window.innerHeight * (cloudOpen ? 0.8 : 0.6);
|
|
417
|
-
|
|
525
|
+
const cap = Math.max(96, Math.round(Math.min(share, room)));
|
|
526
|
+
const was = capPx();
|
|
527
|
+
if (cap === was) return;
|
|
528
|
+
stage.style.setProperty("--stage-max", `${cap}px`);
|
|
529
|
+
fitFrame();
|
|
530
|
+
// A smaller cap (a phone's keyboard came up) keeps a waiting card in
|
|
531
|
+
// sight: the asking text above it would push it out. A larger one
|
|
532
|
+
// leaves the reader's scroll alone.
|
|
533
|
+
if (cap < was && stage.classList.contains("waiting")) {
|
|
534
|
+
requestAnimationFrame(() => reveal(extras, { now: true }));
|
|
535
|
+
}
|
|
418
536
|
}
|
|
419
537
|
new ResizeObserver(() => fitCap()).observe(centerEl);
|
|
420
538
|
new ResizeObserver(() => fitCap()).observe(composerEl);
|
|
421
539
|
window.addEventListener("resize", fitCap);
|
|
422
540
|
|
|
541
|
+
// ── the frame: the one place that writes the stage's height ────────────
|
|
542
|
+
// Nothing in the stage view jumps. The rules: the stage is out of the
|
|
543
|
+
// dock's flow (index.html), so its height never moves the composer; while
|
|
544
|
+
// it holds a turn its height is held and grow-only (frameTarget), so a
|
|
545
|
+
// content change happens inside it; growth eases (frameTo); the chip is a
|
|
546
|
+
// footer no content can push; the hand-off releases the turn at once
|
|
547
|
+
// (releaseTurn) and empties the stage only after it slid away under the
|
|
548
|
+
// composer (a transform, then finishFold/clearStage).
|
|
549
|
+
|
|
550
|
+
// The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
|
|
551
|
+
// Animations: the running ease is +growth+), unless motion is reduced or
|
|
552
|
+
// the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
|
|
553
|
+
function frameTo(px, { animate = false } = {}) {
|
|
554
|
+
const from = stage.offsetHeight;
|
|
555
|
+
stage.style.height = `${px}px`;
|
|
556
|
+
if (px === from) return;
|
|
557
|
+
const ease = animate && px > from && !collapsed && !stage.hidden && !document.hidden && !reducedMotion();
|
|
558
|
+
if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
|
|
559
|
+
growth.cancel();
|
|
560
|
+
growth = null;
|
|
561
|
+
}
|
|
562
|
+
if (!ease || growth) return;
|
|
563
|
+
growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
|
|
564
|
+
growthTo = px;
|
|
565
|
+
const run = growth;
|
|
566
|
+
run.finished.then(() => { if (growth === run) growth = null; }, () => {});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Back to the content's own height (under the cap).
|
|
570
|
+
function frameFree() {
|
|
571
|
+
growth?.cancel();
|
|
572
|
+
growth = null;
|
|
573
|
+
stage.style.height = "";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function capPx() {
|
|
577
|
+
return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The scroller's content height: its last shown slot's end (offsetTop is
|
|
581
|
+
// the scroller's own, whatever its scroll) and its bottom padding.
|
|
582
|
+
function contentHeight() {
|
|
583
|
+
const css = getComputedStyle(scroller);
|
|
584
|
+
let end = parseFloat(css.paddingTop) || 0;
|
|
585
|
+
for (const el of scroller.children) {
|
|
586
|
+
if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
|
|
587
|
+
end = el.offsetTop + el.offsetHeight;
|
|
588
|
+
}
|
|
589
|
+
return end + (parseFloat(css.paddingBottom) || 0);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Holds the frame while the stage holds a turn (stage_model.js
|
|
593
|
+
// frameTarget: grow-only, clamped by the cap; free when collapsed or
|
|
594
|
+
// released). Run from a ResizeObserver on the slots (after layout, before
|
|
595
|
+
// paint: no frame shows the content's own height) and when the cap moves.
|
|
596
|
+
function fitFrame() {
|
|
597
|
+
if (stage.hidden || stage.classList.contains("leaving")) return;
|
|
598
|
+
const content = scroller.hidden ? 0 : contentHeight();
|
|
599
|
+
const holds = holding && !collapsed;
|
|
600
|
+
maxContent = holds ? Math.max(maxContent, content) : 0;
|
|
601
|
+
const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
|
|
602
|
+
if (holdEl.style.top !== mark) holdEl.style.top = mark;
|
|
603
|
+
const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight);
|
|
604
|
+
held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
|
|
605
|
+
if (held === null) frameFree();
|
|
606
|
+
else frameTo(held, { animate: true });
|
|
607
|
+
}
|
|
608
|
+
const frameObserver = new ResizeObserver(() => fitFrame());
|
|
609
|
+
for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud]) frameObserver.observe(el);
|
|
610
|
+
|
|
423
611
|
// ── in use: pointer over it (tracked on the stage itself: :hover goes
|
|
424
612
|
// stale when children change), a touch or wheel in the last 4 s (phones
|
|
425
613
|
// have no hover), keyboard focus or a selection in it ─────────────────
|
|
@@ -469,11 +657,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
469
657
|
|
|
470
658
|
// ── show, clear, hand off ────────────────────────────────────────────────
|
|
471
659
|
|
|
660
|
+
// A fold cut short (the stage shows again): the slide's class goes.
|
|
472
661
|
function cancelFold() {
|
|
473
662
|
clearTimeout(foldTimer);
|
|
474
663
|
foldTimer = null;
|
|
475
664
|
stage.classList.remove("leaving");
|
|
476
|
-
stage.style.height = "";
|
|
477
665
|
}
|
|
478
666
|
|
|
479
667
|
function show() {
|
|
@@ -481,7 +669,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
481
669
|
const hidden = stage.hidden;
|
|
482
670
|
stage.hidden = false;
|
|
483
671
|
applyCollapsed();
|
|
484
|
-
if (hidden && !
|
|
672
|
+
if (hidden && !reducedMotion()) {
|
|
485
673
|
stage.classList.remove("enter");
|
|
486
674
|
void stage.offsetWidth;
|
|
487
675
|
stage.classList.add("enter");
|
|
@@ -489,24 +677,38 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
489
677
|
}
|
|
490
678
|
|
|
491
679
|
function emptySlots() {
|
|
492
|
-
for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, now, trail, ticks]) slot.replaceChildren();
|
|
680
|
+
for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
|
|
493
681
|
promptSlot.classList.remove("full");
|
|
494
|
-
|
|
682
|
+
asking.hidden = true;
|
|
683
|
+
shown.headline = shown.now = shown.trail = shown.asking = null;
|
|
495
684
|
thinkingLine = null;
|
|
496
685
|
flash = null;
|
|
497
686
|
setCloud(false);
|
|
498
|
-
stage.classList.remove("plain", "ended", "waiting", "in-use");
|
|
687
|
+
stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
|
|
688
|
+
stage.style.removeProperty("--live-held");
|
|
499
689
|
}
|
|
500
690
|
|
|
501
|
-
|
|
691
|
+
// The turn leaves the stage's care (the hand-off's start, a reset, a new
|
|
692
|
+
// turn): from here new rows, the next timing line and the history's
|
|
693
|
+
// scroll are the history's (place, timingHost, holds). The slots keep
|
|
694
|
+
// what they show until clearStage.
|
|
695
|
+
function releaseTurn() {
|
|
502
696
|
stopWatch();
|
|
503
697
|
clearTimeout(settleTimer);
|
|
504
698
|
holding = false;
|
|
699
|
+
held = null;
|
|
505
700
|
endKind = null;
|
|
506
701
|
emptyAnswer = false;
|
|
507
702
|
settled = true;
|
|
508
703
|
view.reset();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// The stage empty and hidden, its frame free.
|
|
707
|
+
function clearStage() {
|
|
708
|
+
cancelFold();
|
|
509
709
|
emptySlots();
|
|
710
|
+
stage.hidden = true;
|
|
711
|
+
frameFree();
|
|
510
712
|
if (revealed) {
|
|
511
713
|
revealed = false;
|
|
512
714
|
collapsed = readCollapsed();
|
|
@@ -514,65 +716,76 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
514
716
|
}
|
|
515
717
|
}
|
|
516
718
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
719
|
+
function release() {
|
|
720
|
+
releaseTurn();
|
|
721
|
+
clearStage();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// The hand-off: everything the stage holds goes into the history's end in
|
|
725
|
+
// the turn view's order (prompt, block (chronological; its outline open,
|
|
726
|
+
// else collapsed), extras, answer, timing line, then the end line and
|
|
727
|
+
// what came after the end). The turn is released at once; the stage
|
|
728
|
+
// slides away under the composer still drawn (a transform: nothing on the
|
|
729
|
+
// page moves; app.js eases the history's bottom reserve meanwhile), then
|
|
730
|
+
// the pieces arrive in the history and it follows a reader who was at its
|
|
731
|
+
// end. Not with +immediate+ (another turn takes the stage at once), a
|
|
732
|
+
// reduced motion, a hidden page or a collapsed stage: all at once.
|
|
733
|
+
let folding = null; // { pieces, block, wasAtEnd, animate } while it slides
|
|
523
734
|
function handOff({ immediate = false } = {}) {
|
|
735
|
+
if (folding && immediate) finishFold();
|
|
524
736
|
if (!holding || !endKind) return;
|
|
525
737
|
const { blockEl } = view.current();
|
|
526
738
|
view.chronological();
|
|
527
|
-
|
|
528
|
-
const wasAtEnd = historyNearBottom();
|
|
739
|
+
const block = blockEl && blockEl.parentNode === cloudBody ? blockEl : null;
|
|
529
740
|
const pieces = handOffOrder({
|
|
530
741
|
prompt: promptSlot.firstElementChild,
|
|
531
|
-
block
|
|
742
|
+
block,
|
|
532
743
|
extras: [...extras.children],
|
|
533
744
|
answer: [...answer.children],
|
|
534
745
|
timing: timingSlot.firstElementChild,
|
|
535
746
|
kept: [...tail.children],
|
|
536
747
|
});
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
748
|
+
const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
|
|
749
|
+
folding = { pieces, block: blockEl, wasAtEnd: historyNearBottom(), animate };
|
|
750
|
+
releaseTurn();
|
|
751
|
+
if (!animate) return finishFold();
|
|
752
|
+
stage.classList.add("leaving");
|
|
753
|
+
foldTimer = setTimeout(finishFold, FOLD_MS);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// The fold's end (or a cut-short fold): the original pieces (their ref
|
|
757
|
+
// bindings, selection and late updates with them) move into the history.
|
|
758
|
+
function finishFold() {
|
|
759
|
+
const fold = folding;
|
|
760
|
+
if (!fold) return;
|
|
761
|
+
folding = null;
|
|
762
|
+
// Closed, unless it is an outline (turn_view.js outlineBlock): the
|
|
763
|
+
// history reads its narrated steps without a click.
|
|
764
|
+
if (fold.block) fold.block.open = fold.block.classList.contains("outline");
|
|
543
765
|
historyEl.querySelector(":scope > .hint")?.remove();
|
|
544
|
-
for (const el of pieces) {
|
|
766
|
+
for (const el of fold.pieces) {
|
|
545
767
|
historyEl.appendChild(el);
|
|
546
|
-
if (animate) {
|
|
768
|
+
if (fold.animate) {
|
|
547
769
|
el.classList.add("ts-arrive");
|
|
548
770
|
el.addEventListener("animationend", () => el.classList.remove("ts-arrive"), { once: true });
|
|
549
771
|
}
|
|
550
772
|
}
|
|
551
|
-
|
|
552
|
-
if (!animate)
|
|
553
|
-
stage.hidden = true;
|
|
554
|
-
if (wasAtEnd) historyEl.scrollTop = historyEl.scrollHeight;
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
stage.classList.add("leaving");
|
|
558
|
-
stage.style.height = "0px";
|
|
559
|
-
if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
|
|
560
|
-
foldTimer = setTimeout(() => {
|
|
561
|
-
cancelFold();
|
|
562
|
-
stage.hidden = true;
|
|
563
|
-
if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
|
|
564
|
-
}, FOLD_MS);
|
|
773
|
+
clearStage();
|
|
774
|
+
if (fold.wasAtEnd) easeScrollTo(historyEl, () => historyEl.scrollHeight - historyEl.clientHeight, { reduce: !fold.animate || reducedMotion() });
|
|
565
775
|
}
|
|
566
776
|
|
|
567
777
|
// ── the view interface (app.js) ──────────────────────────────────────────
|
|
568
778
|
|
|
569
779
|
return {
|
|
570
780
|
turnStarted(data) {
|
|
781
|
+
if (folding) finishFold();
|
|
571
782
|
if (holding) {
|
|
572
783
|
if (endKind) handOff({ immediate: true });
|
|
573
784
|
else release();
|
|
574
785
|
}
|
|
575
786
|
holding = true;
|
|
787
|
+
held = null;
|
|
788
|
+
maxContent = 0;
|
|
576
789
|
endKind = null;
|
|
577
790
|
emptyAnswer = false;
|
|
578
791
|
settled = true;
|
|
@@ -585,7 +798,12 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
585
798
|
show();
|
|
586
799
|
refresh();
|
|
587
800
|
},
|
|
588
|
-
generationStarted
|
|
801
|
+
generationStarted(data) {
|
|
802
|
+
// The next step: the live slots' waiting height is let go.
|
|
803
|
+
stage.classList.remove("held-wait");
|
|
804
|
+
stage.style.removeProperty("--live-held");
|
|
805
|
+
view.generationStarted(data);
|
|
806
|
+
},
|
|
589
807
|
chunk: (data) => view.chunk(data),
|
|
590
808
|
generationRestarted: (data) => view.generationRestarted(data),
|
|
591
809
|
generationCompleted: () => view.generationCompleted(),
|
|
@@ -634,14 +852,17 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
634
852
|
view.answerReady();
|
|
635
853
|
},
|
|
636
854
|
containsLive: (el) => view.containsLive(el),
|
|
855
|
+
askingText: () => view.askingText(),
|
|
637
856
|
reset() {
|
|
638
|
-
|
|
857
|
+
if (folding) finishFold();
|
|
639
858
|
release();
|
|
640
|
-
stage.hidden = true;
|
|
641
859
|
},
|
|
642
860
|
// The stage's extras while it holds a turn (a card, a queued prompt, a
|
|
643
861
|
// command, a note, the end line); false: the history takes it.
|
|
644
862
|
place(el) {
|
|
863
|
+
// A turn still sliding away goes into the history first: what comes
|
|
864
|
+
// now comes after it.
|
|
865
|
+
if (folding) finishFold();
|
|
645
866
|
const kind = el.matches?.(".bubble.recap") ? "recap" : "row";
|
|
646
867
|
if (placeFor({ live: holding, handingOff: false, kind }) !== "stage") return false;
|
|
647
868
|
const slot = endKind ? tail : extras;
|
|
@@ -650,15 +871,23 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
650
871
|
slot.hidden = false;
|
|
651
872
|
const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
|
|
652
873
|
if (stick || waits) slot.scrollTop = slot.scrollHeight;
|
|
653
|
-
|
|
874
|
+
// A waiting card and the asking text (refresh) land in one task.
|
|
875
|
+
if (waits) refresh();
|
|
876
|
+
else scheduleRefresh();
|
|
654
877
|
// A card that waits for the user is brought into the stage's view: in
|
|
655
878
|
// a short window the prompt and headline fill it and the card sat
|
|
656
|
-
// below, out of sight (
|
|
657
|
-
if (waits)
|
|
879
|
+
// below, out of sight (once the stage has grown for it).
|
|
880
|
+
if (waits) afterGrowth(() => reveal(slot));
|
|
658
881
|
return true;
|
|
659
882
|
},
|
|
660
883
|
// Where the live timing line goes (the status row), null: the history.
|
|
661
|
-
timingHost: () =>
|
|
884
|
+
timingHost: () => {
|
|
885
|
+
if (folding) finishFold();
|
|
886
|
+
return holding ? timingSlot : null;
|
|
887
|
+
},
|
|
888
|
+
// Before anything else goes into the history (app.js): a turn still
|
|
889
|
+
// sliding away gets there first.
|
|
890
|
+
settleFold: () => { if (folding) finishFold(); },
|
|
662
891
|
// A turn is in the stage (running, or ended and not handed off yet):
|
|
663
892
|
// nothing the turn does touches the history's scroll.
|
|
664
893
|
holds: () => holding,
|
|
@@ -141,12 +141,14 @@ export function createStreamHandlers(deps) {
|
|
|
141
141
|
if (display) await display.promise;
|
|
142
142
|
turnDom.answerReady?.();
|
|
143
143
|
},
|
|
144
|
-
// An after_turn hook presented the answer (AnswerDisplay)
|
|
145
|
-
//
|
|
144
|
+
// An after_turn hook presented the answer (AnswerDisplay) or attached
|
|
145
|
+
// refs to it (AnswerRefs: refs, a count; display null when only refs):
|
|
146
|
+
// it comes after turn_completed, so re-read the answer once that read
|
|
147
|
+
// is done.
|
|
146
148
|
answer_display: async (data) => {
|
|
147
149
|
try {
|
|
148
150
|
await liveTurn.endRead;
|
|
149
|
-
if (!selected() || !data.display) return;
|
|
151
|
+
if (!selected() || (!data.display && !data.refs)) return;
|
|
150
152
|
const d = await getSession(selected(), { tail: true, recent: true });
|
|
151
153
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
152
154
|
} catch (_) {
|
|
@@ -33,7 +33,7 @@ export function liveDurationText(durationMs) {
|
|
|
33
33
|
|
|
34
34
|
// A cancel's reason as the terminal UIs name it too (Formatting::
|
|
35
35
|
// CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
|
|
36
|
-
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
|
|
36
|
+
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped" };
|
|
37
37
|
|
|
38
38
|
// The line a canceled turn ends with: "■ canceled (stopped)", live from
|
|
39
39
|
// turn_canceled's reason and after a re-render from the turn record's
|