samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -47,13 +47,14 @@ import { createSessionStore } from "./session_store.js";
47
47
  import { createFeatureGates } from "./feature_gates.js";
48
48
  import { stillOn } from "./latest.js";
49
49
  import { normalize, cantText } from "./format.js";
50
- import { turnHistoryNodes } from "./turn_block.js";
51
50
  import { elementOf } from "./turn_html.js";
52
51
  import { createBubbles } from "./bubbles.js";
53
52
  import { createCardsPlace } from "./cards_place.js";
54
53
  import { createStageView } from "./stage_view.js";
55
54
  import { createReloadCheck } from "./reload_check.js";
55
+ import { placeCardsOffscreen } from "./cards_offscreen.js";
56
56
  import { createWorkingSet } from "./working_set_view.js";
57
+ import { createWorkingSetPreview } from "./ws_reader.js";
57
58
  import { createObjectives } from "./objectives_wiring.js";
58
59
  import { trackWorkerUnavailable } from "./objectives_track.js";
59
60
  import { createGapClock } from "./time_gaps.js";
@@ -124,7 +125,11 @@ import { createSendTurn } from "./send_turn.js";
124
125
  import { createSessionSync } from "./session_sync.js";
125
126
  import { createSessionView } from "./session_view.js";
126
127
  import { createMarkdownTip } from "./markdown_tip.js";
128
+ import { IMAGES_NOT_HERE } from "./images.js";
127
129
  import { withoutStaleParams } from "./stale_params.js";
130
+ import { createViewer } from "./viewer.js";
131
+ import { createParticipantMain } from "./participant_main.js";
132
+ import { PARTICIPANT_REVISE_UNAVAILABLE, participantMayRevise, participantReviseUnavailable } from "./participant_revise.js";
128
133
 
129
134
  // A link's #code= (behind an auth proxy): traded for the cookie before
130
135
  // anything else runs; the page reloads without it.
@@ -141,6 +146,8 @@ const authLost = createAuthLost();
141
146
  onAuthLost(authLost);
142
147
 
143
148
  const $ = (s) => document.querySelector(s);
149
+ // Who looks and what they may do (viewer.js): every owner-only control and call asks it.
150
+ const viewer = createViewer(document.body);
144
151
  const topStripEl = $("#topStrip");
145
152
  const allListEl = $("#allList");
146
153
  const historyEl = $("#history");
@@ -150,36 +157,38 @@ const composerEl = $("#composer");
150
157
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
151
158
  const sessionTitleEl = $("#sessionTitle");
152
159
  const sessionTitleRowEl = $("#sessionTitleRow");
153
- const titleRenameBtn = $("#titleRenameBtn");
160
+ const titleRenameBtn = viewer.control($("#titleRenameBtn"), "rename");
154
161
  let tabBase = null;
155
162
  let tabShown = null;
156
163
  const infoTextEl = $("#infoText");
157
164
  // The open session's attached context, in its own row of the session bar
158
165
  // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
159
- const contextChips = createContextChips({
166
+ const contextChips = viewer.can("context") ? createContextChips({
160
167
  el: $("#contextChips"),
161
168
  api: { list: listContext, show: getContext, detach: detachContext },
162
169
  toast: (text) => showToast(text),
163
- });
170
+ }) : null;
164
171
  // The open session's ⚙ chip in the info bar, and its panel (thinking,
165
172
  // reasoning history, LLM context): a pick runs its command in the session's
166
173
  // worker.
167
- const settingsControl = createSettingsControl({
174
+ const settingsControl = viewer.can("session_controls") ? createSettingsControl({
168
175
  el: infoTextEl,
169
176
  summaries: () => (store.selected ? { thinking: store.current.thinking, reasoningHistory: store.current.reasoningHistory,
170
177
  llmContext: store.current.llmContext } : null),
171
178
  session: () => store.selected,
172
179
  running: () => liveTurn.running,
173
180
  run: (line) => runSessionCommand(line),
174
- });
175
- // ⋯ on the info bar, and its menu: the chips and buttons the row has no room for.
176
- const moreSheet = createMoreSheet({ el: $("#infoBar"), button: $("#infoMoreBtn"), session: () => store.selected, hash: sessionHash });
181
+ }) : null;
182
+ // ⋯ on the info bar, and its menu: the chips and buttons the row has no room for; and the bar's other buttons.
183
+ const [continueBtn, archiveBtn, stopBtn, deleteBtn, moreBtn] = viewer.controls(
184
+ ["#infoContinueBtn", "#infoArchiveBtn", "#infoStopBtn", "#infoDeleteBtn", "#infoMoreBtn"].map($), "session_controls");
185
+ const moreSheet = moreBtn ? createMoreSheet({ el: $("#infoBar"), button: moreBtn, session: () => store.selected, hash: sessionHash }) : null;
177
186
  // The bar's fold (bar_fold.js): one row, what doesn't fit folded into ⋯. A
178
187
  // resize, the fonts or a zoom refolds it by itself: ⋯ follows on the redraw.
179
188
  const barFold = createBarFold({ barEl: $("#infoBar"), keysFor: barItemKeys, onChange: () => store.touch("infoBar") });
180
189
  // The open session's participants chip in the info bar, and its panel
181
190
  // (Invite, revoke, caps, the participant view).
