samagotchi 0.111.1 → 0.112.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.
@@ -15,6 +15,8 @@
15
15
  :root{--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
16
16
  :root{--icon-archive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
17
17
  *{box-sizing:border-box;margin:0;padding:0}
18
+ /* No iOS text autosizing: it grows text in a box sized for the text as written (it clipped the session cards). */
19
+ html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
18
20
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
19
21
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
20
22
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
@@ -710,7 +712,7 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
710
712
  #modelList .model-option.unavailable:not(.chosen) .model-id,#modelList .model-option.unavailable .model-host{color:var(--text-faint)}
711
713
  #modelList .model-option.unavailable .model-note{color:var(--warn-text)}
712
714
  #modelList .model-empty{padding:10px;font-size:13px;color:var(--text-faint)}
713
- @media(max-width:600px){#modelPanel{left:6px;width:calc(100% - 12px)}#modelSearch{font-size:16px}#modelList .model-option{padding-top:8px;padding-bottom:8px}}
715
+ @media(max-width:600px){#modelPanel{left:6px;width:calc(100% - 12px)}#modelList .model-option{padding-top:8px;padding-bottom:8px}}
714
716
  /* The / autocomplete: a list over the composer card's top edge. It wears the glass edge and shadow, but opaque:
715
717
  inside the dock's own backdrop-filter a second blur sees nothing, and the history would show through its text. */
716
718
  #commandList{position:absolute;left:10px;width:min(640px,calc(100% - 20px));bottom:calc(100% + 8px);max-height:min(260px,40vh);overflow-y:auto;border-radius:12px;padding:4px;z-index:20;display:flex;flex-direction:column;gap:1px;background:var(--bg-panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
@@ -1105,7 +1107,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1105
1107
  .llmctx-form .ctx-pop-head strong{font-family:inherit}
1106
1108
  .llmctx-field{display:flex;align-items:center;gap:8px}
1107
1109
  .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
1108
- .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
1110
+ .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:inherit;font-size:13px;line-height:1.4}
1109
1111
  .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
1110
1112
  /* The settings selects draw their own closed box (each browser's native one differs) with the model picker's drawn caret on the label; the open list stays the OS menu. */
1111
1113
  .llmctx-field select{appearance:none;-webkit-appearance:none;padding-right:26px;cursor:pointer}
@@ -1122,7 +1124,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1122
1124
  #infoBar .ctx-chip.ctx-add{border-style:dashed;color:var(--text-muted);background:transparent}
1123
1125
  .ctx-add-form{display:flex;flex-direction:column;gap:6px;margin:0}
1124
1126
  .ctx-add-form .ctx-pop-head strong{font-family:inherit}
1125
- .ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
1127
+ .ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:inherit;font-size:13px;line-height:1.4}
1126
1128
  .ctx-add-input:focus{outline:none;border-color:var(--accent-border)}
1127
1129
  #infoBar .ctx-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font:600 11px/1.5 ui-monospace,monospace;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3);border:1px solid var(--border-strong);white-space:nowrap}
1128
1130
  #infoBar .ctx-chip:hover,#infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
@@ -1300,8 +1302,6 @@ body.has-session #scope .scope-where{display:none}
1300
1302
  #sessionTitleRow{flex-basis:100%;order:10}
1301
1303
  #sessionTitle{font-size:12px}
1302
1304
  .title-rename{font-size:12px;line-height:18px}
1303
- /* Under 16px iOS Safari zooms the page on focus. */
1304
- .rename-input{font-size:16px}
1305
1305
  #scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
1306
1306
  #stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1307
1307
  #scope .scope-name{flex-shrink:1;min-width:0}
@@ -1471,6 +1471,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1471
1471
  .card{padding:12px;border-radius:10px;background:var(--bg-panel);border:1px solid var(--border);cursor:pointer;display:flex;flex-direction:column;gap:6px;min-height:72px}
1472
1472
  .card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
1473
1473
  #allList .card{position:relative}
1474
+ /* Every row keeps its content's height: a card shorter than its rows (seen on iOS) cut the clamped preview and the
1475
+ single-line title and recap through the middle. */
1476
+ #allList .card > *{flex-shrink:0}
1474
1477
  /* A card's tools in the all view: archive (or unarchive) and delete, shown on hover, always under hover:none. */
1475
1478
  #allList .card-tools{position:absolute;top:6px;right:6px;display:flex;gap:2px}
1476
1479
  .card-tools button{padding:2px 7px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:12px;line-height:1.2;cursor:pointer;opacity:0;transition:opacity .15s}
@@ -1520,6 +1523,14 @@ body.all-view #center, body.all-view #topStrip{display:none}
1520
1523
  #topStrip #allTile{width:auto}
1521
1524
  }
1522
1525
  a{color:var(--accent)}
1526
+ /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back), at any width: every text
1527
+ field gets 16px on a phone or a touch device. Last in the sheet so it wins over each field's own size; keep new text
1528
+ fields in this list (stage.spec.js's phone step checks the visible ones). */
1529
+ @media (max-width:600px),(pointer:coarse){
1530
+ #composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
1531
+ .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ctx-add-input,.ws-add-in,.rename-input,
1532
+ #modelSearch{font-size:16px}
1533
+ }
1523
1534
  </style>
1524
1535
  </head>
1525
1536
  <body>
@@ -25,7 +25,7 @@
25
25
  // onRename() the active card's ✎ clicked (session_rename.js open)
26
26
  // renaming() whether that rename input is open (the strip holds)
27
27
 
28
- import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
28
+ import { ownerBadge, relativeTime, delegatedBy, cardTitleSource } from "./format.js";
29
29
  import { html, nothing, render } from "./vendor/lit-html.js";
30
30
  import { cardCtxText, ctxBarTip } from "./ctx.js";
31
31
  import { allScopeHref, cardFolder } from "./scope.js";
@@ -172,7 +172,8 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
172
172
  const preview = renamable
173
173
  ? html`<div class="preview-row"><div class="preview">${title}</div><button type="button" class="card-rename" data-rename=${s.id} title="Rename this session" aria-label="Rename this session">\u270e</button></div>`
174
174
  : html`<div class="preview">${title}</div>`;
175
- const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
175
+ // No recap line when the title is the recap's first sentence (D5).
176
+ const recap = s.recap && cardTitleSource(s) !== "recap" ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
176
177
  return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
177
178
  }
