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 +4 -4
- data/CHANGELOG.md +25 -1
- data/docs/internals/web-frontend.md +21 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +6 -2
- data/lib/samagotchi/web/public/composer_fold.js +5 -2
- data/lib/samagotchi/web/public/composer_grip.js +45 -2
- data/lib/samagotchi/web/public/index.html +8 -6
- data/lib/samagotchi/web/public/info_bar_model.js +6 -4
- data/lib/samagotchi/web/public/question_cards.js +26 -3
- data/lib/samagotchi/web/public/stage_frame.js +19 -7
- data/lib/samagotchi/web/public/stage_model.js +2 -1
- data/lib/samagotchi/web/public/stage_view.js +39 -24
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6055a05533de1a37a9e9393effa720eb2ca93f0db2715d7f806f83cae3086a8d
|
|
4
|
+
data.tar.gz: ac0a46f034b62cf4c0b6968d6bb9b07fdf8bae8ab331f4745b4a597ef45dd8c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 |
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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;
|
|
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);
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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%
|
|
4
|
-
//
|
|
5
|
-
// panel. The frame: held and grow-only while the
|
|
6
|
-
// (stage_model.js frameTarget), its growth eased
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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. +
|
|
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
|
|
194
|
-
//
|
|
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
|
-
//
|
|
262
|
-
//
|
|
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
|
-
//
|
|
394
|
-
//
|
|
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
|
|
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
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|