samagotchi 0.51.0 → 0.52.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -6,9 +6,21 @@
6
6
  // markdown, annotate and the turn output dedupe work on a plain bubble.
7
7
  // The grouping rules live in turn_model.js.
8
8
  //
9
+ // A step is ONE lit-html template (stepTemplate) for the live page and a
10
+ // reload: the live view keeps each step's state (its rows, the ticker's
11
+ // line, the narration's sentences) and renders the template again when an
12
+ // event changed it; lit writes only what changed, so a hand toggle, a
13
+ // selection in the thinking and a row's kept parts stay.
14
+ //
15
+ // lit leaves comment nodes in the DOM (its part markers: <!--?lit$…$-->
16
+ // and <!---->), in a live step and in a reloaded history alike. Code that
17
+ // walks childNodes (or reads firstChild/lastChild) must skip comments;
18
+ // element queries, :empty and textContent are unaffected.
19
+ //
9
20
  // A block that holds one gen with no thinking and no tool rows hides its
10
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
11
22
  // removed at the end: the answer bubble takes its place.
23
+ import { html, nothing, render } from "./vendor/lit-html.js";
12
24
  import { leavesBlock } from "./card.js";
13
25
  import { copySourceAttr } from "./copy.js";
14
26
  import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
@@ -23,6 +35,86 @@ import * as model from "./turn_model.js";
23
35
  import { createHold } from "./hold.js";
24
36
  import { taskCommand, taskStopButton } from "./task_stop.js";
25
37
 
