samagotchi 0.144.0 → 0.145.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e6a9b3aacbab7f8ce2c03c1bf67ba3b37ba647db975b340b6e146ee544de0d4
4
- data.tar.gz: 65346bf0d90d4355f7d47e7760d952624cd13b02f2e324ec834649c4a2e12e1d
3
+ metadata.gz: 6055a05533de1a37a9e9393effa720eb2ca93f0db2715d7f806f83cae3086a8d
4
+ data.tar.gz: ac0a46f034b62cf4c0b6968d6bb9b07fdf8bae8ab331f4745b4a597ef45dd8c9
5
5
  SHA512:
6
- metadata.gz: 536a9cc745170f15f8b52806bc91fa53fc5faa5e0864aa783d8508142ece75626f589fc62974a0883df922671a1543f02cd5c0c8e16d094fd8e8fb3ce94bdd56
7
- data.tar.gz: '06086d99f2b967fff766b9b29dbaae5792450276ef655f3822c58d6d1c844f84cb77737c99ef0b1eeec0ea80316533900d491624450379f9b2100b8f01d39584'
6
+ metadata.gz: 7e0ab9f589c50aa6c3fa7bdb37b64e17d9963191fa1878e624dd33d68f296d31bd107a09eb8feb5ae7bd85b9c7f2e6bbf1ff1c296ae1fd938a828160c7c5a488
7
+ data.tar.gz: 34688115cd7554ed4f660ab131dfcccb4dd7d15e24f72c7e32f6802d1a2c422d80cfea3cef47f18afc689eb653fc36102b5726d45129202f157d91f1d1db09b7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.145.0] - 2026-11-02
11
+
12
+ ### Changed
13
+
14
+ - On a phone, a web approval uses more of the screen, and a short one shows its question, command and Submit without
15
+ scrolling. A tall card can use the stage's raised height, as a long question already could.
16
+ - The stage shrinks back once at the next step after you answer a question or approval, so no empty room stays under the
17
+ answered card. Otherwise it still only grows during a turn.
18
+ - The web's approval card has no Deny radio. The Deny button denies in one tap, and a reason typed in the box now goes
19
+ with it. Terminals, `chi answer` and relayed approvals still answer Deny by its label.
20
+ - On a phone or touch screen, the composer folds while a card's text field has focus and comes back afterwards. Your
21
+ saved fold and draft are untouched.
22
+ - The session bar says "waiting" instead of "running" while a card blocks the turn.
23
+ - A live answer's new line appears at once instead of fading in, which takes about a quarter off the CPU a streaming
24
+ answer uses on a Mac Studio.
25
+
26
+ ### Fixed
27
+
28
+ - An approval card's folder and reason wrap at words instead of splitting them.
29
+ - The text fields on web cards ask iOS Safari not to offer its AutoFill Contact bar.
30
+
31
+ Update with `chi update`.
32
+
10
33
  ## [0.144.0] - 2026-11-02
11
34
 
12
35
  ### Changed
@@ -5526,7 +5549,8 @@ and long-lived sessions.
5526
5549
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5527
5550
  hotkey panel that send selected text or the clipboard to your sessions.
5528
5551
 
5529
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.144.0...HEAD
5552
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.145.0...HEAD
5553
+ [0.145.0]: https://github.com/dm1try/samagotchi/compare/v0.144.0...v0.145.0
5530
5554
  [0.144.0]: https://github.com/dm1try/samagotchi/compare/v0.143.0...v0.144.0
5531
5555
  [0.143.0]: https://github.com/dm1try/samagotchi/compare/v0.142.0...v0.143.0
5532
5556
  [0.142.0]: https://github.com/dm1try/samagotchi/compare/v0.141.0...v0.142.0
@@ -220,6 +220,23 @@ A new chip on the bar needs its place in that CSS's hidden list and a row in `mo
220
220
  it is lost on a phone. `app.js`'s `updateInfoBar` feeds the sheet (`update`) on every redraw; its rows and actions draw into
221
221
  fixed slots, so a row that joins keeps the focus where it was. The `narrowScreen` listener closes the sheet when the window grows.
222
222
 
