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
@@ -393,12 +393,15 @@ export function createFrameDrawer({
393
393
  // limit what they hold (docs/internals/block-renderers.md, residual risk)
394
394
  // @param drawers core drawers, asked before the bundle renderers
395
395
  // The rest are seams for the specs.
396
- export function createBlockHost({ roots, sessionId, drawers = [], copy = copyText, watch = null, win = window, ...frameOpts }) {
397
- const frames = createFrameDrawer({ roots, win, ...frameOpts });
398
- const watcher = createBlockWatch({ roots, sessionId, drawers: [...drawers, frames], watch, copy, win });
396
+ // +renderers+: false for a viewer without the bundles' renderers (viewer.js):
397
+ // the core +drawers+ only, and GET /api/block_renderers is never asked.
398
+ export function createBlockHost({ roots, sessionId, drawers = [], copy = copyText, watch = null, win = window, renderers = true,
399
+ ...frameOpts }) {
400
+ const frames = renderers ? createFrameDrawer({ roots, win, ...frameOpts }) : null;
401
+ const watcher = createBlockWatch({ roots, sessionId, drawers: frames ? [...drawers, frames] : drawers, watch, copy, win });
399
402
  // A system theme change redraws through the figure (needsDraw's theme check).
400
403
  win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
401
404
  for (const root of roots) watcher.scan(root);
402
405
  });
403
- return { scan: watcher.scan, dropFrames: frames.dropFrames };
406
+ return { scan: watcher.scan, dropFrames: frames ? frames.dropFrames : () => {} };
404
407
  }
@@ -195,6 +195,14 @@ export function createBubbles({
195
195
  return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
196
196
  }
197
197
 
198
+ // A participant's own queued bubble for their turn's start (+mine+: no
199
+ // origin names its enqueued id), the oldest, preferring one with the same text.
200
+ function queuedOwnBubble(prompt) {
201
+ const queued = findInTurnRoots(".bubble.user.queued[data-own][data-enqueued-id]");
202
+ const text = normalize(prompt || "");
203
+ return queued.find((el) => el.dataset.userContent === text) || queued[0] || null;
204
+ }
205
+
198
206
  // The running wake turn's id (a turn chi ran for reports), for its report's bubble.
199
207
  let wakeTurnId = null;
200
208
 
@@ -229,7 +237,7 @@ export function createBubbles({
229
237
  // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
230
238
  else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
231
239
  } else if (op.op === "start") {
232
- const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
240
+ const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt) || (op.mine ? queuedOwnBubble(op.prompt) : null);
233
241
  // A resync (a new worker woken by this page's send) re-rendered the
234
242
  // history and took the echo with it: draw the prompt again.
235
243
  if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
@@ -0,0 +1,69 @@
1
+ // A snapshot's cards placed into a holder that is not the page (cards_offscreen.js):
2
+ // the opt-in reload check (reload_check.js) draws a handed-off turn offscreen and
3
+ // compares it with what the page has. A reload's render (turnHistoryNodes) doesn't
4
+ // run the placement (cards_place.js placeSnapshotCards), so the check needs its own.
5
+ //
6
+ // It is the real placer, not a copy: createCardsPlace, createBubbles and
7
+ // createQuestionCards over a detached holder, with every page hook made inert:
8
+ // no stage (turnDom answers "nothing holds it", and has no settleFold/settleWaiting
9
+ // — cards_place.js calls those with `?.`), no scroll, no hint, no info bar, no
10
+ // session id, no api. Nothing outside +holder+ is read or written; the page's
11
+ // questionCards, its turnDom and its appendToHistory are never touched.
12
+ import { createBubbles } from "./bubbles.js";
13
+ import { createCardsPlace } from "./cards_place.js";
14
+ import { createQuestionCards } from "./question_cards.js";
15
+ import { splitSnapshotCards } from "./card.js";
16
+
17
+ // +cards+ the read's snapshot (data.cards); +capabilities+ the page's, for the
18
+ // notices it shows elsewhere (splitSnapshotCards); +answers+ whether this viewer
19
+ // may answer cards at all (viewer.js answer_cards).
20
+ export function placeCardsOffscreen(holder, cards, { capabilities = null, answers = true, doc = globalThis.document } = {}) {
21
+ const shared = {
22
+ historyEl: holder,
23
+ findInTurnRoots: (selector) => [...holder.querySelectorAll(selector)],
24
+ isNearBottom: () => false,
25
+ selected: () => null,
26
+ clientId: null,
27
+ };
28
+ // No running turn here: nothing holds a piece, nothing takes a card or a steer.
29
+ const turnDom = { card: () => false, steer: () => false, holds: () => false };
30
+ const bubbles = createBubbles({
31
+ ...shared,
32
+ appendToHistory: (el) => holder.appendChild(el),
33
+ turnDom,
34
+ shownTexts: { shows: () => false },
35
+ liveTurn: { timing: { el: null } },
36
+ });
37
+ const questionCards = createQuestionCards({
38
+ ...shared,
39
+ sessionId: () => null,
40
+ place: (card) => { holder.appendChild(card); return false; },
41
+ scrollToEnd: () => {},
42
+ removeHintIfEmpty: () => {},
43
+ askingText: () => "",
44
+ renderMarkdown: () => Promise.resolve(null),
45
+ onPending: () => {},
46
+ onSettled: () => {},
47
+ answers,
48
+ doc,
49
+ // A card's button is inert here: a placed card is a comparison, not a live ask.
50
+ api: {
51
+ sendAnswer: () => Promise.reject(new Error("offscreen card")),
52
+ dismissQuestion: () => Promise.reject(new Error("offscreen card")),
53
+ sendCommand: () => Promise.reject(new Error("offscreen card")),
54
+ },
55
+ });
56
+ const place = createCardsPlace({
57
+ ...shared,
58
+ turnDom,
59
+ questionCards,
60
+ bubbles,
61
+ followWokenWorker: () => {},
62
+ api: {
63
+ getSession: () => Promise.reject(new Error("offscreen card")),
64
+ sendCommand: () => Promise.reject(new Error("offscreen card")),
65
+ },
66
+ });
67
+ place.placeSnapshotCards(splitSnapshotCards(cards, capabilities).placed, { before: null });
68
+ return holder;
69
+ }
@@ -60,7 +60,8 @@ function shownAnchor(anchor, doc) {
60
60
  return standIn?.getClientRects().length ? standIn : anchor;
61
61
  }
62
62
 
63
- // id, className: the popover's; width: its widest (px); el: where the chip
63
+ // id, className: the popover's; width: its widest (px, or a function of
64
+ // none read at each placing: the working set's grows while a row peeks); el: where the chip
64
65
  // is drawn; anchor: the chip's selector; openers: the selector of every
65
66
  // element that opens it (a click there is not a click outside; default the
66
67
  // anchor's); summary(anchor): what the form is drawn from (null: nothing
@@ -72,7 +73,7 @@ function shownAnchor(anchor, doc) {
72
73
  // dialog's accessible name (aria-label), none by default. onOpen(): after
73
74
  // an open has shown and placed it (where focus can go into it: show runs
74
75
  // while it is still hidden). sheetShare: the share of a phone's window the
75
- // sheet takes at most (SHEET_SHARE). Always a modal dialog (modalDialog);
76
+ // sheet takes at most (SHEET_SHARE; a function of none is read at each placing). Always a modal dialog (modalDialog);
76
77
  // the opener clicked (and the anchor it is placed at) carries aria-expanded
77
78
  // "true" while it is open, "false" once closed: a lit-drawn chip writes
78
79
  // aria-expanded="false" as a static attribute (lit leaves it to the shell).
@@ -106,7 +107,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
106
107
  if (narrow) {
107
108
  const vv = win.visualViewport;
108
109
  const at = sheetPlace({ innerWidth: win.innerWidth, innerHeight: win.innerHeight },
109
- { share: sheetShare, viewport: vv ? { height: vv.height, offsetTop: vv.offsetTop } : null });
110
+ { share: typeof sheetShare === "function" ? sheetShare() : sheetShare, viewport: vv ? { height: vv.height, offsetTop: vv.offsetTop } : null });
110
111
  pop.style.width = `${at.width}px`;
111
112
  pop.style.left = `${at.left}px`;
112
113
  pop.style.top = "";
@@ -118,7 +119,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
118
119
  pop.style.maxHeight = `${at.maxHeight}px`;
119
120
  return;
120
121
  }
121
- const width = Math.min(widest, win.innerWidth - 32);
122
+ const width = Math.min(typeof widest === "function" ? widest() : widest, win.innerWidth - 32);
122
123
  pop.style.width = `${width}px`;
123
124
  pop.style.maxHeight = "";
124
125
  const at = popoverPlace(shownAnchor(anchor, doc).getBoundingClientRect(), { width, height: pop.offsetHeight,
@@ -1,7 +1,7 @@
1
1
  // A link to a claim (docs/internals/participants.md, Deep links): one turn
2
2
  // of a session and a quote in it, on either page.
3
3
  //
4
- // participant page: <chi web>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>
4
+ // participant's page:<chi web>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>
5
5
  // owner's page: #/s/<id>?t=<turn8>&q=<quote>&p=<prefix>
6
6
  //
7
7
  // t is the turn's id, its first 8 characters (a turn-opening message's
@@ -11,7 +11,7 @@
11
11
  // when there are none). URLSearchParams encodes both, so a link is
12
12
  // URL-safe and a space is one "+".
13
13
  //
14
- // Normalized: whitespace collapsed, and what the participant page shows
14
+ // Normalized: whitespace collapsed, and what a participant's page shows
15
15
  // instead (Samagotchi::Redact): secret shapes "[redacted]" (so a link
16
16
  // never carries one, and matches the participant's redacted text), and on
17
17
  // the owner's page the session's folder relative ("<cwd>/x" → "x",
@@ -85,7 +85,7 @@ function secretSpans(raw) {
85
85
  return spans;
86
86
  }
87
87
 
88
- // The spans of +raw+ that read differently on the participant page:
88
+ // The spans of +raw+ that read differently on a participant's page:
89
89
  // [{start, end, text}], in order, not overlapping. Secrets first, then
90
90
  // the session's folder (it is usually under home), as Redact.text.
91
91
  function replacements(raw, { home = "", cwd = "" } = {}) {
@@ -191,7 +191,7 @@ export function claimParams({ turnId = "", q = "", p = "" } = {}) {
191
191
  return params.toString();
192
192
  }
193
193
 
194
- // The participant page's link: +base+ the participant link's origin
194
+ // A participant's page's link: +base+ the participant link's origin
195
195
  // ("https://chi.example/", "http://192.168.1.5:4567/"), no token.
196
196
  export function participantClaimLink(base, sessionId, claim) {
197
197
  const params = claimParams(claim);
@@ -1,15 +1,13 @@
1
1
  /* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
2
- (the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
3
- linked after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
2
+ (the image button, Send). Linked by index.html after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
4
3
  own parts stay in index.html: the card's placing (#dock), the delivery line's links, the start page's picker and chips,
5
4
  the / list, and the 16px text fields for iOS. */
6
5
  /* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
7
6
  bar and all view's cards use it too. */
8
7
  :root{--icon-toolkit:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='1.75' y='5.25' width='12.5' height='8' rx='1.5'/%3E%3Cpath d='M5.75 5.25v-1.5a1 1 0 0 1 1-1h2.5a1 1 0 0 1 1 1v1.5M1.75 8.75h12.5M7 8.75v1.5h2v-1.5'/%3E%3C/svg%3E");--icon-settings:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linejoin='round'%3E%3Cpath d='M12.75 6.14L14.45 6.59L14.45 9.41L12.75 9.86L12.67 10.04L13.56 11.56L11.56 13.56L10.04 12.67L9.86 12.75L9.41 14.45L6.59 14.45L6.14 12.75L5.96 12.67L4.44 13.56L2.44 11.56L3.33 10.04L3.25 9.86L1.55 9.41L1.55 6.59L3.25 6.14L3.33 5.96L2.44 4.44L4.44 2.44L5.96 3.33L6.14 3.25L6.59 1.55L9.41 1.55L9.86 3.25L10.04 3.33L11.56 2.44L13.56 4.44L12.67 5.96Z'/%3E%3Ccircle cx='8' cy='8' r='2.1'/%3E%3C/svg%3E");--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
9
- /* The composer card: the glass card both pages' composers sit in (the owner's #dock, the stage view's .dock-card, the
10
- participant page's .p-card), with its focus ring; its footer (the owner's info bar, the participant's usage) under
11
- a hairline; and the composer's line, the quiet row above the text field (the owner's delivery line, the
12
- participant's key hint). A page's own rules only place the card. */
8
+ /* The composer card: the glass card the composer sits in (#dock, the stage view's .dock-card), with its focus ring;
9
+ its footer (the info bar, a participant's bottom row) under a hairline; and the composer's line, the quiet row above
10
+ the text field (the delivery line). A page's own rules only place the card. */
13
11
  .composer-card{display:flex;flex-direction:column;border-radius:16px;position:relative;background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);transition:border-color .12s ease,box-shadow .12s ease}
14
12
  .composer-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
15
13
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.composer-card{background:var(--bg-panel)}}
@@ -15,12 +15,15 @@ export const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ hist
15
15
  // start page), +owner+ its owner, +running+ whether its turn runs, +narrow+ a
16
16
  // phone's width. While a turn runs, Send still sends: the prompt merges into
17
17
  // the turn at its next step (steering); Cancel is its own button then.
18
+ // +steers+ false: a message sent now doesn't go into the running turn (a
19
+ // participant's into someone else's): the plain placeholder, the delivery
20
+ // line says where it goes.
18
21
  // @return {placeholder, hint, button, disabled, empty, clearHistory,
19
22
  // attachHidden, delivery}: +disabled+ undefined while a turn runs (the
20
23
  // button keeps whatever a send in flight set); +empty+ the start page's
21
24
  // empty state shown, +clearHistory+ #history emptied (the start page only);
22
25
  // +delivery+ the delivery line's mode (delivery_line.js show).
23
- export function composerMode({ selected, owner, running, narrow }) {
26
+ export function composerMode({ selected, owner, running, narrow, steers = true }) {
24
27
  const session = !!selected;
25
28
  const tui = session && owner === "tui";
26
29
  const live = !!running && session;
@@ -34,7 +37,7 @@ export function composerMode({ selected, owner, running, narrow }) {
34
37
  // composer is off, so is its row.
35
38
  const delivery = !session || tui ? "off" : running ? "running" : "idle";
36
39
  const base = { attachHidden, delivery, empty: false, clearHistory: false };
37
- if (live) {
40
+ if (live && steers) {
38
41
  return {
39
42
  ...base,
40
43
  placeholder: pick("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…"),
@@ -56,7 +59,7 @@ export function composerMode({ selected, owner, running, narrow }) {
56
59
  };
57
60
  }
58
61
  // The keys are in the delivery line's row above.
59
- return { ...base, placeholder: "Message chi…", hint: SEND_HINT, button: "Send", disabled: false };
62
+ return { ...base, placeholder: "Message chi…", hint: SEND_HINT, button: "Send", disabled: live ? undefined : false };
60
63
  }
61
64
 
62
65
  // The composer's mode drawn: update() is the store's `composer` redraw, and a
@@ -68,9 +71,10 @@ export function composerMode({ selected, owner, running, narrow }) {
68
71
  // live_turn.js's (running). +startPage+: start_page.js's (restoreStartPick,
69
72
  // syncModelPick, dropChoices). +deliveryControl+: delivery_line.js's.
70
73
  // +fitPrompt()+,
71
- // +placeToast()+: the page's. +win+: the window (matchMedia).
74
+ // +placeToast()+: the page's. +win+: the window (matchMedia). +steers()+:
75
+ // whether a message now goes into the running turn (composerMode's).
72
76
  export function createComposerMode({
73
- els, store, liveTurn, startPage, deliveryControl, fitPrompt, placeToast, win = globalThis,
77
+ els, store, liveTurn, startPage, deliveryControl, fitPrompt, placeToast, win = globalThis, steers = () => true,
74
78
  }) {
75
79
  const narrowScreen = win.matchMedia(NARROW_QUERY);
76
80
  let hadSession = false;
@@ -89,8 +93,9 @@ export function createComposerMode({
89
93
  if (store.selected) startPage.dropChoices();
90
94
  const mode = composerMode({
91
95
  selected: store.selected, owner: store.current.owner, running: liveTurn.running, narrow: narrowScreen.matches,
96
+ steers: steers(),
92
97
  });
93
- els.attach.hidden = mode.attachHidden;
98
+ if (els.attach) els.attach.hidden = mode.attachHidden;
94
99
  deliveryControl.show(mode.delivery);
95
100
  els.prompt.placeholder = mode.placeholder;
96
101
  els.hint.textContent = mode.hint;
@@ -14,14 +14,14 @@ import { readStored } from "./storage.js";
14
14
  //
15
15
  // +els+: {composer (the card's box, where files drop), prompt (the text box),
16
16
  // button (Send/Start), chips (the chips' row), attachBtn, attachInput (the
17
- // hidden file picker), grip}.
17
+ // hidden file picker; both null: no image button, viewer.js), grip}.
18
18
  // Hooks: +minPx()+ the empty box's height; +toast(text)+ why images stayed
19
19
  // out; +onWrite()+ the composer wrote the text itself (a recalled entry);
20
20
  // +loadHistory()+ a promise of the ↑/↓ entries (oldest first; none: no
21
21
  // history); +send({queue})+ Enter (+queue+: with Alt/Option) or the button;
22
22
  // +keyFirst(e)+ a key the page takes before the history's (true: taken);
23
23
  // +escape()+ Esc, true when it let go of something. +imagesOff+: why this
24
- // page takes no images (the participant page, until its upload path): a
24
+ // page takes no images (a participant's page, until its upload path): a
25
25
  // paste, drop or pick of one toasts it and adds no chip; null takes them.
26
26
  // +fold+: createComposerGrip's page hooks (session, waiting, allViewOpen,
27
27
  // lightboxOpen, infoBarEl, typeAnywhere).
@@ -79,8 +79,8 @@ export function mountComposer({
79
79
  });
80
80
  // The image button: the file picker's images join the chips. Its value is
81
81
  // cleared after each pick, so the same file can be picked again.
82
- attachBtn.addEventListener("click", () => attachInput.click());
83
- attachInput.addEventListener("change", () => {
82
+ attachBtn?.addEventListener("click", () => attachInput.click());
83
+ attachInput?.addEventListener("change", () => {
84
84
  addImageFiles([...attachInput.files]);
85
85
  attachInput.value = "";
86
86
  });
@@ -124,10 +124,14 @@ export function ctxBarText(pct, windowSource = null) {
124
124
  // page isn't told context.status is off, so "?" never means 0); after one,
125
125
  // "Context window unknown" over the session's tokens and per-model rows with
126
126
  // no window line (a window lookup that failed; a model list without a context
127
- // length still falls back to context.window_tokens or chi's default). +window.afterSwitch+ (the info
127
+ // length still falls back to context.window_tokens or chi's default), or,
128
+ // when the window is known but no count has come yet, "No context count yet:
129
+ // the next request counts it". +window.afterSwitch+ (the info
128
130
  // bar's; a /model whose new window didn't resolve, no request since:
129
131
  // Engine#announce_switched_context! sent usage null) adds "until the next
130
132
  // request", which asks for it again.
133
+ // +window.running+ (the info bar's own running flag) makes the title of a
134
+ // turn in flight: "Context in use (this turn so far)".
131
135
  // +side+ (the timing's side block) adds the side requests' line, in the
132
136
  // "No request in this session yet" answer too (a /btw before any turn).
133
137
  export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = null) {
@@ -136,11 +140,16 @@ export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = n
136
140
  const sideText = sideRequestsText(side);
137
141
  return `No request in this session yet${sideText ? `\nside requests: ${sideText}` : ""}`;
138
142
  }
139
- const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
143
+ const why = window?.afterSwitch
144
+ ? "Context window unknown until the next request"
145
+ : num(window?.tokens) > 0
146
+ ? "No context count yet: the next request counts it"
147
+ : "Context window unknown";
140
148
  return tokensTipText(tokens, why, memoryIndex, null, segments, side);
141
149
  }
142
150
 
143
- return tokensTipText(tokens, undefined, memoryIndex, window, segments, side);
151
+ return tokensTipText(tokens, window?.running ? "Context in use (this turn so far)" : undefined,
152
+ memoryIndex, window, segments, side);
144
153
  }
145
154
 
146
155
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
@@ -29,6 +29,11 @@
29
29
  // asks (never a steer or a cut into their turn): Enter queues, no cut is
30
30
  // offered, and the line says so.
31
31
  //
32
+ // A participant looking (the participant main page: +viewerTurn+) gets no
33
+ // cut and no links: in their own turn the message goes in at its next step
34
+ // (Alt+Enter queues it), on a worker that takes it; in anyone else's it
35
+ // runs as their own turn afterwards (the worker queues it).
36
+ //
32
37
  // deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
33
38
  import { html, nothing, render } from "./vendor/lit-html.js";
34
39
 
@@ -50,8 +55,17 @@ export const APPLY_CANT_QUEUE = "your Apply turn can't queue on this worker (res
50
55
  // mode: review or pair.
51
56
  export const PARTICIPANT_TURN_QUEUES = "goes in as your own turn after this participant's turn";
52
57
  export const PARTICIPANT_TURN_QUEUES_SHORT = "after their turn";
58
+ // A participant's line: their own turn, the owner's, another participant's.
59
+ export const OWN_TURN_STEERS = "goes in at the next step · Alt+Enter keeps it for after the turn";
60
+ export const OWN_TURN_STEERS_SHORT = "goes in at the next step";
61
+ export const OWN_TURN_QUEUES = "goes in as your own turn after this one";
62
+ export const AFTER_OWNER = "Your message goes in as your own turn after the owner's";
63
+ export const afterTheirs = (name) => `Your message goes in as your own turn after ${name}'s`;
64
+ export const AFTER_SHORT = "after this turn";
53
65
  // The idle row: the keys (the placeholder's hint before the row existed).
54
66
  export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
67
+ // The same for a viewer without the prompt history (a participant).
68
+ export const IDLE_HINT_NO_HISTORY = "Enter to send · Shift+Enter newline";
55
69
 
56
70
  // m:ss (h:mm:ss past an hour) of +seconds+; "" when unknown.
57
71
  export function clockText(seconds) {
@@ -106,6 +120,9 @@ function thinkingOnly(gen, serverGen) {
106
120
  // cut still wins)
107
121
  // participantTurn the running turn is a participant's: Enter queues,
108
122
  // no cut (the worker queues the owner's input behind it)
123
+ // viewerTurn a participant looks: {own, steer, by} (their own turn, it
124
+ // takes their message at its next step, its author's name or
125
+ // null for the owner's); null on the owner's page
109
126
  // → {text, short, cut: {enabled, shown, reason, label, short}, queue:
110
127
  // {enabled, shown, label, short}, armed, enter}: short is the phone's text;
111
128
  // armed is what is still armed (a cut no longer possible disarms); enter
@@ -113,7 +130,8 @@ function thinkingOnly(gen, serverGen) {
113
130
  // cutting off the cut link goes (the text says why); without the
114
131
  // feature the queue link goes.
115
132
  export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutAfter = null, serverGen = null, now = Date.now(),
116
- armed = null, canQueue = true, model = "", held = false, participantTurn = false } = {}) {
133
+ armed = null, canQueue = true, model = "", held = false, participantTurn = false, viewerTurn = null } = {}) {
134
+ if (viewerTurn) return viewerLine(viewerTurn);
117
135
  const cutOff = cutAfter === 0 || (cutAfter != null && cutAfter < 0);
118
136
  const thinking = phase === "thinking" && thinkingOnly(gen, serverGen);
119
137
  const age = genAge(gen, serverGen, now);
@@ -177,6 +195,16 @@ export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutA
177
195
  };
178
196
  }
179
197
 
198
+ // A participant's line (deliveryLine's +viewerTurn+): no links; Enter
199
+ // steers only into their own turn on a worker that takes it.
200
+ function viewerLine({ own = false, steer = false, by = null } = {}) {
201
+ const none = { enabled: false, shown: false, reason: "", label: QUEUE_LABEL, short: QUEUE_SHORT };
202
+ const line = (text, short, enter) => ({ text, short, cut: { ...none, label: CUT_LABEL, short: CUT_SHORT }, queue: none, armed: null, enter });
203
+ if (own && steer) return line(`\u21B3 ${OWN_TURN_STEERS}`, `\u21B3 ${OWN_TURN_STEERS_SHORT}`, null);
204
+ if (own) return line(`\u21B3 ${OWN_TURN_QUEUES}`, `\u21B3 ${AFTER_SHORT}`, "queue");
205
+ return line(by ? afterTheirs(by) : AFTER_OWNER, `\u21B3 after ${by ? `${by}'s` : "the owner's"} turn`, "queue");
206
+ }
207
+
180
208
  // The line's view: +line+ is deliveryLine's; +onCut+ / +onQueue+ the
181
209
  // links' clicks. A disabled cut stays in place, its reason on hover.
182
210
  export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
@@ -191,8 +219,8 @@ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
191
219
  }
192
220
 
193
221
  // The idle row: the key hint (none on a phone: no keyboard keys there).
194
- function idleTemplate() {
195
- return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
222
+ function idleTemplate(hint = IDLE_HINT) {
223
+ return html`<span class="delivery-where idle"><span class="long">${hint}</span><span class="short"></span></span>`;
196
224
  }
197
225
 
198
226
  // The row on the page: the key hint while idle, the line while the turn
@@ -205,7 +233,8 @@ function idleTemplate() {
205
233
  // session() the open session's id: an Apply hold is that session's
206
234
  const TICK_MS = 500;
207
235
 
208
- export function createDeliveryControl({ el, read, composerEmpty, send, session = () => null, now = () => Date.now(),
236
+ // +history+: the viewer walks a prompt history with ↑ (the idle hint says so).
237
+ export function createDeliveryControl({ el, read, composerEmpty, send, session = () => null, now = () => Date.now(), history = true,
209
238
  setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
210
239
  // "off" (no session: the row is gone), "idle" or "running".
211
240
  let mode = "off";
@@ -239,7 +268,7 @@ export function createDeliveryControl({ el, read, composerEmpty, send, session =
239
268
  function refresh() {
240
269
  if (mode !== "running") {
241
270
  mark();
242
- if (mode === "idle") render(idleTemplate(), el);
271
+ if (mode === "idle") render(idleTemplate(history ? IDLE_HINT : IDLE_HINT_NO_HISTORY), el);
243
272
  return;
244
273
  }
245
274
  line = deliveryLine({ ...read(), now: now(), armed, held: held() });
@@ -88,7 +88,11 @@ function rowItem(el) {
88
88
  }
89
89
 
90
90
  function cardItem(el) {
91
- return item("card", `${el.dataset.cardId || ""} ${textOf(el.querySelector(".card-title"))}`.trim());
91
+ const card = item("card", `${el.dataset.cardId || ""} ${textOf(el.querySelector(".card-title"))}`.trim());
92
+ // Its id on its own (already in the text): the reload check keeps a turn's
93
+ // own cards by it and drops the rest (reload_check.js).
94
+ card.id = el.dataset.cardId || "";
95
+ return card;
92
96
  }
93
97
 
94
98
  function questionItem(el) {
@@ -19,6 +19,9 @@ export function imageFiles(dataTransfer) {
19
19
  // would (spec/web/images_spec.rb pins both): ImageStore::MAX_TURN_REFS
20
20
  // images a message, Web::App::MAX_IMAGE_UPLOAD_BYTES a file.
21
21
  export const MAX_TURN_IMAGES = 20;
22
+ // Why the composer takes no image (composer_mount.js imagesOff) for a viewer
23
+ // without images (viewer.js): a paste, drop or pick toasts it.
24
+ export const IMAGES_NOT_HERE = "Images can't go in from here yet: describe what the image shows.";
22
25
  export const MAX_IMAGE_MB = 20;
23
26
  const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
24
27
 
@@ -75,8 +78,11 @@ export function imageUrl(sessionId, ref) {
75
78
 
76
79
  // Thumbnails for a bubble or a tool row; nothing for none. `src` may be
77
80
  // given (a blob: URL while the upload is on its way). A redraw with the
78
- // same images keeps the <img> elements (no reload).
81
+ // same images keeps the <img> elements (no reload). A number is a count of
82
+ // images the viewer gets no refs to (another participant's: chi web's
83
+ // ParticipantView): a muted "N images" line instead.
79
84
  export function thumbsTemplate(sessionId, images) {
85
+ if (typeof images === "number") return images > 0 ? html`<div class="thumbs-hidden">${images === 1 ? "1 image" : `${images} images`}</div>` : nothing;
80
86
  const list = (images || []).filter((i) => i && (i.src || i.file));
81
87
  if (!list.length) return nothing;
82
88
  const thumbs = list.map((img) => {
@@ -24,6 +24,7 @@
24
24
  <link rel="stylesheet" href="/assets/forms.css">
25
25
  <link rel="stylesheet" href="/assets/objectives.css">
26
26
  <link rel="stylesheet" href="/assets/block_renderers.css">
27
+ <link rel="stylesheet" href="/assets/ws_preview.css">
27
28
  <style>
28
29
  /* Icon masks shared by the phone info bar and the all view's cards (the stroke only shapes the mask; the icon takes currentColor); the image icon is composer.css's. */
29
30
  :root{--icon-archive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
@@ -40,6 +41,8 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
40
41
  (the gradient covers the body): it is the body's background-color, which is what iOS 26's Home Screen app paints
41
42
  the status bar strip in (not theme-color, not html's background; with no colour the strip is black). */
42
43
  @media(max-width:600px){body{background:linear-gradient(var(--bg-top),var(--bg) 420px),var(--bg-top)}}
44
+ /* iOS 26 blurs the Home Screen app's top band under the clock; measured 28px total is needed, and a fixed element does not help. */
45
+ @media (display-mode:standalone) and (max-width:600px){body{padding-top:12px}}
43
46
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
44
47
  .turn-stage,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
45
48
  @media(hover:hover){#topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}}
@@ -184,6 +187,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:max(16px,var(--s
184
187
  /* Plugins' slow setup (chi.init, init_row.js): a quiet line per task over the composer. */
185
188
  #initRow{display:flex;flex-direction:column;gap:2px;padding:6px 12px 2px;font-size:12px;line-height:1.4;color:var(--text-3)}
186
189
  #initRow[hidden]{display:none}
190
+ /* A control the viewer may not use (viewer.js control): hidden whatever its own display rule says. */
191
+ [data-withheld]{display:none!important}
187
192
  .init-task{display:flex;align-items:center;gap:7px;min-width:0;transition:opacity .4s ease}
188
193
  .init-task.fading{opacity:0}
189
194
  .init-bundle{flex:none;padding:0 5px;border:1px solid var(--border-strong);border-radius:4px;font-size:11px;color:var(--text-muted)}
@@ -759,6 +764,7 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 16px + var(-
759
764
  /* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
760
765
  .bubble.user.queued[data-delivery="queue"]{border:1px dashed var(--accent-border);border-left:3px dashed var(--accent);background:transparent}
761
766
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
767
+ .thumbs-hidden{margin-top:6px;font-size:12px;color:var(--text-muted)}
762
768
  .thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
763
769
  .activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
764
770
  @media(max-width:600px){.activity-row .thumbs{margin-left:0}}
@@ -1355,8 +1361,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1355
1361
  .pp-list{list-style:none;display:flex;flex-direction:column;gap:8px;margin:2px 0}
1356
1362
  .pp-row{display:grid;grid-template-columns:1fr auto;gap:2px 8px;padding:6px 8px;border-radius:8px;background:var(--bg-inset);border-left:3px solid var(--author-color)}
1357
1363
  .pp-row.ended{opacity:.7}
1358
- .pp-who{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text)}
1359
- .pp-who b{color:var(--author-color);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1364
+ .pb-who{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text)}
1365
+ .pb-who b{color:var(--author-color);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1360
1366
  .pp-dot{width:7px;height:7px;border-radius:50%;background:var(--text-faint);flex-shrink:0}
1361
1367
  .pp-dot.present{background:var(--ok)}
1362
1368
  .pp-tag{font-size:10.5px;padding:0 6px;border-radius:999px;background:var(--bg-raised);color:var(--text-3)}
@@ -1435,8 +1441,6 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1435
1441
  /* On a phone the panel is a sheet: the link goes out by Share or Copy, not by scanning this screen. */
1436
1442
  .participants-popover.sheet .pp-qr{display:none}
1437
1443
  .pp-foot{display:flex;align-items:center;gap:8px;justify-content:flex-end;padding-top:4px}
1438
- .pp-view{font-size:12px;color:var(--accent);text-decoration:none}
1439
- @media(hover:hover){.pp-view:hover{text-decoration:underline}}
1440
1444
  /* The Home Screen app's notifications sheet (push_devices.js): the bell opens it; a bottom sheet on a phone. */
1441
1445
  .push-devices .ctx-pop-head strong{font-family:inherit}
1442
1446
  .push-devices.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
@@ -2018,6 +2022,38 @@ a{color:var(--accent)}
2018
2022
  .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
2019
2023
  #modelSearch,.project-create input{font-size:16px}
2020
2024
  }
2025
+ /* A participant's page (participant_main.js): a plain logo, their name and the link's end in the top row; the bottom row in
2026
+ the info line's place; the composer given way to one line (their limit, a stopped session, an ended link); the stage's
2027
+ look while their turn waits for the owner (stage_owner_wait.js). */
2028
+ .plain-brand{font-size:18px;font-weight:700;letter-spacing:.02em;color:var(--accent);padding:1px 4px 2px}
2029
+ #participantWho{display:flex;align-items:baseline;gap:12px;flex:1 1 auto;min-width:0;white-space:nowrap}
2030
+ body.participant-view #sessionTitleRow{flex:0 1 auto}
2031
+ #participantWho .pb-ends{margin-left:auto}
2032
+ .pb-who{display:inline-flex;align-items:center;gap:5px;padding:1px 9px;border-radius:999px;border:1px solid var(--border-strong);background:var(--bg-panel);font-size:12px;color:var(--text-2)}
2033
+ .pb-who b{color:var(--who-color,var(--author-5));font-weight:600}
2034
+ .pb-ends{color:var(--text-faint);font-size:12px}
2035
+ #participantBar{flex:1 1 100%;min-width:0;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--text-3);padding:2px 0}
2036
+ .pb-pill{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;background:var(--bg-raised);color:var(--text-3);white-space:nowrap}
2037
+ .pb-status[data-key]{color:var(--text-2)}
2038
+ .pb-gap{flex:1}
2039
+ .pb-rules{color:var(--text-faint);white-space:nowrap}
2040
+ #participantClosed{padding:12px 14px;color:var(--text-2);font-size:13px;line-height:1.45}
2041
+ #participantClosed b{display:block;color:var(--text);font-weight:600}
2042
+ body.participant-closed :is(#composeGrip,#deliveryLine,#composer,#infoBar){display:none!important}
2043
+ body.participant-view .bubble.user:not(.by-participant)>.origin-label{color:var(--author-owner);text-transform:none;letter-spacing:0;font-weight:600}
2044
+ .ts-owner-wait{margin:4px 0 6px;padding:6px 9px;border-radius:6px;background:var(--warn-bg);border:1px solid var(--warn-border);color:var(--warn-text);font-size:12.5px;line-height:1.4}
2045
+ .ts-owner-wait[hidden]{display:none}
2046
+ .turn-stage.owner-wait{border-left-color:var(--warn)}
2047
+ .turn-stage.owner-wait :is(.ts-now,.ts-trail){display:none}
2048
+ .turn-stage.owner-wait .ts-phase{color:var(--warn-text)}
2049
+ .turn-stage.owner-wait .ts-mark{background:var(--warn);animation:none}
2050
+ @media(max-width:600px){
2051
+ body.participant-view .plain-brand,body.participant-view .pb-ends,body.participant-view .pb-rules{display:none}
2052
+ body.participant-view #sessionTitleRow{flex-basis:auto;order:0}
2053
+ body.participant-view #topBar{flex-wrap:nowrap;align-items:center}
2054
+ /* Their delivery line is its own row over the text field (no session bar's left to lie over). */
2055
+ body.participant-view #deliveryLine.running:not([hidden]){display:flex!important;position:static!important;height:auto!important;padding:6px 2px 0!important}
2056
+ }
2021
2057
  </style>
2022
2058
  </head>
2023
2059
  <body>
@@ -142,7 +142,8 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
142
142
  const tokens = state.timing.tokens;
143
143
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
144
144
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
145
- budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
145
+ budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch,
146
+ running },
146
147
  state.timing.modelSegments, state.timing.side);
147
148
  const speedTextNow = lastSpeedText(tokens);
148
149
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
@@ -18,10 +18,11 @@ export function createMarkdownPlace({
18
18
  const waiting = new WeakSet();
19
19
 
20
20
  // A text the page already shows as its markdown HTML (POST /api/render,
21
- // sanitised there), or null: markdown off here (no request), or a failed
22
- // read (the raw text stays).
21
+ // sanitised there), or null: markdown off here or no +renderText+ (a
22
+ // viewer without render, viewer.js: no request), or a failed read (the
23
+ // raw text stays).
23
24
  function renderMarkdown(text) {
24
- if (capabilities().display !== true) return Promise.resolve(null);
25
+ if (!renderText || capabilities().display !== true) return Promise.resolve(null);
25
26
  return renderText(text)
26
27
  .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
27
28
  .catch(() => null);
@@ -79,7 +80,7 @@ export function createMarkdownPlace({
79
80
  // on the swap, then the `path:line` ones (linkRefsIn). A null render
80
81
  // keeps the text, and the next open asks again.
81
82
  function renderStepTexts() {
82
- if (capabilities().display !== true) return;
83
+ if (!renderText || capabilities().display !== true) return;
83
84
  for (const root of roots()) {
84
85
  for (const el of root.querySelectorAll(RAW_STEPS)) {
85
86
  const step = el.parentElement;