samagotchi 0.119.0 → 0.121.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. metadata +10 -1
@@ -0,0 +1,196 @@
1
+ import { render } from "./vendor/lit-html.js";
2
+ import { admitImages, chipName, chipsTemplate, imageFiles } from "./images.js";
3
+ import { promptHeight } from "./composer_size.js";
4
+ import { GRIP_KEY, createComposerGrip } from "./composer_grip.js";
5
+ import { createPromptHistory } from "./prompt_history.js";
6
+ import { readStored } from "./storage.js";
7
+
8
+ // The composer, mounted on a page's markup (composer.css): the message box,
9
+ // its height (it grows with the text; the grip sets a floor), its image
10
+ // chips (paste, drop and the image button), the grip and fold
11
+ // (composer_grip.js), ↑/↓ over the prompt history (prompt_history.js) and
12
+ // its keys. What a page sends, and how, stays the page's: the owner's page
13
+ // (app.js) hands the result to send_turn.js as its composer.
14
+ //
15
+ // +els+: {composer (the card's box, where files drop), prompt (the text box),
16
+ // button (Send/Start), chips (the chips' row), attachBtn, attachInput (the
17
+ // hidden file picker), grip}.
18
+ // Hooks: +minPx()+ the empty box's height; +toast(text)+ why images stayed
19
+ // out; +onWrite()+ the composer wrote the text itself (a recalled entry);
20
+ // +loadHistory()+ a promise of the ↑/↓ entries (oldest first; none: no
21
+ // history); +send({queue})+ Enter (+queue+: with Alt/Option) or the button;
22
+ // +keyFirst(e)+ a key the page takes before the history's (true: taken);
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
25
+ // paste, drop or pick of one toasts it and adds no chip; null takes them.
26
+ // +fold+: createComposerGrip's page hooks (session, waiting, turnRunning,
27
+ // allViewOpen, lightboxOpen, infoCancelBtn, infoBarEl, typeAnywhere).
28
+ // @return {el, button, chips(), setChips(list), addImageFiles(files), fit(),
29
+ // hasDraft(), refreshHistory(), and the grip's isComposerFolded,
30
+ // openForVisit, focusPrompt, drawFoldControls, newVisit, decideVisit}
31
+ export function mountComposer({
32
+ els, minPx, toast, onWrite = () => {}, loadHistory = null, send, keyFirst = () => false, escape = () => false,
33
+ imagesOff = null, fold, win = globalThis.window, createObjectURL = (f) => URL.createObjectURL(f),
34
+ revokeObjectURL = (src) => URL.revokeObjectURL(src),
35
+ }) {
36
+ const { composer: composerEl, prompt: promptEl, button, chips: chipsEl, attachBtn, attachInput, grip } = els;
37
+
38
+ // ── Images: paste or drop into the composer, chips until sent ─────────────
39
+ let chips = [];
40
+ // Bound once the grip is made (below); a chip drawn before that has no fold to redraw.
41
+ let drawFoldControls = () => {};
42
+
43
+ function renderChips() {
44
+ render(chipsTemplate(chips), chipsEl);
45
+ chipsEl.hidden = !chips.length;
46
+ drawFoldControls();
47
+ }
48
+
49
+ // A chip that goes frees its preview; one kept in +list+ keeps it.
50
+ function setChips(list) {
51
+ for (const c of chips) if (c.file && c.src && !list.includes(c)) revokeObjectURL(c.src);
52
+ chips = list;
53
+ renderChips();
54
+ }
55
+
56
+ // A paste's, a drop's or the image button's files: the images the message
57
+ // may still take become chips (images.js admitImages); a toast says why the
58
+ // rest stayed out. Whether any went in.
59
+ function addImageFiles(files) {
60
+ if (imagesOff) {
61
+ if (files.length) toast(imagesOff);
62
+ return false;
63
+ }
64
+ const { take, refused } = admitImages(files, chips.length);
65
+ if (refused) toast(refused);
66
+ if (!take.length) return false;
67
+ for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: createObjectURL(file) });
68
+ renderChips();
69
+ focusPrompt();
70
+ return true;
71
+ }
72
+
73
+ chipsEl.addEventListener("click", (e) => {
74
+ const btn = e.target.closest(".chip-remove");
75
+ if (!btn) return;
76
+ const [chip] = chips.splice(Number(btn.dataset.index), 1);
77
+ if (chip?.file && chip.src) revokeObjectURL(chip.src);
78
+ renderChips();
79
+ });
80
+ // The image button: the file picker's images join the chips. Its value is
81
+ // cleared after each pick, so the same file can be picked again.
82
+ attachBtn.addEventListener("click", () => attachInput.click());
83
+ attachInput.addEventListener("change", () => {
84
+ addImageFiles([...attachInput.files]);
85
+ attachInput.value = "";
86
+ });
87
+
88
+ // ── Height: grows with the text; the top-edge grip sets a floor ───────────
89
+ // The floor is saved as it was set; fit holds it to half the window it is
90
+ // drawn in (composer_size.js), so a floor saved in a bigger window fits this one
91
+ // and comes back when the window is big again.
92
+ let floor = Number(readStored(GRIP_KEY)) || null;
93
+
94
+ function fit() {
95
+ const borders = promptEl.offsetHeight - promptEl.clientHeight;
96
+ promptEl.style.height = "0px";
97
+ const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: floor, viewportPx: win.innerHeight, minPx: minPx() });
98
+ promptEl.style.height = `${height}px`;
99
+ promptEl.style.overflowY = scrolls ? "auto" : "hidden";
100
+ }
101
+
102
+ promptEl.addEventListener("input", fit);
103
+ win.addEventListener("resize", fit);
104
+ fit();
105
+
106
+ promptEl.addEventListener("paste", (e) => {
107
+ const files = imageFiles(e.clipboardData);
108
+ // Text pasted along with an image still lands in the textarea.
109
+ if (addImageFiles(files) && !e.clipboardData.getData("text/plain")) e.preventDefault();
110
+ });
111
+ composerEl.addEventListener("dragover", (e) => {
112
+ if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
113
+ e.preventDefault();
114
+ composerEl.classList.add("dropping");
115
+ });
116
+ composerEl.addEventListener("dragleave", () => composerEl.classList.remove("dropping"));
117
+ composerEl.addEventListener("drop", (e) => {
118
+ composerEl.classList.remove("dropping");
119
+ const files = imageFiles(e.dataTransfer);
120
+ if (!files.length) return;
121
+ e.preventDefault();
122
+ addImageFiles(files);
123
+ });
124
+
125
+ const hasDraft = () => !!(promptEl.value.trim() || chips.length);
126
+
127
+ // ── The grip and fold (composer_grip.js) ──────────────────────────────────
128
+ const composerGrip = createComposerGrip({
129
+ ...fold,
130
+ composeGrip: grip,
131
+ promptEl,
132
+ fitPrompt: fit,
133
+ promptMinPx: minPx,
134
+ floor: () => floor,
135
+ setFloor: (px) => { floor = px; },
136
+ draft: hasDraft,
137
+ imageFiles,
138
+ addImageFiles,
139
+ win,
140
+ });
141
+ const { focusPrompt } = composerGrip;
142
+ ({ drawFoldControls } = composerGrip);
143
+
144
+ // ── ↑/↓: the prompt history (prompt_history.js) ───────────────────────────
145
+ // From a copy fetched on focus and after each send (keydown can't wait for one).
146
+ const promptHistory = createPromptHistory();
147
+ function refreshHistory() {
148
+ if (!loadHistory) return;
149
+ loadHistory().then((entries) => promptHistory.setEntries(entries), () => {});
150
+ }
151
+ promptEl.addEventListener("focus", refreshHistory);
152
+ function historyKey(e) {
153
+ if (e.isComposing || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return false;
154
+ const step = e.key === "ArrowUp" ? promptHistory.up : e.key === "ArrowDown" ? promptHistory.down : null;
155
+ const recalled = step?.({ value: promptEl.value, start: promptEl.selectionStart, end: promptEl.selectionEnd });
156
+ if (!recalled) return false;
157
+ e.preventDefault();
158
+ promptEl.value = recalled.value;
159
+ onWrite();
160
+ promptEl.setSelectionRange(recalled.caret, recalled.caret);
161
+ fit();
162
+ return true;
163
+ }
164
+
165
+ promptEl.addEventListener("keydown", (e) => {
166
+ if (keyFirst(e)) return;
167
+ if (historyKey(e)) return;
168
+ if (e.key === "Escape" && escape()) {
169
+ e.preventDefault();
170
+ return;
171
+ }
172
+ // An IME's Enter picks the composition, it doesn't send.
173
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
174
+ e.preventDefault();
175
+ send({ queue: e.altKey });
176
+ }
177
+ });
178
+ button.addEventListener("click", () => send({ queue: false }));
179
+
180
+ return {
181
+ el: promptEl,
182
+ button,
183
+ chips: () => chips,
184
+ setChips,
185
+ addImageFiles,
186
+ fit,
187
+ hasDraft,
188
+ refreshHistory,
189
+ isComposerFolded: composerGrip.isComposerFolded,
190
+ openForVisit: composerGrip.openForVisit,
191
+ focusPrompt,
192
+ drawFoldControls: composerGrip.drawFoldControls,
193
+ newVisit: composerGrip.newVisit,
194
+ decideVisit: composerGrip.decideVisit,
195
+ };
196
+ }
@@ -0,0 +1,12 @@
1
+ /* A form's select box (participants_panel.js' Invite, level_chip.js' Level, llm_context_chip.js' Strategy / Apply):
2
+ one shape in every dialog. Each browser draws its own closed box (macOS a pill with a double chevron), so a select
3
+ in a .select-wrap drops it and keeps its dialog's text-field box (the dialog's own `input, select` rule: height,
4
+ radius, border, background, padding, font); the wrapper draws one thin caret at the right. The open list stays the
5
+ OS menu. Both pages link it, after tokens.css and before their own <style>: span.select-wrap>select outweighs a
6
+ dialog's `… select` rule, so its padding shorthand keeps room for the caret. Colours are tokens.css's. */
7
+ .select-wrap{position:relative;display:inline-flex;min-width:0}
8
+ span.select-wrap>select{appearance:none;-webkit-appearance:none;flex:1;min-width:0;padding-right:26px;cursor:pointer}
9
+ .select-wrap::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
10
+ .select-wrap select option{background:var(--bg-raised);color:var(--text)}
11
+ span.select-wrap>select:disabled{opacity:.6;cursor:default}
12
+ .select-wrap:has(select:disabled)::after{opacity:.6}
@@ -10,9 +10,11 @@
10
10
  <meta name="apple-mobile-web-app-title" content="Chi">