223
+
224
+ ## The stage's height
225
+
226
+ `stage_frame.js` is the one writer. The cap is a ceiling: 60% of the page, 80% with the cloud open, 85% while a card has
227
+ the room, and never above the session panel. The frame is grow-only while a turn runs, so a change inside the stage
228
+ never moves its top edge, with one exception: **the next step after a blocking card's answer lets the frame go, once**
229
+ (`stage_view.js releaseHeldWait`, from `generationStarted`). The `held-wait` class says it is due: set with `waiting`,
230
+ cleared there, so a later step, which calls it again, does nothing. What goes together: the live slots' waiting height, a
231
+ raised cap, the tallest content seen (`forgetContent`). At the tap nothing moves: the card collapses to its line inside
232
+ the held frame and the stage's scroll stays, unless the line would be above the view (a reader who scrolled down a card
233
+ taller than the stage), when it comes back to the view's top (`settleAnsweredCard`).
234
+
235
+ Two cards raise the cap: a model's own question whose reply overflows its slot (`drawRoom`, the question room, read from
236
+ the reply's start with a pill to the card) and a blocking approval too tall for the stage's view, a delegate's relayed
237
+ one too (`raiseForApproval`, `stage_frame.js fitsCap`). An approval gets the cap only: no room class and no pill, as it
238
+ opens at its question and command (`revealTop`).
239
+
223
240
  ## Frames
224
241
 
225
242
  During a turn the cost is frames, not code: JS, layout and style are each a few percent of a core, while an animation
@@ -237,6 +254,9 @@ counts. So:
237
254
  it) stops them all, so movement means chi is working; `stage.spec.js`'s question scenario checks it, also after a
238
255
  reload. An asking card (check-in) has no `.waiting`, as its turn goes on: its dot keeps blinking. `.init-spin` is
239
256
  outside the stage (plugin setup only).
257
+ - A one-shot fade on something that arrives many times a second is a recurring cost too. A live answer's new line
258
+ appears at once: its fade alone was about a quarter of the streaming answer's total on the Studio (24-25% of a core to
259
+ 17-19%). The headline's `ts-fade` stays: turning it off saved nothing measurable.
240
260
 
241
261
  A running turn's text reaches the page at most every 100 ms (`turn_block.js FLUSH_MS`): a chunk only marks its step dirty,
242
262
  and `flush` writes the dirty steps, follows and runs `onChange`, which refreshes the stage on the next frame
@@ -295,7 +315,7 @@ reach another device or the server.
295
315
  | Key | Module | What |
296
316
  |---|---|---|
297
317
  | `chi_model`, `chi_model_recent` | `model_pick.js`, `model_picker.js`, `start_page.js` | the start page's model and its recent list |
