samagotchi 0.60.0 → 0.62.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. metadata +8 -1
@@ -13,6 +13,7 @@ require_relative "../analytics_file"
13
13
  require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
+ require_relative "../answer_refs"
16
17
  require_relative "../bridge_client"
17
18
  require_relative "../bridge/bounded_queue"
18
19
  require_relative "../bridge/card_store"
@@ -1695,7 +1696,8 @@ module Samagotchi
1695
1696
  source = if live
1696
1697
  persisted.merge(live_metrics).merge(
1697
1698
  "started_at" => persisted["started_at"] || live_metrics["started_at"],
1698
- "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
1699
+ "turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
1700
+ live_metrics["turn_records"]),
1699
1701
  "tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
1700
1702
  )
1701
1703
  else
@@ -1776,6 +1778,18 @@ module Samagotchi
1776
1778
  AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1777
1779
  end
1778
1780
 
1781
+ # The saved turn records without the one the live worker is running:
1782
+ # its turn checkpoint saves that turn's record so far as a killed
1783
+ # turn's (canceled, worker_stopped, SessionMetrics#persist's
1784
+ # running_turn), which is only true once the worker is gone.
1785
+ def without_running_turn(records, live_metrics)
1786
+ active = live_metrics["active_turn"]
1787
+ id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
1788
+ return records if id.nil?
1789
+
1790
+ Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
1791
+ end
1792
+
1779
1793
  def merge_timing_records(persisted, live)
1780
1794
  (Array(persisted) + Array(live)).each_with_object({}) do |record, records|
1781
1795
  next unless record.is_a?(Hash)
@@ -1907,6 +1921,10 @@ module Samagotchi
1907
1921
  message[:display] = shown unless shown.strip.empty?
1908
1922
  rendered = message[:display] || stripped
1909
1923
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1924
+ # The refs after_turn hooks attached (AnswerRefs), checked again:
1925
+ # a stored file is never trusted. Never raises.
1926
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
1927
+ message[:display_refs] = refs unless refs.empty?
1910
1928
  end
1911
1929
  message[:parts] = did if did
1912
1930
  filtered << message
@@ -9,6 +9,7 @@ require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
10
10
  require_relative "../llm_context_edit"
11
11
  require_relative "../token_usage"
12
+ require_relative "../row_refs"
12
13
 
13
14
  module Samagotchi
14
15
  module Web
@@ -37,7 +38,8 @@ module Samagotchi
37
38
  # A plugin tool's params are what its live row showed (its preview),
38
39
  # saved with the result as tool_params (native: one per call, chat: one
39
40
  # per result); its label ("chrome: screenshot") likewise as tool_labels,
40
- # the part's label:. The web server has no Engine and runs no plugins, so a
41
+ # the part's label:, and its row refs as tool_refs (RowRefs, checked
42
+ # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
41
43
  # result without them (older sessions) falls back to the given
42
44
  # registry: the built-ins', and a plugin tool's arguments as key="value"
43
45
  # from the parsers' args:, as the live row shows them for a tool
@@ -106,11 +108,12 @@ module Samagotchi
106
108
  shown = saved_list(responses, :tool_params, calls.length)
107
109
  labels = saved_list(responses, :tool_labels, calls.length)
108
110
  diffs = saved_list(responses, :tool_diffs, calls.length)
111
+ refs = saved_list(responses, :tool_refs, calls.length)
109
112
  ids = saved_list(responses, :tool_ids, calls.length)
110
113
  images = split_images(responses, calls.length)
111
114
  calls.each_with_index.map do |call, i|
112
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
113
- tool_id: ids[i], marks: marks)
115
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
116
+ cwd: cwd, tool_id: ids[i], marks: marks)
114
117
  end
115
118
  end
116
119
 
@@ -144,7 +147,8 @@ module Samagotchi
144
147
  response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
145
148
  tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
146
149
  response && field(response, :images), label: field(response, :tool_labels),
147
- diff: field(response, :tool_diffs), cwd: cwd,
150
+ diff: field(response, :tool_diffs),
151
+ refs: field(response, :tool_refs), cwd: cwd,
148
152
  tool_id: Array(field(response, :tool_ids)).first, marks: marks)
149
153
  end
150
154
  end
@@ -224,10 +228,12 @@ module Samagotchi
224
228
 
225
229
  # +shown+ is the saved params line, when it is a String.
226
230
  # +diff+ is what an edit/write changed (EditPreview.change), saved as