182
- const participantsControl = createParticipantsControl({
191
+ const participantsControl = viewer.can("participants_admin") ? createParticipantsControl({
183
192
  el: infoTextEl,
184
193
  session: () => store.selected,
185
194
  api: { list: listParticipants, invite: inviteParticipant, revoke: revokeParticipant, cap: setParticipantCap },
@@ -195,7 +204,7 @@ const participantsControl = createParticipantsControl({
195
204
  },
196
205
  onChange: () => store.touch("infoBar"),
197
206
  toast: (text, action, options) => showToast(text, action, options),
198
- });
207
+ }) : null;
199
208
  // The start page's model picker, its ⚙ panel and 🧰 list with the override chips
200
209
  // (start_page.js); a fresh chain link's picker too.
201
210
  const startPage = createStartPage({
@@ -226,15 +235,14 @@ const markdownTip = createMarkdownTip($("#markdownTip"));
226
235
  // What the page renders that a notice may stand in for (the server's
227
236
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
228
237
  // drawn (shownElsewhere). An older server sends none: every notice shows.
229
- // And what this viewer can do (editor: open a path in this machine's
230
- // editor; per request), with the editor's URL template (editor_url) next
231
- // to it: refs.js's actions read both (pageViewer).
238
+ // And whether this request's viewer can open a path in this machine's
239
+ // editor (per request), with the editor's URL template (editor_url) next
240
+ // to it: refs.js's actions read both (refEditor).
232
241
  let pageCapabilities = {};
233
242
  let pageEditorUrl = null;
234
243
  const promptEl = $("#prompt");
235
244
  const actionBtn = $("#actionBtn");
236
- const attachBtn = $("#attachBtn");
237
- const attachInput = $("#attachInput");
245
+ const [attachBtn, attachInput] = viewer.controls([$("#attachBtn"), $("#attachInput")], "images");
238
246
  const composeHintEl = $("#composeHint");
239
247
 
240
248
  // The open session (store.selected), its fields (store.current: session_state.js;
@@ -244,6 +252,12 @@ const composeHintEl = $("#composeHint");
244
252
  // the page's first draw); an input the store doesn't hold calls
245
253
  // store.touch(name).
246
254
  const store = createSessionStore({ initial: emptySessionState() });
255
+ // An invited person's link: their session, name, bottom row and limits (participant_main.js).
256
+ const participant = viewer.kind() === "participant" ? createParticipantMain({ body: document.body, topBar: $("#topBar"), infoBar: $("#infoBar"),
257
+ composer: $("#composer"), store, running: () => liveTurn.running, stage: { redraw: () => turnDom.redraw?.() }, read: (id) => getSession(id),
258
+ onClosed: () => reviseStacks?.decorate() }) : null;
259
+ // Revise's worker gate and its words: a participant's own (participant_revise.js).
260
+ const [reviseGate, reviseOffText] = participant ? [participantReviseUnavailable, PARTICIPANT_REVISE_UNAVAILABLE] : [reviseWorkerUnavailable, undefined];
247
261
  // The page actions the open session's worker must support (feature_gates.js),
248
262
  // the store's featureGates redraw: a gate's redraw runs when its answer
249
263
  // flips (a restart brings the feature, the worker goes). forgetSession and
@@ -258,7 +272,7 @@ const featureGates = createFeatureGates([
258
272
  // Revise on an answer and the composer's Revising chip (revise_stack.js).
259
273
  {
260
274
  name: "revise",
261
- unavailable: (cur) => reviseWorkerUnavailable(cur.workerFeatures, cur.bridgeUp),
275
+ unavailable: (cur) => reviseGate(cur.workerFeatures, cur.bridgeUp),
262
276
  redraw: () => redrawRevise(),
263
277
  },
264
278
  // The objectives' Where are we? (objectives_track.js).
@@ -274,11 +288,11 @@ const openId = () => store.selected;
274
288
  let pendingReviseAnchor = null;
275
289
  let reviseStacks = null;
276
290
  const reviseChipEl = $("#reviseChip");
277
- const reviseUnavailable = () => reviseWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp);
291
+ const reviseUnavailable = () => reviseGate(store.current.workerFeatures, store.current.bridgeUp);
278
292
  function redrawRevise() {
279
293
  if (reviseChipEl) {
280
294
  render(reviseChipTemplate(pendingReviseAnchor, {
281
- unavailable: reviseUnavailable(),
295
+ unavailable: reviseUnavailable(), text: reviseOffText,
282
296
  onClear: () => { setReviseAnchor(null); focusPrompt(); },
283
297
  }), reviseChipEl);
284
298
  reviseChipEl.hidden = !pendingReviseAnchor;
@@ -314,7 +328,7 @@ let eventsControl = null;
314
328
  const shownTexts = createShownTexts();
315
329
  const markdownPlace = createMarkdownPlace({
316
330
  capabilities: () => pageCapabilities,
317
- renderText,
331
+ renderText: viewer.can("render") ? renderText : null,
318
332
  roots: () => turnRoots(),
319
333
  touchedFiles,
320
334
  currentDir: () => store.current.dir,
@@ -331,14 +345,17 @@ const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown } = markdownP
331
345
  // what each turn brought into context, in a rail in the stage, a footprint
332
346
  // under each finished turn and a "set N" chip in the mem chip's place
333
347
  // (working_set_view.js).
334
- const workingSet = document.body.dataset.workingSet === "1"
348
+ const wsLine = viewer.can("session_controls") ? (line) => (store.selected ? sendCommand(store.selected, line, { clientId }) : null) : null;
349
+ // What chi has of an item (the owner's): the rows' peek and the reader over the stage (ws_reader.js).
350
+ const wsPreview = document.body.dataset.workingSet === "1" && viewer.can("working_set_items")
351
+ ? createWorkingSetPreview({ centerEl: $("#center"), session: () => store.selected, state: () => store.current, sendLine: wsLine }) : null;
352
+ const workingSet = document.body.dataset.workingSet === "1" && viewer.can("working_set")
335
353
  ? createWorkingSet({
336
354
  historyEl, stageRoot: () => turnDom.root, state: () => store.current, session: () => store.selected, infoEl: infoTextEl,
337
- onChange: () => store.touch("infoBar"), getWorkingSet,
338
- // A drop or undrop from the list: the session's command, as typed.
339
- sendLine: (line) => (store.selected ? sendCommand(store.selected, line, { clientId }) : null),
355
+ onChange: () => store.touch("infoBar"), getWorkingSet, sendLine: wsLine, peek: wsPreview?.peek ?? null, onTimeline: wsPreview?.open ?? null,
340
356
  })
341
357
  : null;
358
+ wsPreview?.attach(workingSet);
342
359
  document.body.classList.toggle("working-set", !!workingSet);
343
360
  // The running turn's DOM: the stage pinned above the composer
344
361
  // (stage_view.js), the turn block (turn_block.js) in its cloud.
@@ -349,14 +366,15 @@ const turnDom = createStageView({
349
366
  sessionId: () => store.selected,
350
367
  // The stop-task button on a running task_wait, for a worker that has
351
368
  // the route (its sidecar features).
352
- taskStop: createTaskStopper({ sessionId: () => store.selected, features: () => store.current.workerFeatures }),
369
+ taskStop: viewer.can("session_controls") ? createTaskStopper({ sessionId: () => store.selected, features: () => store.current.workerFeatures }) : null,
353
370
  dockEl: $("#dock"),
354
371
  centerEl: $("#center"),
355
372
  renderMarkdown,
356
373
  linkRefs: linkRefsIn,
357
374
  workingSet,
358
375
  // Stop turn in its status row (disabled until the request settles).
359
- onCancel: () => handleCancel(),
376
+ onCancel: viewer.can("stop_any_turn") || viewer.can("stop_own_turn") ? () => handleCancel() : null,
377
+ canCancel: () => !participant || participant.canCancel(), ownerWait: () => participant?.ownerWait() ?? null,
360
378
  onHandedOff: (handed) => reloadCheck.handedOff(handed),
361
379
  // The quiet signal (stage_stall.js): the stream, what the turn waits on, a joiner's step clock.
362
380
  streamLive: () => sessionSync.streamLive(), timingNote: () => liveTurn.timing.note, serverGen: () => liveTurn.serverGen,
@@ -364,11 +382,13 @@ const turnDom = createStageView({
364
382
  // The opt-in reload check (web.reload_check): a handed-off turn against a
365
383
  // reload of it, the counts to chi web (reload_check.js).
366
384
  const reloadCheck = createReloadCheck({
367
- enabled: () => document.body.dataset.reloadCheck === "1",
385
+ enabled: () => document.body.dataset.reloadCheck === "1" && viewer.can("session_controls"),
368
386
  sessionId: () => store.selected,
369
387
  running: () => liveTurn.running,
370
388
  read: (id) => getSession(id),
371
- render: (data) => turnHistoryNodes(data.messages || [], normalizeTiming(data.timing), historyOpts(data)),
389
+ render: (data) => sessionView.reloadRender(data),
390
+ place: (holder, entries) => placeCardsOffscreen(holder, entries, { capabilities: pageCapabilities, answers: viewer.can("answer_cards") }),
391
+ capabilities: () => pageCapabilities,
372
392
  post: (id, body) => api(`/api/sessions/${id}/reload_check`, { method: "POST", body: JSON.stringify(body) }),
373
393
  });
374
394
  // The history's bottom reserve and the toast's place follow the composer
@@ -388,7 +408,7 @@ function turnRoots() {
388
408
  return [historyEl, turnDom.root];
389
409
  }
390
410
  const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => store.selected });
391
- turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
411
+ if (viewer.can("answer_cards")) turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
392
412
  function findInTurnRoots(selector) {
393
413
  return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
394
414
  }
@@ -425,10 +445,11 @@ const deliveryControl = createDeliveryControl({
425
445
  serverGen: liveTurn.serverGen,
426
446
  canQueue: store.current.workerFeatures.includes("delivery"),
427
447
  model: store.current.model,
428
- participantTurn: !!liveTurn.participantTurn,
448
+ participantTurn: !!liveTurn.participantTurn, viewerTurn: participant?.deliveryTurn() ?? null,
429
449
  };
430
450
  },
431
451
  composerEmpty: () => !composer.hasDraft(),
452
+ history: viewer.can("prompt_history"),
432
453
  send: (delivery) => handleSendTurn({ delivery }),
433
454
  session: () => store.selected,
434
455
  });
@@ -445,6 +466,7 @@ const objectives = createObjectives({
445
466
  const questionCards = createQuestionCards({
446
467
  sessionId: () => store.selected,
447
468
  clientId,
469
+ answers: viewer.can("answer_cards"),
448
470
  place: (card) => appendToHistory(card),
449
471
  isNearBottom: () => isNearBottom(),
450
472
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
@@ -498,9 +520,10 @@ turnRoots().forEach((root) => root.addEventListener("click", cards.onCardAction)
498
520
  // the ones made later come in as getters, and each reaches the other lazily.
499
521
  const sessionView = createSessionView({
500
522
  store, historyEl, getSession,
501
- liveTurn, turnDom, questionCards, shownTexts, objectives, featureGates, markdownTip, workingSet, gapClock,
523
+ liveTurn, turnDom, questionCards, shownTexts, objectives, featureGates, markdownTip, workingSet, wsPreview, gapClock,
502
524
  bubbles, cards, markdown: markdownPlace,
503
525
  capabilities: () => pageCapabilities,
526
+ can: viewer.can,
504
527
  page: {
505
528
  setReviseAnchor, renderInitRow, setMarkdownWarning, setCapabilities, findInTurnRoots, appendToHistory,
506
529
  setTurnRunning, mergeTurnTiming, applySessionPayload, turnEndStatus, replaceSelectedCard,
@@ -510,7 +533,7 @@ const sessionView = createSessionView({
510
533
  commandList: () => commandList, initTasks: () => initTasks, resetTurnView: () => sessionSync.resetTurnView(),
511
534
  },
512
535
  });
513
- const { forgetSession, historyOpts, readTurnEnd, endTurnView } = sessionView;
536
+ const { forgetSession, readTurnEnd, endTurnView } = sessionView;
514
537
  const sessionSync = createSessionSync({
515
538
  store, historyEl, emptyEl, getSession, openStream,
516
539
  liveTurn, turnDom, questionCards, contextChips, participantsControl,
@@ -555,9 +578,10 @@ function sweepFoldsOnce() {
555
578
  // The list by fetching: the fallback when there is no events stream (a
556
579
  // chi web without a hub, or ?dir the server refused: the 400 is read here).
557
580
  async function refresh() {
581
+ if (!viewer.can("sessions")) return;
558
582
  try {
559
583
  store.update({ sessions: await listSessions("updated_at", "desc", { dir: scope.dir }) });
560
- notifications.noticeList(store.sessions);
584
+ notifications?.noticeList(store.sessions);
561
585
  sweepFoldsOnce();
562
586
  } catch (e) {
563
587
  if (e.status !== 400 || !scope.dir) throw e;
@@ -571,6 +595,7 @@ async function refresh() {
571
595
  // (re)connect, then upserts and removals. The selected session's owner and
572
596
  // worker follow its events (a new worker's bridge_up reconnects the stream).
573
597
  function openSessionEvents() {
598
+ if (!viewer.can("sessions")) return;
574
599
  eventsControl = openEvents({
575
600
  // The list redraws the lists and the bar (its children chip, its chain's
576
601
  // ← → links): store.update({sessions}).
@@ -578,19 +603,19 @@ function openSessionEvents() {
578
603
  versionNotices.notice(data);
579
604
  versionNotices.reconnected(data.version);
580
605
  if (data.chi_update) versionNotices.frame(data.chi_update, { snapshot: true });
581
- notifications.noticeSessions("snapshot", data);
606
+ notifications?.noticeSessions("snapshot", data);
582
607
  store.update({ sessions: applySessionEvent(store.sessions, "snapshot", data) });
583
608
  sweepFoldsOnce();
584
609
  const sess = store.sessions.find((s) => s.id === store.selected);
585
610
  if (sess) followSelectedSession(sess);
586
611
  }),
587
612
  session: (data) => store.batch(() => {
588
- notifications.noticeSessions("session", data);
613
+ notifications?.noticeSessions("session", data);
589
614
  store.update({ sessions: applySessionEvent(store.sessions, "session", data) });
590
615
  if (data.session && data.session.id === store.selected) followSelectedSession(data.session);
591
616
  }),
592
617
  session_gone: (data) => {
593
- notifications.noticeSessions("session_gone", data);
618
+ notifications?.noticeSessions("session_gone", data);
594
619
  lists.forget(data.id);
595
620
  store.update({ sessions: applySessionEvent(store.sessions, "session_gone", data) });
596
621
  leaveGoneSession(data.id, "deleted");
@@ -621,17 +646,19 @@ const EVENTS_REOPEN_MS = 3000;
621
646
  // to the same place without ?dir); "all projects" otherwise, with where a
622
647
  // new session starts on the start page.
623
648
  function renderScope() {
624
- const el = $("#scope");
625
- render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash }) }), el);
626
- el.hidden = false;
649
+ const el = viewer.control($("#scope"), "sessions");
650
+ if (el) {
651
+ render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash }) }), el);
652
+ el.hidden = false;
653
+ }
627
654
  // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
628
655
  tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
629
656
  updateSessionTitle();
630
657
  }
631
658
 
632
- createProjectPicker({
633
- el: $("#scope"), scope, scopeError: () => scopeError, api,
634
- });
659
+ if (viewer.can("sessions")) createProjectPicker({ el: $("#scope"), scope, scopeError: () => scopeError, api });
660
+ // The strip, its pill and grip: the session list's (lists_view.js draws into the hidden strip all the same).
661
+ viewer.controls([topStripEl, $("#stripStatus"), $("#stripGrip")], "sessions");
635
662
 
636
663
  // The session lists (lists_view.js): the strip, all sessions, their cards,
637
664
  // select mode, the family and chain popovers.
@@ -713,7 +740,7 @@ const rename = createRename({
713
740
  onClose: () => queueMicrotask(() => lists.catchUp()),
714
741
  run: (line) => runSessionCommand(line),
715
742
  });
716
- titleRenameBtn.addEventListener("click", () => rename.open());
743
+ titleRenameBtn?.addEventListener("click", () => rename.open());
717
744
  document.addEventListener("scroll", () => rename.reposition(), true);
718
745
  window.addEventListener("resize", () => rename.reposition());
719
746
 
@@ -723,7 +750,7 @@ const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchiv
723
750
  // leaves it. Opened from the page, it closes with history.back().
724
751
  let allViewPushed = false;
725
752
  function allViewOpen() {
726
- return location.hash === ALL_SESSIONS_HASH;
753
+ return !participant && location.hash === ALL_SESSIONS_HASH;
727
754
  }
728
755
  function openAllView() {
729
756
  if (allViewOpen()) return;
@@ -763,7 +790,8 @@ function setSessionHash(id) {
763
790
  function selectFromHash() {
764
791
  const claim = claimFromHash(location.hash);
765
792
  if (claim) ownerClaim.pending(claim);
766
- const intent = hashIntent(location.hash, { selected: store.selected, claim });
793
+ // A participant's page has one session, whatever the hash names.
794
+ const intent = participant ? { kind: "session", id: participant.sessionId } : hashIntent(location.hash, { selected: store.selected, claim });
767
795
  if (intent.kind === "session" && intent.id !== store.selected) select(intent.id);
768
796
  else if (claim) ownerClaim.showPending();
769
797
  else if (intent.kind === "start") clearSelection();
@@ -771,7 +799,7 @@ function selectFromHash() {
771
799
  // The page's claim links (owner_claim.js): one opened here, once drawn, and
772
800
  // annotate's Copy link.
773
801
  const ownerClaim = createOwnerClaim({
774
- historyEl, store, claimTarget: () => participantsControl.claimTarget(),
802
+ historyEl, store, claimTarget: () => participantsControl?.claimTarget() ?? null,
775
803
  toast: (...args) => showToast(...args), copyText, allViewOpen, home: document.body.dataset.home || "",
776
804
  });
777
805
  function applyView() {
@@ -807,7 +835,7 @@ window.addEventListener("hashchange", () => {
807
835
  const composerMode = createComposerMode({
808
836
  els: { body: document.body, prompt: promptEl, hint: composeHintEl, button: actionBtn, attach: attachBtn, empty: emptyEl,
809
837
  history: historyEl },
810
- store, liveTurn, startPage, deliveryControl, fitPrompt, placeToast,
838
+ store, liveTurn, startPage, deliveryControl, fitPrompt, placeToast, steers: () => !participant || participant.deliveryTurn().steer,
811
839
  });
812
840
 
813
841
  function setTurnRunning(running) {
@@ -863,8 +891,8 @@ function clearSelection() {
863
891
  store.update({ selected: null });
864
892
  setSessionHash(null);
865
893
  forgetSession();
866
- contextChips.clear();
867
- participantsControl.load(null);
894
+ contextChips?.clear();
895
+ participantsControl?.load(null);
868
896
  });
869
897
  focusPrompt();
870
898
  }
@@ -901,22 +929,26 @@ function setMarkdownWarning(warning) {
901
929
  markdownWarningEl.hidden = false;
902
930
  }
903
931
 
904
- // Both from one payload, together; the body's has-editor class follows.
905
- function setCapabilities(capabilities, editorUrl) {
932
+ // Both from one payload, together; the body's has-editor class follows. A
933
+ // show's viewer narrows the page's (viewer.js); none read (forgetSession) is the stamp's again.
934
+ function setCapabilities(capabilities, editorUrl, shownViewer = null) {
906
935
  pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
907
936
  pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
908
937
  document.body.classList.toggle("has-editor", pageEditorUrl !== null);
938
+ if (shownViewer) viewer.update(shownViewer);
939
+ else if (!capabilities) viewer.reset();
940
+ participant?.shown(shownViewer);
909
941
  }
910
942
 
911
- // What this viewer can do with a ref (refs.js REF_ACTIONS).
912
- function pageViewer() {
943
+ // Where this page can open a ref (refs.js REF_ACTIONS): not the viewer's capabilities (viewer.js).
944
+ function refEditor() {
913
945
  return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
914
946
  }
915
947
 
916
948
  // Refs anywhere on the page (a tool row's file, its diff lines, an
917
949
  // answer's path:line): one listener set; "Copy path" says so in a toast.
918
- installRefClicks(document.body, pageViewer, {
919
- copy: async (text) => showToast((await copyText(text)) ? "Path copied" : "Couldn't copy the path"),
950
+ installRefClicks(document.body, refEditor, {
951
+ copy: async (text) => showToast((await copyText(text)) ? "Path copied" : "Couldn't copy the path"), whatChiHas: wsPreview?.whatChiHas ?? null,
920
952
  });
921
953
 
922
954
  // Sticky-follow scroll: only auto-scroll to the bottom while the user is
@@ -963,7 +995,7 @@ const ownerVerify = createOwnerVerify({
963
995
 
964
996
  // Apply: the owner's Apply turn into the composer, after what is typed
965
997
  // there; never sent from here.
966
- historyEl.addEventListener("click", async (e) => {
998
+ if (viewer.can("apply")) historyEl.addEventListener("click", async (e) => {
967
999
  const button = e.target.closest(".review-apply-btn");
968
1000
  if (!button || !store.selected) return;
969
1001
  const id = store.selected;
@@ -992,15 +1024,12 @@ function sessionWaits() {
992
1024
  }
993
1025
  function updateInfoBar() {
994
1026
  annotateBar?.refresh();
1027
+ if (participant) return [participant.drawBar(), updateSessionTitle()];
995
1028
  const { buttons, bar } = sessionBar({
996
1029
  id: store.selected, state: store.current, sessions: store.sessions, versions: versionNotices.versions(), cardTitle: (s) => lists.cardTitle(s),
997
1030
  continuing: sessionActions.continuing, restarting: versionNotices.restarting, streaming: sessionSync.hasStream(), running: liveTurn.running, waiting: sessionWaits(),
998
- workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1031
+ workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl?.chip() ?? null,
999
1032
  });
1000
- const stopBtn = document.getElementById("infoStopBtn");
1001
- const deleteBtn = document.getElementById("infoDeleteBtn");
1002
- const archiveBtn = document.getElementById("infoArchiveBtn");
1003
- const continueBtn = document.getElementById("infoContinueBtn");
1004
1033
  if (continueBtn) {
1005
1034
  continueBtn.style.display = buttons.continue.shown ? "" : "none";
1006
1035
  continueBtn.disabled = buttons.continue.disabled;
@@ -1016,6 +1045,8 @@ function updateInfoBar() {
1016
1045
  archiveBtn.title = buttons.archive.title;
1017
1046
  }
1018
1047
  if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1048
+ // The ⚙ chip only with its panel (session_controls, viewer.js).
1049
+ if (bar && !settingsControl) bar.settings = null;
1019
1050
  drawFoldControls();
1020
1051
  // No session: the start page hides the info bar (the storage path is on
1021
1052
  // #/sessions). The session clock ticks every second while a turn runs:
@@ -1023,7 +1054,7 @@ function updateInfoBar() {
1023
1054
  // mid-press) stay.
1024
1055
  renderInfoBar(infoTextEl, bar);
1025
1056
  const { folded } = barFold.update({ bar, buttons });
1026
- moreSheet.update({ bar, buttons, folded });
1057
+ moreSheet?.update({ bar, buttons, folded });
1027
1058
  updateSessionTitle();
1028
1059
  // A fresh link's picker follows what the bar follows: its model, its
1029
1060
  // owner, a next link continued from elsewhere.
@@ -1035,6 +1066,7 @@ function updateInfoBar() {
1035
1066
  // browser tab names it too. It follows the card's (openTitle): a chain link
1036
1067
  // reads its card's title (cardTitle), whatever opener it was sent.
1037
1068
  function shownTitle() {
1069
+ if (participant) return participant.title();
1038
1070
  const shown = store.selected ? lists.sessionsById().get(store.selected) : null;
1039
1071
  const first = store.current.firstPreview || "";
1040
1072
  const raw = !store.selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
@@ -1053,13 +1085,14 @@ function updateSessionTitle() {
1053
1085
  if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
1054
1086
  if (sessionTitleRowEl.hidden !== !text) sessionTitleRowEl.hidden = !text;
1055
1087
  const renamable = !!text && renameShown(store.current.owner);
1056
- if (titleRenameBtn.hidden !== !renamable) titleRenameBtn.hidden = !renamable;
1088
+ if (titleRenameBtn && titleRenameBtn.hidden !== !renamable) titleRenameBtn.hidden = !renamable;
1057
1089
  const tip = text ? title : "";
1058
1090
  if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
1059
1091
  const tab = tabBase === null ? null : tabTitle({ title, base: tabBase });
1060
1092
  if (tab !== null && tab !== tabShown) {
1061
1093
  tabShown = tab;
1062
- notifications.setBaseTitle(tab);
1094
+ if (notifications) notifications.setBaseTitle(tab);
1095
+ else document.title = tab;
1063
1096
  }
1064
1097
  rename.follow(store.selected);
1065
1098
  rename.reposition();
@@ -1139,7 +1172,7 @@ function turnEndStatus(data) {
1139
1172
 
1140
1173
  // The stream's event handlers (stream_handlers.js); renderSessionView
1141
1174
  // replays a snapshot through them too.
1142
- const streamHandlers = createStreamHandlers({
1175
+ const streamHandlers = (participant ? participant.wrapHandlers : (handlers) => handlers)(createStreamHandlers({
1143
1176
  selected: () => store.selected,
1144
1177
  store,
1145
1178
  clientId,
@@ -1152,7 +1185,7 @@ const streamHandlers = createStreamHandlers({
1152
1185
  contextChips,
1153
1186
  workingSet,
1154
1187
  participantsControl,
1155
- placeApply,
1188
+ placeApply: viewer.can("apply") ? placeApply : null,
1156
1189
  placeVerdict: ownerVerify.placeVerdict,
1157
1190
  restoreVerify: (anchor) => ownerVerify.set(anchor),
1158
1191
  restoreRevise,
@@ -1191,7 +1224,7 @@ const streamHandlers = createStreamHandlers({
1191
1224
  syncModelPick: startPage.commandRan,
1192
1225
  objectivesUpdated: (data) => objectives.update(data),
1193
1226
  cardActionFailed: cards.actionFailed,
1194
- });
1227
+ }));
1195
1228
 
1196
1229
  async function refreshTimingFromSession() {
1197
1230
  if (!store.selected) return;
@@ -1216,7 +1249,7 @@ function addCreatedSession(created) {
1216
1249
 
1217
1250
  // /model, !ls … from the composer, and a control's command (the llm ctx
1218
1251
  // and thinking chips): composer.js.
1219
- const composerCommands = createComposerCommands({
1252
+ const composerCommands = viewer.can("session_controls") ? createComposerCommands({
1220
1253
  session: () => store.selected,
1221
1254
  clientId,
1222
1255
  sendCommand,
@@ -1239,7 +1272,7 @@ const composerCommands = createComposerCommands({
1239
1272
  focusPrompt({ open: false });
1240
1273
  },
1241
1274
  },
1242
- });
1275
+ }) : null;
1243
1276
 
1244
1277
  // The composer's send (send_turn.js): a line into the open session, or a
1245
1278
  // new session first on the start page.
@@ -1283,7 +1316,8 @@ const sendTurnControl = createSendTurn({
1283
1316
  followWokenWorker,
1284
1317
  refreshHistory,
1285
1318
  leaveGoneSession,
1286
- runCommand: (command) => composerCommands.send(command),
1319
+ closesOn: participant?.refused ?? null,
1320
+ runCommand: composerCommands ? (command) => composerCommands.send(command) : null,
1287
1321
  toast: showToast,
1288
1322
  });
1289
1323
  // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
@@ -1293,7 +1327,7 @@ function handleSendTurn({ delivery = null } = {}) {
1293
1327
  }
1294
1328
 
1295
1329
  function runSessionCommand(line) {
1296
- return composerCommands.run(line);
1330
+ return composerCommands?.run(line);
1297
1331
  }
1298
1332
 
1299
1333
  function clearComposer() {
@@ -1321,7 +1355,7 @@ async function handleCancel() {
1321
1355
  // session (from all sessions too).
1322
1356
  // The typed draft stays in the composer: paste in a session, then + new session
1323
1357
  // starts from it (with the model picker). clearSelection leaves #prompt alone.
1324
- $("#newBtn").addEventListener("click", () => {
1358
+ viewer.control($("#newBtn"), "sessions")?.addEventListener("click", () => {
1325
1359
  if (allViewOpen()) leaveAllView("");
1326
1360
  clearSelection();
1327
1361
  startPage.loadModels({ asked: true });
@@ -1347,7 +1381,8 @@ const composer = mountComposer({
1347
1381
  toast: (text) => showToast(text),
1348
1382
  onWrite: () => noteDraft(),
1349
1383
  // ↑/↓: the prompt history shared with the TUI.
1350
- loadHistory: fetchHistory,
1384
+ loadHistory: viewer.can("prompt_history") ? fetchHistory : null,
1385
+ imagesOff: viewer.can("images") ? null : IMAGES_NOT_HERE,
1351
1386
  // Alt/Option+Enter: queued for after the running turn (D6), only to a
1352
1387
  // worker that queues (else it would steer): the text stays, a note says why.
1353
1388
  send: ({ queue }) => {
@@ -1412,19 +1447,19 @@ document.addEventListener("keydown", (e) => {
1412
1447
  const commandList = createCommandList({
1413
1448
  list: $("#commandList"),
1414
1449
  prompt: promptEl,
1415
- commands: () => (store.selected && store.current.owner !== "tui" ? store.current.commands : []),
1450
+ commands: () => (viewer.can("session_controls") && store.selected && store.current.owner !== "tui" ? store.current.commands : []),
1416
1451
  afterPick: () => {
1417
1452
  noteDraft();
1418
1453
  fitPrompt();
1419
1454
  },
1420
1455
  });
1421
1456
 
1422
- $("#infoStopBtn").addEventListener("click", () => sessionActions.stopSelected());
1423
- $("#infoDeleteBtn").addEventListener("click", () => {
1457
+ stopBtn?.addEventListener("click", () => sessionActions.stopSelected());
1458
+ deleteBtn?.addEventListener("click", () => {
1424
1459
  if (store.selected) confirmDelete(store.selected);
1425
1460
  });
1426
- $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1427
- $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
1461
+ continueBtn?.addEventListener("click", () => continueSelected());
1462
+ archiveBtn?.addEventListener("click", () => sessionActions.toggleArchiveSelected());
1428
1463
 
1429
1464
  // The new link's composer: the opener (openerText: the previous link's own,
1430
1465
  // else the constant). A draft written in this session (or the new link, or
@@ -1457,6 +1492,7 @@ function restoreDraft(draft, opener) {
1457
1492
  const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
1458
1493
  // chi's versions, the update sheet and the worker Restart (version_notices.js).
1459
1494
  // After toasts: openSessionEvents, its first caller, runs at boot.
1495
+ if (participant) onAuthLost(participant.linkEnded);
1460
1496
  const versionNotices = createVersionNotices({
1461
1497
  store, toasts, dialog: $("#updateSheet"), loadedVersion: document.body.dataset.version,
1462
1498
  api: { updateChi, restartWorkers, restartWeb, restartSession },
@@ -1515,7 +1551,7 @@ turnRoots().forEach((root) => installCopy(root));
1515
1551
  // chi-ui blocks (inline_widgets.js) are a core drawer, ahead of the bundles'
1516
1552
  // renderers; a picks' Fill reply only fills the composer.
1517
1553
  const widgets = createWidgetDrawer({ roots: turnRoots(), composerText: () => promptEl.value, fillComposer });
1518
- createBlockHost({ roots: turnRoots(), sessionId: () => store.selected, drawers: [widgets] });
1554
+ createBlockHost({ roots: turnRoots(), sessionId: () => store.selected, drawers: [widgets], renderers: viewer.can("block_renderers") });
1519
1555
  // A steps block, a thinking block and the recap open and close by hand
1520
1556
  // with their height animated (details_motion.js), in the history and on
1521
1557
  // the stage.
@@ -1528,34 +1564,34 @@ installAnswerFold({
1528
1564
  });
1529
1565
  // Revise stacks (revise_stack.js): the superseded versions fold to stubs,
1530
1566
  // the top answer gets its version chip, a final answer its Revise button.
1531
- reviseStacks = installReviseStacks({
1567
+ reviseStacks = viewer.can("revise") || viewer.can("revise_own_turn") ? installReviseStacks({
1532
1568
  historyEl,
1533
1569
  sessionId: () => store.selected,
1534
1570
  projectRoot: () => store.sessions.find((s) => s.id === store.selected)?.project_root ?? null,
1535
1571
  running: () => (liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null),
1536
1572
  verifyTurn: (turnId) => (store.current.verdicts || []).some((v) => v.check_turn_id === turnId),
1537
- unavailable: reviseUnavailable,
1538
- armed: () => pendingReviseAnchor?.turn_id ?? null,
1573
+ unavailable: reviseUnavailable, unavailableText: reviseOffText,
1574
+ armed: () => pendingReviseAnchor?.turn_id ?? null, mayRevise: participant ? participantMayRevise(participant.closed) : undefined,
1539
1575
  onRevise: (anchor) => {
1540
1576
  setReviseAnchor(anchor);
1541
1577
  openForVisit();
1542
1578
  focusPrompt();
1543
1579
  },
1544
- });
1580
+ }) : null;
1545
1581
 
1546
1582
  // Notifications (notifications.js): an OS notification and a title count
1547
1583
  // when a session needs the user while this tab is not in front. On a
1548
1584
  // project's page the Home Screen app's device tidy-up still covers every
1549
1585
  // project: it gets a stream of every session of its own (allEvents).
1550
- const notifications = createNotifications({
1586
+ const notifications = viewer.can("push") ? createNotifications({
1551
1587
  button: $("#notifyBtn"),
1552
1588
  scoped: !!scope.dir,
1553
1589
  allEvents: (handlers, opts) => openEvents(handlers, opts),
1554
- });
1590
+ }) : null;
1555
1591
 
1556
1592
  // The strip hidden or shown (strip_hidden.js): its grip, the saved choice and
1557
1593
  // the short window's rule; applied now.
1558
- createStripHidden({
1594
+ if (viewer.can("sessions")) createStripHidden({
1559
1595
  gripEl: $("#stripGrip"), body: document.body, onApply: () => updateSessionTitle(),
1560
1596
  closeFamilyPop: () => lists.familyPop.close(),
1561
1597
  });
@@ -1575,7 +1611,7 @@ document.addEventListener("keydown", (e) => {
1575
1611
  // before it (none at load) would redraw nothing.
1576
1612
  store.on("lists", () => lists.render());
1577
1613
  store.on("paintActive", () => lists.paintActive());
1578
- store.on("settingsPanel", () => settingsControl.refresh());
1614
+ store.on("settingsPanel", () => settingsControl?.refresh());
1579
1615
  // The working set's list offers pin only under forget.
1580
1616
  store.on("workingSetStrategy", () => workingSet?.strategyChanged());
1581
1617
  store.on("featureGates", () => featureGates.check(store.current));
@@ -1583,7 +1619,7 @@ store.on("composer", composerMode.update);
1583
1619
  store.on("composer", () => objectives.redraw());
1584
1620
  // The answers' Revise buttons follow liveTurn.running too (none on the
1585
1621
  // running turn's answer), which touches the composer.
1586
- store.on("composer", () => reviseStacks.decorate());
1622
+ store.on("composer", () => reviseStacks?.decorate());
1587
1623
  store.on("infoBar", updateInfoBar);
1588
1624
 
1589
1625
  applyView();
@@ -1599,7 +1635,7 @@ store.batch(() => {
1599
1635
  store.touch("composer");
1600
1636
  store.touch("infoBar");
1601
1637
  });
1602
- startPage.loadModels();
1638
+ if (viewer.can("models")) startPage.loadModels();
1603
1639
  // The list: the hub's stream when the browser has EventSource, else fetched.
1604
1640
  if (typeof globalThis.EventSource === "function") openSessionEvents();
1605
1641
  else refresh();