298
- | `chi_composer_collapsed` | `composer_fold.js` | the composer folded |
318
+ | `chi_composer_collapsed` | `composer_fold.js` | the composer folded (a card's text field folds it for the time it has the focus on a narrow or touch screen, `composer_grip.js watchCardField`, on top of this and of the visit's state: neither is written) |
299
319
  | `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
300
320
  | `chi_strip_hidden` (`chi_zen`: the old name, read once) | app.js | the session strip hidden |
301
321
  | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.144.0
3
+ version: 0.145.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.144.0"
4
+ VERSION = "0.145.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1508,11 +1508,15 @@ async function restartWorker() {
1508
1508
 
1509
1509
  // The session bar (info_bar_model.js sessionBar): its buttons, and the
1510
1510
  // info line rendered by info_bar.js.
1511
+ // The open session waits on the user: its summary says so, or its card is up.
1512
+ function sessionWaits() {
1513
+ return !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion());
1514
+ }
1511
1515
  function updateInfoBar() {
1512
1516
  annotateBar?.refresh();
1513
1517
  const { buttons, bar } = sessionBar({
1514
1518
  id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
1515
- continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1519
+ continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running, waiting: sessionWaits(),
1516
1520
  workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1517
1521
  });
1518
1522
  const stopBtn = document.getElementById("infoStopBtn");
@@ -1947,7 +1951,7 @@ const composer = mountComposer({
1947
1951
  fold: {
1948
1952
  infoBarEl: $("#infoBar"),
1949
1953
  session: () => store.selected,
1950
- waiting: () => !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion()),
1954
+ waiting: sessionWaits,
1951
1955
  allViewOpen,
1952
1956
  lightboxOpen: () => !lightboxEl.hidden,
1953
1957
  },
@@ -3,13 +3,16 @@
3
3
  // unfold is saved per browser (chi_composer_collapsed); an archived
4
4
  // session opens folded for that visit; the page expanding it by itself (a
5
5
  // focus, a restored line, a quote) opens it for the visit only. A visit is
6
- // one select() call.
6
+ // one select() call. A card's text field with the focus on a narrow or touch
7
+ // screen folds it for that time, on top of all of these (+cardField+): the
8
+ // keyboard is up and the card needs the rows; nothing is saved.
7
9
 
8
10
  export const COMPOSER_FOLD_KEY = "chi_composer_collapsed";
9
11
 
10
12
  // Whether the composer is folded: the saved choice or an archived visit,
11
13
  // unless this visit opened it.
12
- export function composerFolded({ saved, archivedVisit, visitOpen }) {
14
+ export function composerFolded({ saved, archivedVisit, visitOpen, cardField = false }) {
15
+ if (cardField) return true;
13
16
  if (visitOpen) return false;
14
17
  return !!saved || !!archivedVisit;
15
18
  }
@@ -7,6 +7,13 @@ import { readStored, removeStored, writeStored } from "./storage.js";
7
7
  // the box's height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
8
8
  // fitPrompt reads; the floor is saved under GRIP_KEY.
9
9
  export const GRIP_KEY = "chi_composer_floor";
10
+ // A card's text field with the focus folds the composer on these screens.
11
+ const CARD_FIELD_SCREEN = "(max-width: 600px), (hover: none) and (pointer: coarse)";
12
+ const CARD_FIELD = '.bubble.question input[type="text"], .bubble.continue input[type="text"]';
13
+ // Whether it still has the focus is looked at this often, twice before the
14
+ // composer comes back: a field an answer disables or removes fires no blur,
15
+ // and a tap on Submit must land before the card moves under the finger.
16
+ const CARD_FIELD_CHECK_MS = 150;
10
17
 
11
18
  // +session+: the open session's id, or null (the start page). +draft+,
12
19
  // +waiting+, +allViewOpen+, +lightboxOpen+: what the page shows now.
@@ -36,6 +43,9 @@ export function createComposerGrip({
36
43
  // first getSession answer).
37
44
  const newFoldVisit = () => ({ open: false, archived: false, decided: false });
38
45
  let foldVisit = newFoldVisit();
46
+ // The card's text field folding the composer now (watchCardField), or null.
47
+ let cardField = null;
48
+ let cardFieldWatch = null;
39
49
 
40
50
  function cancelPendingFold() {
41
51
  if (!pendingFold) return;
@@ -175,7 +185,7 @@ export function createComposerGrip({
175
185
  return readStored(COMPOSER_FOLD_KEY) === "1";
176
186
  }
177
187
  function isComposerFolded() {
178
- return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
188
+ return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open, cardField: !!cardField });
179
189
  }
180
190
  function applyComposerFolded() {
181
191
  const folded = isComposerFolded();
@@ -187,9 +197,37 @@ export function createComposerGrip({
187
197
  if (was && !folded) fitPrompt();
188
198
  drawFoldControls();
189
199
  }
200
+ // A card's text field took the focus: the composer folds until it lets go.
201
+ // On top of the fold's state, which it never writes: the saved choice and
202
+ // the visit's come back as they were, and the box keeps its draft. Only the
203
+ // reader's own focus gets here while a draft is there: a card doesn't take
204
+ // the focus then (question_cards.js focusWhenIdle).
205
+ function watchCardField(field) {
206
+ releaseCardField();
207
+ cardField = field;
208
+ let missed = 0;
209
+ cardFieldWatch = setInterval(() => {
210
+ const held = doc.activeElement === field && !field.disabled && field.isConnected;
211
+ missed = held ? 0 : missed + 1;
212
+ if (missed < 2) return;
213
+ releaseCardField();
214
+ applyComposerFolded();
215
+ }, CARD_FIELD_CHECK_MS);
216
+ applyComposerFolded();
217
+ }
218
+ function releaseCardField() {
219
+ clearInterval(cardFieldWatch);
220
+ cardFieldWatch = null;
221
+ cardField = null;
222
+ }
223
+ doc.addEventListener("focusin", (e) => {
224
+ if (!session() || !e.target?.matches?.(CARD_FIELD) || !win.matchMedia?.(CARD_FIELD_SCREEN).matches) return;
225
+ watchCardField(e.target);
226
+ });
190
227
  // The user's own fold or unfold (the grip, typing): saved, unless this
191
228
  // is an archived visit (both are for the visit then).
192
229
  function setComposerFolded(folded) {
230
+ releaseCardField();
193
231
  if (foldVisit.archived) {
194
232
  foldVisit.open = !folded;
195
233
  } else {
@@ -200,7 +238,11 @@ export function createComposerGrip({
200
238
  }
201
239
  // The page opens it for this visit: nothing saved.
202
240
  function openForVisit() {
203
- if (!isComposerFolded()) return;
241
+ releaseCardField();
242
+ if (!isComposerFolded()) {
243
+ applyComposerFolded();
244
+ return;
245
+ }
204
246
  foldVisit.open = true;
205
247
  applyComposerFolded();
206
248
  }
@@ -264,6 +306,7 @@ export function createComposerGrip({
264
306
  drawFoldControls,
265
307
  // A new visit (select(), the start page): nothing opened, not decided.
266
308
  newVisit() {
309
+ releaseCardField();
267
310
  foldVisit = newFoldVisit();
268
311
  applyComposerFolded();
269
312
  },
@@ -393,10 +393,8 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
393
393
  .outline-fold:hover{color:var(--text-muted)}
394
394
  .turn-work.outline>.gen.folded.unfolded{margin-left:var(--step-indent)}
395
395
  .gen-text{margin-top:6px;white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.55}
396
- /* The live step's narration box: the complete sentences so far, one per line, three visual lines high (3 × the 1.55 line-height), scrolled to the newest; each new line fades in. */
396
+ /* The live step's narration box: the complete sentences so far, one per line, three visual lines high (3 × the 1.55 line-height), scrolled to the newest; a new line appears at once (its fade was most of a streaming answer's animation cost, docs/internals/web-frontend.md, Frames). */
397
397
  .gen-text.streaming>.narration{max-height:4.65em;overflow:auto;scrollbar-width:thin;white-space:normal}
398
- .narration>.sentence{animation:fadeIn 160ms ease}
399
- @media (prefers-reduced-motion:reduce){.narration>.sentence{animation:none}}
400
398
  .gen:not(.live) .gen-text{font-size:14px;color:var(--text-2)}
401
399
  .gen-text:empty{display:none}
402
400
  .gen .activity-body{margin-top:6px}
@@ -528,7 +526,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
528
526
  .question-relayed a{color:var(--text);font-weight:600}
529
527
  .approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
530
528
  .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
531
- .approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
529
+ .approval-where,.approval-why{font-size:12px;color:var(--text-muted);overflow-wrap:anywhere}
532
530
  .approval-why{color:var(--warn-text)}
533
531
  .approval-label{color:var(--text-faint)}
534
532
  /* A diff (diff_view.js): the approval card's dry run and a tool row's change. */
@@ -815,7 +813,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
815
813
  its room, read from the top. */
816
814
  .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
817
815
  /* The question room (stage_view.js drawRoom): a model's own question whose asking text overflows that cap. The slot loses its
818
- cap and its own scroll, and the stage's one scroll covers the whole reply (the stage's cap rises for the turn, stage_frame.js).
816
+ cap and its own scroll, and the stage's one scroll covers the whole reply (the stage's cap rises until the next step, stage_frame.js).
819
817
  --stage-max also scales the extras, the cloud and the queued slot: a ceiling only, content that fit stays as it is. */
820
818
  .turn-stage.question-room .ts-asking{max-height:none;overflow:visible}
821
819
  .turn-stage.asking .ts-headline{display:none}
@@ -843,7 +841,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
843
841
  .turn-stage.waiting :is(.ts-extras,.ts-tail){max-height:none;overflow:visible}
844
842
  /* The card under the live slots stays put from its arrival to the next step: waiting, the slots keep at least their running
845
843
  height (the asking text in the headline's place, the tool row's and the trail's room left blank); answered, the height
846
- they had while waiting (stage_view.js). */
844
+ they had while waiting. The next step lets it go with the frame (stage_view.js releaseHeldWait). */
847
845
  .turn-stage.waiting .ts-live{min-height:var(--ts-live-h)}
848
846
  .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(var(--ts-live-h),var(--live-held,0px))}
849
847
  /* A 1 px mark at the tallest content's end, so the scroll extent never drops while a turn runs (stage_view.js fitFrame). */
@@ -1082,6 +1080,9 @@ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row
1082
1080
  .ts-defer{display:none}
1083
1081
  .ts-cancel{padding:1px 8px}
1084
1082
  .ts-trail-item{max-width:24ch}
1083
+ /* An approval's Submit in sight on a phone: its folder, reason and rows a little tighter. */
1084
+ .approval-where,.approval-why{line-height:1.35}
1085
+ .bubble.question.approval .question-option{padding:5px 8px}
1085
1086
  }
1086
1087
  @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.ts-spin,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
1087
1088
  #infoBar{display:flex;background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
@@ -1738,6 +1739,7 @@ body.all-view #allHead{display:flex}
1738
1739
  .card .stopped-badge,.family-row .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
1739
1740
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
1740
1741
  .status.idle{background:var(--bg-raised)}
1742
+ .status.waiting{background:var(--warn-bg);color:var(--warn-text)}
1741
1743
  .status.completed{background:var(--accent-bg);color:var(--accent)}
1742
1744
  .status.error{background:var(--danger-bg-dim);color:var(--danger-text)}
1743
1745
  .card .owner,.family-row .owner{font-size:11px;padding:2px 6px;border-radius:999px}
@@ -65,11 +65,12 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
65
65
  // @param badge workerBadgeFor's, or null
66
66
  // @param restarting whether this session's Restart is under way
67
67
  // @param running whether its turn runs (the session clock ticks)
68
+ // @param waiting whether the session waits on the user (app.js sessionWaits)
68
69
  // @param workingSet web.working_set on: the chip's {n, changed} (n 0 while
69
70
  // the set is empty); undefined: off (the mem chip shows)
70
71
  // @param participants the participants chip (participants_panel.js
71
72
  // chipModel), null before the list is read
72
- export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, workingSet = undefined, participants = null }) {
73
+ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, waiting = false, workingSet = undefined, participants = null }) {
73
74
  const tokens = state.timing.tokens;
74
75
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
75
76
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
@@ -85,7 +86,8 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
85
86
  const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
86
87
  return {
87
88
  id,
88
- status: state.status,
89
+ // Its turn runs but a card blocks it: the word `chi sessions list` has for it.
90
+ status: running && waiting ? "waiting" : state.status,
89
91
  model: modelLabel(state.model, ...(state.served || [])),
90
92
  // A link of a session chain: ← the previous link, → the next one (each
91
93
  // only while the list holds it).
@@ -122,12 +124,12 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
122
124
  // The whole bar for the open session +id+ (null: the start page, no line).
123
125
  // @param sessions the page's list; +continuing+, +restarting+: Sets of ids
124
126
  // @return {buttons, bar}
125
- export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, workingSet = undefined, participants = null }) {
127
+ export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, waiting = false, workingSet = undefined, participants = null }) {
126
128
  const chain = id ? chainNeighbours(sessions, id, { continues: state.continues }) : { prev: null, next: null };
127
129
  const archived = !!sessions.find((s) => s.id === id)?.archived;
128
130
  const buttons = infoButtons({ id, state, chain, archived, continuing: continuing.has(id), streaming });
129
131
  if (!id) return { buttons, bar: null };
130
132
  const badge = workerBadgeFor(id, state, versions);
131
- const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, workingSet, participants });
133
+ const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, waiting, workingSet, participants });
132
134
  return { buttons, bar };
133
135
  }
@@ -111,13 +111,18 @@ export function createQuestionCards({
111
111
  }
112
112
 
113
113
  const options = Array.isArray(pq.options) ? pq.options : [];
114
+ // An approval's last option denies: here that is the Deny button beside
115
+ // Submit, one tap, so no radio says it again. The option stays in the
116
+ // data (a terminal, chi answer and a relay answer it by its label).
117
+ const denies = approval && options[options.length - 1] === "Deny";
118
+ const shown = denies ? options.slice(0, -1) : options;
114
119
  if (stepLimit) {
115
120
  renderStepLimitActions(card, pq, options);
116
- } else if (options.length) {
121
+ } else if (shown.length) {
117
122
  const list = doc.createElement("div");
118
123
  list.className = "question-options";
119
124
  const inputType = pq.multi_select ? "checkbox" : "radio";
120
- options.forEach((label, i) => {
125
+ shown.forEach((label, i) => {
121
126
  const row = doc.createElement("label");
122
127
  row.className = "question-option";
123
128
  const input = doc.createElement("input");
@@ -155,6 +160,7 @@ export function createQuestionCards({
155
160
  if (pq.allow_freeform) {
156
161
  freeformInput = doc.createElement("input");
157
162
  freeformInput.type = "text";
163
+ noContactFill(freeformInput);
158
164
  freeformInput.className = "question-freeform";
159
165
  freeformInput.placeholder = approval ? "Reason for the model (with Deny, or alone to deny)…" : "Other…";
160
166
  card.appendChild(freeformInput);
@@ -201,7 +207,13 @@ export function createQuestionCards({
201
207
  err.classList.add("hidden");
202
208
  submit.disabled = true;
203
209
  dismiss.disabled = true;
204
- api.dismissQuestion(sessionId(), pq.id).catch((e) => {
210
+ // A reason typed in the box goes with the Deny: the answer carries it,
211
+ // a dismissal has no text.
212
+ const reason = denies && freeformInput ? freeformInput.value.trim() : "";
213
+ const sent = reason
214
+ ? api.sendAnswer(sessionId(), { id: pq.id, selected: ["Deny"], freeform: reason })
215
+ : api.dismissQuestion(sessionId(), pq.id);
216
+ sent.catch((e) => {
205
217
  submit.disabled = false;
206
218
  dismiss.disabled = false;
207
219
  err.textContent = answerErrorText(e);
@@ -228,6 +240,15 @@ export function createQuestionCards({
228
240
  focusWhenIdle(card, pq.id, { staged });
229
241
  }
230
242
 
243
+ // A card's text field isn't a contact form: what iOS Safari respects
244
+ // against its "AutoFill Contact" bar (no autocomplete, a name with no
245
+ // contact word in it). Autocorrect stays: the reader writes prose here.
246
+ function noContactFill(input) {
247
+ input.setAttribute("autocomplete", "off");
248
+ input.setAttribute("autocorrect", "on");
249
+ input.name = "chi-card-note";
250
+ }
251
+
231
252
  function isPlainEnter(e) {
232
253
  return e.key === "Enter" && !e.isComposing && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
233
254
  }
@@ -258,6 +279,7 @@ export function createQuestionCards({
258
279
  function renderStepLimitActions(card, pq, options) {
259
280
  const reason = doc.createElement("input");
260
281
  reason.type = "text";
282
+ noContactFill(reason);
261
283
  reason.className = "question-freeform continue-reason";
262
284
  reason.placeholder = CONTINUE_REASON_PLACEHOLDER;
263
285
  const err = doc.createElement("div");
@@ -590,6 +612,7 @@ export function createQuestionCards({
590
612
  no.addEventListener("click", () => answer(continueLine("no")));
591
613
  const reason = doc.createElement("input");
592
614
  reason.type = "text";
615
+ noContactFill(reason);
593
616
  reason.className = "continue-reason";
594
617
  reason.placeholder = "Why not? (the model reads it)";
595
618
  const noBecause = doc.createElement("button");
@@ -1,9 +1,10 @@
1
1
  // The stage view's cap and frame (stage_view.js): the one place that
2
2
  // writes the stage's height, and the stage's own scrolls. The cap: at most
3
- // ~60% of the page (~80% with the cloud open, 85% for the rest of a turn
4
- // whose question got the question room), and never above the session
5
- // panel. The frame: held and grow-only while the stage holds a turn
6
- // (stage_model.js frameTarget), its growth eased. The stage's state comes
3
+ // ~60% of the page (~80% with the cloud open, 85% from a card that needs the
4
+ // room until the next step: a long reply's question, a tall approval), and
5
+ // never above the session panel. The frame: held and grow-only while the
6
+ // stage holds a turn (stage_model.js frameTarget), its growth eased; the next
7
+ // step after an answered card lets it go once (forgetContent). The stage's state comes
7
8
  // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
8
9
  // what the stage changes of the frame's goes through resetHeld and
9
10
  // forgetContent. +waitingCard+: the card blocking the turn, or null.
@@ -58,7 +59,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
58
59
  }
59
60
 
60
61
  // ── the cap: at most ~60% of the page (~80% with the cloud open, 85%
61
- // once the turn's question got the room), and never above the session
62
+ // while a card has the room), and never above the session
62
63
  // panel (the dock is absolute: a tall composer or a phone's keyboard
63
64
  // would push the stage's top out) ──────────────────────────────────────
64
65
 
@@ -113,6 +114,14 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
113
114
  if (!card || !cardInSight()) reveal(card?.parentElement ?? extras, { now: true });
114
115
  }
115
116
 
117
+ // +card+ fits the stage's view at the cap now: it, the scroller's padding
118
+ // and the stage's rows around the scroller.
119
+ function fitsCap(card) {
120
+ const css = getComputedStyle(scroller);
121
+ const around = stage.offsetHeight - scroller.offsetHeight + (parseFloat(css.paddingTop) || 0) + (parseFloat(css.paddingBottom) || 0);
122
+ return card.offsetHeight + around <= capPx();
123
+ }
124
+
116
125
  function fitCap() {
117
126
  if (stage.hidden) return;
118
127
  // The composer card and the gap above it (the stage floats over them,
@@ -144,7 +153,8 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
144
153
  // Nothing in the stage view jumps. The rules: the stage is out of the
145
154
  // dock's flow (index.html), so its height never moves the composer; while
146
155
  // it holds a turn its height is held and grow-only (frameTarget), so a
147
- // content change happens inside it; growth eases (frameTo); the chip is a
156
+ // content change happens inside it (the next step after an answered card
157
+ // lets it go once: forgetContent); growth eases (frameTo); the chip is a
148
158
  // footer no content can push; the hand-off releases the turn at once
149
159
  // (releaseTurn) and empties the stage only after it slid away under the
150
160
  // composer (a transform, then finishFold/clearStage).
@@ -215,6 +225,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
215
225
 
216
226
  return {
217
227
  fitCap,
228
+ fitsCap,
218
229
  frameFree,
219
230
  afterGrowth,
220
231
  easeStage,
@@ -222,7 +233,8 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
222
233
  // A new turn, or none: no height held yet.
223
234
  resetHeld: () => { held = null; },
224
235
  // The tallest content seen and the height held for it are forgotten (a
225
- // new turn, the cloud closed by hand): the frame refits to what shows now.
236
+ // new turn, the cloud closed by hand, the next step after an answered
237
+ // card): the frame refits to what shows now.
226
238
  forgetContent: () => {
227
239
  maxContent = 0;
228
240
  held = null;
@@ -198,7 +198,8 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
198
198
 
199
199
  // The stage's frame height (px) while it holds a turn: grow-only, so a
200
200
  // content change inside it (the plain flip, an answered card, a short
201
- // answer) never moves its top edge. +natural+: its content's own height;
201
+ // answer) never moves its top edge. (Its one shrink forgets +held+: the next
202
+ // step after an answered card, stage_view.js releaseHeldWait.) +natural+: its content's own height;
202
203
  // +held+: the height it holds (null: none yet); +cap+: --stage-max. A lower
203
204
  // cap (a phone's keyboard) clamps it at once and the clamp is the new held
204
205
  // height: a cap that rises again doesn't bring the old one back, only new
@@ -38,6 +38,8 @@ const ANSWER_SEEN = 72;
38
38
  const ANSWER_CUT = 12;
39
39
  // An answered card's one line.
40
40
  const ANSWERED_LINE = 40;
41
+ // That line brought back sits this far under the scroller's edge.
42
+ const ANSWERED_GAP = 8;
41
43
  const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
42
44
  // A running turn's card with actions (check-in's Nudge / Keep going / Stop)
43
45
  // asks the user too, but the turn goes on: it waits only while the turn runs
@@ -47,6 +49,9 @@ const ASKS = ".plugin-card[data-in-turn] .card-action";
47
49
  // the step limit, a hook's question or a delegate's relayed one): the one
48
50
  // card whose long asking text gets the question room.
49
51
  const ASK_CARD = '.bubble.question[data-kind="question"]:not([data-relay])';
52
+ // A blocking approval, a delegate's relayed one too: too tall for the stage's
53
+ // view, it gets the question room's raised cap (raiseForApproval).
54
+ const APPROVAL_CARD = '.bubble.question[data-kind="approval"]';
50
55
 
51
56
  function readCollapsed() {
52
57
  return readStored(COLLAPSE_KEY) === "1";
@@ -190,8 +195,9 @@ export function createStageView({
190
195
  let bare = false;
191
196
  let foldTimer = null;
192
197
  // The question room (drawRoom): the pending ask card whose asking text
193
- // overflowed its cap, and the turn's raised cap (stage_frame.js; kept
194
- // until the next turn: the frame is grow-only).
198
+ // overflowed its cap; and the raised cap (stage_frame.js), which an
199
+ // approval too tall for the stage gets too (raiseForApproval), kept until
200
+ // the next step (releaseHeldWait).
195
201
  let roomCard = null;
196
202
  let roomCap = false;
197
203
  // What the overflow was measured for ({ card, text }): measured once the
@@ -258,8 +264,8 @@ export function createStageView({
258
264
  // The question room: while a model's own question waits and its asking
259
265
  // text didn't fit its cap, the slot loses its cap and the stage's one
260
266
  // scroll reads the whole reply (index.html .question-room); the cap rises
261
- // for the rest of the turn. The card keeps the room until it stops
262
- // pending (+text+: the asking text drawn).
267
+ // until the next step. The card keeps the room until it stops pending
268
+ // (+text+: the asking text drawn).
263
269
  function drawRoom(blocked, text) {
264
270
  const ask = blocked ? askCard() : null;
265
271
  if (roomCard && ask === roomCard) return;
@@ -292,6 +298,18 @@ export function createStageView({
292
298
  }
293
299
  }
294
300
 
301
+ // A blocking approval too tall for the stage's view: the same raised cap,
302
+ // the stage takes what the page has. The card still opens at its question
303
+ // and command (stage_frame.js revealTop), so no room class and no pill. The
304
+ // cap is a ceiling: a card that fits never gets here.
305
+ function raiseForApproval(blocked) {
306
+ if (!blocked || roomCap) return;
307
+ const card = extras.querySelector(PENDING) || tail.querySelector(PENDING);
308
+ if (!card?.matches(APPROVAL_CARD) || frame.fitsCap(card)) return;
309
+ roomCap = true;
310
+ frame.fitCap();
311
+ }
312
+
295
313
  // The pill: the room's card out of the stage's view, once its arrival
296
314
  // reveal has run, the stage open.
297
315
  function drawPill() {
@@ -340,7 +358,6 @@ export function createStageView({
340
358
  const wasWaiting = stage.classList.contains("waiting");
341
359
  if (blocked && !wasWaiting) {
342
360
  stage.classList.add("held-wait");
343
- live.style.minHeight = "";
344
361
  }
345
362
  if (!blocked && wasWaiting) stage.style.setProperty("--live-held", `${live.offsetHeight}px`);
346
363
  stage.classList.toggle("plain", plain);
@@ -356,6 +373,7 @@ export function createStageView({
356
373
  slotsView.drawHeadline(slots.headline);
357
374
  slotsView.drawAsking(slots.asking, slots.askingRefs);
358
375
  drawRoom(blocked, slots.asking);
376
+ raiseForApproval(blocked);
359
377
  drawPill();
360
378
  slotsView.drawNow(slots.tool, slots.quiet);
361
379
  slotsView.drawTrail(slots.trail, flash);
@@ -389,15 +407,18 @@ export function createStageView({
389
407
  tail.hidden = !tail.firstChild;
390
408
  }
391
409
 
392
- // A card stopped waiting and its slot has its cap and own scroll again:
393
- // the slot's end shows (the answered card's line), and a reader who had
394
- // scrolled down the tall card isn't left looking at where it was.
410
+ // A card stopped waiting and its slot has its cap and own scroll again: the
411
+ // slot's end shows (the answered card's line). The stage's scroll stays, so
412
+ // nothing moves at the tap (the frame holds, and the blank goes at the next
413
+ // step), unless the reader had scrolled down the tall card past its line:
414
+ // then the line comes back, to the view's top and no further.
395
415
  function settleAnsweredCard() {
396
416
  const slot = tail.firstChild ? tail : extras;
397
417
  if (!slot.firstChild) return;
398
418
  slot.scrollTop = slot.scrollHeight;
399
419
  const end = slot.offsetTop + slot.offsetHeight;
400
- if (scroller.scrollTop > end - ANSWERED_LINE) scroller.scrollTop = Math.max(0, end - scroller.clientHeight);
420
+ if (scroller.scrollTop <= end - ANSWERED_LINE) return;
421
+ scroller.scrollTop = Math.max(0, Math.min(slot.lastElementChild.offsetTop, end - ANSWERED_LINE) - ANSWERED_GAP);
401
422
  }
402
423
 
403
424
  // The prompt's first words (the working set's session tags say whose
@@ -407,23 +428,18 @@ export function createStageView({
407
428
  return text.length > 60 ? `${text.slice(0, 59)}\u2026` : text;
408
429
  }
409
430
 
410
- // The next step: the live slots' waiting height is let go. In a turn
411
- // with the question room that height was the whole reply's: it goes as
412
- // far as the stage's scroll absorbs it (the scroll moves up by as much,
413
- // in the same task: the card in view stays put), the rest kept as the
414
- // live slots' least height until the next wait or turn.
431
+ // The next step after a blocking card's answer: the one time a turn's
432
+ // frame shrinks (stage_frame.js forgetContent; grow-only otherwise). What
433
+ // the wait held goes together: the live slots' waiting height, a raised
434
+ // cap, the tallest content seen. +held-wait+ says it is due, so a later
435
+ // step, or a generation with the card still waiting, changes nothing.
415
436
  function releaseHeldWait() {
416
- const held = roomCap && stage.classList.contains("held-wait") && !stage.classList.contains("waiting");
417
- const before = held ? live.offsetHeight : 0;
418
- const top = scroller.scrollTop;
437
+ if (!stage.classList.contains("held-wait") || stage.classList.contains("waiting")) return;
419
438
  stage.classList.remove("held-wait");
420
439
  stage.style.removeProperty("--live-held");
421
- live.style.minHeight = "";
422
- if (!held) return;
423
- const after = live.offsetHeight;
424
- const absorbed = Math.min(Math.max(0, before - after), top);
425
- if (before - absorbed > after) live.style.minHeight = `${before - absorbed}px`;
426
- if (absorbed) scroller.scrollTop = top - absorbed;
440
+ roomCap = false;
441
+ frame.fitCap();
442
+ frame.forgetContent();
427
443
  }
428
444
 
429
445
  // One line in the trail for FLASH_MS, ahead of the calls (a newer flash
@@ -625,7 +641,6 @@ export function createStageView({
625
641
  setCloud(false);
626
642
  stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use", "question-room");
627
643
  stage.style.removeProperty("--live-held");
628
- live.style.minHeight = "";
629
644
  }
630
645
 
631
646
  // The turn leaves the stage's care (the hand-off's start, a reset, a new
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.144.0
4
+ version: 0.145.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov