samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,725 @@
1
+ // The session lists: the strip of the latest sessions over the chat (with
2
+ // its All sessions tile and the hidden strip's pill), the all-sessions view
3
+ // (#allList, its search, count and select mode), their cards, and the
4
+ // family and chain popovers. Split out of app.js.
5
+ //
6
+ // The pure parts (a card's markup, the search match, the lists' orders and
7
+ // counts) are exported for node specs; createListsView draws them and binds
8
+ // the clicks. Everything that changes while the page runs is read through
9
+ // deps when a list draws, never captured at creation.
10
+ //
11
+ // deps:
12
+ // doc, win document and window
13
+ // topStripEl, allListEl the strip and the all-sessions list
14
+ // sessions() every session the page knows (archived ones too)
15
+ // selected() the open session's id, or null
16
+ // scope the page's scope (scope.js; projectName read live)
17
+ // scopeError() why the server refused ?dir, or null
18
+ // eventsLive() whether the hub's events stream is open
19
+ // refresh() fetch the list (no events stream)
20
+ // select(id) open a session
21
+ // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
+ // updateInfoBar(), updateSessionTitle(), showToast(text, action)
23
+ // allViewOpen(), openAllView(), leaveAllView(hash)
24
+ // setStripHidden(hidden)
25
+
26
+ import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
27
+ import { htmlElement } from "./turn_html.js";
28
+ import { html, nothing, render } from "./vendor/lit-html.js";
29
+ import { cardCtxText, ctxBarTip } from "./ctx.js";
30
+ import { allScopeHref, cardFolder } from "./scope.js";
31
+ import { sessionHash } from "./route.js";
32
+ import { stripColumns, stripShowParts } from "./strip.js";
33
+ import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
34
+ import { allViewChains, chainChipHtml, chainRowsHtml, chainTitle, foldChains } from "./chains.js";
35
+ import {
36
+ batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
37
+ listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
38
+ } from "./sessions_list.js";
39
+
40
+ // Whether a session matches the all view's search +q+ (lower case);
41
+ // +title+ is its card's title (cardTitle).
42
+ export function matchesFilter(s, q, title) {
43
+ return title.toLowerCase().includes(q) ||
44
+ (s.first_preview || "").toLowerCase().includes(q) ||
45
+ s.short_id.toLowerCase().includes(q) ||
46
+ s.id.toLowerCase().includes(q) ||
47
+ s.status.toLowerCase().includes(q) ||
48
+ waitingSearchText(s).includes(q);
49
+ }
50
+
51
+ // The strip's cards before the pointer's hold and the cut to what fits:
52
+ // families in the list's order (a family in its first-listed member's
53
+ // place, so a hub upsert doesn't move it), chains folded, waiting ones
54
+ // first. A family is one card. +shown+: the strip's sessions (no archived
55
+ // ones); +all+: every session (a chain's links).
56
+ export function stripOrder(shown, all) {
57
+ return waitingFirst(foldChains(families(shown, { order: "list" }), all));
58
+ }
59
+
60
+ // The all view's families: +all+ (a search and the archived ones see every
61
+ // link as a card, allViewChains), +shown+ (the ones a search keeps, with
62
+ // their matchIds; every family without a search), waiting ones first, and
63
+ // how many delegates +all+ folds in.
64
+ // @param matches (session) → whether it matches the search
65
+ export function allViewFamilies(listed, every, { query = "", includeArchived = false, matches = () => true } = {}) {
66
+ const all = allViewChains(families(listed), every, { query, includeArchived });
67
+ const shown = query ? filterFamilies(all, matches) : all;
68
+ const delegates = all.reduce((n, f) => n + f.members.length, 0);
69
+ return { all, shown: waitingFirst(shown), delegates };
70
+ }
71
+
72
+ // The all view's count: "14 (+7 delegates)", or "3 / 14" under a search.
73
+ export function allCountText(shown, total, delegates) {
74
+ return shown === total
75
+ ? `${total}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
76
+ : `${shown} / ${total}`;
77
+ }
78
+
79
+ // The All sessions tile's count: the strip's sessions, or "N archived"
80
+ // when every session is archived.
81
+ export function stripCountText(shown, total) {
82
+ return shown || total === shown
83
+ ? `${shown} ${shown === 1 ? "session" : "sessions"}`
84
+ : `${total - shown} archived`;
85
+ }
86
+
87
+ // A card: the first message as its title, then status, how long ago, owner
88
+ // and memories. The id is in the tooltip (and the search) only. A session
89
+ // that waits on the user (a question, an approval, a card with actions)
90
+ // wears the warning colour and says what for.
91
+ // deletable / archivable: the all-sessions view's cards get a delete and an
92
+ // archive (unarchive, on an archived card) button. selectable: select mode's
93
+ // checkbox instead (picked: checked), and skipReason: why a batch skipped it.
94
+ // family: the family the session heads (sessions_list.js families): with
95
+ // members, its chip (which opens their list), and the card waits when any
96
+ // member does; with rows (all sessions) an open family lists its members
97
+ // inline, the strip's opens in a popover; matchIds: a search's matching
98
+ // members. A family in a session chain (chains.js foldChains) carries its
99
+ // `day N` chip, which opens the other links in a popover.
100
+ // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
101
+ // open session), chainOpen (its chain's popover is open), parent (the
102
+ // delegating session, or null).
103
+ export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
104
+ const fam = family && family.members.length ? family : null;
105
+ const ts = s.updated_at || s.created_at || "";
106
+ const tip = `${s.id} · updated ${ts} · ${s.status}`;
107
+ const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
108
+ const ago = relativeTime(ts);
109
+ const time = ago ? html`<span class="time" title=${new Date(ts).toLocaleString()}>${ago}</span>` : nothing;
110
+ const folder = cardFolder(s.working_directory, projectName);
111
+ const folderT = folder ? html`<span class="folder" title=${s.working_directory}>${folder}</span>` : nothing;
112
+ const archived = s.archived ? html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : nothing;
113
+ const ctx = cardCtxText(s.ctx_pct);
114
+ const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct })}>${ctx}</span>` : nothing;
115
+ const waiting = waitingBadge(s);
116
+ const waitingT = waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing;
117
+ // A turn a hook stopped wears its own badge, unless the session waits on
118
+ // the user (the waiting badge wins: the stop is behind the need).
119
+ const stopped = waiting ? null : stoppedBadge(s);
120
+ const stoppedT = stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing;
121
+ const dotWord = waiting ? waiting.title : s.status;
122
+ const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
123
+ const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
124
+ const familyWaiting = !!family && familyWaits(family);
125
+ // The family and chain chips and rows are families_view.js's and
126
+ // chains.js's string builders (not lit yet): one element each, as nodes.
127
+ const chain = family?.chain ? htmlElement(chainChipHtml(family, { open: chainOpen })) : nothing;
128
+ const kids = fam ? htmlElement(familyChipHtml(fam, { open })) : nothing;
129
+ const famRows = fam && rows && open ? htmlElement(familyRowsHtml(fam, { activeId, matchIds, archivable: !selectable })) : nothing;
130
+ // Its delegates and its chain's other links: the card is .contains-active while one is open.
131
+ const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
132
+ ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
133
+ const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}`;
134
+ const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
135
+ return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
136
+ }
137
+
138
+ function cardToolsTemplate(s, { deletable, archivable }) {
139
+ if (!deletable && !archivable) return nothing;
140
+ const short = s.short_id;
141
+ const arch = !archivable ? nothing : s.archived
142
+ ? html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Unarchive: back to the lists"} aria-label=${`Unarchive session ${short}`}></button>`
143
+ : 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>`;
144
+ const del = deletable ? html`<button type="button" class="card-del" data-del=${s.id} title=${"Delete session"} aria-label=${`Delete session ${short}`}>\u2715</button>` : nothing;
145
+ // title is bound, as data-arch and aria-label are, so the attributes keep
146
+ // their order (lit sets bound ones after the static ones).
147
+ return html`<div class="card-tools">${arch}${del}</div>`;
148
+ }
149
+
150
+ // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
151
+ function parentChipTemplate(s, parent) {
152
+ const chip = delegatedBy(s.parent_id, parent);
153
+ return chip ? html`<span class="parent" title=${chip.title}>${chip.text}</span>` : nothing;
154
+ }
155
+
156
+ function ownerBadgeTemplate(s) {
157
+ const badge = ownerBadge(s.owner, s.id);
158
+ return badge ? html`<span class=${`owner ${badge.kind}`} title=${badge.title}>${badge.text}</span>` : nothing;
159
+ }
160
+
161
+ // The strip's end: the All sessions tile (with its count) and hide ▴.
162
+ export function stripTileTemplate(count) {
163
+ return html`<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
164
+ }
165
+
166
+ // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
167
+ // (strip.js stripShowParts).
168
+ export function stripPillTemplate({ count, live, waiting }) {
169
+ const liveT = live ? html` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
170
+ const waitingT = waiting ? html` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
171
+ return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${count}${liveT}${waitingT}`;
172
+ }
173
+
174
+ // Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
175
+ // node of the last draw is kept (the cards' clicks are bound per draw, and
176
+ // a card's checked box or marks never carry over to another session's).
177
+ function draw(el, template) {
178
+ const frag = el.ownerDocument.createDocumentFragment();
179
+ render(template, frag);
180
+ el.replaceChildren(frag);
181
+ }
182
+
183
+ export function createListsView(deps) {
184
+ const {
185
+ doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
186
+ confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
187
+ allViewOpen, openAllView, leaveAllView, setStripHidden,
188
+ } = deps;
189
+ const $ = (s) => doc.querySelector(s);
190
+
191
+ // The strip, its count and the hidden strip's pill leave archived sessions
192
+ // out; the all view shows them with "include archived". sessions() keeps
193
+ // them all (parent chips, the open session).
194
+ function shownSessions() {
195
+ return listedSessions(sessions(), false);
196
+ }
197
+
198
+ function allViewSessions() {
199
+ return listedSessions(sessions(), $("#includeArchived").checked);
200
+ }
201
+
202
+ function filterQuery() {
203
+ return $("#filter").value.trim().toLowerCase();
204
+ }
205
+
206
+ // The page's sessions by id, built again only when the list changed.
207
+ let byIdOf = { list: null, map: new Map() };
208
+ function sessionsById() {
209
+ const all = sessions();
210
+ if (byIdOf.list !== all) byIdOf = { list: all, map: new Map(all.map((s) => [s.id, s])) };
211
+ return byIdOf.map;
212
+ }
213
+
214
+ // A card's title: its preview, or its chain's first link's for a link
215
+ // titled with the opener (chains.js chainTitle; display only).
216
+ function cardTitle(s) {
217
+ return chainTitle(s, sessionsById());
218
+ }
219
+
220
+ function cardFor(s, opts) {
221
+ return cardTemplate(s, opts, {
222
+ title: cardTitle(s),
223
+ projectName: scope.projectName,
224
+ activeId: selected(),
225
+ chainOpen: !!opts.family?.chain && chainOpenHead() === s.id,
226
+ parent: sessions().find((x) => x.id === s.parent_id) || null,
227
+ });
228
+ }
229
+
230
+ // The strip's card count follows the window: more cards on wide screens
231
+ // (each capped at 440 px) instead of 3 stretched ones. main's 16 px padding
232
+ // sits on both sides; measuring main works while the strip is hidden too.
233
+ let stripCols = 3;
234
+ function fitStripColumns() {
235
+ stripCols = stripColumns(topStripEl.parentElement.clientWidth - 32);
236
+ topStripEl.style.setProperty("--strip-cols", stripCols);
237
+ }
238
+
239
+ // The order a list draws its cards in: +next+, what waits on the user
240
+ // first (waitingFirst), back to its place once answered. Never under the
241
+ // pointer, though: while it is over the list the order last drawn holds,
242
+ // and its leaving draws the list again. A hidden list holds nothing: the
243
+ // strip keeps :hover for a moment after a click on its All sessions tile
244
+ // hid it, and an update held then brought it back from the all view in an
245
+ // order nobody saw (a waiting family behind). +idOf+ names a card: a
246
+ // session's id, or familyId (a card per family: its head's id). drawnOrder
247
+ // is the cards drawn, which select mode picks from.
248
+ const drawnOrder = new Map(); // list element → card ids in the order last drawn
249
+ const heldLists = new Set(); // lists drawn in a held order
250
+ const canHover = win.matchMedia?.("(hover: hover)").matches ?? false;
251
+ function listOrder(el, next, idOf = (s) => s.id) {
252
+ // The strip's popover holds it too (the pointer leaves the strip for it,
253
+ // and a touch screen has no hover at all).
254
+ const holding = el.getClientRects().length > 0
255
+ && ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
256
+ const held = holding ? drawnOrder.get(el) : null;
257
+ const ordered = held ? heldOrder(held, next, idOf) : next;
258
+ if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
259
+ else heldLists.delete(el);
260
+ drawnOrder.set(el, ordered.map(idOf));
261
+ return ordered;
262
+ }
263
+
264
+ // The strip's cards: stripOrder, held under the pointer, as many as fit.
265
+ function stripCards() {
266
+ return listOrder(topStripEl, stripOrder(shownSessions(), sessions()), familyId).slice(0, stripCols);
267
+ }
268
+
269
+ // The strip's family popover (families_view.js): the open family's rows,
270
+ // under its chip.
271
+ let stripFamilies = new Map(); // head id → family, as the strip last drew it
272
+ const familyPop = createFamilyPop({
273
+ doc,
274
+ win,
275
+ rowsFor: (id) => {
276
+ const f = stripFamilies.get(id);
277
+ return f && f.members.length ? familyRowsHtml(f, { activeId: selected() }) : null;
278
+ },
279
+ anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
280
+ onPick: (id) => {
281
+ familyPop.close();
282
+ select(id);
283
+ },
284
+ // A strip order held while it was open catches up (unless the pointer is
285
+ // back over the strip: its leaving does it then).
286
+ onClose: () => {
287
+ if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
288
+ },
289
+ });
290
+ win.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
291
+ doc.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
292
+ doc.addEventListener("scroll", () => familyPop.reposition(), true);
293
+
294
+ // A chain's popover (chains.js): the other links of the chain a card's
295
+ // `day N` chip stands for, under the chip, in the strip or all sessions
296
+ // (the list it was opened from: chainFrom). A row opens that link.
297
+ const chainFamilies = new Map(); // head id → family, as the lists last drew it
298
+ let chainFrom = null; // the list element the open popover's chip is in
299
+ const chainPop = createFamilyPop({
300
+ id: "chainPop",
301
+ doc,
302
+ win,
303
+ rowsFor: (id) => {
304
+ const f = chainFamilies.get(id);
305
+ return f?.chain ? chainRowsHtml(f, { activeId: selected() }) : null;
306
+ },
307
+ anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
308
+ onPick: (id) => {
309
+ chainPop.close();
310
+ if (allViewOpen()) leaveAllView(sessionHash(id));
311
+ select(id);
312
+ },
313
+ // chainFrom stays (Esc focuses the chip again after the close); only an
314
+ // open popover holds a list (chainPopIn).
315
+ onClose: () => {
316
+ if (chainFrom === topStripEl && heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
317
+ },
318
+ });
319
+ function chainOpenHead() {
320
+ return chainPop.openHead();
321
+ }
322
+ function chainPopIn(el) {
323
+ return chainPop.isOpen() && chainFrom === el;
324
+ }
325
+ function toggleChainPop(from, id, e) {
326
+ if (chainPop.openHead() === id && chainFrom === from) {
327
+ chainPop.close();
328
+ return;
329
+ }
330
+ chainPop.close();
331
+ chainFrom = from;
332
+ chainPop.open(id);
333
+ // From the keyboard the focus goes into the popover; Esc brings it back.
334
+ if (e?.detail === 0 && chainPop.isOpen()) $("#chainPop .fr-open")?.focus();
335
+ }
336
+ win.addEventListener("keydown", (e) => chainPop.onKeydown(e), true);
337
+ doc.addEventListener("pointerdown", (e) => chainPop.onPointerDown(e), true);
338
+ doc.addEventListener("scroll", () => chainPop.reposition(), true);
339
+
340
+ function render() {
341
+ fitStripColumns();
342
+ renderTop(stripCards());
343
+ if (allViewOpen()) renderAll();
344
+ }
345
+
346
+ let stripResizeTimer = null;
347
+ win.addEventListener("resize", () => {
348
+ clearTimeout(stripResizeTimer);
349
+ stripResizeTimer = setTimeout(() => {
350
+ const before = stripCols;
351
+ fitStripColumns();
352
+ if (stripCols !== before && sessions().length) renderTop(stripCards());
353
+ familyPop.reposition();
354
+ chainPop.reposition();
355
+ }, 150);
356
+ });
357
+
358
+ // onPick: a card's click (open it, or pick it in select mode); onFamily:
359
+ // a family chip's (toggle its list); onRow: a family row's (open that
360
+ // delegate).
361
+ function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
362
+ container.querySelectorAll(".card[data-id]").forEach((el) => {
363
+ el.addEventListener("click", (e) => {
364
+ const chainChip = e.target.closest(".chain-chip");
365
+ if (chainChip) {
366
+ e.stopPropagation();
367
+ toggleChainPop(container, el.dataset.id, e);
368
+ return;
369
+ }
370
+ const chip = e.target.closest(".family-chip");
371
+ if (chip) {
372
+ e.stopPropagation();
373
+ onFamily(el.dataset.id, chip, e);
374
+ return;
375
+ }
376
+ const del = e.target.closest(".card-del");
377
+ if (del) {
378
+ e.stopPropagation();
379
+ confirmDelete(del.dataset.del);
380
+ return;
381
+ }
382
+ // Archive from the list: the view stays open, the toast has Undo.
383
+ const arch = e.target.closest(".card-arch");
384
+ if (arch) {
385
+ e.stopPropagation();
386
+ const id = arch.dataset.arch;
387
+ setArchived(id, !sessions().find((x) => x.id === id)?.archived);
388
+ return;
389
+ }
390
+ const row = e.target.closest(".family-row[data-id]");
391
+ if (row) {
392
+ e.stopPropagation();
393
+ onRow(row.dataset.id, e);
394
+ return;
395
+ }
396
+ onPick(el.dataset.id, e);
397
+ });
398
+ });
399
+ paintActive();
400
+ }
401
+
402
+ // The open session's marks: its card (.active), its row in a family's list
403
+ // (in a card or the strip's popover), and a quieter .contains-active on the
404
+ // card of the family it is folded into.
405
+ function paintActive() {
406
+ const id = selected();
407
+ doc.querySelectorAll(".card[data-id]").forEach((el) => {
408
+ el.classList.toggle("active", !!id && el.dataset.id === id);
409
+ el.classList.toggle("contains-active", !!id && (el.dataset.members || "").split(" ").includes(id));
410
+ });
411
+ doc.querySelectorAll(".family-row[data-id]").forEach((el) => {
412
+ el.classList.toggle("active", !!id && el.dataset.id === id);
413
+ });
414
+ // The open session's card may have come into the strip or left it.
415
+ updateSessionTitle();
416
+ }
417
+
418
+ // The latest sessions (as many as fit, a family one card), then the way
419
+ // to all of them.
420
+ function renderTop(cards) {
421
+ topStripEl.classList.remove("loading");
422
+ renderStripShow();
423
+ stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
424
+ noteChainFamilies(cards);
425
+ const refused = scopeError();
426
+ if (refused) {
427
+ draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
428
+ familyPop.close();
429
+ chainPop.close();
430
+ return;
431
+ }
432
+ const all = sessions();
433
+ // Only archived sessions: no cards, but the tile stays, the way to them.
434
+ if (!all.length) {
435
+ topStripEl.replaceChildren();
436
+ familyPop.close();
437
+ chainPop.close();
438
+ return;
439
+ }
440
+ const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length));
441
+ draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id }))}${tile}`);
442
+ bindCards(topStripEl, select, {
443
+ onFamily: (id, _chip, e) => {
444
+ familyPop.toggle(id);
445
+ // From the keyboard (Enter / Space: no pointer, detail 0) the focus
446
+ // goes into the popover, which sits at the end of body; Esc brings it back.
447
+ if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
448
+ },
449
+ });
450
+ $("#allTile").addEventListener("click", openAllView);
451
+ $("#stripHide").addEventListener("click", () => setStripHidden(true));
452
+ familyPop.refresh();
453
+ if (chainFrom === topStripEl) chainPop.refresh();
454
+ }
455
+
456
+ // The chain families a list drew: the popover's rows come from them.
457
+ function noteChainFamilies(fams) {
458
+ for (const f of fams) {
459
+ if (f.chain) chainFamilies.set(f.head.id, f);
460
+ else chainFamilies.delete(f.head.id);
461
+ }
462
+ }
463
+
464
+ // The hidden strip's pill, following the list: its sessions, the number
465
+ // the All sessions tile shows, and the live and waiting ones (a waiting
466
+ // delegate too).
467
+ function renderStripShow() {
468
+ draw($("#stripShow"), stripPillTemplate(stripShowParts(shownSessions())));
469
+ }
470
+
471
+ // A parent's card holds its delegates (families, by the newest member: a
472
+ // hub snapshot hands the list back in the server's order), folded unless
473
+ // one waits on the user, a search matched one or the user opened it. The
474
+ // search runs over the families, so a matching delegate brings its parent's
475
+ // card along. Select mode picks cards (a family's rows have no checkbox:
476
+ // archiving the parent takes its delegates).
477
+ const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
478
+ const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
479
+ const drawnOpen = new Map(); // head id → open as last drawn in all sessions
480
+
481
+ function renderAll() {
482
+ const listed = allViewSessions();
483
+ const q = filterQuery();
484
+ const { all, shown, delegates } = allViewFamilies(listed, sessions(), {
485
+ query: q, includeArchived: $("#includeArchived").checked, matches: (s) => matchesFilter(s, q, cardTitle(s)),
486
+ });
487
+ noteChainFamilies(all);
488
+ const fams = listOrder(allListEl, shown, familyId);
489
+ $("#count").textContent = allCountText(fams.length, all.length, delegates);
490
+ updateSelectBar();
491
+ if (!fams.length) {
492
+ drawnOpen.clear();
493
+ draw(allListEl, html`<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`);
494
+ if (chainFrom === allListEl) chainPop.refresh();
495
+ return;
496
+ }
497
+ // A hub event redraws the list under a checkbox or a chip the keyboard is on.
498
+ const focusSel = [".card-pick", ".family-chip", ".chain-chip"].find((sel) => doc.activeElement?.matches?.(`#allList ${sel}`));
499
+ const focused = focusSel ? { id: doc.activeElement.closest(".card")?.dataset.id, sel: focusSel } : null;
500
+ const opts = (s) => (selecting.on
501
+ ? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
502
+ : { deletable: true, archivable: true });
503
+ drawnOpen.clear();
504
+ draw(allListEl, html`${fams.map((f) => {
505
+ const matched = !!f.matchIds?.size;
506
+ const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
507
+ drawnOpen.set(f.head.id, open);
508
+ return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
509
+ })}`);
510
+ bindCards(allListEl, (id, e) => {
511
+ if (selecting.on) {
512
+ togglePick(id, e);
513
+ return;
514
+ }
515
+ leaveAllView(sessionHash(id));
516
+ select(id);
517
+ }, {
518
+ onFamily: (headId) => {
519
+ const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
520
+ (matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
521
+ renderAll();
522
+ },
523
+ // In select mode a row does nothing: rows aren't picked.
524
+ onRow: (id) => {
525
+ if (selecting.on) return;
526
+ leaveAllView(sessionHash(id));
527
+ select(id);
528
+ },
529
+ });
530
+ if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
531
+ if (chainFrom === allListEl) chainPop.refresh();
532
+ }
533
+
534
+ // ── Select mode (all sessions) ─────────────────────────────────────────────
535
+ // "Select" turns the cards into picks: a click (or Shift-click, a range in
536
+ // the drawn order) toggles one instead of opening it, and the bar acts on
537
+ // the picked ones that are shown. The picks live here, not in the DOM:
538
+ // renderAll draws the list again on every hub event.
539
+ const selecting = {
540
+ on: false,
541
+ picked: new Set(),
542
+ last: null, // the last toggled id: a Shift-click's range starts there
543
+ skipReasons: new Map(), // id → why the last batch skipped it
544
+ running: null, // {done, total, archive} while a batch runs
545
+ };
546
+
547
+ function enterSelect() {
548
+ selecting.on = true;
549
+ renderAll();
550
+ }
551
+
552
+ function leaveSelect() {
553
+ selecting.on = false;
554
+ selecting.picked.clear();
555
+ selecting.last = null;
556
+ selecting.skipReasons.clear();
557
+ if (allViewOpen()) renderAll();
558
+ else updateSelectBar();
559
+ }
560
+
561
+ function togglePick(id, e) {
562
+ if (selecting.running) return; // the grid reflows while a batch runs
563
+ const order = drawnOrder.get(allListEl) || [];
564
+ const on = !selecting.picked.has(id);
565
+ const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
566
+ for (const x of ids) {
567
+ if (on) selecting.picked.add(x);
568
+ else selecting.picked.delete(x);
569
+ paintPick(x);
570
+ }
571
+ selecting.last = id;
572
+ updateSelectBar();
573
+ }
574
+
575
+ // A card's pick state in place (no redraw: the focus stays put).
576
+ function paintPick(id) {
577
+ const el = allListEl.querySelector(`.card[data-id="${id}"]`);
578
+ if (!el) return;
579
+ const on = selecting.picked.has(id);
580
+ el.classList.toggle("picked", on);
581
+ const box = el.querySelector(".card-pick");
582
+ if (box) box.checked = on;
583
+ }
584
+
585
+ // The bar's numbers: picks of sessions still in the list (a snapshot can
586
+ // drop one with no session_gone), and the shown ones a batch would act on.
587
+ function selectCounts() {
588
+ const byId = new Map(sessions().map((s) => [s.id, s]));
589
+ const shown = drawnOrder.get(allListEl) || [];
590
+ const shownSet = new Set(shown);
591
+ const live = [...selecting.picked].filter((id) => byId.has(id));
592
+ return {
593
+ picked: live.length,
594
+ hidden: live.filter((id) => !shownSet.has(id)).length,
595
+ archive: batchTargets(selecting.picked, shown, true, byId),
596
+ unarchive: batchTargets(selecting.picked, shown, false, byId),
597
+ };
598
+ }
599
+
600
+ function updateSelectBar() {
601
+ $("#allView").classList.toggle("selecting", selecting.on);
602
+ $("#selectBtn").hidden = selecting.on;
603
+ const bar = $("#selectBar");
604
+ bar.hidden = !selecting.on;
605
+ if (!selecting.on) return;
606
+ const c = selectCounts();
607
+ const run = selecting.running;
608
+ $("#selectCount").textContent = run
609
+ ? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}…`
610
+ : `${c.picked} selected${c.hidden ? ` · ${c.hidden} not shown` : ""}`;
611
+ $("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
612
+ $("#batchArchiveBtn").disabled = !!run || !c.archive.length;
613
+ $("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
614
+ $("#batchUnarchiveBtn").hidden = !c.unarchive.length;
615
+ $("#batchUnarchiveBtn").disabled = !!run;
616
+ $("#selectAllBtn").disabled = !!run;
617
+ $("#selectDoneBtn").disabled = !!run;
618
+ }
619
+
620
+ // The batch: the picked shown ones, one request at a time in the drawn
621
+ // order (parents before their delegates), through the info bar's
622
+ // archiveOne. A refused one stays picked with the server's reason on its
623
+ // card; the end toast's Undo runs the opposite batch on the ones that went.
624
+ async function runSelectBatch(archive) {
625
+ if (selecting.running) return;
626
+ const byId = new Map(sessions().map((s) => [s.id, s]));
627
+ const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
628
+ if (!ids.length) return;
629
+ selecting.skipReasons.clear();
630
+ const outcome = await archiveBatch(ids, archive);
631
+ if (!outcome) return;
632
+ if (!allViewOpen()) leaveSelect(); // left the view while it ran
633
+ else renderAll();
634
+ showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
635
+ label: "Undo",
636
+ run: async () => {
637
+ const undone = await archiveBatch(outcome.done, !archive);
638
+ if (!undone) return;
639
+ if (allViewOpen()) renderAll();
640
+ showToast(batchToastText(batchCounts(undone, !archive)));
641
+ },
642
+ } : null);
643
+ }
644
+
645
+ // runBatch over archiveOne, with the bar's progress; the picks and skip
646
+ // reasons follow the outcome. null when a batch already runs.
647
+ async function archiveBatch(ids, archive) {
648
+ if (selecting.running) return null;
649
+ selecting.running = { done: 0, total: ids.length, archive };
650
+ updateSelectBar();
651
+ try {
652
+ const outcome = await runBatch(ids, async (id) => {
653
+ const answer = await archiveOne(id, archive);
654
+ if (answer.ok) {
655
+ render();
656
+ updateInfoBar();
657
+ }
658
+ return answer;
659
+ }, (done) => {
660
+ selecting.running.done = done;
661
+ updateSelectBar();
662
+ });
663
+ for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
664
+ selecting.picked.delete(id);
665
+ selecting.skipReasons.delete(id);
666
+ }
667
+ for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
668
+ return outcome;
669
+ } finally {
670
+ selecting.running = null;
671
+ updateSelectBar();
672
+ if (!eventsLive()) refresh();
673
+ }
674
+ }
675
+
676
+ $("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
677
+ $("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
678
+ $("#selectBtn").addEventListener("click", enterSelect);
679
+ $("#selectDoneBtn").addEventListener("click", leaveSelect);
680
+ $("#selectAllBtn").addEventListener("click", () => {
681
+ for (const id of drawnOrder.get(allListEl) || []) {
682
+ selecting.picked.add(id);
683
+ paintPick(id);
684
+ }
685
+ updateSelectBar();
686
+ });
687
+ // A Shift-click picks a range: keep the browser from selecting the text
688
+ // between the two cards.
689
+ allListEl.addEventListener("mousedown", (e) => {
690
+ if (selecting.on && e.shiftKey) e.preventDefault();
691
+ });
692
+
693
+ $("#includeArchived").addEventListener("change", () => {
694
+ if (allViewOpen()) renderAll();
695
+ });
696
+ $("#filter").addEventListener("input", () => {
697
+ searchToggles.clear();
698
+ renderAll();
699
+ });
700
+ // A list whose order was held under the pointer (listOrder) catches up.
701
+ topStripEl.addEventListener("pointerleave", () => {
702
+ // Not while its family popover is open: the pointer went there (close() catches up).
703
+ if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
704
+ });
705
+ allListEl.addEventListener("pointerleave", () => {
706
+ if (heldLists.has(allListEl) && allViewOpen()) renderAll();
707
+ });
708
+
709
+ return {
710
+ render,
711
+ renderAll,
712
+ paintActive,
713
+ cardTitle,
714
+ sessionsById,
715
+ selecting,
716
+ leaveSelect,
717
+ // A session the hub says is gone: no pick, no skip reason.
718
+ forget(id) {
719
+ selecting.picked.delete(id);
720
+ selecting.skipReasons.delete(id);
721
+ },
722
+ familyPop,
723
+ chainPop,
724
+ };
725
+ }