samagotchi 0.111.0 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/configuration.md +24 -14
  5. data/docs/internals/remote-sessions.md +5 -1
  6. data/docs/internals/web-push.md +122 -46
  7. data/docs/sessions.md +3 -1
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/reply_wait/poll.rb +11 -10
  10. data/lib/samagotchi/reply_wait.rb +30 -3
  11. data/lib/samagotchi/session.rb +4 -2
  12. data/lib/samagotchi/session_access/remote.rb +5 -1
  13. data/lib/samagotchi/version.rb +1 -1
  14. data/lib/samagotchi/web/app.rb +3 -0
  15. data/lib/samagotchi/web/participant_access.rb +1 -1
  16. data/lib/samagotchi/web/public/chains.js +9 -6
  17. data/lib/samagotchi/web/public/format.js +20 -5
  18. data/lib/samagotchi/web/public/index.html +16 -5
  19. data/lib/samagotchi/web/public/lists_view.js +3 -2
  20. data/lib/samagotchi/web/public/notifications.js +115 -13
  21. data/lib/samagotchi/web/public/notify.js +3 -3
  22. data/lib/samagotchi/web/public/participant.html +4 -0
  23. data/lib/samagotchi/web/public/push.js +16 -17
  24. data/lib/samagotchi/web/public/session_state.js +4 -3
  25. data/lib/samagotchi/web/public/sw.js +6 -5
  26. data/lib/samagotchi/web/push/attention.rb +80 -35
  27. data/lib/samagotchi/web/push/client.rb +23 -6
  28. data/lib/samagotchi/web/push/notifier.rb +152 -20
  29. data/lib/samagotchi/web/push/presence.rb +60 -0
  30. data/lib/samagotchi/web/push/store.rb +84 -6
  31. data/lib/samagotchi/web/push.rb +8 -4
  32. data/lib/samagotchi/web/push_routes.rb +49 -8
  33. data/lib/samagotchi/web/server.rb +2 -1
  34. data/lib/samagotchi/web/session_access_routes.rb +19 -8
  35. metadata +2 -1
@@ -63,8 +63,31 @@ module Samagotchi
63
63
  # @!attribute origin [String, nil] "command" for a :no_reply that a
64
64
  # file-delivered session command settled (no turn ran:
65
65
  # TurnCheckpoint.record_command_outcome); nil otherwise
66
+ # @!attribute running_look [RunningLook, nil] on a :timeout, the turn
67
+ # this wait saw running (call's running_look:, for a wait that goes
68
+ # on in a next call: chi web's 30 s slices of a remote wait)
66
69
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
67
- :stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
70
+ :stopped_by, :kept_steps, :session, :turn_id, :origin, :running_look, keyword_init: true)
71
+
72
+ # A turn a wait with no turn: saw running with its checkpoint mark:
73
+ # last_turn's ended_at then and the names of the input files claimed
74
+ # then (*.json.processing), so its settle by a next worker tells from
75
+ # a turn that ended or an input that runs next (Poll#settled_running).
76
+ # Plain strings, so a remote wait carries it from one slice to the next.
77
+ RunningLook = Data.define(:last_end, :claimed) do
78
+ # @return [RunningLook, nil] nil unless +raw+ is {last_end: String or
79
+ # nil, claimed: [file names]} (string keys, as JSON parsed it)
80
+ def self.from_h(raw)
81
+ return nil unless raw.is_a?(Hash) && raw.keys.sort == %w[claimed last_end]
82
+
83
+ last_end = raw["last_end"]
84
+ claimed = raw["claimed"]
85
+ return nil unless (last_end.nil? || last_end.is_a?(String)) && claimed.is_a?(Array) &&
86
+ claimed.all? { |name| name.is_a?(String) && File.basename(name) == name }
87
+
88
+ new(last_end: last_end, claimed: claimed.sort.freeze)
89
+ end
90
+ end
68
91
 
69
92
  # The session as it was before a message went in (call's baseline:,
70
93
  # #baseline_of): its message count, the question pending then, and when
@@ -133,13 +156,17 @@ module Samagotchi
133
156
  # sorts at the cursor, not past it, and its end precedes the
134
157
  # baseline, so without it the wait would time out "running" again.
135
158
  # Not for a turn: wait (chi answer) whose own end it waits for.
159
+ # @param running_look [RunningLook, nil] the running turn an earlier
160
+ # call of the same wait saw (its :timeout result's running_look): its
161
+ # settle ends this call too, as if this call had seen it running
136
162
  # @return [Result]
137
163
  # @raise [ArgumentError] no such session
138
164
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
139
- baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
165
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
166
+ running_look: nil)
140
167
  Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
141
168
  cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
142
- queued: queued, ended_before: ended_before).run
169
+ queued: queued, ended_before: ended_before, running_look: running_look).run
143
170
  end
