samagotchi 0.119.0 → 0.120.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -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 +45 -10
  7. data/docs/internals/participants.md +109 -12
  8. data/docs/internals/remote-sessions.md +6 -1
  9. data/docs/internals/web-push.md +4 -2
  10. data/docs/sessions.md +4 -3
  11. data/docs/testing.md +10 -1
  12. data/lib/samagotchi/answer_command.rb +2 -0
  13. data/lib/samagotchi/bridge.rb +56 -13
  14. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  15. data/lib/samagotchi/bridge_client.rb +58 -19
  16. data/lib/samagotchi/bridge_secret.rb +35 -0
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +3 -0
  19. data/lib/samagotchi/participant_messages.rb +5 -0
  20. data/lib/samagotchi/participants.rb +16 -6
  21. data/lib/samagotchi/review_command.rb +3 -1
  22. data/lib/samagotchi/run_options.rb +3 -1
  23. data/lib/samagotchi/session_access/contract.rb +4 -1
  24. data/lib/samagotchi/session_access/local.rb +15 -4
  25. data/lib/samagotchi/session_access/remote.rb +43 -7
  26. data/lib/samagotchi/session_access.rb +11 -1
  27. data/lib/samagotchi/session_manager.rb +1 -1
  28. data/lib/samagotchi/sessions_command.rb +1 -2
  29. data/lib/samagotchi/share_command.rb +29 -8
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  31. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  32. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +117 -43
  35. data/lib/samagotchi/web/grants.rb +127 -16
  36. data/lib/samagotchi/web/link_codes.rb +24 -15
  37. data/lib/samagotchi/web/participant_access.rb +69 -13
  38. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  39. data/lib/samagotchi/web/participant_review.rb +66 -8
  40. data/lib/samagotchi/web/participants_admin.rb +12 -6
  41. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  42. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  43. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  44. data/lib/samagotchi/web/public/app.js +66 -155
  45. data/lib/samagotchi/web/public/composer.css +73 -0
  46. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  47. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  48. data/lib/samagotchi/web/public/index.html +7 -83
  49. data/lib/samagotchi/web/public/notifications.js +58 -8
  50. data/lib/samagotchi/web/public/participant.html +45 -10
  51. data/lib/samagotchi/web/public/participant.js +92 -18
  52. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  53. data/lib/samagotchi/web/public/participant_model.js +53 -1
  54. data/lib/samagotchi/web/public/participants_panel.js +34 -10
  55. data/lib/samagotchi/web/public/review_history.js +52 -0
  56. data/lib/samagotchi/web/public/scope.js +12 -8
  57. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  58. data/lib/samagotchi/web/public/stage_view.js +4 -4
  59. data/lib/samagotchi/web/push/notifier.rb +17 -3
  60. data/lib/samagotchi/web/running.rb +7 -4
  61. data/lib/samagotchi/web/server.rb +34 -11
  62. data/lib/samagotchi/web/session_create.rb +7 -7
  63. data/lib/samagotchi/web/start_dir.rb +26 -0
  64. data/lib/samagotchi/worker_sidecar.rb +22 -11
  65. metadata +9 -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
+ }
@@ -10,9 +10,10 @@
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">
13
15
  <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")}
16
+ /* 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
17
  :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
18
  *{box-sizing:border-box;margin:0;padding:0}
18
19
  /* No iOS text autosizing: it grows text in a box sized for the text as written (it clipped the session cards). */
@@ -543,22 +544,6 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
543
544
  /* In a step: a row, lighter (no float), like the step's notices. */
544
545
  .gen .bubble.plugin-card{margin:6px 0 4px;box-shadow:inset 0 1px 0 var(--glass-highlight);padding:8px 10px}
545
546
  .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
547
  /* 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
548
  .bubble.output,.bubble.user{position:relative}
564
549
  .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 +571,6 @@ body.no-glass.has-session #center{background:var(--bg);border-color:var(--border
586
571
  body.has-session #dock{position:absolute;left:16px;right:16px;bottom:16px;margin:0;z-index:3}
587
572
  body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
588
573
  #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
574
  /* 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
575
  #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
576
  #deliveryLine[hidden]{display:none}
@@ -602,8 +584,6 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
602
584
  #deliveryLine .delivery-link:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
603
585
  #deliveryLine .delivery-link:disabled{color:var(--text-faint);opacity:1;cursor:default}
604
586
  #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
587
  @media(max-width:600px){
608
588
  #deliveryLine .long,#deliveryLine .delivery-sep{display:none}
609
589
  #deliveryLine .short{display:inline}
@@ -613,12 +593,6 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
613
593
  }
614
594
  /* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
615
595
  .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
596
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
623
597
  .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
598
  .activity-row .thumbs{flex-basis:100%;margin-left:var(--row-gutter)}
@@ -626,56 +600,6 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
626
600
  #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
627
601
  #lightbox[hidden]{display:none}
628
602
  #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
603
  /* 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
604
  #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
605
  #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 +1039,9 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1115
1039
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
1116
1040
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
1117
1041
  #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.
1042
+ /* 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
1043
  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}
1044
+ body.has-session.composer-folded #initRow{display:none}
1121
1045
  body.has-session.composer-folded #infoBar{border-top-color:transparent}
1122
1046
  #infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
1123
1047
  #infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
@@ -1187,6 +1111,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1187
1111
  .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
1112
  .pp-invite input[name=name]{flex:1;min-width:120px}
1189
1113
  .pp-invite input[name=cap]{width:52px}
1114
+ .pp-invite input[name=identity]{flex:1;min-width:160px}
1115
+ .pp-bound{color:var(--text-2)}
1190
1116
  .pp-invite .pp-invite-btn{margin-left:auto;background:var(--primary);border-color:var(--primary);color:var(--on-solid);font-weight:600}
1191
1117
  .pp-invite-done{display:flex;flex-direction:column;gap:6px;padding-top:6px;border-top:1px solid var(--border)}
1192
1118
  .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 +1196,6 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1270
1196
  .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
1271
1197
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable). */
1272
1198
  @media(max-width:600px){
1273
- /* The key hint: cut off at this width, and phones have no Shift+Enter. */
1274
- #composeHint{display:none}
1275
1199
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
1276
1200
  /* No terminal on a phone to attach from. */
1277
1201
  #infoBar .meta .copy-attach{display:none}
@@ -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
  }