227
- # tool_diffs. +cwd+: the session's working directory, for the title.
231
+ # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
232
+ # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
233
+ # session's working directory, for the title.
228
234
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
229
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
230
- marks: {})
235
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
236
+ tool_id: nil, marks: {})
231
237
  name = call[:name].to_s
232
238
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
233
239
  part = { tool: name, params: params.to_s }
@@ -240,9 +246,11 @@ module Samagotchi
240
246
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
241
247
  part[:output_truncated] = true if output.length > OUTPUT_MAX
242
248
  end
243
- refs = Array(images).select { |ref| ref.is_a?(Hash) }
244
- part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
249
+ shots = Array(images).select { |image| image.is_a?(Hash) }
250
+ part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
245
251
  part[:diff] = diff if diff.is_a?(Hash)
252
+ row_refs = RowRefs.for_display(refs, cwd: cwd)
253
+ part[:refs] = row_refs unless row_refs.empty?
246
254
  if tool_id
247
255
  part[:tool_id] = tool_id.to_s
248
256
  mark = mark_of(tool_id.to_s, marks, output)
@@ -45,8 +45,11 @@ export function newActivity() {
45
45
  // ref: what the row traces to (the server's FileRef: {kind: "file",
46
46
  // path, line?, end_line?}, the file a file tool touched), which
47
47
  // refs.js makes the row's title and diff lines open.
48
+ // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
49
+ // label?, actions}]): the first on the row's title, the others in its
50
+ // menu (refs.js bindRowRefs).
48
51
  export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
49
- export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
52
+ export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
50
53
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
51
54
 
52
55
  // The +fields+ of +source+ that are set, as an object (for a spread).
@@ -0,0 +1,217 @@
1
+ // The refs a plugin attached to an answer (the message's display_refs,
2
+ // traceable refs P5b): each {match, kind, target, label?, actions} is
3
+ // paired with the answer's bubble, then bound to what it stands for
4
+ // (refs.js bindRef), so its actions join the ref's menu.
5
+ //
6
+ // A bubble knows its message's refs through setAnswerRefs (a reload's
7
+ // render, a turn-end read's re-render); pairAnswerRefs pairs them once per
8
+ // setAnswerRefs. Three passes over all the refs, strongest evidence
9
+ // first, so a weak text match of one ref never takes another's link:
10
+ // 1. a link whose href is one of the ref's action URLs;
11
+ // 2. a link whose text is the match;
12
+ // 3. the match as a whole token inside one plain text node (not in pre,
13
+ // a link, inline code or another ref), or an inline code that is the
14
+ // match alone.
15
+ // A ref binds every occurrence its first finding pass finds; a ref no
16
+ // pass finds is dropped. A label that differs from the element's text is
17
+ // its hover title (unless the element has one: a markdown link's).
18
+ // The match and label are a plugin's text: they only ever go through
19
+ // textContent, setAttribute and bindRef, never markup or a RegExp.
20
+
21
+ import { COPY_SOURCE_ATTR, copySource, showsAnswer } from "./copy.js";
22
+ import { bindRef, boundRef, httpUrl, refKindOk } from "./refs.js";
23
+
24
+ // bubble → {refs, paired}. A WeakMap: a dropped bubble takes its refs with
25
+ // it; the stage hand-off moves the bubble, so they move along.
26
+ const ANSWER_REFS = new WeakMap();
27
+
28
+ // Text never paired: code blocks, links (passes 1 and 2 are theirs), a ref
29
+ // already made, inline code (only as the match alone).
30
+ const SKIP = "pre, a, [data-ref-kind]";
31
+
32
+ // A token boundary, as the `path:line` scanner's (ref_scan.js REF): not
33
+ // after a path-ish character, not before a word character or a slash.
34
+ const BEFORE = /[\w./@+~-]/;
35
+ const AFTER = /[\w/]/;
36
+
37
+ // +bubble+'s refs are +refs+ (a message's display_refs: entries with a
38
+ // match and a kind bindRef binds); a non-array or one without a usable
39
+ // entry clears them. They pair on the next
40
+ // pairAnswerRefs, even when the same ones were paired before.
41
+ export function setAnswerRefs(bubble, refs) {
42
+ if (!bubble) return;
43
+ const list = (Array.isArray(refs) ? refs : []).filter(
44
+ (ref) => ref && typeof ref === "object" && refKindOk(ref.kind) && typeof ref.match === "string" && ref.match.trim(),
45
+ );
46
+ if (list.length) ANSWER_REFS.set(bubble, { refs: list, paired: false });
47
+ else ANSWER_REFS.delete(bubble);
48
+ }
49
+
50
+ // +bubble+'s refs (setAnswerRefs'), or null.
51
+ export function answerRefsOf(bubble) {
52
+ return ANSWER_REFS.get(bubble)?.refs || null;
53
+ }
54
+
55
+ // The turn's final answer on its bubble (a turn-end read's +messages+):
56
+ // the last assistant message with text, its bubble the last of +bubbles+
57
+ // that shows it (copy.js showsAnswer, with +normalize+). Its rendered html
58
+ // replaces the bubble's when it has one (not with web.markdown off or no
59
+ // renderer: the text stays); its refs are the bubble's either way (a
60
+ // later read without any clears them). The bubble, or null.
61
+ export function applyFinalAnswer(messages, bubbles, normalize) {
62
+ const message = [...(Array.isArray(messages) ? messages : [])].reverse().find(
63
+ (item) => item?.role === "assistant" && (typeof item.html === "string" || String(item.content ?? "").trim()),
64
+ );
65
+ const bubble = message && [...bubbles].reverse().find((item) => showsAnswer(item, message, normalize));
66
+ if (!bubble) return null;
67
+ if (typeof message.html === "string") {
68
+ bubble.classList.add("markdown");
69
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ bubble.innerHTML = message.html;
71
+ }
72
+ setAnswerRefs(bubble, message.display_refs);
73
+ return bubble;
74
+ }
75
+
76
+ // Pairs +bubble+'s refs with its links and text and binds them (once per
77
+ // setAnswerRefs; again a no-op).
78
+ export function pairAnswerRefs(bubble) {
79
+ const entry = bubble && ANSWER_REFS.get(bubble);
80
+ if (!entry || entry.paired) return;
81
+ entry.paired = true;
82
+ let left = entry.refs;
83
+ left = left.filter((ref) => !pairByHref(bubble, ref));
84
+ left = left.filter((ref) => !pairByLinkText(bubble, ref));
85
+ pairByText(bubble, left);
86
+ }
87
+
88
+ // The links a ref may take: not inside a code block or another ref, not
89
+ // bound by a plugin's ref already (a link the regex scanner adopted has
90
+ // attributes only: it may be bound).
91
+ function freeLinks(bubble) {
92
+ return [...bubble.querySelectorAll("a[href]")].filter((a) => !boundRef(a) && !a.parentElement?.closest("pre, [data-ref-kind]"));
93
+ }
94
+
95
+ // Pass 1: true when +ref+ took a link to one of its action URLs.
96
+ function pairByHref(bubble, ref) {
97
+ const urls = new Set((Array.isArray(ref.actions) ? ref.actions : []).map((action) => httpUrl(action?.url)).filter(Boolean));
98
+ if (!urls.size) return false;
99
+ const links = freeLinks(bubble).filter((a) => urls.has(httpUrl(a.getAttribute("href"))));
100
+ return bindAll(links, ref);
101
+ }
102
+
103
+ // Pass 2: true when +ref+ took a link whose text is its match. The link
104
+ // goes somewhere the ref doesn't name (the model's URL): the ref's actions
105
+ // join its menu, but its label isn't the link's title.
106
+ function pairByLinkText(bubble, ref) {
107
+ const match = ref.match.trim();
108
+ return bindAll(freeLinks(bubble).filter((a) => a.textContent.trim() === match), ref, { label: false });
109
+ }
110
+
111
+ // Pass 3, for all the +refs+ left at once: a ref the path:line scan made
112
+ // (attributes only, no plugin data) whose text is a match is bound in
113
+ // place (the scan ran first on a bubble that wasn't re-rendered: an answer
114
+ // without html); an inline code that is a match alone is wrapped whole;
115
+ // then each text node's hits (refs in list order, a hit overlapping an
116
+ // earlier one dropped) are wrapped in one go.
117
+ function pairByText(bubble, refs) {
118
+ if (!refs.length) return;
119
+ const doc = bubble.ownerDocument;
120
+ const matching = (el) => {
121
+ const text = el.textContent.trim();
122
+ return refs.find((candidate) => candidate.match.trim() === text);
123
+ };
124
+ for (const scanned of [...bubble.querySelectorAll("[data-ref-kind]:not(a)")]) {
125
+ if (boundRef(scanned) || scanned.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
126
+ const ref = matching(scanned);
127
+ if (ref) bindAll([scanned], ref);
128
+ }
129
+ for (const code of [...bubble.querySelectorAll("code")]) {
130
+ if (code.closest(SKIP)) continue;
131
+ const ref = matching(code);
132
+ if (!ref) continue;
133
+ const span = refSpan(doc, ref);
134
+ if (!span) continue;
135
+ code.replaceWith(span);
136
+ span.append(code);
137
+ labelOn(span, ref);
138
+ }
139
+ const walker = doc.createTreeWalker(bubble, 4 /* NodeFilter.SHOW_TEXT */);
140
+ const nodes = [];
141
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
142
+ const skip = node.parentElement?.closest(`${SKIP}, code`);
143
+ if (!skip || !bubble.contains(skip)) nodes.push(node);
144
+ }
145
+ const hits = new Map();
146
+ for (const ref of refs) {
147
+ const match = ref.match.trim();
148
+ for (const node of nodes) {
149
+ for (const [start, end] of tokenHits(node.data, match)) {
150
+ const list = hits.get(node) || [];
151
+ if (list.some(([s, e]) => start < e && end > s)) continue;
152
+ list.push([start, end, ref]);
153
+ hits.set(node, list);
154
+ }
155
+ }
156
+ }
157
+ for (const [node, list] of hits) wrap(node, list.sort((a, b) => a[0] - b[0]));
158
+ }
159
+
160
+ // [start, end] of each whole-token +match+ in +text+ (indexOf: the match
161
+ // is a plugin's text, never a pattern).
162
+ function tokenHits(text, match) {
163
+ const found = [];
164
+ for (let at = text.indexOf(match); at >= 0; at = text.indexOf(match, at + 1)) {
165
+ const end = at + match.length;
166
+ if (at > 0 && BEFORE.test(text[at - 1])) continue;
167
+ if (end < text.length && AFTER.test(text[end])) continue;
168
+ found.push([at, end]);
169
+ at = end - 1;
170
+ }
171
+ return found;
172
+ }
173
+
174
+ function wrap(node, hits) {
175
+ const doc = node.ownerDocument;
176
+ const frag = doc.createDocumentFragment();
177
+ let pos = 0;
178
+ for (const [start, end, ref] of hits) {
179
+ const span = refSpan(doc, ref);
180
+ if (!span) continue;
181
+ if (start > pos) frag.append(node.data.slice(pos, start));
182
+ span.textContent = node.data.slice(start, end);
183
+ labelOn(span, ref);
184
+ frag.append(span);
185
+ pos = end;
186
+ }
187
+ if (!pos) return;
188
+ if (pos < node.data.length) frag.append(node.data.slice(pos));
189
+ node.replaceWith(frag);
190
+ }
191
+
192
+ // A span bound to +ref+, or null when bindRef refuses it (a bad kind).
193
+ function refSpan(doc, ref) {
194
+ const span = doc.createElement("span");
195
+ bindRef(span, ref);
196
+ return boundRef(span) ? span : null;
197
+ }
198
+
199
+ // Binds each of +els+ to +ref+; true when one took it.
200
+ function bindAll(els, ref, { label = true } = {}) {
201
+ let bound = false;
202
+ for (const el of els) {
203
+ bindRef(el, ref);
204
+ if (!boundRef(el)) continue;
205
+ if (label) labelOn(el, ref);
206
+ bound = true;
207
+ }
208
+ return bound;
209
+ }
210
+
211
+ // The ref's label as the element's hover title, when it says more than
212
+ // the element's text and the element has no title of its own.
213
+ function labelOn(el, ref) {
214
+ const label = typeof ref.label === "string" ? ref.label.trim() : "";
215
+ if (!label || label === el.textContent.trim() || el.hasAttribute("title")) return;
216
+ el.setAttribute("title", label);
217
+ }
@@ -38,15 +38,15 @@ import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
38
  import { createModelPicker } from "./model_picker.js";
39
39
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
40
40
  import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
- import { createTurnView, turnHistoryNodes } from "./turn_view.js";
41
+ import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
42
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
43
- import { createStageView } from "./stage_view.js";
43
+ import { createStageView, FOLD_MS } from "./stage_view.js";
44
44
  import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
45
45
  import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
- import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
49
+ import { copyText, installCopy } from "./copy.js";
50
50
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
51
51
  import { createQuestionCards } from "./question_cards.js";
52
52
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
@@ -55,7 +55,7 @@ import { createCommandList } from "./command_list.js";
55
55
  import { createPromptHistory } from "./prompt_history.js";
56
56
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
57
57
  import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
58
- import { shouldFollowScroll, keepFollowing } from "./scroll.js";
58
+ import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
59
59
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
60
60
  import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
61
61
  import { createNotifications } from "./notifications.js";
@@ -65,6 +65,7 @@ import {
65
65
  appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
66
66
  } from "./timing.js";
67
67
  import { createLiveTurn } from "./live_turn.js";
68
+ import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
68
69
  import { installRefClicks } from "./refs.js";
69
70
  import { linkRefs, touchedFiles } from "./ref_scan.js";
70
71
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -263,6 +264,7 @@ const questionCards = createQuestionCards({
263
264
  isNearBottom: () => isNearBottom(),
264
265
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
265
266
  removeHintIfEmpty: () => removeHintIfEmpty(),
267
+ askingText: () => turnDom.askingText(),
266
268
  });
267
269
  // The event id (or, from an older worker, the event_seq) the selected session
268
270
  // was rendered at (null: no live worker then), where a stream opened later
@@ -904,30 +906,26 @@ async function readTurnEnd() {
904
906
  } catch(_){}
905
907
  }