11
11
  <meta name="viewport" content="width=device-width,initial-scale=1">
12
12
  <link rel="stylesheet" href="/assets/tokens.css">
13
+ <link rel="stylesheet" href="/assets/annotate_bar.css">
14
+ <link rel="stylesheet" href="/assets/composer.css">
15
+ <link rel="stylesheet" href="/assets/forms.css">
13
16
  <style>
14
- /* Icon masks shared by the phone info bar, the all view's cards and the composer's image button (the stroke only shapes the mask; the icon takes currentColor). */
15
- :root{--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")}
17
+ /* 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. */
16
18
  :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")}
17
19
  *{box-sizing:border-box;margin:0;padding:0}
18
20
  /* No iOS text autosizing: it grows text in a box sized for the text as written (it clipped the session cards). */
@@ -424,6 +426,10 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
424
426
  .question-option:hover{background:var(--bg-hover)}
425
427
  .question-option.selected{border-color:var(--ok);color:var(--text)}
426
428
  .question-option input{margin:0;accent-color:var(--warn)}
429
+ /* The model's recommended option (a label ending in "(Recommended)"): an accent row and a small tag. */
430
+ .question-option.recommended{border-color:var(--accent-border);background:var(--accent-bg);color:var(--text)}
431
+ .question-option.recommended.selected{border-color:var(--ok)}
432
+ .question-option-tag{margin-left:auto;flex:none;font-size:11px;color:var(--accent);border:1px solid var(--accent-border);border-radius:999px;padding:0 6px}
427
433
  .question-freeform{width:100%;box-sizing:border-box;background:var(--bg-inset);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;margin-bottom:8px}
428
434
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
429
435
  .question-error.hidden{display:none}
@@ -543,22 +549,6 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
543
549
  /* In a step: a row, lighter (no float), like the step's notices. */
544
550
  .gen .bubble.plugin-card{margin:6px 0 4px;box-shadow:inset 0 1px 0 var(--glass-highlight);padding:8px 10px}
545
551
  .hint{color:var(--text-muted);font-size:13px;padding:12px}
546
- /* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
547
- #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
548
- #annotateBar[hidden]{display:none}
549
- #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
550
- /* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
551
- #annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
552
- #annotateBar .annotate-actions[hidden]{display:none}
553
- #annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
554
- #annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
555
- #annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
556
- #annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
557
- #annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
558
- #annotateBar.docked{right:8px;justify-content:center}
559
- @media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
560
- #annotateBar .annotate-more-list[hidden]{display:none}
561
- #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
562
552
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
563
553
  .bubble.output,.bubble.user{position:relative}
564
554
  .copy-btn{position:absolute;top:6px;right:6px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);font-size:11px;line-height:1;white-space:nowrap;user-select:none;-webkit-user-select:none;opacity:0;transition:opacity .12s ease,color .12s ease}
@@ -586,9 +576,6 @@ body.no-glass.has-session #center{background:var(--bg);border-color:var(--border
586
576
  body.has-session #dock{position:absolute;left:16px;right:16px;bottom:16px;margin:0;z-index:3}
587
577
  body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
588
578
  #dock:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
589
- #composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
590
- #composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
591
- #composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
592
579
  /* The delivery line (delivery_line.js): a session's row above the text field, always there so the composer never changes height: the key hint while idle; while a turn runs, where a message typed now goes in, then the cut and queue links. One quiet row, ellipsised; a phone gets the short text and chips. */
593
580
  #deliveryLine{display:flex;align-items:center;gap:10px;min-width:0;flex:1 0 100%;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
594
581
  #deliveryLine[hidden]{display:none}
@@ -602,8 +589,6 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
602
589
  #deliveryLine .delivery-link:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
603
590
  #deliveryLine .delivery-link:disabled{color:var(--text-faint);opacity:1;cursor:default}
604
591
  #deliveryLine .delivery-link.armed{color:var(--on-solid);background:var(--accent-strong);padding:0 6px}
605
- /* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
606
- body.has-session #composer{flex-wrap:wrap;row-gap:6px}
607
592
  @media(max-width:600px){
608
593
  #deliveryLine .long,#deliveryLine .delivery-sep{display:none}
609
594
  #deliveryLine .short{display:inline}
@@ -613,12 +598,6 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
613
598
  }
614
599
  /* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
615
600
  .bubble.user.queued[data-delivery="queue"]{border:1px dashed var(--accent-border);border-left:3px dashed var(--accent);background:transparent}
616
- #chips{display:flex;flex-wrap:wrap;gap:6px}
617
- #chips[hidden]{display:none}
618
- .chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
619
- .chip img{width:32px;height:32px;object-fit:cover;border-radius:5px}
620
- .chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
621
- .chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
622
601
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
623
602
  .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)}
624
603
  .activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
@@ -626,56 +605,6 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
626
605
  #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
627
606
  #lightbox[hidden]{display:none}
628
607
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
629
- #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
630
- /* An edge grip: the thin handle on an edge between two panes (the composer's,
631
- the latest-sessions strip's). The pill is the handle's look, a 36×4 px bar
632
- centred on the edge, drawn on hover, while dragging or focused; the id
633
- rules hold each grip's own box: height and margins (they depend on the flex
634
- gap around it: none for the composer's, main's 16 px for the strip's) and
635
- its own states. */
636
- .edge-grip{position:relative;z-index:2;cursor:ns-resize;touch-action:none}
637
- .edge-grip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
638
- .edge-grip:hover::after,.edge-grip.dragging::after{opacity:1}
639
- .edge-grip:hover::after,.edge-grip.dragging::after{background:var(--accent)}
640
- .edge-grip:focus-visible{outline:none}
641
- .edge-grip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
642
- #composeGrip{height:10px;margin:-5px 0}
643
- #composeGrip:has(+ #composer:hover)::after{opacity:1}
644
- /* Folded (composer_grip.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
645
- draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
646
- body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
647
- #composeGrip.draft::after{background:var(--accent)}
648
- #composeGrip.waiting::after{background:var(--warn)}
649
- #composeGrip.will-fold::after{opacity:1;background:var(--accent)}
650
- /* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
651
- info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
652
- border and padding, so it never covers a button of the bar. */
653
- @media(max-width:600px){
654
- #composeGrip{width:96px;height:26px;margin:-19px auto -7px}
655
- #composeGrip::after{top:17px;opacity:1}
656
- }
657
- #composer textarea::placeholder{color:var(--text-faint)}
658
- /* Key hint: its line is always reserved, so focusing never shifts the layout. */
659
- #composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
660
- #composer:focus-within #composeHint:not(:empty){opacity:1}
661
- #composer textarea:focus{outline:none}
662
- /* The toolbar row: model picker · hint · Cancel · Send (the JS display toggles decide what shows). */
663
- #composer .actions-col{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
664
- #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
665
- #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
666
- #composer #actionBtn{min-width:64px;font-weight:600}
667
- /* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
668
- an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
669
- #composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
670
- #composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
671
- #composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
672
- #composer .actions-col .attach-btn[hidden]{display:none}
673
- /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
674
- and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
675
- body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
676
- body.has-session #composer textarea{min-height:0}
677
- body.has-session #composeHint{display:none}
678
- body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
679
608
  /* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
680
609
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
681
610
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
@@ -1115,9 +1044,9 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1115
1044
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
1116
1045
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
1117
1046
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
1118
- /* The composer folded away (composer_grip.js: the grip dragged down, Enter/Space on it): the info bar is the page's bottom edge.
1047
+ /* The composer folded away (composer_grip.js, composer.css): the info bar is the page's bottom edge; the plugins' setup row folds with it.
1119
1048
  Stop turn comes into the bar while folded and a turn runs. */
1120
- body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
1049
+ body.has-session.composer-folded #initRow{display:none}
1121
1050
  body.has-session.composer-folded #infoBar{border-top-color:transparent}
1122
1051
  #infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
1123
1052
  #infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
@@ -1131,12 +1060,9 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1131
1060
  .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
1132
1061
  .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:inherit;font-size:13px;line-height:1.4}