144
171
 
145
172
  # The session as it was before a message went in (call's baseline:):
@@ -378,7 +378,8 @@ module Samagotchi
378
378
 
379
379
  # The question this session waits on, as the lists show it: {id:, kind:}
380
380
  # with kind "question" (the model's), "approval", "hook" or "continue"
381
- # (the step-limit question, asked between turns); nil with
381
+ # (the step-limit question, asked between turns), +relayed_to+ and
382
+ # +created_at+ (iso8601: chi web's push catch-up) when set; nil with
382
383
  # none. Only while a worker runs it (+live+): a question saved by a
383
384
  # worker that died waits for no one (the next worker drops it). The
384
385
  # web's summaries and `chi sessions list` both ask this.
@@ -391,7 +392,8 @@ module Samagotchi
391
392
  kind = pending.kind.to_s
392
393
  relayed = pending.relayed_to
393
394
  relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
394
- { id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
395
+ { id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to,
396
+ created_at: pending.created_at }.compact
395
397
  end
396
398
 
397
399
  # A question saved by a process that no longer runs this session (the
@@ -333,7 +333,9 @@ module Samagotchi
333
333
  end
334
334
 
335
335
  # ReplyWait on the server, asked again each WAIT_SLICE until it ends
336
- # or +timeout+ passes.
336
+ # or +timeout+ passes. Each slice is a fresh ReplyWait there: the
337
+ # running turn a slice saw (its timeout's running_look) goes into the
338
+ # next, so a settle between two slices ends the wait as it would here.
337
339
  # @return [ReplyWait::Result]
338
340
  # @raise [ArgumentError] the session is gone
339
341
  def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
@@ -356,6 +358,8 @@ module Samagotchi
356
358
  @strategies[id] = strategy_of(reply.json["parent_strategy"])
357
359
  result = result_of(reply.json)
358
360
  return result unless result.status == :timeout
361
+
362
+ body[:running_look] = reply.json["running_look"]
359
363
  end
360
364
  end
361
365
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.111.0"
4
+ VERSION = "0.112.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -271,7 +271,10 @@ module Samagotchi
271
271
  ["GET", %r{\A/api/push\z}, :handle_push_info, :owner],
272
272
  ["POST", %r{\A/api/push/subscriptions\z}, :handle_push_subscribe, :owner],
273
273
  ["DELETE", %r{\A/api/push/subscriptions\z}, :handle_push_unsubscribe, :owner],
274
+ # The id checked in the handler (participant_gate_spec fills ([^/]+) captures only).
275
+ ["PATCH", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_prefs, :owner],
274
276
  ["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
277
+ ["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
275
278
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
276
279
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
277
280
  ].freeze
@@ -341,7 +341,7 @@ module Samagotchi
341
341
  grant = principal(req).grant
342
342
  return not_permitted_for(req, principal(req)) unless own_turn?(grant, id, options[:turn])
343
343
 
344
- options = options.merge(queued: true, cursor: nil)
344
+ options = options.merge(queued: true, cursor: nil, running_look: nil)
345
345
  session = session_access.show(id)
346
346
  result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
347
347
  cancelled: -> { !@server_running.call || !@grants.active?(grant.id) })
@@ -60,11 +60,12 @@ export function isOpener(text) {
60
60
  // reads its chain's first link's title instead, so the links of a chain
61
61
  // keep its name. Its own title when the first link isn't in +byId+ (id →
62
62
  // session; deleted, or a walk a missing link cut short) or is the opener too.
63
- // A link with a title of its own (/title; a new link inherits it) reads it,
64
- // and a titled first link names its chain.
63
+ // A link with a title of its own (/title; a new link inherits it), an auto
64
+ // title or a recap of its own reads it, and a first link with any of them
65
+ // names its chain.
65
66
  export function chainTitle(s, byId) {
66
67
  const own = previewForCard(s);
67
- if (s?.title || !s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
68
+ if (s?.title || s?.auto_title || s?.recap || !s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
68
69
  const seen = new Set([s.id]);
69
70
  let at = s;
70
71
  while (at.continues) {
@@ -73,7 +74,8 @@ export function chainTitle(s, byId) {
73
74
  seen.add(prev.id);
74
75
  at = prev;
75
76
  }
76
- return !at.title && isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
77
+ const named = at.title || at.auto_title || at.recap;
78
+ return !named && isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
77
79
  }
78
80
 
79
81
  // The open session's title (app.js updateSessionTitle: the top bar and the
@@ -81,10 +83,11 @@ export function chainTitle(s, byId) {
81
83
  // (+shown+, its summary, has continues) reads +cardTitle+(shown) whatever
82
84
  // its first message: a web link inherits its chain's title, and its first
83
85
  // message is an opener (the constant or the user's own). Else +first+,
84
- // unless it is the opener. A titled session (/title) reads its card's too.
86
+ // unless it is the opener. A titled session (/title), or one with an auto
87
+ // title or a recap (its card's name then), reads its card's too.
85
88
  // +shown+ null: not listed yet, +first+.
86
89
  export function openTitle({ shown, first, cardTitle }) {
87
- if (shown && (shown.title || shown.continues || isOpener(first))) return cardTitle(shown);
90
+ if (shown && (shown.title || shown.auto_title || shown.recap || shown.continues || isOpener(first))) return cardTitle(shown);
88
91
  return first;
89
92
  }
90
93
 
@@ -57,11 +57,26 @@ export function firstPreview(text) {
57
57
  return clip(n, FIRST_PREVIEW_CHARS + 1);
58
58
  }
59
59
 
60
+ // What names a session's card, first found: its title (/title), its auto
61
+ // title (from the recap call), its recap's first sentence (+recap+), its
62
+ // first message, its last prompt. Display only.
63
+ function cardName(session) {
64
+ return session.title || session.auto_title || session.recap || session.first_preview || session.last_prompt || "";
65
+ }
66
+
60
67
  export function previewForCard(session) {
61
- // Unified card preview: its title (/title), else its first message (80),
62
- // else its last prompt.
63
- const raw = session.title || session.first_preview || session.last_prompt || "";
64
- return firstPreview(raw) || "\u2014";
68
+ // Unified card preview: cardName, cut at 80.
69
+ return firstPreview(cardName(session)) || "\u2014";
70
+ }
71
+
72
+ // Where previewForCard's words came from: "title", "auto" (auto_title),
73
+ // "recap" (the card then drops its recap line: the same sentence twice),
74
+ // "preview" (the first message or last prompt, or none).
75
+ export function cardTitleSource(session) {
76
+ if (session.title) return "title";
77
+ if (session.auto_title) return "auto";
78
+ if (session.recap) return "recap";
79
+ return "preview";
65
80
  }
66
81
 
67
82
  const DELETE_PREVIEW_CHARS = 40;
@@ -70,7 +85,7 @@ const DELETE_PREVIEW_CHARS = 40;
70
85
  // list's owner can be stale (a worker idle-exits), so a worker is only
71
86
  // "stopped if it's still running", never promised.
72
87
  export function deleteConfirmText(session) {
73
- const n = normalize(session.title || session.first_preview || session.last_prompt || "");
88
+ const n = normalize(cardName(session));
74
89
  const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
75
90
  const shortId = session.short_id || String(session.id).slice(0, 8);
76
91
  const lines = [
@@ -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,25 +6,45 @@
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";
35
+ // The device id chi web gave this app's subscription (POST subscribe's
36
+ // answer): the next subscribe sends it as replaces, so a subscription iOS
37
+ // revoked silently (its push service keeps answering 201) leaves the store.
38
+ export const PUSH_DEVICE_KEY = "chi_push_device";
39
+ // A long-lived Home Screen app checks for a new sw.js at most this often
40
+ // when it comes back in front (reg.update()).
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;
28
48
 
29
49
  const NOTIFY_TITLES = {
30
50
  on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
@@ -33,8 +53,8 @@ const NOTIFY_TITLES = {
33
53
  };
34
54
 
35
55
  const PUSH_TITLES = {
36
- on: "Notifications to this phone: on (approvals and questions, also while the app is closed). Tap to turn off",
37
- 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",
38
58
  denied: "Notifications are blocked for this app in the phone's settings",
39
59
  };
40
60
 
@@ -50,6 +70,21 @@ async function defaultApi(method, path, body) {
50
70
  return data;
51
71
  }
52
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
+
53
88
  function defaultChannel() {
54
89
  try {
55
90
  if (typeof globalThis.BroadcastChannel === "function") return new BroadcastChannel(NOTIFY_CHANNEL);
@@ -68,6 +103,11 @@ function defaultChannel() {
68
103
  // @param api (method, path, body) => Promise of chi web's JSON
69
104
  // @param scoped the page shows one project's sessions (?dir=): it never
70
105
  // tidies the device, as it doesn't see the others'
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
71
111
  // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
72
112
  export function createNotifications({
73
113
  button,
@@ -80,6 +120,10 @@ export function createNotifications({
80
120
  nav = globalThis.navigator,
81
121
  api = defaultApi,
82
122
  scoped = false,
123
+ now = () => Date.now(),
124
+ sendPresence = presenceSender({ nav, api }),
125
+ every = (fn, ms) => setInterval(fn, ms),
126
+ pageId = randomTabId(),
83
127
  }) {
84
128
  let attention = initialAttentionState();
85
129
  // Web Push in the Home Screen app: null until chi web says it pushes
@@ -88,6 +132,8 @@ export function createNotifications({
88
132
  let pushBusy = false;
89
133
  let syncing = null;
90
134
  let syncAgain = false;
135
+ // When this page last had the browser check for a newer sw.js.
136
+ let swCheckedAt = null;
91
137
  const badgeKeys = new Set();
92
138
  let baseTitle = "Chi";
93
139
 
@@ -101,6 +147,17 @@ export function createNotifications({
101
147
  return doc.visibilityState === "visible" && doc.hasFocus();
102
148
  }
103
149
 
150
+ function stored(key) {
151
+ try { return storage().getItem(key); } catch (_) { return null; }
152
+ }
153
+
154
+ function store(key, value) {
155
+ try {
156
+ if (value == null) storage().removeItem(key);
157
+ else storage().setItem(key, value);
158
+ } catch (_) {}
159
+ }
160
+
104
161
  function notifyWanted() {
105
162
  try { return storage().getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
106
163
  }
@@ -148,13 +205,19 @@ export function createNotifications({
148
205
  let failed = null;
149
206
  try {
150
207
  if (push.on) {
151
- await pushUnsubscribe({ reg: push.reg, del: (endpoint) => api("DELETE", "/api/push/subscriptions", { endpoint }) });
208
+ const had = await pushUnsubscribe({ reg: push.reg, del: (endpoint) => api("DELETE", "/api/push/subscriptions", { endpoint }) });
209
+ // Without a subscription to delete (revoked meanwhile) the id stays
210
+ // for the next subscribe to replace.
211
+ if (had) store(PUSH_DEVICE_KEY, null);
152
212
  push.on = false;
153
213
  setDeviceBadge(0);
154
214
  } else if (NotificationApi.permission === "granted" || (await NotificationApi.requestPermission()) === "granted") {
155
- const { subscription } = await pushSubscribe({
156
- reg: push.reg, key: push.key, post: (json) => api("POST", "/api/push/subscriptions", { subscription: json }),
215
+ const replaces = stored(PUSH_DEVICE_KEY);
216
+ const { subscription, answer } = await pushSubscribe({
217
+ reg: push.reg, key: push.key,
218
+ post: (json) => api("POST", "/api/push/subscriptions", replaces ? { subscription: json, replaces } : { subscription: json }),
157
219
  });
220
+ if (answer?.id) store(PUSH_DEVICE_KEY, answer.id);
158
221
  push.on = true;
159
222
  api("POST", "/api/push/test", { endpoint: subscription.endpoint }).catch(() => {});
160
223
  syncDevice();
@@ -180,11 +243,23 @@ export function createNotifications({
180
243
  const info = await api("GET", `/api/push${query}`);
181
244
  if (!info?.enabled || !info.public_key) return;
182
245
  push = { reg, key: info.public_key, on: !!(sub && info.this) };
246
+ // register() has just checked for a newer sw.js.
247
+ swCheckedAt = now();
183
248
  renderNotifyBtn();
184
249
  } catch (_) {}
185
250
  syncDevice();
186
251
  }
187
252
 
253
+ // A Home Screen app can stay open for days: when it comes back in front
254
+ // it asks for a newer sw.js, at most once per SW_UPDATE_MS.
255
+ function updateWorker() {
256
+ if (!push || typeof push.reg?.update !== "function") return;
257
+ const at = now();
258
+ if (swCheckedAt !== null && at - swCheckedAt < SW_UPDATE_MS) return;
259
+ swCheckedAt = at;
260
+ try { push.reg.update()?.catch?.(() => {}); } catch (_) {}
261
+ }
262
+
188
263
  function setDeviceBadge(count) {
189
264
  try {
190
265
  const done = count > 0 && typeof nav?.setAppBadge === "function" ? nav.setAppBadge(count)
@@ -194,7 +269,8 @@ export function createNotifications({
194
269
  }
195
270
 
196
271
  // The Home Screen app with the bell on: closes the pushed notifications
197
- // 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
198
274
  // app's badge. Never before the first snapshot (an empty byId would close
199
275
  // them all) nor on a project's page (its byId lacks the others). One at a
200
276
  // time; asked again meanwhile, it runs once more after.
@@ -210,7 +286,7 @@ export function createNotifications({
210
286
  const open = new Set(openPushKeys(attention.byId));
211
287
  try {
212
288
  const shown = await push.reg.getNotifications();
213
- 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();
214
290
  } catch (_) {}
215
291
  if (push?.on) setDeviceBadge(open.size);
216
292
  } while (syncAgain);
@@ -279,7 +355,33 @@ export function createNotifications({
279
355
  if (doc.visibilityState !== "visible") return;
280
356
  clearBadge();
281
357
  syncDevice();
358
+ updateWorker();
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);
282
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
+
283
385
  doc.addEventListener("visibilitychange", backInFront);
284
386
  win.addEventListener("focus", backInFront);
285
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).