samagotchi 0.135.0 → 0.136.1
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 +43 -1
- data/docs/cli.md +7 -6
- data/docs/configuration.md +10 -7
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/web-frontend.md +10 -9
- data/docs/internals/working-set.md +2 -2
- data/docs/sessions.md +3 -3
- data/docs/testing.md +11 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -4
- data/lib/samagotchi/web/message_display.rb +16 -0
- data/lib/samagotchi/web/public/answer_fold.js +110 -30
- data/lib/samagotchi/web/public/app.js +22 -40
- data/lib/samagotchi/web/public/bubbles.js +27 -7
- data/lib/samagotchi/web/public/chip_popover.js +2 -2
- data/lib/samagotchi/web/public/details_motion.js +74 -0
- data/lib/samagotchi/web/public/history_reveal.js +2 -2
- data/lib/samagotchi/web/public/index.html +75 -54
- data/lib/samagotchi/web/public/info_bar.js +11 -12
- data/lib/samagotchi/web/public/info_bar_model.js +8 -10
- data/lib/samagotchi/web/public/level_chip.js +33 -95
- data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
- data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
- data/lib/samagotchi/web/public/revise_stack.js +9 -3
- data/lib/samagotchi/web/public/session_fields.js +4 -4
- data/lib/samagotchi/web/public/session_state.js +7 -5
- data/lib/samagotchi/web/public/session_store.js +1 -1
- data/lib/samagotchi/web/public/settings_chip.js +212 -0
- data/lib/samagotchi/web/public/settings_panel.js +121 -0
- data/lib/samagotchi/web/public/shell_command.js +26 -0
- data/lib/samagotchi/web/public/start_settings.js +25 -88
- data/lib/samagotchi/web/public/thinking_chip.js +7 -45
- data/lib/samagotchi/web/public/time_gaps.js +5 -2
- data/lib/samagotchi/web/public/timing.js +13 -3
- data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
- data/lib/samagotchi/web/public/turn_block.js +6 -1
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +16 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +6 -3
- metadata +5 -1
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
// gets a fold row: a sibling just before the bubble, never inside it (the
|
|
4
4
|
// bubble's copy text, its claims and a live end's innerHTML stay the
|
|
5
5
|
// bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
|
|
6
|
-
// no inline style) and the row
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// no inline style) and the row is the answer's box closed: "▸ ‹first
|
|
7
|
+
// line› · N lines" in the bubble's shape; unfolded, the row is that box's
|
|
8
|
+
// top edge, "▾ N lines", the bubble its rest (CSS joins the two). The box
|
|
9
|
+
// never changes shape, a click changes its height, animated
|
|
10
|
+
// (details_motion.js animateHeight) unless the viewer prefers no motion.
|
|
11
|
+
//
|
|
12
|
+
// A delegate report (a user line chi brought in, data-source
|
|
13
|
+
// "delegate_report") gets the same row, folded by default (it is written
|
|
14
|
+
// for the model; the reply under it is the user's summary): the row sits
|
|
15
|
+
// where the report sits and reads "delegate report · ‹first line› · N
|
|
16
|
+
// lines". Its key: a merged one's data-line-key, a wake turn's opening
|
|
17
|
+
// "<turn id>:prompt". A live merged report has no key until a reload.
|
|
9
18
|
//
|
|
10
19
|
// Which answers are folded is this browser's choice (localStorage through
|
|
11
|
-
// storage.js): one key per session holding the folded answers' keys
|
|
12
|
-
// last FOLD_KEEP of
|
|
20
|
+
// storage.js): one key per session holding the folded answers' keys and
|
|
21
|
+
// the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
|
|
13
22
|
// (data-answer-turn), a delegate report's reply its own (data-reply-key);
|
|
14
23
|
// a bubble with neither (an answer before any prompt, a canceled turn's
|
|
15
24
|
// partial) gets no row. The keys of sessions gone from the session list go
|
|
@@ -23,6 +32,7 @@
|
|
|
23
32
|
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
24
33
|
// export on an old module would blank the page.
|
|
25
34
|
import { bubbleCopyText } from "./copy.js";
|
|
35
|
+
import { animateHeight, canAnimate } from "./details_motion.js";
|
|
26
36
|
import { clip } from "./format.js";
|
|
27
37
|
import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
|
|
28
38
|
|
|
@@ -36,7 +46,9 @@ export const FOLD_PREFIX = "chi_answer_fold:";
|
|
|
36
46
|
const LABEL_CHARS = 160;
|
|
37
47
|
|
|
38
48
|
const ROW_CLASS = "answer-fold";
|
|
39
|
-
|
|
49
|
+
// What gets a row: answers and delegate reports (user lines chi brought in).
|
|
50
|
+
const REPORTS = '.bubble.user[data-source="delegate_report"]';
|
|
51
|
+
const ANSWERS = `.bubble.output, ${REPORTS}`;
|
|
40
52
|
// Not done yet: still streaming.
|
|
41
53
|
const UNFINISHED = ".streaming";
|
|
42
54
|
|
|
@@ -71,12 +83,13 @@ export function foldLabel(text, { display = false } = {}) {
|
|
|
71
83
|
return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
// The row's words: folded, the label and the size; unfolded, the size
|
|
75
|
-
|
|
86
|
+
// The row's words: folded, the label and the size; unfolded, the size
|
|
87
|
+
// alone (the content is right under it). +label+: the row's own (a
|
|
88
|
+
// report's, reportLabel), else the answer's.
|
|
89
|
+
export function foldRowText(text, { display = false, folded = true, label = foldLabel(text, { display }) } = {}) {
|
|
76
90
|
const n = lineCount(text);
|
|
77
91
|
const size = `${n} line${n === 1 ? "" : "s"}`;
|
|
78
92
|
if (!folded) return { label: "", size };
|
|
79
|
-
const label = foldLabel(text, { display });
|
|
80
93
|
return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
|
|
81
94
|
}
|
|
82
95
|
|
|
@@ -86,11 +99,17 @@ export function foldStoreKey(sessionId) {
|
|
|
86
99
|
return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
|
|
87
100
|
}
|
|
88
101
|
|
|
102
|
+
// A session's entry: {root, turns, open}. `turns` holds the answers folded
|
|
103
|
+
// by hand (and Revise stubs opened, revise_stack.js); `open` the delegate
|
|
104
|
+
// reports unfolded by hand (a report folds by default). An entry written
|
|
105
|
+
// before `open` reads as none unfolded.
|
|
106
|
+
const strings = (list) => (Array.isArray(list) ? list.filter((t) => typeof t === "string") : []);
|
|
107
|
+
|
|
89
108
|
function readEntry(key) {
|
|
90
109
|
try {
|
|
91
110
|
const entry = JSON.parse(readStored(key, "null"));
|
|
92
|
-
if (!entry || !Array.isArray(entry.turns)) return null;
|
|
93
|
-
return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns
|
|
111
|
+
if (!entry || (!Array.isArray(entry.turns) && !Array.isArray(entry.open))) return null;
|
|
112
|
+
return { root: typeof entry.root === "string" ? entry.root : null, turns: strings(entry.turns), open: strings(entry.open) };
|
|
94
113
|
} catch (_) {
|
|
95
114
|
return null;
|
|
96
115
|
}
|
|
@@ -103,6 +122,12 @@ export function readFolded(sessionId) {
|
|
|
103
122
|
return (key && readEntry(key)?.turns) || [];
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
// The session's delegate reports unfolded by hand, oldest first.
|
|
126
|
+
export function readOpened(sessionId) {
|
|
127
|
+
const key = foldStoreKey(sessionId);
|
|
128
|
+
return (key && readEntry(key)?.open) || [];
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
// +list+ with +answerKey+ folded (moved to the end) or not, at most
|
|
107
132
|
// FOLD_KEEP long. Pure.
|
|
108
133
|
export function withFold(list, answerKey, folded) {
|
|
@@ -113,11 +138,23 @@ export function withFold(list, answerKey, folded) {
|
|
|
113
138
|
// Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
|
|
114
139
|
// project root, kept for the sweep of a project's list.
|
|
115
140
|
export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
|
|
141
|
+
writeList(sessionId, "turns", answerKey, folded, root);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Saves the report +reportKey+ unfolded (+open+) or not for +sessionId+.
|
|
145
|
+
export function writeOpened(sessionId, reportKey, open, { root = null } = {}) {
|
|
146
|
+
writeList(sessionId, "open", reportKey, open, root);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// +member+ in or out of the entry's +list+ ("turns" or "open"), the other
|
|
150
|
+
// list kept; the key goes when both are empty.
|
|
151
|
+
function writeList(sessionId, list, member, inList, root) {
|
|
116
152
|
const key = foldStoreKey(sessionId);
|
|
117
|
-
if (!key || !
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
153
|
+
if (!key || !member) return;
|
|
154
|
+
const entry = readEntry(key) || { turns: [], open: [] };
|
|
155
|
+
const next = { turns: entry.turns, open: entry.open, [list]: withFold(entry[list], member, inList) };
|
|
156
|
+
if (!next.turns.length && !next.open.length) removeStored(key);
|
|
157
|
+
else writeStored(key, JSON.stringify({ root, turns: next.turns, ...(next.open.length ? { open: next.open } : {}) }));
|
|
121
158
|
}
|
|
122
159
|
|
|
123
160
|
// The session list loaded (+sessions+, each {id, project_root}): the fold
|
|
@@ -137,8 +174,17 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
|
|
|
137
174
|
|
|
138
175
|
// ---- The rows ----
|
|
139
176
|
|
|
140
|
-
|
|
177
|
+
const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
|
|
178
|
+
|
|
179
|
+
// The key an answer bubble is folded by, or null. A delegate report's: a
|
|
180
|
+
// merged one's line key (data-line-key, "<turn id>:input:<place>"), one
|
|
181
|
+
// that opened a wake turn its turn's id and ":prompt" (never the answer's
|
|
182
|
+
// key, the bare id).
|
|
141
183
|
export function answerKey(bubble) {
|
|
184
|
+
if (isReport(bubble)) {
|
|
185
|
+
const turn = bubble.getAttribute("data-turn-id");
|
|
186
|
+
return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
|
|
187
|
+
}
|
|
142
188
|
return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
|
|
143
189
|
}
|
|
144
190
|
|
|
@@ -146,13 +192,30 @@ function answerText(bubble) {
|
|
|
146
192
|
return bubbleCopyText(bubble);
|
|
147
193
|
}
|
|
148
194
|
|
|
195
|
+
// A delegate report's lines before its body: "session: <id>",
|
|
196
|
+
// "status: <s>", "---".
|
|
197
|
+
const REPORT_HEADER = /^(?:(?:session|status):\s|---\s*$)/;
|
|
198
|
+
|
|
199
|
+
// A report row's label: its origin label (the bubble's) and its first
|
|
200
|
+
// heading or first line after the header.
|
|
201
|
+
export function reportLabel(text, origin = "") {
|
|
202
|
+
const body = String(text ?? "").replace(/\r\n?/g, "\n").split("\n").filter((l) => !REPORT_HEADER.test(l.trim())).join("\n");
|
|
203
|
+
const first = foldLabel(body);
|
|
204
|
+
return clip([origin.trim(), first].filter(Boolean).join(" · "), LABEL_CHARS);
|
|
205
|
+
}
|
|
206
|
+
|
|
149
207
|
function drawRow(row, bubble) {
|
|
150
208
|
const folded = bubble.classList.contains("folded");
|
|
151
209
|
const text = answerText(bubble);
|
|
152
|
-
const
|
|
210
|
+
const report = isReport(bubble);
|
|
211
|
+
const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
|
|
212
|
+
const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded, ...own });
|
|
213
|
+
const what = report ? "report" : "answer";
|
|
153
214
|
row.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
154
|
-
row.title = folded ?
|
|
215
|
+
row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
|
|
155
216
|
row.classList.toggle("folded", folded);
|
|
217
|
+
row.classList.toggle("report", report);
|
|
218
|
+
row.classList.toggle("steered", report && bubble.classList.contains("steered"));
|
|
156
219
|
const [labelEl, sizeEl] = row.children;
|
|
157
220
|
if (labelEl.textContent !== label) labelEl.textContent = label;
|
|
158
221
|
if (sizeEl.textContent !== size) sizeEl.textContent = size;
|
|
@@ -186,8 +249,9 @@ export function keepRowInView(scroller, row, mutate) {
|
|
|
186
249
|
// @param historyEl the history (#history), also its scroller
|
|
187
250
|
// @param sessionId () => the open session's id
|
|
188
251
|
// @param projectRoot () => the open session's project root, or null
|
|
252
|
+
// @param motion (bubble) => whether a fold or unfold animates (canAnimate)
|
|
189
253
|
// @return {decorate(root)} (the observer runs it; specs call it)
|
|
190
|
-
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
|
|
254
|
+
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null, motion = canAnimate }) {
|
|
191
255
|
// Each bubble's row, and each row's bubble.
|
|
192
256
|
const rows = new WeakMap();
|
|
193
257
|
const bubbleOf = new WeakMap();
|
|
@@ -202,14 +266,18 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
202
266
|
rows.set(bubble, row);
|
|
203
267
|
bubbleOf.set(row, bubble);
|
|
204
268
|
// The stored choice, once: after that the bubble's class is the state
|
|
205
|
-
// (a jump that unfolds it, history_reveal.js, isn't saved).
|
|
206
|
-
|
|
269
|
+
// (a jump that unfolds it, history_reveal.js, isn't saved). An answer
|
|
270
|
+
// is open unless folded by hand, a report folded unless opened.
|
|
271
|
+
const folded = isReport(bubble) ? !stored().open.includes(key) : stored().turns.includes(key);
|
|
272
|
+
if (folded) bubble.classList.add("folded");
|
|
207
273
|
}
|
|
208
274
|
if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
|
|
209
275
|
drawRow(row, bubble);
|
|
210
276
|
}
|
|
211
277
|
|
|
212
|
-
// Rows whose bubble left (a re-render, a replaced bubble) go with it
|
|
278
|
+
// Rows whose bubble left (a re-render, a replaced bubble) go with it; a
|
|
279
|
+
// row something was placed after (a card or a recap placed before a
|
|
280
|
+
// report that opens its turn) goes back just before its bubble.
|
|
213
281
|
function dropOrphans() {
|
|
214
282
|
for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
|
|
215
283
|
const bubble = bubbleOf.get(row);
|
|
@@ -219,8 +287,8 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
219
287
|
}
|
|
220
288
|
|
|
221
289
|
function decorate(root) {
|
|
222
|
-
let
|
|
223
|
-
const stored = () => (
|
|
290
|
+
let entry = null;
|
|
291
|
+
const stored = () => (entry ??= { turns: readFolded(sessionId()), open: readOpened(sessionId()) });
|
|
224
292
|
if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
|
|
225
293
|
for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
|
|
226
294
|
}
|
|
@@ -229,18 +297,22 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
229
297
|
// the same frame as its bubble, so nothing below it shifts after a paint.
|
|
230
298
|
new MutationObserver((records) => {
|
|
231
299
|
const roots = new Set();
|
|
232
|
-
let
|
|
300
|
+
let settle = false;
|
|
233
301
|
for (const r of records) {
|
|
234
302
|
if (r.type === "attributes") {
|
|
235
303
|
if (r.target.matches?.(ANSWERS)) roots.add(r.target);
|
|
236
304
|
continue;
|
|
237
305
|
}
|
|
238
|
-
if (r.removedNodes.length)
|
|
306
|
+
if (r.removedNodes.length) settle = true;
|
|
239
307
|
const answer = r.target.closest?.(ANSWERS);
|
|
240
308
|
if (answer) roots.add(answer);
|
|
241
|
-
for (const n of r.addedNodes)
|
|
309
|
+
for (const n of r.addedNodes) {
|
|
310
|
+
if (n.nodeType !== 1 || n.classList.contains(ROW_CLASS)) continue;
|
|
311
|
+
roots.add(n);
|
|
312
|
+
if (r.target === historyEl) settle = true;
|
|
313
|
+
}
|
|
242
314
|
}
|
|
243
|
-
if (
|
|
315
|
+
if (settle) dropOrphans();
|
|
244
316
|
for (const el of roots) if (el.isConnected) decorate(el);
|
|
245
317
|
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
246
318
|
decorate(historyEl);
|
|
@@ -251,11 +323,19 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
251
323
|
if (!bubble) return;
|
|
252
324
|
e.preventDefault();
|
|
253
325
|
const folded = !bubble.classList.contains("folded");
|
|
254
|
-
keepRowInView(historyEl, row, () => {
|
|
326
|
+
const settle = () => keepRowInView(historyEl, row, () => {
|
|
255
327
|
bubble.classList.toggle("folded", folded);
|
|
256
328
|
drawRow(row, bubble);
|
|
257
329
|
});
|
|
258
|
-
|
|
330
|
+
if (!motion(bubble)) settle();
|
|
331
|
+
else if (folded) animateHeight(bubble, bubble.offsetHeight, 0, { fade: true }).then(settle);
|
|
332
|
+
else {
|
|
333
|
+
settle();
|
|
334
|
+
animateHeight(bubble, 0, bubble.offsetHeight, { fade: true });
|
|
335
|
+
}
|
|
336
|
+
// An answer's choice is its fold, a report's its unfold (D2: the default differs).
|
|
337
|
+
if (isReport(bubble)) writeOpened(sessionId(), answerKey(bubble), !folded, { root: projectRoot() });
|
|
338
|
+
else writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
|
|
259
339
|
});
|
|
260
340
|
|
|
261
341
|
return { decorate };
|
|
@@ -35,10 +35,8 @@ import {
|
|
|
35
35
|
import { authProbe, createAuthLost } from "./auth_lost.js";
|
|
36
36
|
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
37
37
|
import { createContextChips } from "./context_chips.js";
|
|
38
|
-
import { createLlmContextControl } from "./llm_context_chip.js";
|
|
39
38
|
import { createParticipantsControl } from "./participants_panel.js";
|
|
40
|
-
import {
|
|
41
|
-
import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
39
|
+
import { createSettingsControl } from "./settings_chip.js";
|
|
42
40
|
import { renderInfoBar } from "./info_bar.js";
|
|
43
41
|
import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
|
|
44
42
|
import { createShownTexts } from "./shown_texts.js";
|
|
@@ -74,6 +72,7 @@ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
|
74
72
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
75
73
|
import { copyText, installCopy } from "./copy.js";
|
|
76
74
|
import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
|
|
75
|
+
import { installDetailsMotion } from "./details_motion.js";
|
|
77
76
|
import { answerLabel, installReviseStacks, reviseChipTemplate, reviseWorkerUnavailable } from "./revise_stack.js";
|
|
78
77
|
import { createQuestionCards } from "./question_cards.js";
|
|
79
78
|
import { splitSnapshotCards } from "./card.js";
|
|
@@ -155,29 +154,13 @@ const contextChips = createContextChips({
|
|
|
155
154
|
api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
|
|
156
155
|
toast: (text) => showToast(text),
|
|
157
156
|
});
|
|
158
|
-
// The open session's
|
|
159
|
-
//
|
|
160
|
-
|
|
157
|
+
// The open session's ⚙ chip in the info bar, and its panel (thinking,
|
|
158
|
+
// reasoning history, LLM context): a pick runs its command in the session's
|
|
159
|
+
// worker.
|
|
160
|
+
const settingsControl = createSettingsControl({
|
|
161
161
|
el: infoTextEl,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
running: () => liveTurn.running,
|
|
165
|
-
run: (line) => runSessionCommand(line),
|
|
166
|
-
});
|
|
167
|
-
// The open session's thinking chip, beside the llm ctx one: /thinking runs
|
|
168
|
-
// in the session's worker.
|
|
169
|
-
const thinkingControl = createThinkingControl({
|
|
170
|
-
el: infoTextEl,
|
|
171
|
-
summary: () => (store.selected ? store.current.thinking : null),
|
|
172
|
-
session: () => store.selected,
|
|
173
|
-
running: () => liveTurn.running,
|
|
174
|
-
run: (line) => runSessionCommand(line),
|
|
175
|
-
});
|
|
176
|
-
// The open session's reasoning history chip, after the thinking one (a
|
|
177
|
-
// chat host's only): /reasoning-history runs in the session's worker.
|
|
178
|
-
const reasoningHistoryControl = createReasoningHistoryControl({
|
|
179
|
-
el: infoTextEl,
|
|
180
|
-
summary: () => (store.selected ? store.current.reasoningHistory : null),
|
|
162
|
+
summaries: () => (store.selected ? { thinking: store.current.thinking, reasoningHistory: store.current.reasoningHistory,
|
|
163
|
+
llmContext: store.current.llmContext } : null),
|
|
181
164
|
session: () => store.selected,
|
|
182
165
|
running: () => liveTurn.running,
|
|
183
166
|
run: (line) => runSessionCommand(line),
|
|
@@ -1191,14 +1174,15 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
1191
1174
|
const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
|
|
1192
1175
|
if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
|
|
1193
1176
|
if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
|
|
1194
|
-
// The recap saved with the session (also a stopped one's), else a live
|
|
1195
|
-
// worker's from before saved_recap existed.
|
|
1196
|
-
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
|
|
1197
|
-
else showRecap(data.recap);
|
|
1198
1177
|
// A live worker's last cards and between-turns notices, where they
|
|
1199
1178
|
// arrived; the running turn's go into its step once it is drawn.
|
|
1200
1179
|
const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
|
|
1201
1180
|
placeSnapshotCards(snapshotCards.placed);
|
|
1181
|
+
// The recap saved with the session (also a stopped one's), else a live
|
|
1182
|
+
// worker's from before saved_recap existed. After the cards: the last
|
|
1183
|
+
// turn's check-in notice goes to the end, and the recap closes the page.
|
|
1184
|
+
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
|
|
1185
|
+
else showRecap(data.recap);
|
|
1202
1186
|
const replay = snapshotEvents({
|
|
1203
1187
|
current_turn: data.current_turn,
|
|
1204
1188
|
queued: data.queued,
|
|
@@ -1373,8 +1357,6 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1373
1357
|
if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
|
|
1374
1358
|
}
|
|
1375
1359
|
|
|
1376
|
-
// The info bar's "copy chi --attach": the full id, which the page shows
|
|
1377
|
-
// only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
|
|
1378
1360
|
infoTextEl.addEventListener("click", (e) => {
|
|
1379
1361
|
if (e.target.closest(".worker-restart")) restartWorker();
|
|
1380
1362
|
// Pushed as openAllView does, so the view's back returns here.
|
|
@@ -1383,12 +1365,10 @@ infoTextEl.addEventListener("click", (e) => {
|
|
|
1383
1365
|
openAllView();
|
|
1384
1366
|
}
|
|
1385
1367
|
});
|
|
1368
|
+
// The info bar's id: a click copies the full id, which the bar shows only short.
|
|
1386
1369
|
infoTextEl.addEventListener("click", async (e) => {
|
|
1387
|
-
if (!e.target.closest(".
|
|
1388
|
-
|
|
1389
|
-
if (!(await copyText(`chi --attach ${id}`)) || id !== store.selected) return;
|
|
1390
|
-
store.update({ current: { attachCopiedAt: Date.now() } });
|
|
1391
|
-
setTimeout(() => store.touch("infoBar"), 2000);
|
|
1370
|
+
if (!e.target.closest(".id") || !store.selected) return;
|
|
1371
|
+
showToast((await copyText(store.selected)) ? "Session id copied" : "Couldn't copy the session id");
|
|
1392
1372
|
});
|
|
1393
1373
|
|
|
1394
1374
|
// Apply (review_apply.js) under a participant's review turn that just
|
|
@@ -1503,7 +1483,7 @@ function updateInfoBar() {
|
|
|
1503
1483
|
const { buttons, bar } = sessionBar({
|
|
1504
1484
|
id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
|
|
1505
1485
|
continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
|
|
1506
|
-
|
|
1486
|
+
workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
|
|
1507
1487
|
});
|
|
1508
1488
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1509
1489
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
@@ -2106,6 +2086,10 @@ historyEl.addEventListener("click", (e) => {
|
|
|
2106
2086
|
});
|
|
2107
2087
|
turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
|
|
2108
2088
|
turnRoots().forEach((root) => installCopy(root));
|
|
2089
|
+
// A steps block, a thinking block and the recap open and close by hand
|
|
2090
|
+
// with their height animated (details_motion.js), in the history and on
|
|
2091
|
+
// the stage.
|
|
2092
|
+
installDetailsMotion({ root: document.body, selector: ".turn-work, .bubble.thinking, .bubble.recap" });
|
|
2109
2093
|
// Long answers fold by hand (answer_fold.js), per viewer and session.
|
|
2110
2094
|
installAnswerFold({
|
|
2111
2095
|
historyEl,
|
|
@@ -2193,9 +2177,7 @@ document.addEventListener("keydown", (e) => {
|
|
|
2193
2177
|
// before it (none at load) would redraw nothing.
|
|
2194
2178
|
store.on("lists", () => lists.render());
|
|
2195
2179
|
store.on("paintActive", () => lists.paintActive());
|
|
2196
|
-
store.on("
|
|
2197
|
-
store.on("thinkingChip", () => thinkingControl.refresh());
|
|
2198
|
-
store.on("reasoningChip", () => reasoningHistoryControl.refresh());
|
|
2180
|
+
store.on("settingsPanel", () => settingsControl.refresh());
|
|
2199
2181
|
// The working set's list offers pin only under forget.
|
|
2200
2182
|
store.on("workingSetStrategy", () => workingSet?.strategyChanged());
|
|
2201
2183
|
store.on("featureGates", () => featureGates.check(store.current));
|
|
@@ -14,6 +14,11 @@ import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
15
|
import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
|
|
16
16
|
|
|
17
|
+
// What a session's load places at a turn's prompt from the snapshot
|
|
18
|
+
// (cards_place.js placeSnapshotCards): its cards, answered questions,
|
|
19
|
+
// notices and warnings.
|
|
20
|
+
const SNAPSHOT_PLACED = ".plugin-card, .bubble.question, .bubble.hook-notice, .bubble.guardrail-warning";
|
|
21
|
+
|
|
17
22
|
export function removeQueuedBubble(bubble) {
|
|
18
23
|
if (!bubble) return;
|
|
19
24
|
const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
|
|
@@ -159,10 +164,11 @@ export function createBubbles({
|
|
|
159
164
|
|
|
160
165
|
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
161
166
|
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
162
|
-
// queued badge says what it waits for.
|
|
163
|
-
|
|
167
|
+
// queued badge says what it waits for. +source+: a delegate report's
|
|
168
|
+
// "delegate_report" (its fold row, answer_fold.js).
|
|
169
|
+
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null, author = null, turnId = null, source = null } = {}) {
|
|
164
170
|
removeHint();
|
|
165
|
-
const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId }, {
|
|
171
|
+
const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId, source }, {
|
|
166
172
|
label, state, own, enqueuedId, delivery, author, key: normalize(prompt), thumbs: thumbsTemplate(selected(), images),
|
|
167
173
|
}));
|
|
168
174
|
return placeFollowing(div);
|
|
@@ -182,10 +188,16 @@ export function createBubbles({
|
|
|
182
188
|
return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
// The running wake turn's id (a turn chi ran for reports), for its report's bubble.
|
|
192
|
+
let wakeTurnId = null;
|
|
193
|
+
|
|
185
194
|
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
186
195
|
// into its prompt line), else null.
|
|
187
196
|
function applyPromptOps(event) {
|
|
188
|
-
if (event.type === "turn_started")
|
|
197
|
+
if (event.type === "turn_started") {
|
|
198
|
+
liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
199
|
+
wakeTurnId = liveTurn.wakeStart ? event.turn_id || null : null;
|
|
200
|
+
}
|
|
189
201
|
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
|
|
190
202
|
if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
|
|
191
203
|
if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
|
|
@@ -194,7 +206,10 @@ export function createBubbles({
|
|
|
194
206
|
if (op.op === "report") {
|
|
195
207
|
liveTurn.unmatchedMerge = "report";
|
|
196
208
|
} else if (op.op === "add") {
|
|
197
|
-
|
|
209
|
+
// A report that opens a wake turn carries that turn's id, as a
|
|
210
|
+
// reload's does (its fold key); a merged one has none until reload.
|
|
211
|
+
const turnId = op.source && !op.state && liveTurn.wakeStart ? wakeTurnId : null;
|
|
212
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery, author: op.author, source: op.source, turnId });
|
|
198
213
|
if (op.state === "started") started = bubble;
|
|
199
214
|
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
200
215
|
} else if (op.op === "tag") {
|
|
@@ -241,8 +256,13 @@ export function createBubbles({
|
|
|
241
256
|
// Never in the stage: a recap is between turns.
|
|
242
257
|
turnDom.settleFold();
|
|
243
258
|
const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
|
|
244
|
-
// Above that turn's gap divider too: the recap was written before the quiet stretch ended.
|
|
245
|
-
|
|
259
|
+
// Above that turn's gap divider too: the recap was written before the quiet stretch ended. The
|
|
260
|
+
// snapshot's cards placed at that prompt (placed first) sit between the divider and the prompt.
|
|
261
|
+
// A delegate report's fold row (answer_fold.js) heads its prompt.
|
|
262
|
+
const head = prompt?.previousElementSibling?.matches(".answer-fold") ? prompt.previousElementSibling : prompt;
|
|
263
|
+
let back = head?.previousElementSibling;
|
|
264
|
+
while (back?.matches(SNAPSHOT_PLACED)) back = back.previousElementSibling;
|
|
265
|
+
const before = back?.matches(`.${TIME_GAP_CLASS}`) ? back : head;
|
|
246
266
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
247
267
|
}
|
|
248
268
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The popover shell a session-bar chip opens (
|
|
2
|
-
//
|
|
1
|
+
// The popover shell a session-bar chip opens (settings_chip.js' ⚙ panel,
|
|
2
|
+
// the working set's, the start page's panels): a div.ctx-popover on the body, placed
|
|
3
3
|
// at the chip, opened by a click on it (delegated on +el+) or by open()
|
|
4
4
|
// (another opener: the working set's rail and footprint), closed by a second
|
|
5
5
|
// click, its × (data-act="close"), a click outside or Esc; focus goes back
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Collapsibles keep their shape: a steps block, a thinking block, the recap
|
|
2
|
+
// and the answer fold are the same box open or closed (the bar, the border,
|
|
3
|
+
// the width), and a click only changes the height. This module is the
|
|
4
|
+
// motion: a click on one of their summaries animates the height between the
|
|
5
|
+
// two states (Web Animations, MOTION_MS) instead of snapping, and the
|
|
6
|
+
// answer fold borrows animateHeight for its bubble. Nothing moves when the
|
|
7
|
+
// viewer prefers reduced motion or the element cannot animate (an old
|
|
8
|
+
// engine, a spec's DOM): the click is the native toggle then.
|
|
9
|
+
//
|
|
10
|
+
// Only a hand's click animates: the page's own opens and closes (a turn's
|
|
11
|
+
// end closing a block, a reload drawing the outline) stay instant, as they
|
|
12
|
+
// are not a change the viewer watches.
|
|
13
|
+
//
|
|
14
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
15
|
+
// export on an old module would blank the page.
|
|
16
|
+
|
|
17
|
+
export const MOTION_MS = 220;
|
|
18
|
+
|
|
19
|
+
// Whether +el+ can animate for this viewer.
|
|
20
|
+
export function canAnimate(el) {
|
|
21
|
+
if (typeof el?.animate !== "function") return false;
|
|
22
|
+
const win = el.ownerDocument?.defaultView;
|
|
23
|
+
return !win?.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Animates +el+'s height from +from+ to +to+ (px), its overflow clipped
|
|
27
|
+
// meanwhile; +fade+ fades it with the height. Resolves when the animation
|
|
28
|
+
// is over (or canceled).
|
|
29
|
+
export async function animateHeight(el, from, to, { fade = false, ms = MOTION_MS } = {}) {
|
|
30
|
+
const at = (h, o) => (fade ? { height: `${h}px`, opacity: String(o) } : { height: `${h}px` });
|
|
31
|
+
const anim = el.animate([at(from, from ? 1 : 0), at(to, to ? 1 : 0)], { duration: ms, easing: "ease" });
|
|
32
|
+
el.style.overflow = "hidden";
|
|
33
|
+
try {
|
|
34
|
+
await anim.finished;
|
|
35
|
+
} catch {
|
|
36
|
+
// canceled: the element is where its class puts it
|
|
37
|
+
} finally {
|
|
38
|
+
el.style.overflow = "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A <details>' height closed: its summary and its own padding and borders.
|
|
43
|
+
function closedHeight(details, summary) {
|
|
44
|
+
const win = details.ownerDocument?.defaultView;
|
|
45
|
+
const cs = win?.getComputedStyle?.(details);
|
|
46
|
+
const px = (v) => Number.parseFloat(v) || 0;
|
|
47
|
+
const frame = cs ? px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth) : 0;
|
|
48
|
+
return summary.offsetHeight + frame;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Installs the motion on +root+ for the <details> matching +selector+ whose
|
|
52
|
+
// own summary is clicked. +motion+: whether to animate +el+ (canAnimate).
|
|
53
|
+
export function installDetailsMotion({ root, selector, motion = canAnimate }) {
|
|
54
|
+
const busy = new WeakSet();
|
|
55
|
+
root.addEventListener("click", (e) => {
|
|
56
|
+
const summary = e.target.closest?.("summary");
|
|
57
|
+
const details = summary?.parentElement;
|
|
58
|
+
if (!details || !details.matches?.(selector) || summary.parentElement !== details) return;
|
|
59
|
+
if (e.target.closest("a, button, input, label") !== null && e.target !== summary) return;
|
|
60
|
+
if (!motion(details)) return;
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
if (busy.has(details)) return;
|
|
63
|
+
busy.add(details);
|
|
64
|
+
const done = () => busy.delete(details);
|
|
65
|
+
if (details.open) {
|
|
66
|
+
const from = details.offsetHeight;
|
|
67
|
+
animateHeight(details, from, closedHeight(details, summary)).then(() => { details.open = false; done(); });
|
|
68
|
+
} else {
|
|
69
|
+
const from = closedHeight(details, summary);
|
|
70
|
+
details.open = true;
|
|
71
|
+
animateHeight(details, from, details.offsetHeight).then(done);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The one way a jump into the history shows what it lands on: whatever
|
|
2
2
|
// hides +el+ unfolds: a closed <details> around it (a turn's steps block, a
|
|
3
|
-
// step, a diff), an answer
|
|
3
|
+
// step, a diff), an answer or a delegate report folded (answer_fold.js: its row follows
|
|
4
4
|
// the bubble's class; the jump's unfold isn't saved), a superseded version
|
|
5
5
|
// of a Revise stack (revise_stack.js: its stub opens, not saved). Every
|
|
6
6
|
// jump goes through it (claim_link.js showClaim, refs.js showRow and so the
|
|
@@ -19,7 +19,7 @@ import { openStubOver } from "./revise_stack.js";
|
|
|
19
19
|
export function revealInHistory(el) {
|
|
20
20
|
if (!el) return null;
|
|
21
21
|
openStubOver(el);
|
|
22
|
-
el.closest?.(".bubble.
|
|
22
|
+
el.closest?.(".bubble.folded")?.classList.remove("folded");
|
|
23
23
|
for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
|
|
24
24
|
details.open = true;
|
|
25
25
|
}
|