samagotchi 0.78.0 → 0.79.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.
@@ -0,0 +1,231 @@
1
+ import { GRIP_OPEN_PX, gripFloor, gripFolds } from "./composer_size.js";
2
+ import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
3
+ import { readStored, removeStored, writeStored } from "./storage.js";
4
+
5
+ // The composer's grip and fold (used by app.js): the grip's drag, tap and keys, the
6
+ // fold's state per visit and its page-wide listeners. app.js keeps the box's
7
+ // height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
8
+ // fitPrompt reads; the floor is saved under GRIP_KEY.
9
+ export const GRIP_KEY = "chi_composer_floor";
10
+
11
+ // +session+: the open session's id, or null (the start page). +draft+,
12
+ // +waiting+, +turnRunning+, +allViewOpen+, +lightboxOpen+: what the page
13
+ // shows now. +imageFiles+ / +addImageFiles+: a drop's images and the chips.
14
+ export function createComposerGrip({
15
+ composeGrip, promptEl, infoCancelBtn, infoBarEl,
16
+ session, fitPrompt, promptMinPx, floor, setFloor,
17
+ draft, waiting, turnRunning, allViewOpen, lightboxOpen,
18
+ imageFiles, addImageFiles,
19
+ doc = globalThis.document, win = globalThis.window,
20
+ }) {
21
+ // The composer's fold (composer_fold.js): the grip, which is the handle
22
+ // while folded, and the info bar's Stop turn while folded (the composer's
23
+ // #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
24
+ // past the fold threshold.
25
+ const gripStartTitle = composeGrip.title;
26
+ let gripWillFold = false;
27
+ // This visit's state (a visit is one select() call): opened by the page
28
+ // (a focus, a restored line), archived (decided once, from the visit's
29
+ // first getSession answer).
30
+ const newFoldVisit = () => ({ open: false, archived: false, decided: false });
31
+ let foldVisit = newFoldVisit();
32
+
33
+ // A drag down past the box folds the composer on release (a session only: the
34
+ // start page's grip only resizes); from folded, a pull up opens it and the box
35
+ // follows the pointer in the same gesture, a tap opens it and focuses the box.
36
+ // No click listener: a click still fires after pointerdown's preventDefault
37
+ // and would toggle twice.
38
+ composeGrip.addEventListener("pointerdown", (e) => {
39
+ e.preventDefault();
40
+ composeGrip.setPointerCapture(e.pointerId);
41
+ composeGrip.classList.add("dragging");
42
+ const tapY = e.clientY;
43
+ let startY = tapY;
44
+ let startH = promptEl.offsetHeight;
45
+ let opened = false;
46
+ const move = (ev) => {
47
+ if (session() && isComposerFolded()) {
48
+ if (startY - ev.clientY < GRIP_OPEN_PX) return;
49
+ setComposerFolded(false);
50
+ opened = true;
51
+ // Re-based after the unfold (its fitPrompt sized the box: the minimum
52
+ // when empty, the text's height with a draft).
53
+ startH = promptEl.offsetHeight;
54
+ startY = ev.clientY;
55
+ return;
56
+ }
57
+ const requested = startH + (startY - ev.clientY);
58
+ setFloor(gripFloor(requested, win.innerHeight, promptMinPx()));
59
+ fitPrompt();
60
+ const willFold = !!session() && gripFolds({ requestedPx: requested, shownPx: promptEl.offsetHeight });
61
+ if (willFold !== gripWillFold) {
62
+ gripWillFold = willFold;
63
+ composeGrip.classList.toggle("will-fold", willFold);
64
+ drawFoldControls();
65
+ }
66
+ };
67
+ const end = () => {
68
+ composeGrip.classList.remove("dragging");
69
+ composeGrip.removeEventListener("pointermove", move);
70
+ composeGrip.removeEventListener("pointerup", up);
71
+ composeGrip.removeEventListener("pointercancel", cancel);
72
+ const folds = gripWillFold;
73
+ gripWillFold = false;
74
+ composeGrip.classList.remove("will-fold");
75
+ return folds;
76
+ };
77
+ const up = (ev) => {
78
+ const folds = end();
79
+ if (folds) {
80
+ // The next opening starts at the minimum (or the text's height).
81
+ setFloor(null);
82
+ removeStored(GRIP_KEY);
83
+ setComposerFolded(true);
84
+ } else if (session() && isComposerFolded()) {
85
+ if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
86
+ setComposerFolded(false);
87
+ promptEl.focus();
88
+ }
89
+ } else {
90
+ writeStored(GRIP_KEY, String(floor() || ""));
91
+ // A pull that opened the box leaves the focus in it, as a tap does.
92
+ if (opened) promptEl.focus();
93
+ }
94
+ drawFoldControls();
95
+ };
96
+ const cancel = () => {
97
+ end();
98
+ drawFoldControls();
99
+ };
100
+ composeGrip.addEventListener("pointermove", move);
101
+ composeGrip.addEventListener("pointerup", up);
102
+ composeGrip.addEventListener("pointercancel", cancel);
103
+ });
104
+ composeGrip.addEventListener("dblclick", () => {
105
+ if (session() && isComposerFolded()) return;
106
+ setFloor(null);
107
+ removeStored(GRIP_KEY);
108
+ fitPrompt();
109
+ });
110
+ // Enter or Space on the focused handle folds, or opens and focuses the box
111
+ // (a session only; preventDefault: Space would scroll the page).
112
+ composeGrip.addEventListener("keydown", (e) => {
113
+ if (!session() || (e.key !== "Enter" && e.key !== " ")) return;
114
+ e.preventDefault();
115
+ if (isComposerFolded()) {
116
+ setComposerFolded(false);
117
+ promptEl.focus();
118
+ } else {
119
+ setComposerFolded(true);
120
+ }
121
+ });
122
+
123
+ // ── Composer fold (composer_fold.js) ───────────────────────────────────────
124
+ // Dragging the composer's grip down folds the session's composer away; the
125
+ // grip, now the handle on the info bar's top edge, pulled up or clicked, or
126
+ // typing on the page, brings it back. The user's own choice is saved per
127
+ // browser; an archived session opens folded for its visit, and expanding
128
+ // there saves nothing. The page's own focus or a restored line opens it
129
+ // for the visit only. The start page never folds.
130
+ function composerFoldSaved() {
131
+ return readStored(COMPOSER_FOLD_KEY) === "1";
132
+ }
133
+ function isComposerFolded() {
134
+ return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
135
+ }
136
+ function applyComposerFolded() {
137
+ const folded = isComposerFolded();
138
+ const was = doc.body.classList.contains("composer-folded");
139
+ doc.body.classList.toggle("composer-folded", folded);
140
+ // Its height was measured while hidden.
141
+ if (was && !folded) fitPrompt();
142
+ drawFoldControls();
143
+ }
144
+ // The user's own fold or unfold (the grip, typing): saved, unless this
145
+ // is an archived visit (both are for the visit then).
146
+ function setComposerFolded(folded) {
147
+ if (foldVisit.archived) {
148
+ foldVisit.open = !folded;
149
+ } else {
150
+ writeStored(COMPOSER_FOLD_KEY, folded ? "1" : "0");
151
+ foldVisit.open = false;
152
+ }
153
+ applyComposerFolded();
154
+ }
155
+ // The page opens it for this visit: nothing saved.
156
+ function openForVisit() {
157
+ if (!isComposerFolded()) return;
158
+ foldVisit.open = true;
159
+ applyComposerFolded();
160
+ }
161
+ // Every focus of the message box: in a session it opens a folded composer
162
+ // for the visit first (+open+ false: only when it shows already).
163
+ function focusPrompt({ open = true } = {}) {
164
+ if (session() && isComposerFolded()) {
165
+ if (!open) return;
166
+ openForVisit();
167
+ }
168
+ promptEl.focus();
169
+ }
170
+ // The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
171
+ // while folded. The start page's grip only resizes: no role, no fold state.
172
+ function drawFoldControls() {
173
+ const folded = isComposerFolded();
174
+ infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
175
+ if (!session()) {
176
+ composeGrip.removeAttribute("role");
177
+ composeGrip.removeAttribute("aria-expanded");
178
+ composeGrip.title = gripStartTitle;
179
+ for (const c of ["folded", "draft", "waiting"]) composeGrip.classList.remove(c);
180
+ return;
181
+ }
182
+ const h = foldHandle({ folded, draft: draft(), waiting: folded && waiting() });
183
+ composeGrip.title = gripWillFold ? "Release to hide the message box" : h.title;
184
+ composeGrip.setAttribute("role", "button");
185
+ composeGrip.setAttribute("aria-expanded", folded ? "false" : "true");
186
+ composeGrip.classList.toggle("folded", h.folded);
187
+ composeGrip.classList.toggle("draft", h.draft);
188
+ composeGrip.classList.toggle("waiting", h.waiting);
189
+ }
190
+ promptEl.addEventListener("input", () => drawFoldControls());
191
+ // Typing on the page opens a folded composer: the key lands in the box
192
+ // (the class goes before the focus, so the browser types it there).
193
+ doc.addEventListener("keydown", (e) => {
194
+ if (!isComposerFolded()) return;
195
+ if (!opensOnKey(e, { active: doc.activeElement, allView: allViewOpen(), lightbox: lightboxOpen() })) return;
196
+ setComposerFolded(false);
197
+ promptEl.focus();
198
+ });
199
+ // A file dropped on the folded info bar: open (for the visit), then add it.
200
+ infoBarEl.addEventListener("dragover", (e) => {
201
+ if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
202
+ e.preventDefault();
203
+ });
204
+ infoBarEl.addEventListener("drop", (e) => {
205
+ if (!isComposerFolded()) return;
206
+ const files = imageFiles(e.dataTransfer);
207
+ if (!files.length) return;
208
+ e.preventDefault();
209
+ openForVisit();
210
+ addImageFiles(files);
211
+ });
212
+
213
+ return {
214
+ isComposerFolded,
215
+ openForVisit,
216
+ focusPrompt,
217
+ drawFoldControls,
218
+ // A new visit (select(), the start page): nothing opened, not decided.
219
+ newVisit() {
220
+ foldVisit = newFoldVisit();
221
+ applyComposerFolded();
222
+ },
223
+ // The visit's first getSession answer: an archived session opens folded.
224
+ decideVisit(archived) {
225
+ if (foldVisit.decided) return;
226
+ foldVisit.decided = true;
227
+ foldVisit.archived = !!archived;
228
+ applyComposerFolded();
229
+ },
230
+ };
231
+ }
@@ -594,7 +594,7 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
594
594
  #composeGrip::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}
