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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -0,0 +1,291 @@
1
+ // The history's bubbles, the open session's lines between its turns' rows:
2
+ // the placeholder hint, output chunks, end lines, the prompt bubbles (every
3
+ // client's, by enqueued_id), the idle recap, guardrail warnings, hooks'
4
+ // notices, plugins' steers, command and note bubbles. The markup is lit
5
+ // (turn_html.js elementOf); createBubbles puts them in the history.
6
+ import { html, nothing } from "./vendor/lit-html.js";
7
+ import { normalize, recapLabel, recapPlace } from "./format.js";
8
+ import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
9
+ import { thumbsTemplate } from "./images.js";
10
+ import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
11
+ import { keepFollowing } from "./scroll.js";
12
+ import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
13
+ import { drawCommandBubble } from "./commands.js";
14
+
15
+ // A prompt that started a turn: not a line steered into a running one.
16
+ export const TURN_PROMPT = ".bubble.user:not(.steered)";
17
+
18
+ // The history's turn prompts, oldest first (what turns_since counts).
19
+ export function turnPromptBubbles(historyEl) {
20
+ return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
21
+ }
22
+
23
+ // The history's one line while it has nothing else (loading, an error,
24
+ // no output yet).
25
+ export function hintTemplate(text) {
26
+ return html`<div class="hint">${text}</div>`;
27
+ }
28
+ export function hintEl(text) {
29
+ return elementOf(hintTemplate(text));
30
+ }
31
+
32
+ // A piece of output text (a chunk, an older session's plain history item).
33
+ export function outputBubbleTemplate(text) {
34
+ return html`<div class="bubble output">${String(text)}</div>`;
35
+ }
36
+
37
+ // +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
38
+ // the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
39
+ export function endBubbleTemplate(text, { stopped = false } = {}) {
40
+ return html`<div class=${stopped ? STOPPED_LINE_CLASS : "bubble cancel"}>${text}</div>`;
41
+ }
42
+
43
+ // The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
44
+ export function recapTemplate(text, turnsSince = 0) {
45
+ return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} open><summary>${recapLabel(turnsSince)}</summary><div class="recap-body">${text}</div></details>`;
46
+ }
47
+
48
+ // A guardrail hook or rule file that failed to load (once per worker), or
49
+ // a bundle's plugin (+label+ "plugins").
50
+ export function guardrailWarningTemplate(text, label = "guardrails") {
51
+ return html`<div class="bubble guardrail-warning">${`${label}: ${text}`}</div>`;
52
+ }
53
+
54
+ // A hook's line to the user (event[:notify]), in the warning colour for
55
+ // level warn; +cls+ adds a kind's class (llm_context_edited's). +bubble+:
56
+ // one of the history's own, else a row of a turn's step.
57
+ export function hookNoticeTemplate({ line, level = null, cls = null, title = null }, { bubble = true } = {}) {
58
+ const klass = (bubble ? "bubble hook-notice" : "hook-notice") + (level === "warn" ? " warn" : "") + (cls ? ` ${cls}` : "");
59
+ return html`<div class=${klass} title=${title || nothing}>${line}</div>`;
60
+ }
61
+
62
+ // A hook notice's line: its own (a snapshot's notice), else "<hook>: text".
63
+ export function hookNoticeLine(data) {
64
+ return data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
65
+ }
66
+
67
+ // A prompt bubble's state badge (queued, steered, failed) and class; none
68
+ // for any other state (a started turn's prompt).
69
+ export function setBubbleState(bubble, state) {
70
+ bubble.classList.remove("queued", "steered", "failed");
71
+ bubble.querySelector(".state-badge")?.remove();
72
+ if (!BADGES[state]) {
73
+ delete bubble.dataset.userState;
74
+ return;
75
+ }
76
+ bubble.classList.add(state);
77
+ bubble.dataset.userState = state;
78
+ bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
79
+ }
80
+
81
+ // A bubble's delivery changed (the ack's cut: off goes in at the next
82
+ // step; a queue to a turn that ended meanwhile is just the next turn): its
83
+ // badge follows.
84
+ export function setBubbleDelivery(bubble, delivery) {
85
+ if ((bubble.dataset.delivery || null) === (delivery || null)) return;
86
+ if (delivery) bubble.dataset.delivery = delivery;
87
+ else delete bubble.dataset.delivery;
88
+ if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
89
+ }
90
+
91
+ // @param historyEl the history
92
+ // @param appendToHistory (el) puts +el+ at the history's end (or the stage's,
93
+ // while it holds a turn), above the live timing line
94
+ // @param isNearBottom () => whether the history follows its end
95
+ // @param turnDom the running turn's view (stage_view.js / turn_view.js)
96
+ // @param findInTurnRoots (selector) => the matching elements in the history
97
+ // and the stage
98
+ // @param shownTexts the running turn's texts on the page (shown_texts.js)
99
+ // @param liveTurn the running turn as the page follows it (live_turn.js)
100
+ // @param selected () => the open session's id
101
+ // @param clientId this tab's id in the session's events
102
+ export function createBubbles({
103
+ historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, shownTexts, liveTurn, selected, clientId,
104
+ }) {
105
+ // Placed with the sticky follow: the decision taken before the history
106
+ // grows. +before+: the element it goes above (a snapshot's), else the end.
107
+ function placeFollowing(el, before = null) {
108
+ const follow = isNearBottom();
109
+ if (before) historyEl.insertBefore(el, before);
110
+ else appendToHistory(el);
111
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
112
+ return el;
113
+ }
114
+
115
+ function removeHint() {
116
+ const hint = historyEl.querySelector(".hint");
117
+ if (hint) hint.remove();
118
+ }
119
+
120
+ function removeHintIfEmpty() {
121
+ const hint = historyEl.querySelector(".hint");
122
+ if (!hint) return;
123
+ // Remove the placeholder when the card will be the first content in history.
124
+ if (!historyEl.querySelector(".bubble")) hint.remove();
125
+ }
126
+
127
+ function appendChunk(text) {
128
+ if (!text) return;
129
+ const k = normalize(text);
130
+ if (!k || shownTexts.shows(k)) return;
131
+ const follow = isNearBottom();
132
+ removeHint();
133
+ appendToHistory(elementOf(outputBubbleTemplate(text)));
134
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
135
+ }
136
+
137
+ function addEndBubble(text, { stopped = false } = {}) {
138
+ appendToHistory(elementOf(endBubbleTemplate(text, { stopped })));
139
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
140
+ }
141
+
142
+ // ── Prompt bubbles ───────────────────────────────────────────────────────
143
+ // Every client's prompts, keyed by enqueued_id: queued (badge) until their
144
+ // turn starts, or steered when merged into a running turn. This tab's own
145
+ // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
146
+
147
+ // The bubble a reload draws (turn_html.js), with this page's own fields.
148
+ // +delivery+: how it went into a running turn (next_step, cut, queue): its
149
+ // queued badge says what it waits for.
150
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
151
+ removeHint();
152
+ const div = elementOf(userBubbleTemplate({ content: prompt }, {
153
+ label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected(), images),
154
+ }));
155
+ return placeFollowing(div);
156
+ }
157
+
158
+ function bubbleById(enqueuedId) {
159
+ if (!enqueuedId) return null;
160
+ return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
161
+ (el) => el.dataset.enqueuedId === enqueuedId,
162
+ ) || null;
163
+ }
164
+
165
+ // This tab's oldest untagged echo, preferring one with the same text.
166
+ function untaggedOwnBubble(prompt) {
167
+ const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
168
+ const text = normalize(prompt || "");
169
+ return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
170
+ }
171
+
172
+ // Returns the bubble of the turn a turn_started starts (the stage takes it
173
+ // into its prompt line), else null.
174
+ function applyPromptOps(event) {
175
+ if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
176
+ const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
177
+ if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
178
+ if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
179
+ let started = null;
180
+ for (const op of ops) {
181
+ if (op.op === "report") {
182
+ liveTurn.unmatchedMerge = "report";
183
+ } else if (op.op === "add") {
184
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
185
+ if (op.state === "started") started = bubble;
186
+ if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
187
+ } else if (op.op === "tag") {
188
+ if (bubbleById(op.enqueuedId)) continue;
189
+ const echo = untaggedOwnBubble(op.prompt);
190
+ if (echo) {
191
+ echo.dataset.enqueuedId = op.enqueuedId;
192
+ if (op.delivery) setBubbleDelivery(echo, op.delivery);
193
+ }
194
+ // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
195
+ else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
196
+ } else if (op.op === "start") {
197
+ const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
198
+ // A resync (a new worker woken by this page's send) re-rendered the
199
+ // history and took the echo with it: draw the prompt again.
200
+ if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
201
+ else {
202
+ if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
203
+ setBubbleState(bubble, null);
204
+ started = bubble;
205
+ }
206
+ } else if (op.op === "steer") {
207
+ const bubble = bubbleById(op.enqueuedId);
208
+ if (bubble) setBubbleState(bubble, "steered");
209
+ else liveTurn.unmatchedMerge = true;
210
+ }
211
+ }
212
+ return started;
213
+ }
214
+
215
+ // ── Idle recap ───────────────────────────────────────────────────────────
216
+ // A summary the worker writes after a quiet stretch or as it leaves: shown at
217
+ // the end of the history. A turn starting leaves it in place, marked
218
+ // earlier; only a new recap replaces it.
219
+
220
+ function showRecap(text, turnsSince = 0) {
221
+ if (!text) return;
222
+ removeRecap();
223
+ const details = elementOf(recapTemplate(text, turnsSince));
224
+ // A stale one sits where it was written, above the turns it doesn't cover.
225
+ // Never in the stage: a recap is between turns.
226
+ turnDom.settleFold?.();
227
+ const before = recapPlace(turnPromptBubbles(historyEl), turnsSince);
228
+ keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
229
+ }
230
+
231
+ // A turn started: the recap describes the chat before it.
232
+ function markRecapStale() {
233
+ historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
234
+ el.classList.add("stale");
235
+ const summary = el.querySelector("summary");
236
+ if (summary) summary.textContent = recapLabel(0, true);
237
+ });
238
+ }
239
+
240
+ function removeRecap() {
241
+ historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
242
+ }
243
+
244
+ // +before+: the element it goes above (a snapshot's), else the end.
245
+ function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
246
+ if (!text) return;
247
+ placeFollowing(elementOf(guardrailWarningTemplate(text, label)), before);
248
+ }
249
+
250
+ // A plugin's steers (pending_input_merged's steers): a row of the running
251
+ // turn's step, else (no turn runs) a bubble.
252
+ function showSteers(steers) {
253
+ for (const steer of steers || []) {
254
+ if (turnDom.steer(steer)) continue;
255
+ removeHintIfEmpty();
256
+ placeFollowing(elementOf(steerRowTemplate(steer, { bubble: true })));
257
+ }
258
+ }
259
+
260
+ // "<bundle>: text", or the notice's own line.
261
+ // +before+: the element it goes above (a snapshot's notice), else the end.
262
+ function showHookNotice(data, { before = null } = {}) {
263
+ removeHintIfEmpty();
264
+ placeFollowing(elementOf(hookNoticeTemplate({ ...data, line: hookNoticeLine(data) })), before);
265
+ }
266
+
267
+ function addCommandBubble(view) {
268
+ removeHintIfEmpty();
269
+ const div = document.createElement("div");
270
+ // Page-only: kept across a resync of this session (keptCommandBubbles).
271
+ const id = selected();
272
+ if (id) div.dataset.keptFor = id;
273
+ drawCommandBubble(div, view);
274
+ placeFollowing(div);
275
+ }
276
+
277
+ function commandBubble(commandId) {
278
+ if (!commandId) return null;
279
+ return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
280
+ }
281
+
282
+ function addNoteBubble(note) {
283
+ removeHintIfEmpty();
284
+ placeFollowing(elementOf(noteBubbleTemplate(note)));
285
+ }
286
+
287
+ return {
288
+ removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
289
+ markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
290
+ };
291
+ }
@@ -0,0 +1,184 @@
1
+ // Where the open session's plugin cards and a snapshot's notices go in the
2
+ // history (the cards' own markup is card.js'):
3
+ //
4
+ // A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
5
+ // a row of the running turn's step. The same id updates the card in place.
6
+ // An action button runs its command line in the session (sendCommand), as
7
+ // if typed. A snapshot's cards and notices go back where they showed live:
8
+ // above the first turn after them, a finished turn's notice in its step, a
9
+ // finished turn's question resolved where it was asked.
10
+ import { getSession, sendCommand } from "./data.js";
11
+ import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
12
+ import { elementOf } from "./turn_html.js";
13
+ import { stepText } from "./turn_view.js";
14
+ import { llmContextNotice, noticeLine } from "./turn_events.js";
15
+ import { hookNoticeTemplate, TURN_PROMPT, turnPromptBubbles } from "./bubbles.js";
16
+
17
+ // The elements of the reloaded turn that ends before +before+ (the next
18
+ // turn's prompt, or null for the last turn), its prompt first.
19
+ export function reloadedTurnEls(historyEl, before) {
20
+ const turnEls = [];
21
+ for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
22
+ turnEls.unshift(el);
23
+ if (el.matches(TURN_PROMPT)) break;
24
+ }
25
+ return turnEls;
26
+ }
27
+
28
+ // What goes after a reloaded turn's own rows and cards: its answer, else
29
+ // its timing line; null when it has neither.
30
+ export function turnAnswerEl(turnEls) {
31
+ return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
32
+ }
33
+
34
+ // @param historyEl the history
35
+ // @param appendToHistory (el) puts +el+ at the history's end (or the stage's)
36
+ // @param isNearBottom () => whether the history follows its end
37
+ // @param turnDom the running turn's view (its card(el) takes a turn's card)
38
+ // @param findInTurnRoots (selector) => the matching elements in the history
39
+ // and the stage
40
+ // @param selected () => the open session's id
41
+ // @param clientId this tab's id in the session's events
42
+ // @param questionCards the history's question cards (question_cards.js)
43
+ // @param bubbles {removeHintIfEmpty, showGuardrailWarning, showHookNotice}
44
+ // (bubbles.js createBubbles)
45
+ // @param followWokenWorker () follows the worker a card's command woke
46
+ // @param api {getSession, sendCommand} (data.js)
47
+ // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
48
+ // refreshCardBodies(), onCardAction(event)}
49
+ export function createCardsPlace({
50
+ historyEl, appendToHistory, isNearBottom, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
51
+ followWokenWorker, api = { getSession, sendCommand },
52
+ }) {
53
+ function cardElement(id) {
54
+ if (!id) return null;
55
+ return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
56
+ }
57
+
58
+ // Show +card+: update the one with its id where it is, else add it (a
59
+ // running turn's card as a row of its step; else before +before+ or at the
60
+ // end of the history). A running turn's card with actions asks the user
61
+ // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
62
+ // block, not into a step that collapses when the next one starts.
63
+ function upsertCard(card, { before = null } = {}) {
64
+ if (!card || !card.id) return;
65
+ const existing = cardElement(card.id);
66
+ if (existing) {
67
+ const drawn = drawCard(existing, card);
68
+ drawn.classList.add("card-updated");
69
+ setTimeout(() => drawn.classList.remove("card-updated"), 900);
70
+ return;
71
+ }
72
+ bubbles.removeHintIfEmpty();
73
+ const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
74
+ const asks = (card.actions || []).some((a) => a && a.command);
75
+ if (card.in_turn && !before && !asks && turnDom.card(el)) return;
76
+ const follow = isNearBottom();
77
+ if (before) historyEl.insertBefore(el, before);
78
+ else appendToHistory(el);
79
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
80
+ }
81
+
82
+ // A snapshot's cards and notices, each above the first turn after it; a
83
+ // turn's own notice where it showed live (placeTurnNotice).
84
+ function placeSnapshotCards(entries) {
85
+ const users = turnPromptBubbles(historyEl);
86
+ for (const entry of entries) {
87
+ const before = cardPlace(users, entry);
88
+ if (isCard(entry)) upsertCard(entry, { before });
89
+ else if (entry.type === "question") placeResolvedQuestion(entry, before);
90
+ else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
91
+ else if (entry.in_turn) placeTurnNotice(entry, before);
92
+ else if (entry.type === "hook_notice") bubbles.showHookNotice(entry, { before });
93
+ }
94
+ }
95
+
96
+ // A finished turn's row, a hook's notice or the loop's asking again
97
+ // (+before+: the next turn's prompt, or null for the last turn): a row of
98
+ // its step, above the row of the call it came before, as live; else (a
99
+ // plain answer, a step the history doesn't have) a bubble before the
100
+ // turn's answer, or before its timing line when it has none.
101
+ function placeTurnNotice(entry, before) {
102
+ const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
103
+ const line = noticeLine(entry);
104
+ const turnEls = reloadedTurnEls(historyEl, before);
105
+ const place = turnNoticePlace(entry);
106
+ const block = turnEls.find((el) => el.matches(".turn-work"));
107
+ const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
108
+ if (!step) {
109
+ bubbles.showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
110
+ return;
111
+ }
112
+ const el = elementOf(hookNoticeTemplate({ line, level: entry.level, cls: extra.cls, title: extra.title }, { bubble: false }));
113
+ let body = step.querySelector(":scope > .activity-body");
114
+ if (!body) {
115
+ body = document.createElement("div");
116
+ body.className = "activity-body";
117
+ step.appendChild(body);
118
+ }
119
+ const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
120
+ body.insertBefore(el, row || null);
121
+ }
122
+
123
+ // A finished turn's question (the snapshot's cards keep it with its
124
+ // answer): the card as it was left live, resolved, before the lines the
125
+ // user sent into the turn after it (a steered bubble's data-step: the step
126
+ // that read it) and the turn's answer. A pending one is the running turn's
127
+ // (pending_question draws it).
128
+ function placeResolvedQuestion(entry, before) {
129
+ const pq = entry.pending_question;
130
+ if (!pq?.id || !(entry.answer || entry.cancelled)) return;
131
+ const turnEls = reloadedTurnEls(historyEl, before);
132
+ const asked = Number(entry.iteration) || 1;
133
+ // The reply that asked: its step's text in the reloaded block (the card
134
+ // keeps it, as live), never the running turn's.
135
+ const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
136
+ questionCards.renderQuestion(pq, { said });
137
+ const card = questionCards.questionCard();
138
+ if (!card || card.dataset.qid !== String(pq.id)) return;
139
+ questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
140
+ const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
141
+ historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
142
+ }
143
+
144
+ // A live card's body comes as text; the worker's snapshot (through the
145
+ // server) has it rendered. One re-read for a burst of cards.
146
+ let cardBodiesTimer = null;
147
+ function refreshCardBodies() {
148
+ clearTimeout(cardBodiesTimer);
149
+ const id = selected();
150
+ cardBodiesTimer = setTimeout(async () => {
151
+ try {
152
+ const data = await api.getSession(id, { cards: true });
153
+ if (id !== selected()) return;
154
+ for (const card of data.cards || []) {
155
+ const el = card.type === "card" && cardElement(card.id);
156
+ if (el) refreshCardBody(el, card);
157
+ }
158
+ } catch (_) {}
159
+ }, 150);
160
+ }
161
+
162
+ // A card's action button (a click anywhere in the turn roots).
163
+ function onCardAction(e) {
164
+ const button = e.target.closest(".plugin-card .card-action");
165
+ const id = selected();
166
+ if (!button || !id) return;
167
+ const card = button.closest(".plugin-card");
168
+ const err = card.querySelector(".card-error");
169
+ err.classList.add("hidden");
170
+ button.disabled = true;
171
+ api.sendCommand(id, button.dataset.command, { clientId, card: true })
172
+ .then(() => {
173
+ // The command woke the worker: follow its stream if none is open.
174
+ followWokenWorker();
175
+ })
176
+ .catch((error) => {
177
+ err.textContent = error.message;
178
+ err.classList.remove("hidden");
179
+ })
180
+ .finally(() => { button.disabled = false; });
181
+ }
182
+
183
+ return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction };
184
+ }
@@ -0,0 +1,111 @@
1
+ // The popover shell a session-bar chip opens (llm_context_chip.js,
2
+ // level_chip.js): a div.ctx-popover on the body, placed at the chip,
3
+ // opened by a click on it (delegated on +el+), closed by a second click,
4
+ // its × (data-act="close"), a click outside or Esc. Another session's
5
+ // summary (refresh) closes it. What it draws, and what a Set or another
6
+ // data-act does, is the caller's.
7
+
8
+ import { nothing, render } from "./vendor/lit-html.js";
9
+
10
+ const EDGE = 16;
11
+ const GAP = 8;
12
+
13
+ // Where the popover goes for the chip's +rect+: above it, or below when
14
+ // the room above is short of its height and below has more; capped to the
15
+ // room on that side (it scrolls), never past the window's edges.
16
+ // @return {{left, top?, bottom?, maxHeight?}}
17
+ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
18
+ const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
19
+ const above = rect.top - GAP - EDGE;
20
+ const below = innerHeight - rect.bottom - GAP - EDGE;
21
+ const down = height > above && below > above;
22
+ const room = Math.max(0, down ? below : above);
23
+ const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
24
+ return height > room ? { ...place, maxHeight: room } : place;
25
+ }
26
+
27
+ // id, className: the popover's; width: its widest (px); el: where the chip
28
+ // is drawn; anchor: the chip's selector; summary(): what the form is drawn
29
+ // from (null: nothing opens); session(): the open session's id.
30
+ // show(data, opening): draws the form into the popover (opening: a fresh
31
+ // open, else a refresh with a newer summary); onClose(): the caller forgets
32
+ // its open state; onSubmit(form); onAct(name): a data-act other than close.
33
+ // @return {pop, close, refresh, isOpen}
34
+ export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, summary,
35
+ session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {},
36
+ doc = document, win = window }) {
37
+ const pop = doc.createElement("div");
38
+ pop.id = id;
39
+ pop.className = className;
40
+ pop.setAttribute("role", "dialog");
41
+ pop.hidden = true;
42
+ doc.body.appendChild(pop);
43
+ // The session the open form is for.
44
+ let openFor = null;
45
+
46
+ function place(anchor) {
47
+ const width = Math.min(widest, win.innerWidth - 32);
48
+ pop.style.width = `${width}px`;
49
+ pop.style.maxHeight = "";
50
+ const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
51
+ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
52
+ pop.style.left = `${at.left}px`;
53
+ pop.style.top = at.top === undefined ? "" : `${at.top}px`;
54
+ pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
55
+ if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
56
+ }
57
+
58
+ function open(anchor) {
59
+ const data = summary();
60
+ if (!data) return;
61
+ openFor = session();
62
+ show(data, true);
63
+ pop.hidden = false;
64
+ place(anchor);
65
+ }
66
+
67
+ // A closed popover is empty, so each open draws a fresh form (unsent
68
+ // edits are gone).
69
+ function close() {
70
+ pop.hidden = true;
71
+ openFor = null;
72
+ onClose();
73
+ render(nothing, pop);
74
+ }
75
+
76
+ function isOpen() { return !pop.hidden; }
77
+
78
+ // A new summary (a command_ran, a re-read): the caller redraws the open
79
+ // form; another session closes it.
80
+ function refresh() {
81
+ if (!isOpen()) return;
82
+ const data = summary();
83
+ if (!data || session() !== openFor) return close();
84
+ show(data, false);
85
+ }
86
+
87
+ el.addEventListener("click", (e) => {
88
+ const anchor = e.target.closest(anchorSel);
89
+ if (!anchor) return;
90
+ e.stopPropagation();
91
+ if (isOpen()) close();
92
+ else open(anchor);
93
+ });
94
+ pop.addEventListener("submit", (e) => {
95
+ e.preventDefault();
96
+ onSubmit(e.target);
97
+ });
98
+ pop.addEventListener("click", (e) => {
99
+ const act = e.target.closest("[data-act]")?.dataset.act;
100
+ if (act === "close") close();
101
+ else if (act) onAct(act);
102
+ });
103
+ doc.addEventListener("click", (e) => {
104
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
105
+ });
106
+ doc.addEventListener("keydown", (e) => {
107
+ if (e.key === "Escape" && isOpen()) close();
108
+ });
109
+
110
+ return { pop, close, refresh, isOpen };
111
+ }
@@ -3,10 +3,18 @@
3
3
  // the session's commands (its worker's, plugins' too): arrows move, ⏎/Tab
4
4
  // pick, Esc closes, a tap picks. A closed list takes no keys.
5
5
  //
6
- // The matching is command_complete.js'; this is the list's DOM.
6
+ // The matching is command_complete.js'; this is the list's DOM (a lit-html
7
+ // template).
7
8
 
8
- import { escapeHtml } from "./format.js";
9
9
  import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
10
+ import { html, nothing, render as draw } from "./vendor/lit-html.js";
11
+
12
+ // One option row; +active+ is the arrows' row.
13
+ function optionTemplate(command, i, active) {
14
+ const row = commandRow(command);
15
+ return html`<div class=${`command-option${active ? " active" : ""}`} role="option" id=${`command-option-${i}`} aria-selected=${String(active)} data-index=${i}><span class="command-name">${row.name}</span><span class="command-desc">${row.description}</span>${
16
+ row.note ? html`<span class="command-note">${row.note}</span>` : nothing}</div>`;
17
+ }
10
18
 
11
19
  // @param list the list element (#commandList)
12
20
  // @param prompt the composer's textarea
@@ -30,13 +38,7 @@ export function createCommandList({ list, prompt, commands, afterPick = () => {}
30
38
  }
31
39
 
32
40
  function render() {
33
- list.innerHTML = shown.map((c, i) => {
34
- const row = commandRow(c);
35
- const note = row.note ? `<span class="command-note">${escapeHtml(row.note)}</span>` : "";
36
- return `<div class="command-option${i === index ? " active" : ""}" role="option" id="command-option-${i}" ` +
37
- `aria-selected="${i === index}" data-index="${i}"><span class="command-name">${escapeHtml(row.name)}</span>` +
38
- `<span class="command-desc">${escapeHtml(row.description)}</span>${note}</div>`;
39
- }).join("");
41
+ draw(shown.map((c, i) => optionTemplate(c, i, i === index)), list);
40
42
  list.hidden = false;
41
43
  prompt.setAttribute("aria-expanded", "true");
42
44
  prompt.setAttribute("aria-activedescendant", `command-option-${index}`);
@@ -47,7 +49,7 @@ export function createCommandList({ list, prompt, commands, afterPick = () => {}
47
49
  shown = [];
48
50
  index = 0;
49
51
  list.hidden = true;
50
- list.innerHTML = "";
52
+ draw(nothing, list);
51
53
  prompt.setAttribute("aria-expanded", "false");
52
54
  prompt.removeAttribute("aria-activedescendant");
53
55
  }