906
908
 
907
- // The turn's answer as its rendered markdown, once it is final (the
908
- // turn-end read, an answer_display re-read); then its refs.
909
+ // The turn's answer as its rendered markdown and with its plugin refs,
910
+ // once it is final (the turn-end read, an answer_display re-read:
911
+ // answer_refs.js applyFinalAnswer); then the refs.
909
912
  function applyFinalMarkdown(messages) {
910
- const message = [...messages].reverse().find(
911
- (item) => item?.role === "assistant" && typeof item.html === "string",
912
- );
913
- const bubble = message && findInTurnRoots(".bubble.output")
914
- .reverse()
915
- .find((item) => showsAnswer(item, message, normalize));
916
- if (bubble) {
917
- bubble.classList.add("markdown");
918
- bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
919
- bubble.innerHTML = message.html;
920
- }
913
+ applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
921
914
  linkTextRefs();
922
915
  }
923
916
 
924
- // The `path:line` refs in the answers' and closed steps' texts that name a
925
- // file the session's rows (and cards) touched, on this page (ref_scan.js).
926
- // Not mid-stream: at a turn's end and after a reload's render. A ref
927
- // already made stays as it is.
917
+ // The refs on the answers' and closed steps' texts, on this page: first
918
+ // each answer's own (a plugin's, answer_refs.js), then the `path:line`
919
+ // refs that name a file the session's rows (and cards) touched
920
+ // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
921
+ // turn's end and after a reload's render. A ref already made stays as it
922
+ // is.
928
923
  function linkTextRefs() {
929
924
  const roots = turnRoots();
930
925
  const touched = touchedFiles(roots);
926
+ for (const root of roots) {
927
+ for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
928
+ }
931
929
  if (!touched.size) return;
932
930
  for (const root of roots) {
933
931
  for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
@@ -1104,6 +1102,7 @@ function showRecap(text, turnsSince = 0) {
1104
1102
  details.appendChild(body);
1105
1103
  // A stale one sits where it was written, above the turns it doesn't cover.
1106
1104
  // Never in the stage: a recap is between turns.
1105
+ turnDom.settleFold?.();
1107
1106
  const before = recapPlace(turnPromptBubbles(), turnsSince);
1108
1107
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1109
1108
  }
@@ -1302,12 +1301,15 @@ function turnAnswerEl(turnEls) {
1302
1301
  function placeResolvedQuestion(entry, before) {
1303
1302
  const pq = entry.pending_question;
1304
1303
  if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1305
- questionCards.renderQuestion(pq);
1304
+ const turnEls = reloadedTurnEls(before);
1305
+ const asked = Number(entry.iteration) || 1;
1306
+ // The reply that asked: its step's text in the reloaded block (the card
1307
+ // keeps it, as live), never the running turn's.
1308
+ const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
1309
+ questionCards.renderQuestion(pq, { said });
1306
1310
  const card = questionCards.questionCard();
1307
1311
  if (!card || card.dataset.qid !== String(pq.id)) return;
1308
1312
  questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1309
- const turnEls = reloadedTurnEls(before);
1310
- const asked = Number(entry.iteration) || 1;
1311
1313
  const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1312
1314
  historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1313
1315
  }
@@ -1512,6 +1514,7 @@ function currentSessionDuration() {
1512
1514
 
1513
1515
  // The stage takes a running turn's rows (its extras); else the history.
1514
1516
  function appendToHistory(el) {
1517
+ // (The stage's place first moves a turn still sliding away into the history.)
1515
1518
  if (turnDom.place?.(el)) return;
1516
1519
  appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1517
1520
  }
@@ -2042,15 +2045,51 @@ fitPrompt();
2042
2045
  // The card floats over the history: keep the history's bottom padding at the
2043
2046
  // card's height, and stay pinned to the end if the reader was there, since the
2044
2047
  // card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
2048
+ // In the stage view the stage floats above the card, out of the dock's flow
2049
+ // (stage_view.js): the reserve counts it too, with its 10 px gap, while it
2050
+ // shows.
2045
2051
  const dockEl = $("#dock");
2046
2052
  const centerEl = $("#center");
2047
- new ResizeObserver(() => {
2048
- // The stage's height too: a reader at the end stays there.
2049
- const follow = atHistoryEnd();
2050
- centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2051
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2053
+ // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
2054
+ // down over the slide (index.html, #center.reserve-easing).
2055
+ const stageEl = view === "stage" ? turnDom.root : null;
2056
+ const STAGE_GAP = 10;
2057
+ function dockReserve() {
2058
+ const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
2059
+ return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
2060
+ }
2061
+ const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
2062
+ // A reader at the end stays there when the reserve changes: at once for the
2063
+ // composer card, eased for the stage (+fromStage+: it appeared, grew or
2064
+ // leaves), so the history glides rather than jumps. An eased follow running
2065
+ // counts as at the end.
2066
+ function updateReserve({ fromStage = false } = {}) {
2067
+ const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
2068
+ const leaving = !!stageEl?.classList.contains("leaving");
2069
+ centerEl.classList.toggle("reserve-easing", leaving);
2070
+ const reserve = `${dockReserve()}px`;
2071
+ const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
2072
+ if (changed) centerEl.style.setProperty("--dock-h", reserve);
2073
+ if (changed && follow) {
2074
+ if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
2075
+ else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
2076
+ }
2052
2077
  placeToast();
2053
- }).observe(dockEl);
2078
+ }
2079
+ const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
2080
+ dockObserver.observe(dockEl);
2081
+ if (stageEl) {
2082
+ dockObserver.observe(stageEl);
2083
+ // Only showing, hiding and leaving change the reserve (not the stage's
2084
+ // other classes: waiting, in-use…).
2085
+ let stageShown = "";
2086
+ new MutationObserver(() => {
2087
+ const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
2088
+ if (shown === stageShown) return;
2089
+ stageShown = shown;
2090
+ updateReserve({ fromStage: true });
2091
+ }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
2092
+ }
2054
2093
  window.addEventListener("resize", () => placeToast());
2055
2094
 
2056
2095
  promptEl.addEventListener("paste", (e) => {
@@ -2263,14 +2302,16 @@ async function setArchived(id, archive, { undo = true } = {}) {
2263
2302
 
2264
2303
  // The toast sits above the session's composer card (measured: it grows with
2265
2304
  // a wrapping footer or a taller box), so it never covers the card's
2266
- // controls; with no card in sight (the start page, all sessions) the CSS's
2267
- // 24 px from the bottom.
2305
+ // controls, and above the stage while it shows; with no card in sight (the
2306
+ // start page, all sessions) the CSS's 24 px from the bottom.
2268
2307
  function placeToast() {
2269
2308
  const gap = 12;
2270
2309
  const dock = document.getElementById("dock");
2271
2310
  const r = dock.getBoundingClientRect();
2272
2311
  const over = document.body.classList.contains("has-session") && r.height > 0;
2273
- const bottom = over ? `${Math.round(window.innerHeight - r.top + gap)}px` : "";
2312
+ const stage = document.getElementById("turnStage");
2313
+ const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
2314
+ const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
2274
2315
  const root = document.documentElement.style;
2275
2316
  if (root.getPropertyValue("--toast-bottom") !== bottom) {
2276
2317
  if (bottom) root.setProperty("--toast-bottom", bottom);
@@ -4,10 +4,13 @@
4
4
  // through `continues`, forward through the session whose `continues` names
5
5
  // this one. The lists fold a chain into its latest listed link's card (a
6
6
  // `day N` chip, its earlier links in a popover); the session page links the
7
- // previous and the next link. Pure: app.js places the markup and binds it.
7
+ // previous and the next link. Pure: lists_view.js places the chip and rows
8
+ // (lit-html templates) and app.js the toast's markup, and they bind them.
8
9
  import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
9
10
  import { stoppedBadge, waitingBadge } from "./sessions_list.js";
10
11
  import { waitingOn } from "./notify.js";
12
+ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_view.js";
13
+ import { html, nothing } from "./vendor/lit-html.js";
11
14
 
12
15
  // The opener a new link's composer is filled with (never sent: the user
13
16
  // edits or sends it). The same for every chain.
@@ -148,10 +151,11 @@ export function chainOthers(f) {
148
151
  // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
149
152
  // later links aren't listed), with a live dot / "waiting" when one of the
150
153
  // other links has a worker / waits on the user (typing in an archived link
151
- // brings it back, but it stays folded here). "" for a family in no chain.
154
+ // brings it back, but it stays folded here). nothing for a family in no
155
+ // chain. A lit-html template, as families_view.js's chip.
152
156
  // @param open whether the list is open (aria-expanded)
153
- export function chainChipHtml(f, { open = false } = {}) {
154
- if (!f?.chain) return "";
157
+ export function chainChipTemplate(f, { open = false } = {}) {
158
+ if (!f?.chain) return nothing;
155
159
  const { links, at } = f.chain;
156
160
  const others = chainOthers(f);
157
161
  const live = others.filter(({ session: s }) => s.owner === "worker").length;
@@ -162,9 +166,10 @@ export function chainChipHtml(f, { open = false } = {}) {
162
166
  const later = links.length - 1 - at;
163
167
  const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
164
168
  const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
165
- const liveHtml = live ? `<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : "";
166
- const waitingHtml = waiting ? ` · <span class="cc-waiting">${waiting} waiting</span>` : "";
167
- return `<button type="button" class="chain-chip${waiting ? " waiting" : ""}" aria-expanded="${open ? "true" : "false"}" title="${escapeHtml(title)}"><span class="cc-arrow" aria-hidden="true">↩</span>${escapeHtml(text)}${liveHtml}${waitingHtml}</button>`;
169
+ const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
170
+ const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
171
+ return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
172
+ title=${title}><span class="cc-arrow" aria-hidden="true">↩</span>${text}${liveT}${waitingT}</button>`;
168
173
  }
169
174
 
170
175
  // The chain's list (the chip's popover): one row per other link, newest
@@ -172,7 +177,7 @@ export function chainChipHtml(f, { open = false } = {}) {
172
177
  // its preview), the waiting / stopped badge, archived. A row's click opens
173
178
  // that link (families_view.js's popover binds .family-row[data-id]).
174
179
  // @param activeId the open session's id (its row is .active)
175
- export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
180
+ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
176
181
  const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
177
182
  const rows = chainOthers(f).map(({ session: s, day }) => {
178
183
  const cls = ["family-row", "chain-row"];
@@ -180,22 +185,15 @@ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
180
185
  if (s.archived) cls.push("archived");
181
186
  const waiting = waitingBadge(s, { inFamily: true });
182
187
  const stopped = waiting ? null : stoppedBadge(s);
183
- const dotWord = waiting ? waiting.title : s.status;
184
188
  const date = chainDate(s.created_at);
185
189
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
186
- const parts = [
187
- `<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
188
- `<span class="cr-day">day ${day}</span>`,
189
- date ? `<span class="cr-date">${escapeHtml(date)}</span>` : "",
190
- `<span class="fr-preview">${escapeHtml(s.recap || chainTitle(s, byId))}</span>`,
191
- waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
192
- stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
193
- s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
194
- ].join("");
195
190
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
196
- return `<div class="${cls.join(" ")}" data-id="${escapeHtml(s.id)}" style="--depth:1" role="listitem"><button type="button" class="fr-open" title="${escapeHtml(tip)}">${parts}</button></div>`;
191
+ return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
192
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">day ${day}</span>${
193
+ date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
194
+ rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
197
195
  });
198
- return `<div class="family-rows chain-rows" role="list">${rows.join("")}</div>`;
196
+ return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
199
197
  }
200
198
 
201
199
  // What the page does with a refused continue (POST /api/sessions