1133
1062
  .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
1134
- /* The settings selects draw their own closed box (each browser's native one differs) with the model picker's drawn caret on the label; the open list stays the OS menu. */
1135
- .llmctx-field select{appearance:none;-webkit-appearance:none;padding-right:26px;cursor:pointer}
1136
- .llmctx-field select option{background:var(--bg-raised);color:var(--text)}
1063
+ /* A select's closed box and caret: forms.css (.select-wrap); here the wrapper takes the field's width. */
1064
+ .llmctx-field>.select-wrap{flex:1}
1137
1065
  .llmctx-field input::placeholder{color:var(--text-faint);opacity:1}
1138
- .llmctx-field:has(select){position:relative}
1139
- .llmctx-field:has(select)::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
1140
1066
  .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
1141
1067
  .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
1142
1068
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
@@ -1187,6 +1113,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1187
1113
  .participants-popover input,.participants-popover select{font:inherit;font-size:12.5px;color:var(--text);background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:6px;padding:3px 7px}
1188
1114
  .pp-invite input[name=name]{flex:1;min-width:120px}
1189
1115
  .pp-invite input[name=cap]{width:52px}
1116
+ .pp-invite input[name=identity]{flex:1;min-width:160px}
1117
+ .pp-bound{color:var(--text-2)}
1190
1118
  .pp-invite .pp-invite-btn{margin-left:auto;background:var(--primary);border-color:var(--primary);color:var(--on-solid);font-weight:600}