178
179
 
@@ -6,22 +6,29 @@
6
6
  // (notify.js, Several tabs).
7
7
  //
8
8
  // In the Home Screen app (push.js pushSupported) the bell means "push to
9
- // this device" instead (Web Push): chi web pushes approvals and questions to
10
- // it while the app is closed. chi web without push (no https public URL)
9
+ // this device" instead (Web Push): chi web pushes what needs the user (an
10
+ // approval, a question, a step-limit continue, a card, a failed turn) to it
11
+ // while the app is closed. chi web without push (no https public URL)
11
12
  // leaves the bell as it is in a tab. With the bell on, the app also tidies
12
13
  // the device (syncDevice): it closes the pushed notifications whose
13
- // question isn't open any more and sets the app's badge, as chi web sends
14
- // no close push (WebKit's silent-push quota).
14
+ // question or card isn't open any more and sets the app's badge, as chi
15
+ // web sends no close push (WebKit's silent-push quota).
16
+ //
17
+ // Every page, in a tab or the app, also tells chi web whether the user is
18
+ // at it (POST /api/push/presence, reportPresence): chi web pushes nothing
19
+ // while one is in front, as the user is looking (the iPhone's service
20
+ // worker can't see the app in front, so chi web decides).
15
21
  //
16
22
  // The browser parts come in as deps (globals by default) so the specs can
17
23
  // drive it without a DOM.
18
24
 
19
25
  import { sessionHash } from "./route.js";
20
26
  import {
21
- NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention,
27
+ NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, randomTabId,
28
+ trackAttention,
22
29
  } from "./notify.js";
23
30
  import {
24
- isQuestionTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
31
+ isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
25
32
  } from "./push.js";
26
33
 
27
34
  export const NOTIFY_KEY = "chi_notify";
@@ -32,6 +39,12 @@ export const PUSH_DEVICE_KEY = "chi_push_device";
32
39
  // A long-lived Home Screen app checks for a new sw.js at most this often
33
40
  // when it comes back in front (reg.update()).
34
41
  export const SW_UPDATE_MS = 60 * 60 * 1000;