595
595
  #composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
596
596
  #composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
597
- /* Folded (app.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;
597
+ /* 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;
598
598
  draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
599
599
  body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
600
600
  #composeGrip.draft::after{background:var(--accent)}
@@ -866,7 +866,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
866
866
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
867
867
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
868
868
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
869
- /* The composer folded away (composer_fold.js, the fold button): the info bar is the page's bottom edge.
869
+ /* The composer folded away (composer_grip.js: the grip dragged down, Enter/Space on it): the info bar is the page's bottom edge.
870
870
  Stop turn comes into the bar while folded and a turn runs. */
871
871
  body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
872
872
  body.has-session.composer-folded #infoBar{border-top-color:transparent}
@@ -10,6 +10,7 @@
10
10
  // ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
11
11
 
12
12
  import { moveSelection } from "./command_complete.js";
13
+ import { readStored, writeStored } from "./storage.js";
13
14
  import {
14
15
  MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
15
16
  } from "./model_pick.js";
@@ -39,7 +40,7 @@ function headTemplate(label, note = "") {
39
40
 
40
41
  function readRecent() {
41
42
  try {
42
- const list = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
43
+ const list = JSON.parse(readStored(RECENT_KEY) || "[]");
43
44
  return Array.isArray(list) ? list : [];
44
45
  } catch (_) {
45
46
  return [];
@@ -163,10 +164,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
163
164
  if (!row) return;
164
165
  setChosen(row.name);
165
166
  if (remember()) {
166
- try {
167
- localStorage.setItem(MODEL_KEY, row.name);
168
- localStorage.setItem(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
169
- } catch (_) {}
167
+ writeStored(MODEL_KEY, row.name);
168
+ writeStored(RECENT_KEY, JSON.stringify(pushRecent(readRecent(), row.name)));
170
169
  }
171
170
  close();
172
171
  onPick?.(row.name);
@@ -22,6 +22,7 @@ import { createStageFrame, reducedMotion } from "./stage_frame.js";
22
22
  import { createInUseWatch } from "./stage_watch.js";
23
23
  import { formatDuration } from "./timing.js";
24
24
  import { easeScrollTo } from "./scroll.js";
25
+ import { readStored, writeStored } from "./storage.js";
25
26
 
26
27
  const COLLAPSE_KEY = "chi_stage_collapsed";
27
28
  // The turn-end read (markdown, answer_display) settles within this, or the
@@ -41,7 +42,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
41
42
  const ASKS = ".plugin-card[data-in-turn] .card-action";
42
43
 
43
44
  function readCollapsed() {
44
- try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
45
+ return readStored(COLLAPSE_KEY) === "1";
45
46
  }
46
47
 
47
48
  // +historyNearBottom+: the history's own follow check (the hand-off scrolls
@@ -289,7 +290,7 @@ export function createStageView({
289
290
  function setCollapsed(value) {
290
291
  collapsed = value;
291
292
  revealed = false;
292
- try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
293
+ writeStored(COLLAPSE_KEY, value ? "1" : "0");
293
294
  applyCollapsed();
294
295
  }
295
296
 
@@ -0,0 +1,23 @@
1
+ // The page's per-browser choices in localStorage, guarded: storage can be
2
+ // missing or throw (a private window, blocked site data, a full quota), and
3
+ // a choice that can't be read or written is just not remembered. Reads
4
+ // globalThis.localStorage at each call, never at import (the specs install
5
+ // their own).
6
+
7
+ // The stored string, or +fallback+ when there is none or it can't be read.
8
+ export function readStored(key, fallback = null) {
9
+ try {
10
+ const value = globalThis.localStorage.getItem(key);
11
+ return value === null ? fallback : value;
12
+ } catch (_) {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ export function writeStored(key, value) {
18
+ try { globalThis.localStorage.setItem(key, value); } catch (_) {}
19
+ }
20
+
21
+ export function removeStored(key) {
22
+ try { globalThis.localStorage.removeItem(key); } catch (_) {}
23
+ }
@@ -37,7 +37,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
37
37
  }
38
38
 
39
39
  function show(text, action = null, { sticky = false } = {}) {
40
- const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
40
+ const candidate = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
41
41
  candidate.el.className = "toast-item";
42
42
  draw(candidate, text, action, sticky);
43
43
  candidate.key = candidate.el.querySelector(".toast-message").textContent;
@@ -46,12 +46,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
46
46
  if (duplicate) {
47
47
  if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
48
  duplicate.timer = null;
49
- duplicate.sticky = sticky;
49
+ duplicate.sticky = sticky || duplicate.sticky;
50
+ duplicate.action = action || duplicate.action;
50
51
  duplicate.key = candidate.key;
51
- draw(duplicate, text, action, sticky);
52
+ draw(duplicate, text, duplicate.action, duplicate.sticky);
52
53
  entries.push(...entries.splice(entries.indexOf(duplicate), 1));
53
54
  el.appendChild(duplicate.el);
54
- if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
+ if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
56
  sync();
56
57
  return;
57
58
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.78.0
4
+ version: 0.79.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -337,6 +337,7 @@ files:
337
337
  - lib/samagotchi/llm/adapter_common.rb
338
338
  - lib/samagotchi/llm/adapters.rb
339
339
  - lib/samagotchi/llm/anthropic_assembly.rb
340
+ - lib/samagotchi/llm/anthropic_binding_recovery.rb
340
341
  - lib/samagotchi/llm/anthropic_messages.rb
341
342
  - lib/samagotchi/llm/anthropic_wire.rb
342
343
  - lib/samagotchi/llm/api_key.rb
@@ -595,6 +596,7 @@ files:
595
596
  - lib/samagotchi/web/public/commands.js
596
597
  - lib/samagotchi/web/public/composer.js
597
598
  - lib/samagotchi/web/public/composer_fold.js
599
+ - lib/samagotchi/web/public/composer_grip.js
598
600
  - lib/samagotchi/web/public/composer_size.js
599
601
  - lib/samagotchi/web/public/context_chips.js
600
602
  - lib/samagotchi/web/public/copy.js
@@ -647,6 +649,7 @@ files:
647
649
  - lib/samagotchi/web/public/stage_slots.js
648
650
  - lib/samagotchi/web/public/stage_view.js
649
651
  - lib/samagotchi/web/public/stage_watch.js
652
+ - lib/samagotchi/web/public/storage.js
650
653
  - lib/samagotchi/web/public/stream_handlers.js
651
654
  - lib/samagotchi/web/public/strip.js
652
655
  - lib/samagotchi/web/public/tally.js