1191
1119
  .pp-invite-done{display:flex;flex-direction:column;gap:6px;padding-top:6px;border-top:1px solid var(--border)}
1192
1120
  .pp-warning{font-size:12px;line-height:1.45;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:6px;padding:5px 8px}
@@ -1270,8 +1198,6 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1270
1198
  .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
1271
1199
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable). */
1272
1200
  @media(max-width:600px){
1273
- /* The key hint: cut off at this width, and phones have no Shift+Enter. */
1274
- #composeHint{display:none}
1275
1201
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
1276
1202
  /* No terminal on a phone to attach from. */
1277
1203
  #infoBar .meta .copy-attach{display:none}
@@ -69,8 +69,8 @@ export function formTemplate(cfg, summary, { running = false, kind = SESSION, ch
69
69
  ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
70
70
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
71
71
  ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
72
- ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
73
- cfg.levels.map((level) => option(level, level, chosen))}</select></label
72
+ ><label class="llmctx-field"><span>Level</span><span class="select-wrap"><select name="level">${option(DEFAULT, follow, chosen)}${
73
+ cfg.levels.map((level) => option(level, level, chosen))}</select></span></label
74
74
  ><div class="ctx-pop-meta">${cfg.when(fresh)}${running ? " A turn is running: it applies after this turn." : ""}</div
75
75
  ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
76
76
  }
@@ -179,10 +179,10 @@ export function formTemplate(summary, { running = false, kind = SESSION, choice
179
179
  ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
180
180
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
181
181
  ><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
182
- ><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
183
- option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></label
184
- ><label class="llmctx-field"><span>Apply</span><select name="apply" .value=${chosen(own.apply)}>${
185
- option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></label
182
+ ><label class="llmctx-field"><span>Strategy</span><span class="select-wrap"><select name="strategy" .value=${chosen(own.strategy)}>${
183
+ option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></span></label
184
+ ><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select name="apply" .value=${chosen(own.apply)}>${
185
+ option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></span></label
186
186
  ><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
187
187
  placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
188
188
  ><div class="ctx-pop-error llmctx-error" ?hidden=${!error}>${error || ""}</div
@@ -12,7 +12,10 @@
12
12
  // leaves the bell as it is in a tab. With the bell on, the app also tidies
13
13
  // the device (syncDevice): it closes the pushed notifications whose
14
14
  // question or card isn't open any more and sets the app's badge, as chi
15
- // web sends no close push (WebKit's silent-push quota). There the bell
15
+ // web sends no close push (WebKit's silent-push quota). The device's
16
+ // notifications are every project's, so on a project's page (?dir=, or
17
+ // the bare page on chi web's start project) the tidy-up follows a stream
18
+ // of every session of its own (watchAll), not the page's. There the bell
16
19
  // opens the notifications sheet (push_devices.js): this device's switch,
17
20
  // the other devices with their "long turns done" switch and Remove.
18
21
  //
@@ -104,8 +107,12 @@ function defaultChannel() {
104
107
  // @param gateOptions passed to createNotifyGate (specs: schedule, now)
105
108
  // @param nav the navigator (serviceWorker; push.js pushSupported)
106
109
  // @param api (method, path, body) => Promise of chi web's JSON
107
- // @param scoped the page shows one project's sessions (?dir=): it never
108
- // tidies the device, as it doesn't see the others'
110
+ // @param scoped the page shows one project's sessions (?dir=, or the
111
+ // start project): the device's tidy-up follows every session through
112
+ // allEvents instead of the page's frames
113
+ // @param allEvents (handlers, {onError, onClosed}) => {close()}: a stream
114
+ // of every session's hub frames (app.js: data.js openEvents without a
115
+ // dir); null: a project's page never tidies the device
109
116
  // @param now () => ms (the service worker update's spacing, presence's
110
117
  // touch; specs)
111
118
  // @param sendPresence (body, leaving) => void (presenceSender's by default)
@@ -125,6 +132,7 @@ export function createNotifications({
125
132
  nav = globalThis.navigator,
126
133
  api = defaultApi,
127
134
  scoped = false,
135
+ allEvents = null,
128
136
  now = () => Date.now(),
129
137
  sendPresence = presenceSender({ nav, api }),
130
138
  every = (fn, ms) => setInterval(fn, ms),
@@ -132,6 +140,10 @@ export function createNotifications({
132
140
  devices = (hooks) => createPushDevices({ ...hooks, doc, win }),
133
141
  }) {
134
142
  let attention = initialAttentionState();
143
+ // A project's page with the bell on in the app: what is open across
144
+ // every project (the device's notifications are), from allEvents.
145
+ let allAttention = initialAttentionState();
146
+ let allStream = null;
135
147
  // Web Push in the Home Screen app: null until chi web says it pushes
136
148
  // ({reg, key, on}), and for good in a tab or without push.
137
149
  let push = null;
@@ -219,6 +231,7 @@ export function createNotifications({
219
231
  // for the next subscribe to replace.
220
232
  if (had) store(PUSH_DEVICE_KEY, null);
221
233
  push.on = false;
234
+ unwatchAll();
222
235
  setDeviceBadge(0);
223
236
  } else if (NotificationApi.permission === "granted" || (await NotificationApi.requestPermission()) === "granted") {
224
237
  const replaces = stored(PUSH_DEVICE_KEY);
@@ -229,6 +242,7 @@ export function createNotifications({
229
242
  if (answer?.id) store(PUSH_DEVICE_KEY, answer.id);
230
243
  push.on = true;
231
244
  api("POST", "/api/push/test", { endpoint: subscription.endpoint }).catch(() => {});
245
+ watchAll();
232
246
  syncDevice();
233
247
  }
234
248
  } catch (e) {
@@ -261,6 +275,7 @@ export function createNotifications({
261
275
  swCheckedAt = now();
262
276
  renderNotifyBtn();
263
277
  } catch (_) {}
278
+ watchAll();
264
279
  syncDevice();
265
280
  }
266
281
 
@@ -282,14 +297,48 @@ export function createNotifications({
282
297
  } catch (_) {}
283
298
  }
284
299
 
300
+ // What the device's tidy-up goes by: the page's own frames, or on a
301
+ // project's page every session's (allEvents).
302
+ const deviceAttention = () => (scoped ? allAttention : attention);
303
+
304
+ // A project's page in the app with the bell on: a stream of every
305
+ // session, for the tidy-up only (the page's list and badges stay the
306
+ // project's). One at a time; a stream that ended is opened again the
307
+ // next time the page comes back in front.
308
+ function watchAll() {
309
+ if (!scoped || !allEvents || !push?.on || allStream) return;
310
+ allAttention = initialAttentionState();
311
+ const frame = (type) => (data) => {
312
+ allAttention = trackAttention(allAttention, type, data).state;
313
+ if (doc.visibilityState === "visible") syncDevice();
314
+ };
315
+ const ended = () => {
316
+ allStream = null;
317
+ allAttention = initialAttentionState();
318
+ };
319
+ try {
320
+ allStream = allEvents({ snapshot: frame("snapshot"), session: frame("session"), session_gone: frame("session_gone") },
321
+ { onError: ended, onClosed: ended });
322
+ } catch (_) {
323
+ allStream = null;
324
+ }
325
+ }
326
+
327
+ function unwatchAll() {
328
+ try { allStream?.close(); } catch (_) {}
329
+ allStream = null;
330
+ allAttention = initialAttentionState();
331
+ }
332
+
285
333
  // The Home Screen app with the bell on: closes the pushed notifications
286
334
  // whose question or card isn't open (chi's own, chi:test, and a failed
287
335
  // or done turn's stay) and sets the
288
- // app's badge. Never before the first snapshot (an empty byId would close
289
- // them all) nor on a project's page (its byId lacks the others). One at a
290
- // time; asked again meanwhile, it runs once more after.
336
+ // app's badge. Never before the first snapshot of every session (an
337
+ // empty byId would close them all): the page's own, or on a project's
338
+ // page allEvents'. One at a time; asked again meanwhile, it runs once
339
+ // more after.
291
340
  function syncDevice() {
292
- if (!push || !push.on || scoped || !attention.seeded) return syncing;
341
+ if (!push || !push.on || !deviceAttention().seeded) return syncing;
293
342
  if (syncing) {
294
343
  syncAgain = true;
295
344
  return syncing;
@@ -297,7 +346,7 @@ export function createNotifications({
297
346
  syncing = (async () => {
298
347
  do {
299
348
  syncAgain = false;
300
- const open = new Set(openPushKeys(attention.byId));
349
+ const open = new Set(openPushKeys(deviceAttention().byId));
301
350
  try {
302
351
  const shown = await push.reg.getNotifications();
303
352
  for (const n of shown || []) if (isWaitingTag(n.tag) && !open.has(n.tag)) n.close();
@@ -368,6 +417,7 @@ export function createNotifications({
368
417
  function backInFront() {
369
418
  if (doc.visibilityState !== "visible") return;
370
419
  clearBadge();
420
+ watchAll();
371
421
  syncDevice();
372
422
  updateWorker();
373
423
  }