@@ -6,10 +6,12 @@
6
6
  <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
7
7
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
8
8
  <link rel="stylesheet" href="/assets/tokens.css">
9
+ <link rel="stylesheet" href="/assets/annotate_bar.css">
10
+ <link rel="stylesheet" href="/assets/composer.css">
9
11
  <style>
10
12
  /* The participant page (participant.js): the thread with the composer pinned, and for a review session its subjects
11
13
  (the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
12
- The owner's participant view is the same page with no composer. Colours are tokens.css's. */
14
+ The owner's participant view is the same page with no composer (nothing mounted, the dock hidden). Colours are tokens.css's. */
13
15
  *{box-sizing:border-box;margin:0;padding:0}
14
16
  /* No iOS text autosizing (index.html's twin). */
15
17
  html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
@@ -107,9 +109,29 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
107
109
  .p-queued span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
108
110
  .p-queue-note{font-size:12.5px;color:var(--text-muted)}
109
111
  .p-error{font-size:13px;color:var(--danger-text);background:var(--danger-bg-dim);border-radius:8px;padding:6px 10px}
110
- .p-compose{display:flex;gap:8px;align-items:flex-end}
111
- .p-compose textarea{flex:1;resize:none;min-height:42px;max-height:40vh;font:inherit;color:var(--text);background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:10px;padding:9px 12px}
112
- .p-compose textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
112
+ /* The comment box: the shared composer (composer.css, composer_mount.js), as the owner's session composer lays it out
113
+ (participant.js sets body.has-session for a participant: one row, the text field and Send at its bottom right). Its
114
+ grip straddles the dock's top border; folded, the dock keeps the queue and the grip as the handle. No `/` list,
115
+ delivery line or model picker here. */
116
+ #composer{background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:12px}
117
+ #composer:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
118
+ body.has-session #composer{padding:6px 6px 6px 10px}
119
+ #composer textarea{font:inherit;line-height:1.45;padding:2px 0}
120
+ #composer textarea:disabled{cursor:not-allowed}
121
+ #composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
122
+ .p-dock #composeGrip{margin:-15px 0 5px}
123
+ body.composer-folded .p-hint{display:none}
124
+ /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line
125
+ beside the hint; a phone shows it alone, one line, the end cut. Folded, it goes with the hint. */
126
+ .p-foot{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px;min-width:0}
127
+ .p-foot .p-hint{flex:1 1 320px;min-width:0}
128
+ .p-usage{display:flex;align-items:baseline;min-width:0;max-width:100%;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
129
+ .p-usage span{flex:0 0 auto}
130
+ .p-usage span+span::before{content:"·";padding:0 6px;color:var(--text-faint)}
131
+ .p-usage span.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
132
+ .p-usage .warn{color:var(--warn-text)}
133
+ .p-usage .over{color:var(--danger-text);font-weight:600}
134
+ body.composer-folded .p-foot{display:none}
113
135
  button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text);padding:6px 12px}
114
136
  button:hover{background:var(--bg-hover)}
115
137
  button.primary{background:var(--primary);border-color:var(--primary);color:var(--on-solid);height:42px;padding:0 18px}
@@ -129,7 +151,9 @@ button.small{font-size:12px;padding:2px 8px}
129
151
  .p-hint,.p-note{display:none}
130
152
  }
131
153
  /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
132
- @media (max-width:600px),(pointer:coarse){.p-compose textarea{font-size:16px}}
154
+ @media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
155
+ /* A phone's grip (composer.css: 26 px tall, the pill at 17 px): the pill on the dock's top border. */
156
+ @media (max-width:600px){.p-dock #composeGrip{margin:-29px auto 3px}}
133
157
  </style>
134
158
  </head>
135
159
  <body>
@@ -157,14 +181,25 @@ button.small{font-size:12px;padding:2px 8px}
157
181
  <div class="p-thread" id="thread"></div>
158
182
  </main>
159
183
  <footer class="p-dock" id="dock" hidden>
184
+ <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
160
185
  <div class="p-dock-in">
161
186
  <div class="p-queue" id="queue"></div>
162
187
  <div class="p-error" id="error" role="alert" hidden></div>
163
- <form class="p-compose" id="compose">
164
- <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
165
- <button class="primary" id="send" type="submit">Send</button>
166
- </form>
167
- <div class="p-hint">⏎ sends, ⇧⏎ a new line. The model answers in a read-only turn; the owner sees everything.</div>
188
+ <div id="composer">
189
+ <div class="compose-col">
190
+ <div id="chips" hidden></div>
191
+ <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
192
+ </div>
193
+ <div class="actions-col">
194
+ <button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
195
+ <input type="file" id="attachInput" accept="image/*" multiple hidden>
196
+ <div class="split"><button type="button" class="primary" id="send">Send</button></div>
197
+ </div>
198
+ </div>
199
+ <div class="p-foot">
200
+ <div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
201
+ <div class="p-hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
202
+ </div>
168
203
  </div>
169
204
  </footer>
170
205
  </div>