42
+ // Presence: a page in front reports this often (chi web counts a report
43
+ // 45 s), and is in front only if touched this recently (a tab left focused
44
+ // on a screen nobody watches stops quieting the phone).
45
+ export const PRESENCE_PATH = "/api/push/presence";
46
+ export const PRESENCE_EVERY_MS = 30 * 1000;
47
+ export const TOUCH_FRESH_MS = 2 * 60 * 1000;
35
48
 
36
49
  const NOTIFY_TITLES = {
37
50
  on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
@@ -40,8 +53,8 @@ const NOTIFY_TITLES = {
40
53
  };
41
54
 
42
55
  const PUSH_TITLES = {
43
- on: "Notifications to this phone: on (approvals and questions, also while the app is closed). Tap to turn off",
44
- off: "Notify this phone when a session needs approval or an answer, also while the app is closed",
56
+ on: "Notifications to this phone: on (a session that needs you or failed, also while the app is closed). Tap to turn off",
57
+ off: "Notify this phone when a session needs you or a turn fails, also while the app is closed",
45
58
  denied: "Notifications are blocked for this app in the phone's settings",
46
59
  };
47
60
 
@@ -57,6 +70,21 @@ async function defaultApi(method, path, body) {
57
70
  return data;
58
71
  }
59
72
 
73
+ // How a presence report goes: a beacon when the page is leaving (it
74
+ // outlives the page; same origin, so the cookie goes), else the api's
75
+ // fetch. Errors swallowed: a lost report only means a push or a quiet one.
76
+ // @return (body, leaving) => void
77
+ export function presenceSender({ nav, api, BlobApi = globalThis.Blob }) {
78
+ return (body, leaving) => {
79
+ if (leaving && typeof nav?.sendBeacon === "function" && typeof BlobApi === "function") {
80
+ try {
81
+ if (nav.sendBeacon(PRESENCE_PATH, new BlobApi([JSON.stringify(body)], { type: "application/json" }))) return;
82
+ } catch (_) {}
83
+ }
84
+ try { api("POST", PRESENCE_PATH, body)?.catch?.(() => {}); } catch (_) {}
85
+ };
86
+ }
87
+
60
88
  function defaultChannel() {
61
89
  try {
62
90
  if (typeof globalThis.BroadcastChannel === "function") return new BroadcastChannel(NOTIFY_CHANNEL);
@@ -75,7 +103,11 @@ function defaultChannel() {
75
103
  // @param api (method, path, body) => Promise of chi web's JSON
76
104
  // @param scoped the page shows one project's sessions (?dir=): it never
77
105
  // tidies the device, as it doesn't see the others'
78
- // @param now () => ms (the service worker update's spacing; specs)
106
+ // @param now () => ms (the service worker update's spacing, presence's
107
+ // touch; specs)
108
+ // @param sendPresence (body, leaving) => void (presenceSender's by default)
109
+ // @param every (fn, ms) => void, a repeating timer (presence's tick)
110
+ // @param pageId this page load's id in its presence reports
79
111
  // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
80
112
  export function createNotifications({
81
113
  button,
@@ -89,6 +121,9 @@ export function createNotifications({
89
121
  api = defaultApi,
90
122
  scoped = false,
91
123
  now = () => Date.now(),
124
+ sendPresence = presenceSender({ nav, api }),
125
+ every = (fn, ms) => setInterval(fn, ms),
126
+ pageId = randomTabId(),
92
127
  }) {
93
128
  let attention = initialAttentionState();
94
129
  // Web Push in the Home Screen app: null until chi web says it pushes
@@ -234,7 +269,8 @@ export function createNotifications({
234
269
  }
235
270
 
236
271
  // The Home Screen app with the bell on: closes the pushed notifications
237
- // whose question isn't open (chi's own, chi:test, stay) and sets the
272
+ // whose question or card isn't open (chi's own, chi:test, and a failed
273
+ // or done turn's stay) and sets the
238
274
  // app's badge. Never before the first snapshot (an empty byId would close
239
275
  // them all) nor on a project's page (its byId lacks the others). One at a
240
276
  // time; asked again meanwhile, it runs once more after.
@@ -250,7 +286,7 @@ export function createNotifications({
250
286
  const open = new Set(openPushKeys(attention.byId));
251
287
  try {
252
288
  const shown = await push.reg.getNotifications();
253
- for (const n of shown || []) if (isQuestionTag(n.tag) && !open.has(n.tag)) n.close();
289
+ for (const n of shown || []) if (isWaitingTag(n.tag) && !open.has(n.tag)) n.close();
254
290
  } catch (_) {}
255
291
  if (push?.on) setDeviceBadge(open.size);
256
292
  } while (syncAgain);
@@ -321,6 +357,31 @@ export function createNotifications({
321
357
  syncDevice();
322
358
  updateWorker();
323
359
  }
360
+ // ── Presence ──────────────────────────────────────────────────────────
361
+ // Loading the page counts as a touch.
362
+ let touchedAt = now();
363
+ let reportedFront = null;
364
+ const atPage = () => tabInFront() && now() - touchedAt < TOUCH_FRESH_MS;
365
+ function reportPresence(front = atPage(), leaving = false) {
366
+ reportedFront = front;
367
+ sendPresence({ page: pageId, front }, leaving);
368
+ }
369
+ // The first touch that brings the page to the front (after being idle,
370
+ // or behind) says so at once; the tick has the rest.
371
+ function touched() {
372
+ touchedAt = now();
373
+ if (reportedFront !== true && atPage()) reportPresence(true);
374
+ }
375
+ // Every 30 s while in front, and once more when it no longer is (no
376
+ // touch for 2 min).
377
+ every(() => { if (reportedFront || atPage()) reportPresence(); }, PRESENCE_EVERY_MS);
378
+ doc.addEventListener("visibilitychange", () => (doc.visibilityState === "visible" ? reportPresence() : reportPresence(false, true)));
379
+ win.addEventListener("focus", () => reportPresence());
380
+ win.addEventListener("blur", () => reportPresence(false));
381
+ win.addEventListener("pagehide", () => reportPresence(false, true));
382
+ for (const type of ["pointerdown", "keydown", "touchstart"]) doc.addEventListener(type, touched, { capture: true, passive: true });
383
+ if (atPage()) reportPresence(true);
384
+
324
385
  doc.addEventListener("visibilitychange", backInFront);
325
386
  win.addEventListener("focus", backInFront);
326
387
  doc.addEventListener("pointerdown", clearBadge, true);
@@ -96,7 +96,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
96
96
  // @param prev the summary held before (null for a session not seen yet)
97
97
  // @param next the new summary (null when the session is gone)
98
98
  // @return [String] keys, maybe none
99
- function closedKeys(prev, next) {
99
+ export function closedKeys(prev, next) {
100
100
  const keys = [];
101
101
  const q = prev?.pending_question;
102
102
  const nq = next?.pending_question;
@@ -152,7 +152,7 @@ export function trackAttention(state, type, data = {}) {
152
152
  // The notification's words: a short title (the session) and the reason.
153
153
  // No question or answer text (it would show on a lock screen).
154
154
  export function attentionText(session, attention) {
155
- const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
155
+ const name = String(session?.title || session?.auto_title || session?.first_preview || session?.last_prompt || "chi session").trim();
156
156
  const title = clip(name, 61);
157
157
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
158
158
  const body = {
@@ -262,7 +262,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
262
262
  };
263
263
  }
264
264
 
265
- function randomTabId() {
265
+ export function randomTabId() {
266
266
  try {
267
267
  if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
268
268
  } catch (_) {}
@@ -11,6 +11,8 @@
11
11
  (the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
12
12
  The owner's participant view is the same page with no composer. Colours are tokens.css's. */
13
13
  *{box-sizing:border-box;margin:0;padding:0}
14
+ /* No iOS text autosizing (index.html's twin). */
15
+ html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
14
16
  html,body{height:100%;height:100dvh}
15
17
  .p-body{flex:1;min-height:0;display:flex}
16
18
  .p-col{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column}
@@ -126,6 +128,8 @@ button.small{font-size:12px;padding:2px 8px}
126
128
  .p-bubble{padding:9px 12px}
127
129
  .p-hint,.p-note{display:none}
128
130
  }
131
+ /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
132
+ @media (max-width:600px),(pointer:coarse){.p-compose textarea{font-size:16px}}
129
133
  </style>
130
134
  </head>
131
135
  <body>
@@ -6,31 +6,30 @@
6
6
  // a browser tab keeps the in-tab notifications). See
7
7
  // docs/internals/web-push.md.
8
8
 
9
- import { questionReason } from "./notify.js";
10
-
11
- // The reasons chi web pushes (Web::Push::Attention::PUSHED).
12
- const PUSHED = new Set(["approval", "question"]);
13
-
14
- // The keys still open in a byId (notify.js trackAttention's: session id →
15
- // summary): a pending question with an id, not relayed, of a reason chi
16
- // web pushes. Attention.open_keys's rule, so the page, the worker's open
17
- // list and the badge agree.
18
- // @return [String] "<session id>:<question id>" keys
9
+ // The waiting keys still open in a byId (notify.js trackAttention's:
10
+ // session id → summary): a pending question with an id, not relayed (any
11
+ // reason: a step-limit continue too), and a card with actions.
12
+ // Attention.open_keys's rule, so the page, the worker's open list and the
13
+ // badge agree.
14
+ // @return [String] "<session id>:<question id>" and "<session id>:card:<card id>" keys
19
15
  export function openPushKeys(byId) {
20
16
  const keys = [];
21
17
  for (const summary of Object.values(byId || {})) {
22
18
  const q = summary?.pending_question;
23
- if (!q || !q.id || q.relayed_to || !PUSHED.has(questionReason(q))) continue;
24
- keys.push(`${summary.id}:${q.id}`);
19
+ if (q && q.id && !q.relayed_to) keys.push(`${summary.id}:${q.id}`);
20
+ const card = summary?.pending_card;
21
+ if (card && card.id) keys.push(`${summary.id}:card:${card.id}`);
25
22
  }
26
23
  return keys;
27
24
  }
28
25
 
29
- // A pushed question's tag ("<session id>:<question id>"), not one of chi's
30
- // own (chi:test, chi:other): only these are ever cleaned up. sw.js keeps
31
- // the same pattern (a classic script imports nothing).
32
- export const QUESTION_TAG = /^(?!chi:)[^:]+:[^:]+$/;
33
- export const isQuestionTag = (tag) => typeof tag === "string" && QUESTION_TAG.test(tag);
26
+ // A pushed waiting key's tag: a question's "<session id>:<question id>" or
27
+ // a card's "<session id>:card:<card id>", not one of chi's own (chi:test,
28
+ // chi:other) nor a failed or done turn's "<session id>:<ended_at>" (its
29
+ // time has colons): only these are ever cleaned up. sw.js keeps the same
30
+ // pattern (a classic script imports nothing).
31
+ export const WAITING_TAG = /^(?!chi:)[^:]+:(?:card:)?[^:]+$/;
32
+ export const isWaitingTag = (tag) => typeof tag === "string" && WAITING_TAG.test(tag);
34
33
 
35
34
  // A standalone app with a service worker and PushManager, on a secure
36
35
  // context (chi web's https public URL; the LAN's plain http has none).
@@ -121,12 +121,13 @@ export function sessionStateFrom(data) {
121
121
  return state;
122
122
  }
123
123
 
124
- // The info bar's title: the session's own title (/title), else the first
125
- // user message when the payload has messages, else the card's preview, else
124
+ // The info bar's title: the session's own title (/title), else its auto
125
+ // title, else its recap's first sentence (as its card), else the first user message when the payload has messages, else the card's preview, else
126
126
  // the first prompt of its history.
127
127
  function sessionFirstPreview(data) {
128
128
  const s = data.session;
129
- if (s.title) return firstPreview(s.title);
129
+ const named = s.title || s.auto_title || s.recap;
130
+ if (named) return firstPreview(named);
130
131
  let preview = s.first_preview || previewForCard(s);
131
132
  if (data.messages && data.messages.length) {
132
133
  const first = data.messages.find((m) => m.role === "user");
@@ -17,9 +17,10 @@
17
17
 
18
18
  const ICON = "/icons/chi-192.png";
19
19
  const FALLBACK = { title: "chi", body: "open chi", tag: "chi:other", url: "/" };
20
- // push.js QUESTION_TAG: a pushed question's "<session id>:<question id>",
21
- // never chi's own (chi:test, chi:other).
22
- const QUESTION_TAG = /^(?!chi:)[^:]+:[^:]+$/;
20
+ // push.js WAITING_TAG: a pushed question's "<session id>:<question id>" or
21
+ // card's "<session id>:card:<card id>", never chi's own (chi:test,
22
+ // chi:other) nor a failed or done turn's (its time has colons).
23
+ const WAITING_TAG = /^(?!chi:)[^:]+:(?:card:)?[^:]+$/;
23
24
 
24
25
  function payloadOf(event) {
25
26
  try {
@@ -53,7 +54,7 @@ async function show({ title, body, tag, url }) {
53
54
  }
54
55
  }
55
56
 
56
- // Closes the shown question notifications chi web no longer has open
57
+ // Closes the shown waiting notifications chi web no longer has open
57
58
  // (never the new one nor chi's own). The unfiltered list only names the
58
59
  // tags: on the iPhone (iOS 26, 2026-10-24) close() on a notification from
59
60
  // it did nothing, while one looked up by its tag closes (the probe in
@@ -63,7 +64,7 @@ async function closeAnswered(open, keep) {
63
64
  const still = new Set(open.map(String));
64
65
  const tags = new Set();
65
66
  for (const n of await self.registration.getNotifications()) {
66
- if (n.tag !== keep && QUESTION_TAG.test(n.tag || "") && !still.has(n.tag)) tags.add(n.tag);
67
+ if (n.tag !== keep && WAITING_TAG.test(n.tag || "") && !still.has(n.tag)) tags.add(n.tag);
67
68
  }
68
69
  for (const tag of tags) {
69
70
  for (const n of await self.registration.getNotifications({ tag })) n.close();
@@ -4,12 +4,12 @@ module Samagotchi
4
4
  module Web
5
5
  module Push
6
6
  # When a session needs the user, decided from two of the hub's session
7
- # summaries (SessionSummary.build: symbol keys): the server's port of
8
- # the page's rules (web/public/notify.js attentionFor, closedKeys,
9
- # attentionText), so a push and the tab's notification ring for the
10
- # same thing under the same key. P1 pushes approvals and questions
11
- # only (the plan's D1): a step-limit question, a card, a failed or
12
- # done turn come later. See docs/internals/web-push.md.
7
+ # summaries (SessionSummary.build: symbol keys, but +last_turn+'s are
8
+ # strings): the server's port of the page's rules (web/public/notify.js
9
+ # attentionFor, closedKeys, attentionText), so a push and the tab's
10
+ # notification ring for the same thing under the same key.
11
+ # spec/fixtures/attention_cases.json holds the cases both sides answer.
12
+ # See docs/internals/web-push.md.
13
13
  module Attention
14
14
  module_function
15
15
 
@@ -23,11 +23,20 @@ module Samagotchi
23
23
  # (or hook question) waits, as its parent may relay it.
24
24
  RELAY_HOLD = 3.5
25
25
  RELAYED_KINDS = %w[approval hook].freeze
26
- # The reasons P1 pushes.
27
- PUSHED = %w[approval question].freeze
26
+ # The reasons that wait on the user: open until answered, counted in
27
+ # the badge, cleaned up once answered. A failed or done turn is
28
+ # neither (nothing to answer).
29
+ WAITING = %w[approval question continue card].freeze
30
+ # notify.js DONE_MIN_SECONDS, UNWATCHED_ORIGINS: a finished turn
31
+ # shorter than this was watched (no "done"), unless nobody started
32
+ # it (a delegate child's report, a context change, a finished task).
33
+ DONE_MIN_SECONDS = 10
34
+ UNWATCHED_ORIGINS = %w[delegate_report context task].freeze
28
35
  TITLE_MAX = 61
29
36
  BODIES = { "question" => "needs an answer", "approval" => "needs approval", "continue" => "hit its step limit",
30
- "card" => "needs you" }.freeze
37
+ "card" => "needs you", "failed" => "turn failed" }.freeze
38
+
39
+ def waiting?(reason) = WAITING.include?(reason)
31
40
 
32
41
  # notify.js questionReason: a hook's question is a question.
33
42
  def reason_of(question)
@@ -39,52 +48,81 @@ module Samagotchi
39
48
  end
40
49
 
41
50
  # What +nxt+ newly needs the user for, compared with +prev+ (nil for
42
- # a session not seen yet).
51
+ # a session not seen yet): a new question first, then a new card,
52
+ # then a finished turn (notify.js attentionFor's order).
43
53
  # @return [Event, nil]
44
54
  def for(prev, nxt)
45
55
  return nil unless nxt && nxt[:id]
46
56
 
47
57
  question = nxt[:pending_question]
48
- return nil unless question && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
49
- # Relayed to the parent already: the parent's notifies.
50
- return nil if question[:relayed_to]
58
+ if question.is_a?(Hash) && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
59
+ # Relayed to the parent already: the parent's notifies.
60
+ return nil if question[:relayed_to]
51
61
 
52
- reason = reason_of(question)
53
- return nil unless PUSHED.include?(reason)
62
+ hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
63
+ return Event.new(reason: reason_of(question), session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
64
+ end
65
+ card = nxt[:pending_card]
66
+ if card.is_a?(Hash) && card[:id] && card[:id] != prev&.dig(:pending_card, :id)
67
+ return Event.new(reason: "card", session_id: nxt[:id], key: card_key(nxt[:id], card[:id]), hold: nil)
68
+ end
69
+
70
+ turn_event(prev, nxt)
71
+ end
54
72
 
55
- hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
56
- Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
73
+ # A top-level turn that ended since +prev+: failed, or done when it
74
+ # ran long or nobody watched it. A delegated child's turn is
75
+ # reported by its parent's.
76
+ def turn_event(prev, nxt)
77
+ turn = nxt[:last_turn]
78
+ return nil unless turn.is_a?(Hash) && turn["ended_at"]
79
+ return nil if turn["ended_at"] == prev&.dig(:last_turn, "ended_at") || nxt[:parent_id]
80
+
81
+ reason = case turn["outcome"]
82
+ when "failed" then "failed"
83
+ when "completed"
84
+ "done" if seconds(turn) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.include?(turn["origin"])
85
+ end
86
+ reason && Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], turn["ended_at"]), hold: nil)
57
87
  end
58
88
 
59
89
  # The keys of what +prev+ had open and +nxt+ no longer has: its
60
- # question answered (anywhere), gone or newly relayed; everything
61
- # when the session is gone (+nxt+ nil).
90
+ # question answered (anywhere), gone or newly relayed, its card
91
+ # resolved or replaced; everything when the session is gone (+nxt+
92
+ # nil).
62
93
  # @return [Array<String>]
63
94
  def closed_keys(prev, nxt)
95
+ keys = []
64
96
  question = prev&.dig(:pending_question)
65
- return [] unless question && question[:id]
66
-
67
- now = nxt&.dig(:pending_question)
68
- gone = now.nil? || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
69
- gone ? [key(prev[:id], question[:id])] : []
97
+ if question.is_a?(Hash) && question[:id]
98
+ now = nxt&.dig(:pending_question)
99
+ gone = !now.is_a?(Hash) || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
100
+ keys << key(prev[:id], question[:id]) if gone
101
+ end
102
+ card = prev&.dig(:pending_card)
103
+ keys << card_key(prev[:id], card[:id]) if card.is_a?(Hash) && card[:id] && nxt&.dig(:pending_card, :id) != card[:id]
104
+ keys
70
105
  end
71
106
 
72
- # Every key still open in +by_id+ (session id → summary): a
73
- # pending question with an id, not relayed, of a reason P1 pushes.
74
- # The rule push.js openPushKeys keeps, so the show's +open+ list,
75
- # the page's cleanup and the badge agree.
107
+ # Every waiting key still open in +by_id+ (session id → summary): a
108
+ # pending question with an id, not relayed (any reason: a step-limit
109
+ # continue too), and a card with actions. The rule push.js
110
+ # openPushKeys keeps, so the show's +open+ list, the page's cleanup
111
+ # and the badge agree.
76
112
  # @return [Array<String>]
77
113
  def open_keys(by_id)
78
- by_id.each_value.filter_map do |summary|
114
+ by_id.each_value.flat_map do |summary|
79
115
  question = summary[:pending_question]
80
- next unless question.is_a?(Hash) && question[:id] && !question[:relayed_to]
81
- next unless PUSHED.include?(reason_of(question))
82
-
83
- key(summary[:id], question[:id])
116
+ card = summary[:pending_card]
117
+ keys = []
118
+ keys << key(summary[:id], question[:id]) if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
119
+ keys << card_key(summary[:id], card[:id]) if card.is_a?(Hash) && card[:id]
120
+ keys
84
121
  end
85
122
  end
86
123
 
87
- def key(session_id, question_id) = "#{session_id}:#{question_id}"
124
+ def key(session_id, id) = "#{session_id}:#{id}"
125
+ def card_key(session_id, card_id) = "#{session_id}:card:#{card_id}"
88
126
 
89
127
  # notify.js attentionText: the session's name (clipped) and the
90
128
  # reason, never the question or the command (a lock screen shows it).
@@ -92,7 +130,14 @@ module Samagotchi
92
130
  def text(summary, reason)
93
131
  name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]].find { |v| present?(v) }) ||
94
132
  "chi session"
95
- [clip(name.to_s.strip, TITLE_MAX), BODIES.fetch(reason, "")]
133
+ body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
134
+ [clip(name.to_s.strip, TITLE_MAX), body]
135
+ end
136
+
137
+ # notify.js Number(turn.seconds): anything that isn't a number is 0.
138
+ def seconds(turn)
139
+ value = turn["seconds"]
140
+ value.is_a?(Numeric) ? value.to_f : Float(value.to_s, exception: false) || 0.0
96
141
  end
97
142
 
98
143
  def present?(value) = !value.nil? && !value.to_s.strip.empty?
@@ -11,18 +11,25 @@ module Samagotchi
11
11
  module Push
12
12
  # One push to one device: the payload encrypted for its keys
13
13
  # (Crypto.encrypt), POSTed to its endpoint with chi web's VAPID header,
14
- # and the push service's answer as a Result. No retry (P1). See
15
- # docs/internals/web-push.md.
14
+ # and the push service's answer as a Result. It never retries itself:
15
+ # a Result says when a retry may help (+retry_after+), and the
16
+ # Notifier keeps it. See docs/internals/web-push.md.
16
17
  class Client
17
18
  OPEN_TIMEOUT = 5
18
19
  READ_TIMEOUT = 10
20
+ # A 429 or 5xx's Retry-After (delta-seconds) is taken up to this;
21
+ # without one (or as an HTTP date) a retry waits RETRY_DEFAULT.
22
+ RETRY_MAX = 60
23
+ RETRY_DEFAULT = 10
19
24
 
20
25
  # +status+ :ok (201/200/202), :gone (404/410: the device dropped
21
26
  # it), :bad_jwt (403 BadJwtToken: the push service refused chi's
22
27
  # VAPID token) or :failed; +code+ the HTTP status (nil when none
23
- # came), +error+ a short word for a failure, +ms+ the call's time.
24
- Result = Data.define(:status, :code, :ms, :error) do
25
- def initialize(error: nil, **) = super
28
+ # came), +error+ a short word for a failure, +ms+ the call's time,
29
+ # +retry_after+ the seconds after which one retry may go (a 429 or
30
+ # 5xx; nil for anything else).
31
+ Result = Data.define(:status, :code, :ms, :error, :retry_after) do
32
+ def initialize(error: nil, retry_after: nil, **) = super
26
33
  def ok? = status == :ok
27
34
  end
28
35
 
@@ -93,7 +100,17 @@ module Samagotchi
93
100
  when 403 then response.body.to_s.include?("BadJwtToken") ? :bad_jwt : :failed
94
101
  else :failed
95
102
  end
96
- Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil)
103
+ Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil,
104
+ retry_after: retry_after(response, code))
105
+ end
106
+
107
+ # The push service is busy (429) or failing (5xx): when to try once
108
+ # more.
109
+ def retry_after(response, code)
110
+ return nil unless code == 429 || (500..599).cover?(code)
111
+
112
+ value = response["Retry-After"].to_s.strip
113
+ value.match?(/\A\d+\z/) ? [value.to_i, RETRY_MAX].min : RETRY_DEFAULT
97
114
  end
98
115
 
99
116
  def ms(started) = ((@monotonic.call - started) * 1000).round