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
@@ -7,6 +7,9 @@
7
7
  // provider can attach one: GET …/context's can_add_url) opens a form that
8
8
  // attaches a URL (a GitHub PR); the web never adds a command.
9
9
 
10
+ import { html, nothing, render } from "./vendor/lit-html.js";
11
+ import { keepFocus } from "./keep_focus.js";
12
+
10
13
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
11
14
  export function ageText(iso, now = Date.now()) {
12
15
  const at = Date.parse(iso || "");
@@ -38,33 +41,40 @@ export function chipModel(rows, now = Date.now()) {
38
41
  });
39
42
  }
40
43
 
41
- // addUrl: end with the "+ URL" chip.
42
- export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
43
- return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
44
- `title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
45
- `${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
46
- (addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
44
+ const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about when it changes";
45
+
46
+ // The chips (a lit-html template; attributes after a bound one are bound
47
+ // too, so they keep their order). addUrl: end with the "+ URL" chip;
48
+ // open: the chip whose popover is open (a source's name), addOpen: the
49
+ // "+ URL" form is.
50
+ export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
51
+ return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
52
+ data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
53
+ chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
54
+ addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
55
+ aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
47
56
  }
48
57
 
49
- // The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
50
- // after a try that failed.
51
- export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
52
- return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
53
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
54
- `<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
55
- `<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
56
- `<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
57
- (error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
58
- `<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
58
+ // The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
59
+ // try that failed, +busy+ while one runs.
60
+ export function addFormTemplate({ error = null, busy = false } = {}) {
61
+ return html`<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong
62
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
63
+ ><div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div
64
+ ><input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" required autocomplete="off"
65
+ ><input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off"
66
+ >${error ? html`<div class="ctx-pop-error" role="alert">${error}</div>` : nothing
67
+ }<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit" ?disabled=${busy}>Attach</button></div></form>`;
59
68
  }
60
69
 
61
- // The popover's inside for one row (GET …/context's).
62
- export function popoverHtml(row, escapeHtml, now = Date.now()) {
70
+ // The popover's inside for one row (GET …/context's); +text+ the row's
71
+ // text once "view text" read it (null before).
72
+ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
63
73
  const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
64
74
  const url = hintUrl(row.hint);
65
75
  const hint = url
66
- ? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
67
- : (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
76
+ ? html`<a class="ctx-pop-hint" href=${url} target=${"_blank"} rel=${"noopener noreferrer"}>${url}</a>`
77
+ : (row.hint ? html`<div class="ctx-pop-hint">${row.hint}</div>` : nothing);
68
78
  const age = ageText(row.fetched_at, now);
69
79
  const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
70
80
  row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
@@ -73,29 +83,32 @@ export function popoverHtml(row, escapeHtml, now = Date.now()) {
73
83
  const detachTitle = row.scope === "project"
74
84
  ? "The project's source: this session stops seeing it (chi context unmute brings it back)"
75
85
  : "Remove it from this session";
76
- return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
77
- `<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
78
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
79
- (row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
80
- hint +
81
- (row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
82
- `<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
83
- (row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
84
- `<pre class="ctx-pop-text" hidden></pre>` +
85
- `<div class="ctx-pop-actions">` +
86
- (row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
87
- `<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
86
+ return html`<div class="ctx-pop-head"><strong>${row.name}</strong
87
+ ><span class="ctx-pop-kind">${row.scope} · ${kind}</span
88
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
89
+ >${row.why ? html`<div class="ctx-pop-why">${row.why}</div>` : nothing}${hint}${
90
+ row.summary ? html`<div class="ctx-pop-summary">${row.summary}</div>` : nothing
91
+ }<div class="ctx-pop-meta">${meta}</div
92
+ >${row.error ? html`<div class="ctx-pop-error">last refresh failed: ${row.error}</div>` : nothing
93
+ }<pre class="ctx-pop-text" ?hidden=${text === null}>${text ?? ""}</pre
94
+ ><div class="ctx-pop-actions">${row.has_text && text === null ? html`<button type="button" data-act="view">view text</button>` : nothing
95
+ }<button type="button" class="ctx-pop-detach" data-act="detach" title=${detachTitle}>${detach}</button></div>`;
88
96
  }
89
97
 
90
98
  // The chips for the session bar. el: #contextChips; api: {list(id),
91
- // show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
92
- // toast(text).
93
- export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
99
+ // show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
100
+ // Chips, popover and form are lit templates: a redraw (a refresh, the
101
+ // minute's tick) keeps their nodes, so focus and typed text stay.
102
+ export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
94
103
  let sessionId = null;
95
104
  let rows = [];
96
105
  let canAdd = false;
97
106
  let openName = null;
98
107
  let loadSeq = 0;
108
+ // The open row's text once read (view text), and the "+ URL" form's
109
+ // state ({error, busy}).
110
+ let text = null;
111
+ let addState = {};
99
112
  const pop = doc.createElement("div");
100
113
  pop.id = "contextPopover";
101
114
  pop.className = "ctx-popover";
@@ -106,17 +119,20 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
106
119
  // openName ADD: the "+ URL" form is open.
107
120
  const ADD = "\u0000add";
108
121
 
109
- function render() {
122
+ function drawChips() {
110
123
  const chips = chipModel(rows);
111
- el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
124
+ // A source inserted before the focused chip: the focus stays on its source.
125
+ keepFocus(el, "data-name", () => render(chipsTemplate(chips, { addUrl: canAdd, open: openName === ADD ? null : openName,
126
+ addOpen: openName === ADD }), el));
112
127
  el.hidden = chips.length === 0 && !canAdd;
113
128
  // The "+ URL" chip alone sits inline, not on a row of its own.
114
129
  el.classList.toggle("only-add", chips.length === 0 && canAdd);
115
- if (openName === ADD) {
116
- if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
117
- else close();
118
- } else if (openName && !chips.some((chip) => chip.name === openName)) close();
119
- else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
130
+ }
131
+
132
+ // The chips, and the open popover closed when what it is for has gone.
133
+ function draw() {
134
+ if (openName === ADD ? !canAdd : openName && !chipModel(rows).some((chip) => chip.name === openName)) close();
135
+ else drawChips();
120
136
  }
121
137
 
122
138
  async function refresh() {
@@ -134,10 +150,11 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
134
150
  rows = [];
135
151
  canAdd = false;
136
152
  }
137
- render();
138
- if (openName && openName !== ADD) {
153
+ draw();
154
+ // An open popover follows its row, but not while it shows the text.
155
+ if (openName && openName !== ADD && text === null) {
139
156
  const row = rows.find((r) => r.name === openName);
140
- if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
157
+ if (row) fill(row);
141
158
  }
142
159
  }
143
160
 
@@ -146,7 +163,6 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
146
163
  rows = [];
147
164
  canAdd = false;
148
165
  close();
149
- render();
150
166
  return refresh();
151
167
  }
152
168
 
@@ -155,11 +171,10 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
155
171
  rows = [];
156
172
  canAdd = false;
157
173
  close();
158
- render();
159
174
  }
160
175
 
161
176
  function fill(row) {
162
- pop.innerHTML = popoverHtml(row, escapeHtml);
177
+ render(popoverTemplate(row, { text }), pop);
163
178
  }
164
179
 
165
180
  // Above the chip, inside the window with a 16px gutter.
@@ -174,19 +189,30 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
174
189
  function open(name, chip) {
175
190
  const row = rows.find((r) => r.name === name);
176
191
  if (!row) return;
192
+ // Another popover's nodes go, so this one starts fresh.
193
+ render(nothing, pop);
177
194
  openName = name;
195
+ text = null;
178
196
  fill(row);
179
197
  pop.hidden = false;
180
198
  place(chip);
181
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
199
+ drawChips();
182
200
  }
183
201
 
184
- function openAdd(chip, state = {}) {
202
+ const drawAdd = (changes = {}) => {
203
+ addState = { ...addState, ...changes };
204
+ render(addFormTemplate(addState), pop);
205
+ };
206
+
207
+ function openAdd(chip) {
208
+ render(nothing, pop);
185
209
  openName = ADD;
186
- pop.innerHTML = addFormHtml(escapeHtml, state);
210
+ text = null;
211
+ addState = {};
212
+ drawAdd();
187
213
  pop.hidden = false;
188
214
  place(chip);
189
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
215
+ drawChips();
190
216
  pop.querySelector('input[name="url"]')?.focus();
191
217
  }
192
218
 
@@ -195,8 +221,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
195
221
  const url = form.elements.url.value.trim();
196
222
  const why = form.elements.why.value.trim();
197
223
  if (!id || !url) return;
198
- const button = form.querySelector(".ctx-add-submit");
199
- if (button) button.disabled = true;
224
+ drawAdd({ busy: true });
200
225
  try {
201
226
  const data = await api.add(id, { url, why: why || undefined });
202
227
  if (id !== sessionId) return;
@@ -205,32 +230,35 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
205
230
  toast(`Attached ${data.name}: the agent gets a note before your next message`);
206
231
  } catch (e) {
207
232
  if (id !== sessionId || openName !== ADD) return;
208
- const chip = el.querySelector(".ctx-add");
209
- if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
233
+ // The same form again: what the user typed stays; the URL gets focus.
234
+ drawAdd({ busy: false, error: String(e.message).replace(/ \(\d+\)$/, "") });
235
+ pop.querySelector('input[name="url"]')?.focus();
210
236
  }
211
237
  }
212
238
 
213
239
  function close() {
214
240
  openName = null;
241
+ text = null;
242
+ addState = {};
215
243
  pop.hidden = true;
216
- el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
244
+ render(nothing, pop);
245
+ drawChips();
217
246
  }
218
247
 
219
248
  async function viewText() {
220
249
  const name = openName;
221
- const pre = pop.querySelector(".ctx-pop-text");
222
- if (!name || !pre) return;
250
+ if (!name || name === ADD) return;
223
251
  try {
224
252
  const data = await api.show(sessionId, name);
225
253
  if (name !== openName) return;
226
- pre.textContent = data.text || "";
227
- pre.hidden = false;
228
- pop.querySelector('[data-act="view"]')?.remove();
254
+ const row = rows.find((r) => r.name === name);
255
+ if (!row) return;
256
+ text = data.text || "";
257
+ fill(row);
229
258
  } catch (e) {
230
259
  toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
231
260
  }
232
261
  }
233
-
234
262
  async function detach() {
235
263
  const row = rows.find((r) => r.name === openName);
236
264
  if (!row) return;
@@ -279,7 +307,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
279
307
  });
280
308
  win.addEventListener("focus", () => refresh());
281
309
  // The ages move on by themselves.
282
- win.setInterval(() => { if (rows.length) render(); }, 60_000);
310
+ win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
283
311
 
284
312
  return { load, clear, refresh, close, get rows() { return rows; } };
285
313
  }
@@ -1,23 +1,44 @@
1
1
  // A parent's card with its delegates folded in (docs/sessions.md, the web):
2
- // the chip that opens the family's list, and the list's rows. Pure string
3
- // builders; app.js places them (inline in all sessions, a popover over the
4
- // strip) and binds the clicks.
5
- import { escapeHtml, ownerBadge, previewForCard, relativeTime } from "./format.js";
2
+ // the chip that opens the family's list, and the list's rows, as lit-html
3
+ // templates; lists_view.js places them (inline in all sessions, a popover
4
+ // over the strip) and binds the clicks. Attributes after a bound one are
5
+ // bound too, so they keep their order in the markup (lit sets bound ones
6
+ // after the static ones).
7
+ import { ownerBadge, previewForCard, relativeTime } from "./format.js";
6
8
  import { familySummary, stoppedBadge, waitingBadge } from "./sessions_list.js";
9
+ import { html, nothing, render } from "./vendor/lit-html.js";
10
+ import { keepFocus } from "./keep_focus.js";
7
11
 
8
12
  // The chip in a parent's meta row: "▸ 3 delegates · ● 1 live · 1 waiting"
9
13
  // (a waiting one in the attention colour), its tooltip the delegates by
10
- // state. "" for a family with no members.
14
+ // state. nothing for a family with no members.
11
15
  // @param open whether the list is open (aria-expanded; the arrow turns)
12
- export function familyChipHtml(f, { open = false } = {}) {
16
+ export function familyChipTemplate(f, { open = false } = {}) {
13
17
  const sum = familySummary(f);
14
- if (!sum) return "";
18
+ if (!sum) return nothing;
15
19
  const waiting = sum.counts.waiting;
16
- const liveHtml = sum.live ? `<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : "";
17
- const waitingHtml = waiting ? ` · <span class="fc-waiting">${waiting} waiting</span>` : "";
18
- return `<button type="button" class="family-chip${waiting ? " waiting" : ""}" aria-expanded="${open ? "true" : "false"}" title="${escapeHtml(sum.title)}"><span class="fc-arrow" aria-hidden="true">▸</span>${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}${liveHtml}${waitingHtml}</button>`;
20
+ const live = sum.live ? html`<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : nothing;
21
+ const waitingT = waiting ? html` · <span class="fc-waiting">${waiting} waiting</span>` : nothing;
22
+ return html`<button type="button" class=${`family-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
23
+ title=${sum.title}><span class="fc-arrow" aria-hidden="true">▸</span>${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}${
24
+ live}${waitingT}</button>`;
19
25
  }
20
26
 
27
+ // A row's status dot (a family's or a chain's): the waiting title over the
28
+ // status when the session waits on the user.
29
+ export function rowDotTemplate(s, waiting) {
30
+ const word = (waiting ? waiting.title : s.status) || "";
31
+ return html`<span class=${`status dot ${s.status || ""}${waiting ? " waiting" : ""}`} title=${word} aria-label=${word}></span>`;
32
+ }
33
+
34
+ // A row's waiting or stopped badge, and the archived one.
35
+ export function rowBadgesTemplate(waiting, stopped) {
36
+ return html`${waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing}${
37
+ stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing}`;
38
+ }
39
+
40
+ export const archivedBadge = html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>`;
41
+
21
42
  // The family's list: one row per member, depth first, indented one step
22
43
  // per depth (--depth): status dot, preview, time, the waiting / stopped
23
44
  // badge, the owner badge, archived. A row's click opens that session.
@@ -25,7 +46,7 @@ export function familyChipHtml(f, { open = false } = {}) {
25
46
  // @param matchIds a search's matching members (.match; the rest .dim), or
26
47
  // null without a search
27
48
  // @param archivable an archive button per row (all sessions, not in select mode)
28
- export function familyRowsHtml(f, { activeId = null, matchIds = null, archivable = false, now = Date.now() } = {}) {
49
+ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archivable = false, now = Date.now() } = {}) {
29
50
  const rows = f.members.map(({ session: s, depth }) => {
30
51
  const cls = ["family-row"];
31
52
  if (activeId && s.id === activeId) cls.push("active");
@@ -36,23 +57,17 @@ export function familyRowsHtml(f, { activeId = null, matchIds = null, archivable
36
57
  const stopped = waiting ? null : stoppedBadge(s);
37
58
  const owner = ownerBadge(s.owner, s.id);
38
59
  const ago = relativeTime(ts, now);
39
- const dotWord = waiting ? waiting.title : s.status;
40
- const parts = [
41
- `<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
42
- `<span class="fr-preview">${escapeHtml(previewForCard(s))}</span>`,
43
- waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
44
- stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
45
- ago ? `<span class="time">${escapeHtml(ago)}</span>` : "",
46
- owner ? `<span class="owner ${owner.kind}" title="${escapeHtml(owner.title)}">${escapeHtml(owner.text)}</span>` : "",
47
- s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
48
- ].join("");
49
- const short = escapeHtml(s.short_id || String(s.id).slice(0, 8));
50
- const arch = !archivable ? "" : s.archived
51
- ? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
52
- : `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Archive: hide from the lists, keep for good" aria-label="Archive session ${short}"></button>`;
53
- return `<div class="${cls.join(" ")}" data-id="${escapeHtml(s.id)}" style="--depth:${depth}" role="listitem"><button type="button" class="fr-open" title="${escapeHtml(`${s.id} · ${s.status || ""}`)}">${parts}</button>${arch}</div>`;
60
+ const short = s.short_id || String(s.id).slice(0, 8);
61
+ const arch = !archivable ? nothing : s.archived
62
+ ? html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Unarchive: back to the lists"} aria-label=${`Unarchive session ${short}`}></button>`
63
+ : html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Archive: hide from the lists, keep for good"} aria-label=${`Archive session ${short}`}></button>`;
64
+ return html`<div class=${cls.join(" ")} data-id=${s.id} style=${`--depth:${depth}`} role=${"listitem"}><button type="button" class="fr-open"
65
+ title=${`${s.id} · ${s.status || ""}`}>${rowDotTemplate(s, waiting)}<span class="fr-preview">${previewForCard(s)}</span>${
66
+ rowBadgesTemplate(waiting, stopped)}${ago ? html`<span class="time">${ago}</span>` : nothing}${
67
+ owner ? html`<span class=${`owner ${owner.kind}`} title=${owner.title}>${owner.text}</span>` : nothing}${
68
+ s.archived ? archivedBadge : nothing}</button>${arch}</div>`;
54
69
  });
55
- return `<div class="family-rows" role="list">${rows.join("")}</div>`;
70
+ return html`<div class="family-rows" role="list">${rows}</div>`;
56
71
  }
57
72
 
58
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
@@ -61,11 +76,13 @@ export const PHONE_MAX_PX = 600;
61
76
  // The strip's family popover: one #familyPop in body (position: fixed, so
62
77
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
63
78
  // it was opened from and as wide as that card (300 px at least, inside the
64
- // window; on a phone, the window less 16 px gutters). The strip draws again on every hub event: refresh() then finds
65
- // the new chip for the same head, rewrites the rows only when they changed,
79
+ // window; on a phone, the window less 16 px gutters). The strip draws again
80
+ // on every hub event: refresh() then finds the new chip for the same head,
81
+ // draws the rows again (lit writes only what changed: a row keeps its
82
+ // nodes, reused by position, so keepFocus keeps the focus on its session)
66
83
  // and closes when the family is gone.
67
- // @param rowsFor (headId) → the rows' html, or null when the family has no
68
- // members (any more)
84
+ // @param rowsFor (headId) → the rows' template, or null when the family
85
+ // has no members (any more)
69
86
  // @param anchorFor (headId) → its chip in the strip, or null
70
87
  // @param onPick (id) a row's click
71
88
  // @param onClose () after it closed (the strip's held order catches up)
@@ -73,7 +90,6 @@ export const PHONE_MAX_PX = 600;
73
90
  export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
74
91
  let el = null;
75
92
  let headId = null;
76
- let html = "";
77
93
 
78
94
  const element = () => {
79
95
  if (el) return el;
@@ -110,10 +126,8 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
110
126
  close();
111
127
  return false;
112
128
  }
113
- if (rows !== html) {
114
- el.innerHTML = rows;
115
- html = rows;
116
- }
129
+ // A row inserted before the focused one: the focus stays on its session.
130
+ keepFocus(el, "data-id", () => render(rows, el));
117
131
  anchor.setAttribute("aria-expanded", "true");
118
132
  place(anchor);
119
133
  return true;
@@ -123,7 +137,6 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
123
137
  if (headId && headId !== id) close();
124
138
  headId = id;
125
139
  element().hidden = false;
126
- html = "";
127
140
  draw();
128
141
  }
129
142
 
@@ -131,9 +144,8 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
131
144
  if (!headId) return;
132
145
  anchorFor(headId)?.setAttribute("aria-expanded", "false");
133
146
  headId = null;
134
- html = "";
135
147
  el.hidden = true;
136
- el.innerHTML = "";
148
+ render(nothing, el);
137
149
  onClose();
138
150
  }
139
151