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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// choice for the next create only (POST /api/sessions' llm_context), never
|
|
13
13
|
// remembered: nothing is sent unless the user sets one.
|
|
14
14
|
|
|
15
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
15
16
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
17
|
|
|
17
18
|
const STRATEGIES = [
|
|
@@ -201,113 +202,40 @@ export function renderForm(target, summary, opts = {}) {
|
|
|
201
202
|
render(formTemplate(summary, opts), target);
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
// the room above is short of its height and below has more; capped to the
|
|
209
|
-
// room on that side (it scrolls), never past the window's edges.
|
|
210
|
-
// @return {{left, top?, bottom?, maxHeight?}}
|
|
211
|
-
export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
212
|
-
const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
|
|
213
|
-
const above = rect.top - GAP - EDGE;
|
|
214
|
-
const below = innerHeight - rect.bottom - GAP - EDGE;
|
|
215
|
-
const down = height > above && below > above;
|
|
216
|
-
const room = Math.max(0, down ? below : above);
|
|
217
|
-
const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
|
|
218
|
-
return height > room ? { ...place, maxHeight: room } : place;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
|
|
222
|
-
// clicks are delegated); summary(): the open session's llm_context;
|
|
223
|
-
// session(): the open session's id; running(): a turn runs; run(line):
|
|
224
|
-
// sends the command.
|
|
205
|
+
// The chip's popover (chip_popover.js' shell). el: where the chip is drawn
|
|
206
|
+
// (#infoText, info_bar.js: clicks are delegated); summary(): the open
|
|
207
|
+
// session's llm_context; session(): the open session's id; running(): a
|
|
208
|
+
// turn runs; run(line): sends the command.
|
|
225
209
|
// The start page's (kind NEW_CHAT): summary() is the picked model's,
|
|
226
210
|
// choice() the pending choice, and choose(next) takes a Set's (null for
|
|
227
211
|
// "follow the model") instead of a command running. id and anchor (the
|
|
228
212
|
// chip's selector) keep the two controls apart.
|
|
229
213
|
export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
|
|
230
214
|
kind = SESSION, choice = () => null, choose = null,
|
|
231
|
-
id = "llmContextPopover", anchor
|
|
215
|
+
id = "llmContextPopover", anchor = ".llmctx-chip",
|
|
232
216
|
doc = document, win = window }) {
|
|
233
217
|
const fresh = kind === NEW_CHAT;
|
|
234
|
-
|
|
235
|
-
pop.id = id;
|
|
236
|
-
pop.className = "ctx-popover llmctx-popover";
|
|
237
|
-
pop.setAttribute("role", "dialog");
|
|
238
|
-
pop.hidden = true;
|
|
239
|
-
doc.body.appendChild(pop);
|
|
240
|
-
// The values the form opened with, and the session it is for.
|
|
218
|
+
// The values the form opened with.
|
|
241
219
|
let initial = null;
|
|
242
|
-
let openFor = null;
|
|
243
220
|
// What the open form was drawn from: the summary, the form's options and
|
|
244
|
-
// the summary its "Now:" line reads.
|
|
245
|
-
// open draws a fresh form (unsent edits are gone).
|
|
221
|
+
// the summary its "Now:" line reads.
|
|
246
222
|
let shown = null;
|
|
247
223
|
const draw = (changes = {}) => {
|
|
248
224
|
shown = { ...shown, ...changes };
|
|
249
|
-
renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
|
|
225
|
+
renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
|
|
250
226
|
};
|
|
251
227
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
258
|
-
pop.style.left = `${at.left}px`;
|
|
259
|
-
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
260
|
-
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
261
|
-
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function open(anchor) {
|
|
265
|
-
const data = summary();
|
|
266
|
-
if (!data) return;
|
|
228
|
+
// A refresh (a newer summary): an open form keeps what the user typed;
|
|
229
|
+
// only its "Now:" line follows (the same template again: lit writes that
|
|
230
|
+
// line alone).
|
|
231
|
+
function show(data, opening) {
|
|
232
|
+
if (!opening) return draw({ now: data });
|
|
267
233
|
initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
|
|
268
|
-
openFor = session();
|
|
269
234
|
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
270
235
|
draw({ now: data, error: null });
|
|
271
|
-
pop.hidden = false;
|
|
272
|
-
place(anchor);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function close() {
|
|
276
|
-
pop.hidden = true;
|
|
277
|
-
initial = null;
|
|
278
|
-
openFor = null;
|
|
279
|
-
shown = null;
|
|
280
|
-
render(nothing, pop);
|
|
281
236
|
}
|
|
282
237
|
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
// A new summary (a command_ran, a re-read): an open form keeps what the
|
|
286
|
-
// user typed; only its "Now:" line follows (the same template again: lit
|
|
287
|
-
// writes that line alone). Another session closes it.
|
|
288
|
-
function refresh() {
|
|
289
|
-
if (!isOpen()) return;
|
|
290
|
-
const data = summary();
|
|
291
|
-
if (!data || session() !== openFor) return close();
|
|
292
|
-
draw({ now: data });
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
el.addEventListener("click", (e) => {
|
|
296
|
-
const anchor = e.target.closest(anchorSel);
|
|
297
|
-
if (!anchor) return;
|
|
298
|
-
e.stopPropagation();
|
|
299
|
-
if (isOpen()) close();
|
|
300
|
-
else open(anchor);
|
|
301
|
-
});
|
|
302
|
-
// An edit makes an error the last Set showed stale.
|
|
303
|
-
const clearError = () => {
|
|
304
|
-
if (shown?.error) draw({ error: null });
|
|
305
|
-
};
|
|
306
|
-
pop.addEventListener("input", clearError);
|
|
307
|
-
pop.addEventListener("change", clearError);
|
|
308
|
-
pop.addEventListener("submit", (e) => {
|
|
309
|
-
e.preventDefault();
|
|
310
|
-
const form = e.target;
|
|
238
|
+
function submit(form) {
|
|
311
239
|
const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
|
|
312
240
|
budget: form.elements.budget.value };
|
|
313
241
|
if (fresh) {
|
|
@@ -318,29 +246,32 @@ export function createLlmContextControl({ el, summary, session = () => null, run
|
|
|
318
246
|
draw({ error: err.message });
|
|
319
247
|
return;
|
|
320
248
|
}
|
|
321
|
-
close();
|
|
249
|
+
shell.close();
|
|
322
250
|
choose(next);
|
|
323
251
|
return;
|
|
324
252
|
}
|
|
325
253
|
const line = commandLine(values, initial);
|
|
326
|
-
close();
|
|
254
|
+
shell.close();
|
|
327
255
|
if (line) run(line);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function act(name) {
|
|
259
|
+
if (name !== "reset") return;
|
|
260
|
+
shell.close();
|
|
261
|
+
if (fresh) choose(null);
|
|
262
|
+
else run(commandLine());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const shell = createChipPopover({
|
|
266
|
+
id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
|
|
267
|
+
onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
|
|
328
268
|
});
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
else run(commandLine());
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
doc.addEventListener("click", (e) => {
|
|
339
|
-
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
340
|
-
});
|
|
341
|
-
doc.addEventListener("keydown", (e) => {
|
|
342
|
-
if (e.key === "Escape" && isOpen()) close();
|
|
343
|
-
});
|
|
269
|
+
// An edit makes an error the last Set showed stale.
|
|
270
|
+
const clearError = () => {
|
|
271
|
+
if (shown?.error) draw({ error: null });
|
|
272
|
+
};
|
|
273
|
+
shell.pop.addEventListener("input", clearError);
|
|
274
|
+
shell.pop.addEventListener("change", clearError);
|
|
344
275
|
|
|
345
|
-
return { close, refresh, get open() { return isOpen(); } };
|
|
276
|
+
return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
|
|
346
277
|
}
|
|
@@ -9,26 +9,32 @@
|
|
|
9
9
|
// has it. The search input keeps the focus:
|
|
10
10
|
// ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
|
|
11
11
|
|
|
12
|
-
import { escapeHtml } from "./format.js";
|
|
13
12
|
import { moveSelection } from "./command_complete.js";
|
|
14
13
|
import {
|
|
15
14
|
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
|
|
16
15
|
} from "./model_pick.js";
|
|
16
|
+
import { html, nothing, render as draw } from "./vendor/lit-html.js";
|
|
17
17
|
|
|
18
18
|
const PAGE = 8;
|
|
19
19
|
const MAX_HEIGHT = 360;
|
|
20
20
|
const MIN_ROOM = 200;
|
|
21
21
|
const GAP = 8;
|
|
22
22
|
|
|
23
|
-
// The text with the [start, end) ranges in <mark
|
|
24
|
-
function
|
|
25
|
-
|
|
23
|
+
// The text with the [start, end) ranges in <mark> (lit-html parts).
|
|
24
|
+
function markedTemplate(text, marks) {
|
|
25
|
+
const parts = [];
|
|
26
26
|
let at = 0;
|
|
27
27
|
for (const [a, b] of marks || []) {
|
|
28
|
-
|
|
28
|
+
parts.push(text.slice(at, a), html`<mark>${text.slice(a, b)}</mark>`);
|
|
29
29
|
at = b;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
parts.push(text.slice(at));
|
|
32
|
+
return parts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A group's head ("Recent", a host with its notes).
|
|
36
|
+
function headTemplate(label, note = "") {
|
|
37
|
+
return html`<div class="model-group" role="presentation">${label}${note ? html` <span>${note}</span>` : nothing}</div>`;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function readRecent() {
|
|
@@ -58,40 +64,38 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
58
64
|
button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
// One option row. The active one is drawn so here and kept so by
|
|
68
|
+
// showActive (the arrows and the pointer move it without a redraw).
|
|
69
|
+
function rowTemplate(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
|
|
70
|
+
const host = showHost
|
|
71
|
+
? html`<span class="model-host">${markedTemplate(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : nothing;
|
|
63
72
|
const noteText = rowNote(row, defaultName);
|
|
64
|
-
const note = noteText ? `<span class="model-note">${noteText}</span>` :
|
|
73
|
+
const note = noteText ? html`<span class="model-note">${noteText}</span>` : nothing;
|
|
65
74
|
const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
|
|
66
|
-
|
|
67
|
-
const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
|
|
68
|
-
return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
|
|
69
|
-
`<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
|
|
75
|
+
return html`<div class=${cls} role="option" id=${`model-option-${i}`} aria-selected=${String(i === active)} data-index=${i} title=${rowTitle(row) || nothing}><span class="model-id">${host}${markedTemplate(row.id, idMarks)}</span>${note}</div>`;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function render() {
|
|
73
79
|
const query = input.value.trim();
|
|
74
|
-
|
|
80
|
+
const parts = [];
|
|
75
81
|
shown = [];
|
|
76
|
-
const add = (row, opts) => {
|
|
82
|
+
const add = (row, opts) => { parts.push(rowTemplate(row, shown.length, opts)); shown.push(row); };
|
|
77
83
|
if (query) {
|
|
78
84
|
for (const m of matchModels(rows, query)) add(m.row, { hostMarks: m.hostMarks, idMarks: m.idMarks });
|
|
79
|
-
if (!shown.length) html
|
|
85
|
+
if (!shown.length) parts.push(html`<div class="model-empty" role="presentation">Nothing matches</div>`);
|
|
80
86
|
} else {
|
|
81
87
|
const recent = recentRows(rows, readRecent());
|
|
82
|
-
const head = (label, note = "") =>
|
|
83
|
-
`<div class="model-group" role="presentation">${escapeHtml(label)}${note ? ` <span>${escapeHtml(note)}</span>` : ""}</div>`;
|
|
84
88
|
if (recent.length) {
|
|
85
|
-
|
|
89
|
+
parts.push(headTemplate("Recent"));
|
|
86
90
|
recent.forEach((row) => add(row));
|
|
87
91
|
}
|
|
88
92
|
for (const group of groupRows(rows)) {
|
|
89
|
-
|
|
93
|
+
parts.push(headTemplate(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · ")));
|
|
90
94
|
group.rows.forEach((row) => add(row, { showHost: false }));
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
if (active >= shown.length) active = 0;
|
|
94
|
-
list
|
|
98
|
+
draw(parts, list);
|
|
95
99
|
showActive();
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -144,7 +148,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
144
148
|
function close({ refocus = false } = {}) {
|
|
145
149
|
if (!isOpen()) return;
|
|
146
150
|
panel.hidden = true;
|
|
147
|
-
list
|
|
151
|
+
draw(nothing, list);
|
|
148
152
|
shown = [];
|
|
149
153
|
button.setAttribute("aria-expanded", "false");
|
|
150
154
|
input.setAttribute("aria-expanded", "false");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// are left alone, inline code only when it is the ref alone, a bare URL's
|
|
8
8
|
// text too.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { refSpan } from "./refs.js";
|
|
11
11
|
|
|
12
12
|
// A path (no spaces), a line, an optional end line and an optional column
|
|
13
13
|
// (kept in the text, not used). LineLinker::REF.
|
|
@@ -168,12 +168,6 @@ function wrapRefs(node, hits) {
|
|
|
168
168
|
node.replaceWith(frag);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
function refSpan(doc, ref) {
|
|
172
|
-
const holder = doc.createElement("div");
|
|
173
|
-
holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
|
|
174
|
-
return holder.firstChild;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
171
|
// [start, end] of each fenced block (an unclosed one runs to the end).
|
|
178
172
|
function fencedBlocks(text) {
|
|
179
173
|
const blocks = [];
|
|
@@ -32,6 +32,19 @@ export function refAttrs(ref) {
|
|
|
32
32
|
return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// A span with refAttrs' ref class and data attributes, for a page that
|
|
36
|
+
// builds DOM (ref_scan.js).
|
|
37
|
+
export function refSpan(doc, ref) {
|
|
38
|
+
const span = doc.createElement("span");
|
|
39
|
+
span.className = "ref";
|
|
40
|
+
if (!ref?.kind) return span;
|
|
41
|
+
span.dataset.refKind = ref.kind;
|
|
42
|
+
for (const [name, value] of [["refPath", ref.path], ["refLine", ref.line], ["refEndLine", ref.end_line]]) {
|
|
43
|
+
if (value) span.dataset[name] = String(value);
|
|
44
|
+
}
|
|
45
|
+
return span;
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
// A kind, as the server's Ref allows it (Ref::KIND).
|
|
36
49
|
const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
37
50
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The stage view's cap and frame (stage_view.js): the one place that
|
|
2
|
+
// writes the stage's height, and the stage's own scrolls. The cap: at most
|
|
3
|
+
// ~60% of the page (~80% with the cloud open), and never above the session
|
|
4
|
+
// panel. The frame: held and grow-only while the stage holds a turn
|
|
5
|
+
// (stage_model.js frameTarget), its growth eased. The stage's state comes
|
|
6
|
+
// in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+); what the stage
|
|
7
|
+
// changes of the frame's goes through resetHeld and forgetContent.
|
|
8
|
+
import { frameTarget } from "./stage_model.js";
|
|
9
|
+
import { easeScrollTo } from "./scroll.js";
|
|
10
|
+
|
|
11
|
+
// The stage grows (a card, the long answer, the cloud opening) with a short
|
|
12
|
+
// ease: deliberate, never a snap.
|
|
13
|
+
const GROW_MS = 180;
|
|
14
|
+
const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
|
|
15
|
+
// The room between the stage and the composer card (index.html's margin-bottom).
|
|
16
|
+
const STAGE_GAP = 10;
|
|
17
|
+
|
|
18
|
+
export function reducedMotion() {
|
|
19
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// +observed+: the elements whose size changes refit the frame (the stage's
|
|
23
|
+
// slots, in the order they are observed).
|
|
24
|
+
export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen }) {
|
|
25
|
+
// The frame (fitFrame): the height held while the turn runs and the
|
|
26
|
+
// tallest content seen.
|
|
27
|
+
let held = null;
|
|
28
|
+
let maxContent = 0;
|
|
29
|
+
let growth = null; // the running ease of the frame's growth (an Animation)
|
|
30
|
+
let growthTo = 0; // its end height
|
|
31
|
+
|
|
32
|
+
// The stage's own scrolls, eased (instant with reduced motion).
|
|
33
|
+
function easeStage(top) {
|
|
34
|
+
if (Math.abs(top - scroller.scrollTop) < 1) return;
|
|
35
|
+
easeScrollTo(scroller, top, { reduce: reducedMotion() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// +fn+ once the frame has grown for a change made now: two frames on (the
|
|
39
|
+
// ResizeObserver starts the ease in the frame after the change), then at
|
|
40
|
+
// the ease's end.
|
|
41
|
+
function afterGrowth(fn) {
|
|
42
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
43
|
+
if (growth?.playState === "running") growth.finished.then(fn, fn);
|
|
44
|
+
else fn();
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── the cap: at most ~60% of the page (~80% with the cloud open), and
|
|
49
|
+
// never above the session panel (the dock is absolute: a tall composer or
|
|
50
|
+
// a phone's keyboard would push the stage's top out) ────────────────────
|
|
51
|
+
|
|
52
|
+
// Where the stage's scroll goes so +slot+ (the scroller's child) shows:
|
|
53
|
+
// its end in sight, and the asking text's top too when it and the slot
|
|
54
|
+
// (a waiting card) fit together (the reply reads from its start, the card
|
|
55
|
+
// still in sight). null: nothing to show.
|
|
56
|
+
function revealTop(slot) {
|
|
57
|
+
if (stage.hidden || slot.hidden) return null;
|
|
58
|
+
let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
|
|
59
|
+
if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
|
|
60
|
+
return top;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
|
|
64
|
+
// dropped under it).
|
|
65
|
+
function reveal(slot, { now = false } = {}) {
|
|
66
|
+
const top = revealTop(slot);
|
|
67
|
+
if (top === null) return;
|
|
68
|
+
if (now) scroller.scrollTop = top;
|
|
69
|
+
else easeStage(top);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fitCap() {
|
|
73
|
+
if (stage.hidden) return;
|
|
74
|
+
// The composer card and the gap above it (the stage floats over them,
|
|
75
|
+
// out of the dock's flow).
|
|
76
|
+
const rest = dockEl.offsetHeight + STAGE_GAP;
|
|
77
|
+
const room = centerEl.clientHeight - rest - 32;
|
|
78
|
+
const share = window.innerHeight * (isCloudOpen() ? 0.8 : 0.6);
|
|
79
|
+
const cap = Math.max(96, Math.round(Math.min(share, room)));
|
|
80
|
+
const was = capPx();
|
|
81
|
+
if (cap === was) return;
|
|
82
|
+
stage.style.setProperty("--stage-max", `${cap}px`);
|
|
83
|
+
fitFrame();
|
|
84
|
+
// A smaller cap (a phone's keyboard came up) keeps a waiting card in
|
|
85
|
+
// sight: the asking text above it would push it out. A larger one
|
|
86
|
+
// leaves the reader's scroll alone.
|
|
87
|
+
if (cap < was && stage.classList.contains("waiting")) {
|
|
88
|
+
requestAnimationFrame(() => reveal(extras, { now: true }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
new ResizeObserver(() => fitCap()).observe(centerEl);
|
|
92
|
+
new ResizeObserver(() => fitCap()).observe(composerEl);
|
|
93
|
+
window.addEventListener("resize", fitCap);
|
|
94
|
+
|
|
95
|
+
// ── the frame: the one place that writes the stage's height ────────────
|
|
96
|
+
// Nothing in the stage view jumps. The rules: the stage is out of the
|
|
97
|
+
// dock's flow (index.html), so its height never moves the composer; while
|
|
98
|
+
// it holds a turn its height is held and grow-only (frameTarget), so a
|
|
99
|
+
// content change happens inside it; growth eases (frameTo); the chip is a
|
|
100
|
+
// footer no content can push; the hand-off releases the turn at once
|
|
101
|
+
// (releaseTurn) and empties the stage only after it slid away under the
|
|
102
|
+
// composer (a transform, then finishFold/clearStage).
|
|
103
|
+
|
|
104
|
+
// The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
|
|
105
|
+
// Animations: the running ease is +growth+), unless motion is reduced or
|
|
106
|
+
// the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
|
|
107
|
+
function frameTo(px, { animate = false } = {}) {
|
|
108
|
+
const from = stage.offsetHeight;
|
|
109
|
+
stage.style.height = `${px}px`;
|
|
110
|
+
if (px === from) return;
|
|
111
|
+
const ease = animate && px > from && !isCollapsed() && !stage.hidden && !document.hidden && !reducedMotion();
|
|
112
|
+
if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
|
|
113
|
+
growth.cancel();
|
|
114
|
+
growth = null;
|
|
115
|
+
}
|
|
116
|
+
if (!ease || growth) return;
|
|
117
|
+
growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
|
|
118
|
+
growthTo = px;
|
|
119
|
+
const run = growth;
|
|
120
|
+
run.finished.then(() => { if (growth === run) growth = null; }, () => {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Back to the content's own height (under the cap).
|
|
124
|
+
function frameFree() {
|
|
125
|
+
growth?.cancel();
|
|
126
|
+
growth = null;
|
|
127
|
+
stage.style.height = "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function capPx() {
|
|
131
|
+
return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The scroller's content height: its last shown slot's end (offsetTop is
|
|
135
|
+
// the scroller's own, whatever its scroll) and its bottom padding.
|
|
136
|
+
function contentHeight() {
|
|
137
|
+
const css = getComputedStyle(scroller);
|
|
138
|
+
let end = parseFloat(css.paddingTop) || 0;
|
|
139
|
+
for (const el of scroller.children) {
|
|
140
|
+
if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
|
|
141
|
+
end = el.offsetTop + el.offsetHeight;
|
|
142
|
+
}
|
|
143
|
+
return end + (parseFloat(css.paddingBottom) || 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Holds the frame while the stage holds a turn (stage_model.js
|
|
147
|
+
// frameTarget: grow-only, clamped by the cap; free when collapsed or
|
|
148
|
+
// released). Run from a ResizeObserver on the slots (after layout, before
|
|
149
|
+
// paint: no frame shows the content's own height) and when the cap moves.
|
|
150
|
+
function fitFrame() {
|
|
151
|
+
if (stage.hidden || stage.classList.contains("leaving")) return;
|
|
152
|
+
const content = scroller.hidden ? 0 : contentHeight();
|
|
153
|
+
const holding = isHolding();
|
|
154
|
+
const collapsed = isCollapsed();
|
|
155
|
+
const holds = holding && !collapsed;
|
|
156
|
+
maxContent = holds ? Math.max(maxContent, content) : 0;
|
|
157
|
+
const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
|
|
158
|
+
if (holdEl.style.top !== mark) holdEl.style.top = mark;
|
|
159
|
+
const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
|
|
160
|
+
+ queuedSlot.offsetHeight;
|
|
161
|
+
held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
|
|
162
|
+
if (held === null) frameFree();
|
|
163
|
+
else frameTo(held, { animate: true });
|
|
164
|
+
}
|
|
165
|
+
const frameObserver = new ResizeObserver(() => fitFrame());
|
|
166
|
+
for (const el of observed) frameObserver.observe(el);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
fitCap,
|
|
170
|
+
frameFree,
|
|
171
|
+
afterGrowth,
|
|
172
|
+
easeStage,
|
|
173
|
+
reveal,
|
|
174
|
+
// A new turn, or none: no height held yet.
|
|
175
|
+
resetHeld: () => { held = null; },
|
|
176
|
+
// The tallest content seen is forgotten (a new turn, the cloud closed by hand).
|
|
177
|
+
forgetContent: () => { maxContent = 0; },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -124,6 +124,39 @@ export function trailFlashText(flash, mark) {
|
|
|
124
124
|
return { text, hover: text };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// The status row's mark for an ended turn's phase (liveSlots), none while
|
|
128
|
+
// it runs.
|
|
129
|
+
export const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
|
|
130
|
+
// The mark of a flash in the trail: a warn notice, a hook's info notice,
|
|
131
|
+
// an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
|
|
132
|
+
export const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
|
|
133
|
+
|
|
134
|
+
// "1 step", "3 steps".
|
|
135
|
+
export function stepsText(n) {
|
|
136
|
+
return `${n} step${n === 1 ? "" : "s"}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The chip's label: the steps, the calls, and the turn's time when the
|
|
140
|
+
// status row's timing line isn't there to say it (+elapsed+, "" for none).
|
|
141
|
+
export function chipLabelText({ steps, calls, elapsed = "" }) {
|
|
142
|
+
return `${stepsText(steps)} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The status row's step: running, the step it is on; ended, the count,
|
|
146
|
+
// unless the chip says it (+chipHidden+: no chip shows).
|
|
147
|
+
export function stepSlotText({ step, ended, chipHidden }) {
|
|
148
|
+
return !ended ? `step ${step}` : chipHidden ? stepsText(step) : "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The collapsed stage's mini tool in the status row: the running call's
|
|
152
|
+
// name and title, its full command as the hover; "" for both otherwise.
|
|
153
|
+
export function miniToolText({ collapsed, tool }) {
|
|
154
|
+
return {
|
|
155
|
+
text: collapsed && tool ? `${tool.name} ${tool.title}` : "",
|
|
156
|
+
title: (collapsed && tool?.command) || "",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
128
161
|
// its text streams in the stage's answer slot, as the turn view's
|
|
129
162
|
// chrome-less block streams like a bubble.
|