38
+ // ---- The templates: one step, its thinking, its label ----
39
+
40
+ // A closed step's summary (model.genLabel). When its head is the words the
41
+ // body starts with (the narration's first line, the first call's title)
42
+ // and it has calls, the summary is marked .repeats and its parts are
43
+ // spans: an open step shows only its calls (index.html), so it doesn't say
44
+ // the same words twice. Its text stays genLabel's either way.
45
+ function closedLabel(gen) {
46
+ const { head, calls, repeats } = model.genLabelParts(gen);
47
+ if (!repeats || !calls) return { repeats: false, content: model.genLabel(gen) };
48
+ return { repeats: true, content: html`<span class="gen-head">${head}</span><span class="gen-sep"> · </span><span class="gen-calls">${calls}</span>` };
49
+ }
50
+
51
+ // A step's thinking: a collapsed <details> whose summary is "thinking" (a
52
+ // live step's ticker: "thinking · <newest sentence>") over its body; a
53
+ // thinking-only step's body sits bare under the step's own summary (no wrap
54
+ // that would repeat "thinking"). +body+ is the text: a string, or the live
55
+ // step's Text node (appended by delta: a selection in it survives).
56
+ // +cls+ "bubble thinking" is a plain answer's collapsed thinking block.
57
+ export function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
58
+ const inner = html`<div class="thinking-body">${body}</div>`;
59
+ if (!wrapped) return inner;
60
+ const summary = line === null ? "thinking" : html`<span class="thinking-tag">thinking · </span>${line}`;
61
+ return html`<details class=${cls} @toggle=${on.thinkingToggle ?? nothing}><summary @click=${on.thinkingClick ?? nothing}>${summary}</summary>${inner}</details>`;
62
+ }
63
+
64
+ // A step's narration: the live box of complete sentences (one per line,
65
+ // the newest last), the live "…" until the first one, or a closed step's
66
+ // full text. None when there is nothing to say.
67
+ function textTemplate(text, { live, sentences, placeholder }) {
68
+ if (live && sentences?.length) {
69
+ return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
70
+ }
71
+ if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
72
+ return text ? html`<div class="gen-text">${text}</div>` : nothing;
73
+ }
74
+
75
+ // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
76
+ // taken answer's text is out of the step). The live view's state: +live+
77
+ // (open, "step N" by its +index+, no failed mark yet), +open+ (lit sets it
78
+ // only when it changes: a hand toggle stays), the +steers+ rows it
79
+ // answers, its +items+ (tool rows, notices, cards: nodes or templates; a
80
+ // reload's are its rows' nodes), the thinking +body+ node, the ticker's
81
+ // +line+, the narration's +sentences+ and "…" +placeholder+, +on+ (the
82
+ // click and toggle handlers).
83
+ export function stepTemplate(gen, st = {}) {
84
+ const { live = false, index = 0, open = live, steers = [], items = [], body = gen.thinking, line = null,
85
+ sentences = null, placeholder = false, on = {} } = st;
86
+ const text = gen.answer ? "" : gen.text || "";
87
+ const label = live ? { repeats: false, content: `step ${index + 1}` } : closedLabel({ ...gen, text });
88
+ const failed = !live && model.failedCount(gen.tools) > 0;
89
+ // A step left with its thinking only reads "thinking" once.
90
+ const wrapped = live || !!text || gen.tools.length > 0;
91
+ return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
92
+ }
93
+
94
+ // A hook's line (hook_notice) as a step's row. data.line is a whole row of
95
+ // chi's own (an empty-answer retry), shown as is; data.cls a class of its
96
+ // own and data.title its hover (a ✂ row's).
97
+ function noticeTemplate(data) {
98
+ const cls = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
99
+ return html`<div class=${cls} title=${data.title || nothing}>${data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`}</div>`;
100
+ }
101
+
102
+ // +template+ rendered as detached nodes (a fragment).
103
+ function nodesOf(template) {
104
+ const frag = document.createDocumentFragment();
105
+ render(template, frag);
106
+ return frag;
107
+ }
108
+
109
+ // +markup+ (any HTML string) as nodes.
110
+ function rawNodes(markup) {
111
+ const t = document.createElement("template");
112
+ t.innerHTML = markup;
113
+ return t.content;
114
+ }
115
+
116
+ // ---- The live view ----
117
+
26
118
  // The parts of a drawn tool row a user may have changed (the command's
27
119
  // raw toggle, an opened diff) or that never change once there (the
28
120
  // thumbs): a redraw keeps them as they are.
@@ -33,8 +125,8 @@ const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains
33
125
  // A live tool row drawn again from its activity row (status, title,
34
126
  // output): the reloaded row's markup (toolRowInnerHtml), the kept parts
35
127
  // and the stop-task button carried over. +html+ is that markup.
36
- function redrawRow(el, html) {
37
- const fresh = htmlElement(`<div>${html}</div>`);
128
+ function redrawRow(el, markup) {
129
+ const fresh = htmlElement(`<div>${markup}</div>`);
38
130
  for (const cls of KEPT_PARTS) {
39
131
  const old = childWith(el, cls);
40
132
  const drawn = childWith(fresh, cls);
@@ -82,7 +174,8 @@ export function createTurnView({
82
174
  let blockEl = null;
83
175
  let blockSummaryEl = null;
84
176
  let blockToggled = false;
85
- // gen → { el, summary, thinking, text, activity, rows: Map(key → el), toggled }
177
+ // gen → a step's live state (newStep), its rendered nodes between
178
+ // part.startNode and end.
86
179
  const genEls = new Map();
87
180
  // Steer rows waiting for the step that answers them (steer()).
88
181
  let pendingSteers = [];
@@ -97,11 +190,6 @@ export function createTurnView({
97
190
  if (hint) hint.remove();
98
191
  }
99
192
 
100
- // A hand toggle: the code's auto-collapse then leaves it as the user left it.
101
- function rememberToggle(summary, mark) {
102
- summary.addEventListener("click", mark);
103
- }
104
-
105
193
  function createBlock() {
106
194
  removeHint();
107
195
  blockEl = document.createElement("details");
@@ -110,7 +198,7 @@ export function createTurnView({
110
198
  blockToggled = false;
111
199
  blockSummaryEl = document.createElement("summary");
112
200
  blockEl.appendChild(blockSummaryEl);
113
- rememberToggle(blockSummaryEl, () => { blockToggled = true; });
201
+ blockSummaryEl.addEventListener("click", () => { blockToggled = true; });
114
202
  updateBlockSummary();
115
203
  blockHost(blockEl);
116
204
  }
@@ -119,77 +207,89 @@ export function createTurnView({
119
207
  if (blockSummaryEl && turn) blockSummaryEl.textContent = model.blockSummary(turn, { running: !turn.ended });
120
208
  }
121
209
 
210
+ // The step rendered again from its state (stepTemplate). Each live step
211
+ // is its own lit root, not an item of one block template: newestFirst
212
+ // puts a new step on top and chronological() reorders them, and lit
213
+ // (without the keyed repeat directive, not vendored) matches list items
214
+ // by index, so a block-wide render would hand step N's <details> (and
215
+ // its hand toggle) to another gen. The root's part lives on entry.end
216
+ // (render's renderBefore), so the step's nodes can move anywhere in the
217
+ // page between lit's start marker and that end comment (stepNodes).
218
+ function draw(entry) {
219
+ entry.part = render(stepTemplate(entry.gen, {
220
+ live: !entry.closed, index: entry.index, open: entry.open, steers: entry.steers, items: entry.items,
221
+ body: entry.thinkingText, line: entry.line, sentences: entry.sentences, placeholder: entry.placeholder, on: entry.on,
222
+ }), entry.frag, { renderBefore: entry.end });
223
+ }
224
+
225
+ // A step's nodes (lit's start marker, the <details>, the end marker), to
226
+ // move or remove them together.
227
+ function stepNodes(entry) {
228
+ const nodes = [];
229
+ for (let n = entry.part.startNode; n; n = n.nextSibling) {
230
+ nodes.push(n);
231
+ if (n === entry.end) break;
232
+ }
233
+ return nodes;
234
+ }
235
+
236
+ const thinkingWrap = (entry) => entry.el.querySelector(":scope > details.thinking");
237
+ const narrationEl = (entry) => entry.el.querySelector(":scope > .gen-text > .narration");
238
+
122
239
  function createGenEl(gen) {
123
- const el = document.createElement("details");
124
- el.className = "gen live";
125
- el.open = true;
126
- const summary = document.createElement("summary");
127
- summary.textContent = `step ${turn.gens.length}`;
128
- el.appendChild(summary);
129
- // A plugin's steers merged since the last step: this step answers them.
130
- for (const row of pendingSteers) el.appendChild(row);
240
+ const entry = {
241
+ gen, index: turn.gens.length - 1, frag: document.createDocumentFragment(), end: document.createComment(""),
242
+ part: null, el: null, open: true, toggled: false, closed: false,
243
+ // A plugin's steers merged since the last step: this step answers them.
244
+ steers: pendingSteers, items: [], rows: new Map(),
245
+ // A step after the first shows "…" until its text comes (the first one
246
+ // only for a prompt).
247
+ placeholder: turn.gens.length > 1, sentences: null, feed: createSentenceFeed(),
248
+ thinkingText: null, line: null, ticker: null, tickerTimer: null, thinkingToggled: false, on: null,
249
+ };
131
250
  pendingSteers = [];
132
- const text = document.createElement("div");
133
- text.className = "gen-text streaming";
134
- // A step after the first shows "…" until its text comes (the first one
135
- // only for a prompt).
136
- if (turn.gens.length > 1) text.textContent = "…";
137
- el.appendChild(text);
138
- const entry = { el, summary, thinking: null, text, activity: null, rows: new Map(), toggled: false,
139
- thinkingWrap: null, thinkingLine: null, ticker: null, tickerTimer: null, thinkingWritten: 0, thinkingToggled: false,
140
- feed: createSentenceFeed(), box: null };
141
- rememberToggle(summary, () => { entry.toggled = true; });
251
+ // A real click on a summary is a hand toggle: the code's auto-collapse
252
+ // then leaves it as the user left it. A peek at the thinking is per step
253
+ // (the page is for glancing): later steps start closed. The toggle event
254
+ // (queued after <details>.open flips) only scrolls the body to its tail
255
+ // on open.
256
+ entry.on = {
257
+ stepClick: () => { entry.toggled = true; },
258
+ thinkingClick: () => { entry.thinkingToggled = true; },
259
+ thinkingToggle: (e) => {
260
+ const wrap = e.currentTarget;
261
+ const body = wrap.querySelector(".thinking-body");
262
+ if (wrap.open && body) body.scrollTop = body.scrollHeight;
263
+ },
264
+ };
265
+ entry.frag.append(entry.end);
142
266
  genEls.set(gen, entry);
267
+ draw(entry);
268
+ entry.el = entry.end.previousElementSibling || entry.frag.querySelector("details.gen");
143
269
  const stick = isNearBottom();
144
- if (newestFirst) blockEl.insertBefore(el, blockSummaryEl.nextSibling);
145
- else blockEl.appendChild(el);
270
+ if (newestFirst) blockEl.insertBefore(entry.frag, blockSummaryEl.nextSibling);
271
+ else blockEl.appendChild(entry.frag);
146
272
  if (stick) follow();
147
273
  return entry;
148
274
  }
149
275
 
150
- // The step's thinking as its own collapsed <details class="thinking"> (the
151
- // ticker lives in its summary) before the narration; entry.thinking stays
152
- // the body, entry.thinkingWrap the <details>, entry.thinkingLine its summary.
153
- function thinkingEl(gen) {
154
- const entry = genEls.get(gen);
155
- if (!entry.thinking) {
156
- entry.thinkingWrap = document.createElement("details");
157
- entry.thinkingWrap.className = "thinking";
158
- entry.thinkingLine = document.createElement("summary");
159
- entry.thinkingLine.textContent = "thinking";
160
- entry.thinkingWrap.appendChild(entry.thinkingLine);
161
- entry.thinking = document.createElement("div");
162
- entry.thinking.className = "thinking-body";
163
- entry.thinkingWrap.appendChild(entry.thinking);
164
- // A real click on the summary is a hand toggle of *this* step: its wrap
165
- // then stays as the user left it at the step's end. A peek is per step
166
- // (the page is for glancing): later steps start closed. The toggle
167
- // event (queued after <details>.open flips) only scrolls the body to
168
- // its tail on open.
169
- rememberToggle(entry.thinkingLine, () => { entry.thinkingToggled = true; });
170
- entry.thinkingWrap.addEventListener("toggle", () => {
171
- if (entry.thinkingWrap.open) entry.thinking.scrollTop = entry.thinking.scrollHeight;
172
- });
173
- entry.ticker = createTicker({ now: () => Date.now(), dwellMs: 1500 });
174
- entry.el.insertBefore(entry.thinkingWrap, entry.text);
175
- }
176
- return entry.thinking;
276
+ function stopTicker(entry) {
277
+ if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
278
+ entry.ticker?.close();
177
279
  }
178
280
 
179
- // The ticker's newest sentence into the summary: "thinking · <line>", the
180
- // prefix a <span class="thinking-tag">, the sentence a text node.
281
+ // The ticker's newest sentence into the thinking's summary, with a subtle
282
+ // fade on change; the class is removed at close (reload parity: the
283
+ // re-rendered summary is plain "thinking").
181
284
  function setThinkingLine(entry, sentence) {
182
- entry.thinkingLine.textContent = "";
183
- const tag = document.createElement("span");
184
- tag.className = "thinking-tag";
185
- tag.textContent = "thinking · ";
186
- entry.thinkingLine.appendChild(tag);
187
- entry.thinkingLine.appendChild(document.createTextNode(sentence));
188
- // A subtle fade on change; the class is removed at close (reload parity:
189
- // the re-rendered summary is plain "thinking").
190
- entry.thinkingLine.classList.remove("thinking-fade");
191
- void entry.thinkingLine.offsetWidth;
192
- entry.thinkingLine.classList.add("thinking-fade");
285
+ entry.line = sentence;
286
+ draw(entry);
287
+ const summary = thinkingWrap(entry)?.querySelector(":scope > summary");
288
+ if (summary) {
289
+ summary.classList.remove("thinking-fade");
290
+ void summary.offsetWidth;
291
+ summary.classList.add("thinking-fade");
292
+ }
193
293
  onThinkingLine(sentence);
194
294
  }
195
295
 
@@ -208,76 +308,46 @@ export function createTurnView({
208
308
  }
209
309
  }
210
310
 
211
- // The live step's narration box (D1–D4 of the narration-box plan): the
212
- // complete sentences so far, one per line, the newest last, inside
213
- // entry.text (so the plain-answer chrome and containsLive see one element).
214
- // Created on the first sentence, in place of the "…" placeholder.
215
- function narrationBox(entry) {
216
- if (!entry.box) {
217
- entry.text.textContent = "";
218
- entry.box = document.createElement("div");
219
- entry.box.className = "narration";
220
- entry.text.appendChild(entry.box);
221
- }
222
- return entry.box;
223
- }
224
-
225
- // Append complete sentences to the box, which follows its own tail unless
226
- // the user scrolled up inside it to read (decided before the append: a
227
- // burst lands several lines at once).
228
- function appendSentences(entry, sentences) {
311
+ // Complete sentences into the narration box, which follows its own tail
312
+ // unless the user scrolled up inside it to read (decided before the
313
+ // append: a burst lands several lines at once).
314
+ function addSentences(entry, sentences) {
229
315
  if (!sentences.length) return;
230
- const box = narrationBox(entry);
231
- const stick = shouldFollowScroll(box, 24);
232
- for (const sentence of sentences) {
233
- const line = document.createElement("div");
234
- line.className = "sentence";
235
- line.textContent = sentence;
236
- box.appendChild(line);
237
- }
238
- if (stick) box.scrollTop = box.scrollHeight;
316
+ const box = narrationEl(entry);
317
+ const stick = !box || shouldFollowScroll(box, 24);
318
+ entry.sentences = [...(entry.sentences || []), ...sentences];
319
+ draw(entry);
320
+ const drawn = narrationEl(entry);
321
+ if (stick && drawn) drawn.scrollTop = drawn.scrollHeight;
239
322
  }
240
323
 
241
- function activityEl(gen) {
242
- const entry = genEls.get(gen);
243
- if (!entry.activity) {
244
- entry.activity = document.createElement("div");
245
- entry.activity.className = "activity-body";
246
- entry.el.appendChild(entry.activity);
247
- }
248
- return entry.activity;
249
- }
250
-
251
- // The gen's text and thinking into its elements. The thinking is appended by
252
- // delta (a selection survives; a full rewrite would drop it) and rewritten
253
- // only when it shrank (the close's rtrim); the ticker advances from it while
254
- // the step is live. The live narration goes into the box by complete
255
- // sentences; a closed gen's text (the close itself, a late chunk via genFor)
256
- // is written in full. The trailing whitespace is trimmed by the model at
257
- // close.
324
+ // The gen's text and thinking into its step. The thinking is appended by
325
+ // delta to one Text node (a selection survives; a full rewrite would drop
326
+ // it) and rewritten only when it shrank (the close's rtrim); the ticker
327
+ // advances from it while the step is live. The live narration goes into
328
+ // the box by complete sentences; a closed gen shows its full text (the
329
+ // close itself, a late chunk via genFor).
258
330
  function writeGen(gen, { follow = false } = {}) {
259
331
  const entry = genEls.get(gen);
260
332
  if (!entry) return;
261
333
  if (gen.thinking) {
262
- const body = thinkingEl(gen);
263
- const written = entry.thinkingWritten;
264
- if (gen.thinking.length >= written) {
265
- if (gen.thinking.length > written) {
266
- body.appendChild(document.createTextNode(gen.thinking.slice(written)));
267
- entry.thinkingWritten = gen.thinking.length;
268
- }
269
- } else {
270
- body.textContent = gen.thinking;
271
- entry.thinkingWritten = gen.thinking.length;
334
+ const before = entry.thinkingText?.parentNode;
335
+ const stick = follow && before && shouldFollowScroll(before, 24);
336
+ if (!entry.thinkingText) {
337
+ entry.thinkingText = document.createTextNode("");
338
+ entry.ticker = createTicker({ now: () => Date.now(), dwellMs: 1500 });
272
339
  }
273
- if (follow && shouldFollowScroll(body, 24)) body.scrollTop = body.scrollHeight;
340
+ const node = entry.thinkingText;
341
+ if (gen.thinking.length > node.length) node.appendData(gen.thinking.slice(node.length));
342
+ else if (gen.thinking.length < node.length) node.data = gen.thinking;
343
+ draw(entry);
344
+ if (stick) before.scrollTop = before.scrollHeight;
274
345
  // Late chunks reach closed gens (genFor); their ticker stays still.
275
346
  if (!gen.closed) feedTicker(gen);
276
347
  }
277
- // An empty text keeps the "…" placeholder.
278
348
  if (!gen.text) return;
279
- if (gen.closed) entry.text.textContent = gen.text;
280
- else appendSentences(entry, entry.feed.feed(gen.text));
349
+ if (gen.closed) draw(entry);
350
+ else addSentences(entry, entry.feed.feed(gen.text));
281
351
  }
282
352
 
283
353
  function flush() {
@@ -298,68 +368,47 @@ export function createTurnView({
298
368
  requestAnimationFrame(flush);
299
369
  }
300
370
 
301
- // A step left with its thinking only would read "thinking" twice (its own
302
- // summary and its wrap's): the wrap goes and the body sits directly under
303
- // the step's summary, as turnHistoryHtml draws it.
304
- function unwrapThinking(entry) {
305
- if (!entry.thinkingWrap) return;
306
- if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
307
- entry.ticker.close();
308
- entry.thinkingWrap.replaceWith(entry.thinking);
309
- entry.thinkingWrap = null;
310
- entry.thinkingLine = null;
311
- }
312
-
313
371
  // A closed gen: its final text, collapsed to its label (unless the user
314
- // opened it by hand), no longer live.
372
+ // opened it by hand), no longer live; its thinking's summary back to the
373
+ // plain "thinking", collapsed unless the user opened it.
315
374
  function closeGenEl(gen) {
316
375
  const entry = genEls.get(gen);
317
376
  if (!entry) return;
318
377
  dirty.delete(gen);
319
378
  writeGen(gen);
320
- // The box gone: the step's full text (rtrimmed by the model at close),
321
- // today's closed-step DOM and the reloaded one.
322
- entry.text.textContent = gen.text;
323
- entry.box = null;
324
- entry.text.classList.remove("streaming");
325
- entry.el.classList.remove("live");
326
- if (entry.thinkingWrap) {
327
- // Stop the ticker, revert its summary to the plain "thinking" (reload
328
- // parity: no span, no fade class) and close the wrap unless the user
329
- // opened it by hand.
330
- if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
331
- entry.ticker.close();
332
- entry.thinkingLine.textContent = "thinking";
333
- entry.thinkingLine.classList.remove("thinking-fade");
334
- if (!entry.thinkingToggled) entry.thinkingWrap.open = false;
335
- // A thinking-only middle step (a single-gen turn is promote's plain
336
- // answer, which reuses the wrap).
337
- if (!gen.text && !gen.tools.length && turn.gens.length > 1) unwrapThinking(entry);
379
+ stopTicker(entry);
380
+ entry.line = null;
381
+ entry.closed = true;
382
+ entry.sentences = null;
383
+ if (!entry.toggled) entry.open = false;
384
+ const wrap = thinkingWrap(entry);
385
+ if (wrap && !entry.thinkingToggled) wrap.open = false;
386
+ draw(entry);
387
+ // The fade gone without a class="" left behind (the reloaded summary has none).
388
+ const summary = thinkingWrap(entry)?.querySelector(":scope > summary");
389
+ if (summary?.classList.contains("thinking-fade")) {
390
+ summary.classList.remove("thinking-fade");
391
+ if (!summary.classList.length) summary.removeAttribute("class");
338
392
  }
339
- writeClosedLabel(entry, gen);
340
- if (!entry.toggled) entry.el.open = false;
341
393
  // Every generation's text goes into the shown texts (the turn output dedupe).
342
394
  const k = normalize(gen.text);
343
395
  if (k) sawText(k);
344
396
  }
345
397
 
346
- // A closed step's label and its failed mark (a red chevron that survives
347
- // an ellipsed label): at close, and again when a late tool result lands
348
- // in a step that already closed.
349
- function writeClosedLabel(entry, gen) {
350
- setLabel(entry.summary, gen);
351
- entry.el.classList.toggle("has-failed", model.failedCount(gen.tools) > 0);
352
- }
353
-
354
398
  // A step that closes mid-turn (the next one starts) collapses: a warn card
355
399
  // in it would go out of sight until the turn ends. It moves out of the
356
400
  // step, right after it in the block (open while the turn runs), where
357
401
  // turnEnded still finds it and takes it out of the block (leavesBlock).
402
+ // The card stays in entry.items while it sits outside the step: safe only
403
+ // because the step's items are never removed or reordered after this (a
404
+ // late row is appended, a no-op for the parts before it). A render whose
405
+ // items shifted would make lit insert the card back into the step (or
406
+ // another item into its slot).
358
407
  function liftWarnCards(gen) {
359
408
  const entry = genEls.get(gen);
360
409
  if (!entry) return;
361
410
  const cards = [...entry.el.querySelectorAll(".plugin-card.warn")];
362
- if (cards.length) entry.el.after(...cards);
411
+ if (cards.length) entry.end.after(...cards);
363
412
  }
364
413
 
365
414
  function apply(r) {
@@ -376,28 +425,34 @@ export function createTurnView({
376
425
  const rowHtml = new WeakMap();
377
426
  const rowThumbs = (images) => thumbsHtml(sessionId(), images);
378
427
 
428
+ // A row, notice or card into the step's activity; a step that wrote no
429
+ // narration before it drops the "…".
430
+ function addItem(entry, item) {
431
+ if (!entry.gen.text) entry.placeholder = false;
432
+ const stick = isNearBottom();
433
+ entry.items.push(item);
434
+ draw(entry);
435
+ if (stick) follow();
436
+ }
437
+
379
438
  function toolEvent(r) {
380
439
  apply(r);
381
440
  const entry = genEls.get(r.gen);
382
- // A step that wrote no narration before its calls drops the "…".
383
- if (!r.gen.text) entry.text.textContent = "";
384
- const body = activityEl(r.gen);
385
441
  let el = entry.rows.get(r.row.key);
386
- const html = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
442
+ const inner = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
387
443
  if (!el) {
388
444
  el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
389
- rowHtml.set(el, html);
445
+ rowHtml.set(el, inner);
390
446
  entry.rows.set(r.row.key, el);
391
- const stick = isNearBottom();
392
- body.appendChild(el);
393
- syncTaskStop(el, r.row);
394
- if (stick) follow();
447
+ addItem(entry, el);
395
448
  } else {
396
- if (rowHtml.get(el) !== html) redrawRow(el, html);
397
- rowHtml.set(el, html);
398
- syncTaskStop(el, r.row);
449
+ if (!r.gen.text) entry.placeholder = false;
450
+ if (rowHtml.get(el) !== inner) redrawRow(el, inner);
451
+ rowHtml.set(el, inner);
452
+ // A late result in a closed step: its label and failed mark.
453
+ draw(entry);
399
454
  }
400
- if (!entry.el.classList.contains("live")) writeClosedLabel(entry, r.gen);
455
+ syncTaskStop(el, r.row);
401
456
  updateBlockSummary();
402
457
  onChange();
403
458
  }
@@ -421,21 +476,11 @@ export function createTurnView({
421
476
 
422
477
  // A hook's line (hook_notice) as a row of the current step, above the row
423
478
  // of the call it judged; false with no running turn (app.js shows the
424
- // bubble then). data.line is a whole row of chi's own (an empty-answer
425
- // retry), shown as is; data.cls a class of its own and data.title its
426
- // hover (a ✂ row's).
479
+ // bubble then).
427
480
  function notice(data) {
428
481
  const r = turn && model.notice(turn, data);
429
482
  if (!r) return false;
430
- const entry = genEls.get(r.gen);
431
- if (!r.gen.text) entry.text.textContent = "";
432
- const el = document.createElement("div");
433
- el.className = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
434
- el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
435
- if (data.title) el.title = data.title;
436
- const stick = isNearBottom();
437
- activityEl(r.gen).appendChild(el);
438
- if (stick) follow();
483
+ addItem(genEls.get(r.gen), noticeTemplate(data));
439
484
  return true;
440
485
  }
441
486
 
@@ -446,9 +491,7 @@ export function createTurnView({
446
491
  // that ends first puts it in its last step). False with no running turn.
447
492
  function steer(data) {
448
493
  if (!turn || turn.ended) return false;
449
- const holder = document.createElement("div");
450
- holder.innerHTML = steerRowHtml(data);
451
- pendingSteers.push(holder.firstChild);
494
+ pendingSteers.push(htmlElement(steerRowHtml(data)));
452
495
  return true;
453
496
  }
454
497
 
@@ -456,7 +499,10 @@ export function createTurnView({
456
499
  function flushSteers() {
457
500
  const gen = turn.gens[turn.gens.length - 1];
458
501
  const entry = gen && genEls.get(gen);
459
- if (entry) for (const row of pendingSteers) entry.el.insertBefore(row, entry.thinkingWrap || entry.text);
502
+ if (entry) {
503
+ entry.steers = [...entry.steers, ...pendingSteers];
504
+ draw(entry);
505
+ }
460
506
  pendingSteers = [];
461
507
  }
462
508
 
@@ -467,14 +513,11 @@ export function createTurnView({
467
513
  const gen = turn && !turn.ended ? model.currentGen(turn) : null;
468
514
  const entry = gen && genEls.get(gen);
469
515
  if (!entry) return false;
470
- if (!gen.text) entry.text.textContent = "";
471
- const stick = isNearBottom();
472
- activityEl(gen).appendChild(el);
473
- if (stick) follow();
516
+ addItem(entry, el);
474
517
  return true;
475
518
  }
476
519
 
477
- // The answer: the gen's narration moves out of the block as a bubble. With
520
+ // The answer: the gen's narration leaves the block as a bubble. With
478
521
  // +heldHeight+ (the live box's height, measured before the step closed) the
479
522
  // bubble is held at that height, scrolled to its tail, until answerReady()
480
523
  // (the rendered markdown landed) or 1.5 s reveals it.
@@ -482,63 +525,52 @@ export function createTurnView({
482
525
  // builds it), placed where the answer bubble would go.
483
526
  function promote(gen, { heldHeight = 0, endNote = null } = {}) {
484
527
  const entry = genEls.get(gen);
485
- const bubble = entry.text;
486
- // No text (an empty answer): no bubble, not even an empty one left in
487
- // the step.
488
- if (gen.text) bubble.className = "bubble output";
489
- else bubble.remove();
490
- // A hidden tab gets no pop: nobody sees it, and its timers are throttled.
491
- const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
492
- let holdHeight = 0;
493
- if (holding) {
494
- // Bubbles are border-box: the box's height plus the bubble's own
495
- // padding and border keeps the same three lines in view.
496
- const cs = getComputedStyle(bubble);
497
- const chrome = ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"]
498
- .reduce((sum, k) => sum + (parseFloat(cs[k]) || 0), 0);
499
- holdHeight = heldHeight + chrome;
528
+ // No text (an empty answer): no bubble.
529
+ let bubble = null;
530
+ if (gen.text) {
531
+ bubble = document.createElement("div");
532
+ bubble.className = "bubble output";
533
+ bubble.textContent = gen.text;
500
534
  }
501
- entry.text = document.createElement("div");
502
- entry.text.className = "gen-text";
503
- setLabel(entry.summary, { ...gen, text: "" });
535
+ // A hidden tab gets no pop: nobody sees it, and its timers are throttled.
536
+ const holding = useHold && heldHeight > 0 && !!bubble && !document.hidden;
504
537
  model.takeAnswer(turn, gen);
538
+ draw(entry);
505
539
  // Cards and hook notices in a step that goes (below, or with the whole
506
540
  // block for a plain answer) stay: before the answer, out of the block,
507
541
  // where a reload puts a turn's cards; a notice as the bubble it is
508
- // between turns.
542
+ // between turns. These are lit-rendered nodes taken out of the step:
543
+ // safe only because the step is never rendered again with other items
544
+ // or steers (it is removed, its block goes, or it only closes); such a
545
+ // render would move them back in, and a changed class binding on a
546
+ // notice would drop the "bubble" class added here.
509
547
  const leaving = !gen.thinking && gen.tools.length === 0;
510
548
  const plain = turn.gens.length === 1 && gen.tools.length === 0;
511
549
  const cards = leaving || plain ? [...entry.el.querySelectorAll(".plugin-card, .hook-notice, .steer-row")] : [];
512
550
  cards.forEach((el) => { if (!el.classList.contains("plugin-card")) el.classList.add("bubble"); });
513
- if (leaving) entry.el.remove();
551
+ if (leaving) stepNodes(entry).forEach((n) => n.remove());
514
552
  if (plain) {
515
553
  // A plain answer: no block, the bubble where it was; its thinking, if
516
554
  // any, as a collapsed thinking block above it.
517
555
  const pieces = [];
518
- if (gen.thinking) {
519
- if (!entry.thinkingWrap) thinkingEl(gen);
520
- // Reuse the live step's thinking wrap, re-classed as the plain
521
- // answer's collapsed block (the same element, not a new one): plain
522
- // summary, no timer, closed.
523
- if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
524
- entry.ticker.close();
525
- entry.thinkingWrap.className = "bubble thinking";
526
- entry.thinkingWrap.open = false;
527
- entry.thinkingLine.textContent = "thinking";
528
- pieces.push(entry.thinkingWrap);
529
- }
556
+ if (gen.thinking) pieces.push(nodesOf(thinkingTemplate({ body: gen.thinking, cls: "bubble thinking" })).firstElementChild);
530
557
  pieces.push(...cards);
531
- if (gen.text) pieces.push(bubble);
558
+ if (bubble) pieces.push(bubble);
532
559
  else if (endNote) pieces.push(endNote);
533
560
  placeAnswer(pieces, { plain: true, blockEl });
534
561
  blockEl = null;
535
562
  blockSummaryEl = null;
536
563
  } else {
537
- placeAnswer(gen.text ? [...cards, bubble] : [...cards, ...(endNote ? [endNote] : [])], { plain: false, blockEl });
538
- // The last step, left with its thinking only, reads "thinking" once.
539
- if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
564
+ placeAnswer(bubble ? [...cards, bubble] : [...cards, ...(endNote ? [endNote] : [])], { plain: false, blockEl });
565
+ }
566
+ if (holding) {
567
+ // Bubbles are border-box: the box's height plus the bubble's own
568
+ // padding and border keeps the same three lines in view.
569
+ const cs = getComputedStyle(bubble);
570
+ const chrome = ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"]
571
+ .reduce((sum, k) => sum + (parseFloat(cs[k]) || 0), 0);
572
+ hold.start(bubble, heldHeight + chrome);
540
573
  }
541
- if (holding) hold.start(bubble, holdHeight);
542
574
  }
543
575
 
544
576
  // Returns the cards that left the block (card.js leavesBlock), detached:
@@ -557,7 +589,8 @@ export function createTurnView({
557
589
  // still open: the apply below swaps it for the full text and collapses
558
590
  // the step. Only a completed turn's answer pops; a canceled or failed
559
591
  // one shows at once.
560
- const box = promoting && kind === "completed" ? genEls.get(last)?.box : null;
592
+ const lastEntry = promoting && kind === "completed" ? genEls.get(last) : null;
593
+ const box = lastEntry ? narrationEl(lastEntry) : null;
561
594
  const heldHeight = box ? box.getBoundingClientRect().height : 0;
562
595
  if (pendingSteers.length) flushSteers();
563
596
  apply(model.turnEnded(turn));
@@ -586,9 +619,7 @@ export function createTurnView({
586
619
  blockSummaryEl = null;
587
620
  blockToggled = false;
588
621
  // Clear every ticker timer before the map goes (a live step mid-dwell).
589
- for (const entry of genEls.values()) {
590
- if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
591
- }
622
+ for (const entry of genEls.values()) stopTicker(entry);
592
623
  genEls.clear();
593
624
  pendingSteers = [];
594
625
  dirty.clear();
@@ -602,7 +633,11 @@ export function createTurnView({
602
633
  createBlock();
603
634
  const r = model.turnStarted(turn);
604
635
  apply(r);
605
- if (data.prompt) genEls.get(r.gen).text.textContent = "…";
636
+ if (data.prompt) {
637
+ const entry = genEls.get(r.gen);
638
+ entry.placeholder = true;
639
+ draw(entry);
640
+ }
606
641
  },
607
642
  generationStarted(data) {
608
643
  if (!turn) this.turnStarted({});
@@ -624,14 +659,10 @@ export function createTurnView({
624
659
  const entry = r && genEls.get(r.gen);
625
660
  if (!entry) return;
626
661
  dirty.delete(r.gen);
627
- if (entry.thinkingWrap) {
628
- if (entry.tickerTimer) { clearTimeout(entry.tickerTimer); entry.tickerTimer = null; }
629
- entry.ticker.close();
630
- entry.thinkingWrap.remove();
631
- }
632
- Object.assign(entry, { thinking: null, thinkingWrap: null, thinkingLine: null, ticker: null, thinkingWritten: 0,
633
- box: null, feed: createSentenceFeed() });
634
- entry.text.textContent = "…";
662
+ stopTicker(entry);
663
+ Object.assign(entry, { thinkingText: null, line: null, ticker: null, sentences: null, placeholder: true,
664
+ feed: createSentenceFeed() });
665
+ draw(entry);
635
666
  onChange();
636
667
  },
637
668
  generationCompleted() {
@@ -644,7 +675,7 @@ export function createTurnView({
644
675
  // The remainder is the step's last line: narration without a final
645
676
  // period ("Let me check the shell first") would otherwise never show.
646
677
  const entry = genEls.get(r.gen);
647
- if (entry && r.gen.text && !r.gen.closed) appendSentences(entry, entry.feed.flush(r.gen.text));
678
+ if (entry && r.gen.text && !r.gen.closed) addSentences(entry, entry.feed.flush(r.gen.text));
648
679
  },
649
680
  toolStarted(data) {
650
681
  if (!turn) this.turnStarted({});
@@ -669,7 +700,9 @@ export function createTurnView({
669
700
  containsLive(el) {
670
701
  const gen = turn && model.currentGen(turn);
671
702
  const entry = gen && genEls.get(gen);
672
- return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
703
+ if (!entry) return false;
704
+ const text = entry.el.querySelector(":scope > .gen-text");
705
+ return !!text?.contains(el) || !!entry.thinkingText?.parentNode?.contains(el);
673
706
  },
674
707
  reset,
675
708
  // For a view that composes this one (the stage): the turn's model and
@@ -678,13 +711,13 @@ export function createTurnView({
678
711
  current() { return { turn, blockEl }; },
679
712
  step(i) {
680
713
  const entry = turn && genEls.get(turn.gens[i]);
681
- return entry ? { el: entry.el, summary: entry.summary, thinkingWrap: entry.thinkingWrap } : null;
714
+ return entry ? { el: entry.el, summary: entry.el.querySelector(":scope > summary"), thinkingWrap: thinkingWrap(entry) } : null;
682
715
  },
683
716
  chronological() {
684
717
  if (!blockEl || !turn) return;
685
718
  for (const gen of turn.gens) {
686
- const el = genEls.get(gen)?.el;
687
- if (el?.parentNode === blockEl) blockEl.appendChild(el);
719
+ const entry = genEls.get(gen);
720
+ if (entry?.el.parentNode === blockEl) blockEl.append(...stepNodes(entry));
688
721
  }
689
722
  },
690
723
  };
@@ -699,28 +732,7 @@ function defaultPlaceAnswer(appendToHistory) {
699
732
  };
700
733
  }
701
734
 
702
- // A closed step's summary (model.genLabel). When its head is the words the
703
- // body starts with (the narration's first line, the first call's title)
704
- // and it has calls, the summary is marked .repeats and its parts are
705
- // spans: an open step shows only its calls (index.html), so it doesn't say
706
- // the same words twice. Its text stays genLabel's either way.
707
- // { repeats, html } (the summary's inside).
708
- function labelParts(gen) {
709
- const { head, calls, repeats } = model.genLabelParts(gen);
710
- if (!repeats || !calls) return { repeats: false, html: escapeHtml(model.genLabel(gen)) };
711
- return { repeats: true, html: `<span class="gen-head">${escapeHtml(head)}</span><span class="gen-sep"> · </span><span class="gen-calls">${escapeHtml(calls)}</span>` };
712
- }
713
-
714
- function labelHtml(gen) {
715
- const { repeats, html } = labelParts(gen);
716
- return `<summary${repeats ? ' class="repeats"' : ""}>${html}</summary>`;
717
- }
718
-
719
- function setLabel(summary, gen) {
720
- const { repeats, html } = labelParts(gen);
721
- summary.classList.toggle("repeats", repeats);
722
- summary.innerHTML = html;
723
- }
735
+ // ---- A reload ----
724
736
 
725
737
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
726
738
  // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
@@ -733,13 +745,28 @@ function failedLineHtml(record) {
733
745
  return `<div class="bubble cancel">${escapeHtml(failedTurnText(failure))}</div>`;
734
746
  }
735
747
 
736
- // A reloaded history in the turn view (renderHistory's object path): per
737
- // turn the prompt, a collapsed block of its steps when it had any (texts,
738
- // and with the server's parts the thinking, params and output, from the
739
- // saved messages; status and duration from the timing's tool records),
740
- // then the answer and the turn's timing line under it.
748
+ // Parts (HTML strings and templates, in order) as one fragment.
749
+ function fragmentOf(parts) {
750
+ const out = document.createDocumentFragment();
751
+ let markup = "";
752
+ for (const part of parts) {
753
+ if (typeof part === "string") { markup += part; continue; }
754
+ if (markup) out.append(rawNodes(markup));
755
+ markup = "";
756
+ out.append(nodesOf(part));
757
+ }
758
+ if (markup) out.append(rawNodes(markup));
759
+ return out;
760
+ }
761
+
762
+ // A reloaded history in the turn view (renderHistory's object path), as
763
+ // nodes: per turn the prompt, a collapsed block of its steps when it had
764
+ // any (stepTemplate, the live page's: texts, and with the server's parts
765
+ // the thinking, params and output, from the saved messages; status and
766
+ // duration from the timing's tool records), then the answer and the turn's
767
+ // timing line under it.
741
768
  // +thumbs(images)+ draws a prompt's or a tool row's image thumbs.
742
- export function turnHistoryHtml(items, timing, { thumbs }) {
769
+ export function turnHistoryNodes(items, timing, { thumbs }) {
743
770
  const timingHtml = (group) => {
744
771
  const record = group.record;
745
772
  // Numbered by its place in the turn records, as live (a failed turn
@@ -773,40 +800,31 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
773
800
  return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
774
801
  };
775
802
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
776
- return turnGroups(items, timing).map((group) => {
777
- if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
778
- if (group.kind === "steer") return steerHtml(group.i, true);
779
- if (group.kind === "answer") return answerHtml(items[group.answer], "");
803
+ const parts = [];
804
+ for (const group of turnGroups(items, timing)) {
805
+ if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
806
+ if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
807
+ if (group.kind === "answer") { parts.push(answerHtml(items[group.answer], "")); continue; }
780
808
  const prompt = items[group.user];
781
809
  // A context wake turn starts with its note.
782
- const parts = [prompt.role === "note"
783
- ? `<div class="bubble note">${noteHtml(prompt)}</div>`
784
- : userHtml(prompt)];
810
+ parts.push(prompt.role === "note" ? `<div class="bubble note">${noteHtml(prompt)}</div>` : userHtml(prompt));
785
811
  const [only] = group.steps;
786
812
  const ended = group.answer !== null || group.emptyAnswer != null;
787
813
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
788
814
  // A lone thinking step before an answer (or the notice of a turn with
789
- // none): no block, as live.
815
+ // none): no block, the plain answer's collapsed thinking block.
790
816
  const plainThinking = group.steps.length === 1 && ended && textless && !only.tools.length && only.thinking;
791
817
  const noBlock = !group.steps.length || plainThinking;
792
818
  if (plainThinking) {
793
- // A plain answer with thinking: the collapsed thinking block, as live.
794
- parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
819
+ parts.push(thinkingTemplate({ body: only.thinking, cls: "bubble thinking" }));
795
820
  } else if (group.steps.length) {
796
821
  const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
797
- const rows = gens.map((gen, k) => {
798
- // A thinking-only step: the body directly under the step's own
799
- // "thinking" summary (no wrap that would repeat it), as live.
800
- const body = gen.thinking ? `<div class="thinking-body">${escapeHtml(gen.thinking)}</div>` : "";
801
- const thinking = body && (gen.text || gen.tools.length) ? `<details class="thinking"><summary>thinking</summary>${body}</details>` : body;
802
- const text = gen.text ? `<div class="gen-text">${escapeHtml(gen.text)}</div>` : "";
822
+ const steps = gens.map((gen, k) => stepTemplate(gen, {
803
823
  // The steers the step answered first, where the live rows went.
804
- const steers = (group.steps[k].steers || []).map((i) => steerHtml(i, false)).join("");
805
- const tools = gen.tools.length ? `<div class="activity-body">${gen.tools.map((r) => toolRowHtml(r, { thumbs })).join("")}</div>` : "";
806
- const failed = model.failedCount(gen.tools) ? " has-failed" : "";
807
- return `<details class="gen${failed}">${labelHtml(gen)}${steers}${thinking}${text}${tools}</details>`;
808
- });
809
- parts.push(`<details class="turn-work done"><summary>${escapeHtml(model.blockSummary({ gens, ended: true }))}</summary>${rows.join("")}</details>`);
824
+ steers: (group.steps[k].steers || []).map((i) => rawNodes(steerHtml(i, false))),
825
+ items: gen.tools.length ? [rawNodes(gen.tools.map((r) => toolRowHtml(r, { thumbs })).join(""))] : [],
826
+ }));
827
+ parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
810
828
  }
811
829
  // Steers with no step to sit in, or in a turn that shows no block: on
812
830
  // their own after the prompt.
@@ -831,6 +849,6 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
831
849
  // stayed with its failure line, as the live view left them.
832
850
  parts.push(cancelLineHtml(group.record, escapeHtml));
833
851
  parts.push(failedLineHtml(group.record));
834
- return parts.join("");
835
- }).join("");
852
+ }
853
+ return fragmentOf(parts);
836
854
  }