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