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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- 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
|
|
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
|
|
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 #
|
|
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
|
-
/*
|
|
1135
|
-
.llmctx-field
|
|
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).
|
|
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
|
|
108
|
-
//
|
|
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
|
|
289
|
-
// them all)
|
|
290
|
-
// time; asked again meanwhile, it runs once
|
|
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 ||
|
|
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(
|
|
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
|
}
|