samagotchi 0.111.1 → 0.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "cli/command"
6
+ require_relative "cli/exit"
7
+ require_relative "cli/flags"
8
+ require_relative "config"
9
+ require_relative "web/push"
10
+ require_relative "web/running"
11
+
12
+ module Samagotchi
13
+ # `chi web push`: this machine's Web Push devices (Web::Push::Store),
14
+ # without chi web running: list them, remove one, send a test push.
15
+ # Never prints an endpoint or keys: a device is its short id and chi's
16
+ # label. bin/chi branches here on `web push` before RunOptions (which
17
+ # would refuse the word); `chi --remote X web push` is refused before
18
+ # that (chi web runs on this machine only). See docs/cli.md.
19
+ class PushCommand
20
+ include CLI::Command
21
+
22
+ USAGE = <<~TEXT
23
+ Usage: chi web push <ls|rm|test> [options]
24
+ ls [--format json] the devices chi web pushes to: id, label, origin, added, last delivery,
25
+ last error, done turns; "stale" for another origin's (never sent to)
26
+ rm ID remove a device (it turns the bell on again to come back)
27
+ test [ID] a test push to ID, or to every device not stale (chi web need not run)
28
+ A device is added by the bell in the phone's Home Screen app (docs/configuration.md, Phone notifications).
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[ls rm test].freeze
32
+
33
+ FLAGS = {
34
+ "ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
35
+ "rm" => CLI::Flags.new(help: HELP_WORDS),
36
+ "test" => CLI::Flags.new(help: HELP_WORDS)
37
+ }.freeze
38
+
39
+ # @param store [Web::Push::Store, nil] this machine's by default
40
+ # @param origin [#call] → the public origin chi web runs on now (the
41
+ # running one's, else web.public_url's), nil when neither
42
+ # @param client [#call, nil] (sub) → a Client for its origin; one
43
+ # signed with the store's key by default
44
+ def initialize(argv, stdout: $stdout, stderr: $stderr, store: nil, origin: nil, client: nil)
45
+ @argv = argv.dup
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @store = store || Web::Push::Store.new
49
+ @origin = origin || -> { PushCommand.current_origin }
50
+ @client = client
51
+ end
52
+
53
+ # The running chi web's https origin (a --tunnel's too), else
54
+ # web.public_url's; nil when neither.
55
+ def self.current_origin
56
+ Web::Running.live_public_url&.origin || Web::PublicUrl.from_config(url: Config.get("web.public_url"))&.origin
57
+ rescue Web::PublicUrl::Invalid
58
+ nil
59
+ end
60
+
61
+ # @return [Integer] 0 done, 1 refused (a store others can read, a
62
+ # corrupt one, an unknown device, a failed test), 2 usage
63
+ def run
64
+ @sub = @argv[0]
65
+ if @sub.nil? || HELP_WORDS.include?(@sub)
66
+ @stdout.puts(USAGE)
67
+ return 0
68
+ end
69
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
70
+
71
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
72
+ return parsed if parsed.is_a?(Integer)
73
+
74
+ send(:"run_#{@sub}", parsed.options, parsed.args)
75
+ rescue Web::Push::Store::Insecure, Web::Push::Store::Corrupt, Web::Push::Refused => e
76
+ error_line("#{command_name}: #{e.message}")
77
+ CLI::Exit::FAILED
78
+ end
79
+
80
+ private
81
+
82
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi web push #{@sub}" : "chi web push"
83
+ def usage_on_error = nil
84
+
85
+ def run_ls(options, args)
86
+ return usage_error("ls takes no arguments") unless args.empty?
87
+
88
+ format = options[:format]
89
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
90
+
91
+ subs = devices
92
+ origin = @origin.call
93
+ if format == "json"
94
+ @stdout.puts(JSON.generate(subs.map { |sub| sub.public_h.merge(stale: stale?(sub, origin)) }))
95
+ elsif subs.empty?
96
+ @stdout.puts("no devices (turn the bell on in the phone's Home Screen app)")
97
+ else
98
+ print_table(subs, origin)
99
+ end
100
+ 0
101
+ end
102
+
103
+ def run_rm(_options, args)
104
+ return usage_error("give one device ID (chi web push ls)") unless args.size == 1
105
+
106
+ devices
107
+ id = args.first
108
+ gone = Web::Push::Store.short_id?(id) && @store.remove_device(id)
109
+ return no_device(id) unless gone
110
+
111
+ @stdout.puts("#{gone.id} #{gone.label} removed")
112
+ 0
113
+ end
114
+
115
+ def run_test(_options, args)
116
+ return usage_error("give at most one device ID (chi web push ls)") if args.size > 1
117
+
118
+ subs = devices
119
+ if args.empty?
120
+ origin = @origin.call
121
+ subs = subs.reject { |sub| stale?(sub, origin) }
122
+ if subs.empty?
123
+ error_line("#{command_name}: no device to push to (chi web push ls)")
124
+ return CLI::Exit::FAILED
125
+ end
126
+ else
127
+ subs = subs.select { |sub| sub.id == args.first }
128
+ return no_device(args.first) if subs.empty?
129
+ end
130
+ results = subs.map { |sub| test(sub) }
131
+ results.all? ? 0 : CLI::Exit::FAILED
132
+ end
133
+
134
+ # One test push, its line printed; a :gone device is removed (as the
135
+ # route does). @return [Boolean] delivered
136
+ def test(sub)
137
+ result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
138
+ words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
139
+ words += ": #{result.error}" if result.error && !result.ok?
140
+ if result.status == :gone
141
+ @store.remove(sub.id)
142
+ words += ": removed (the push service no longer knows it)"
143
+ else
144
+ @store.mark(sub.id, ok: result.ok?, error: result.ok? ? nil : (result.error || result.code))
145
+ end
146
+ @stdout.puts("#{sub.id} #{sub.label} #{words}")
147
+ result.ok?
148
+ end
149
+
150
+ def client_for(sub)
151
+ return @client.call(sub) if @client
152
+
153
+ Web::Push::Client.new(key: Web::Push.load_key(@store), contact: Web::Push.contact(sub.origin))
154
+ end
155
+
156
+ # The store's devices, after its permission check.
157
+ def devices
158
+ @store.check_permissions!
159
+ @store.subscriptions!
160
+ end
161
+
162
+ def no_device(id)
163
+ error_line("#{command_name}: no device #{id[0, 16]} (chi web push ls)")
164
+ CLI::Exit::FAILED
165
+ end
166
+
167
+ # Stale: made on another origin than chi web's now; none is when that
168
+ # isn't known.
169
+ def stale?(sub, origin) = !origin.nil? && sub.origin != origin
170
+
171
+ def print_table(subs, origin)
172
+ rows = subs.map do |sub|
173
+ [sub.id, sub.label.to_s, sub.origin.to_s, when_text(sub.created_at), when_text(sub.last_ok_at) || "never",
174
+ sub.done? ? "on" : "off", [stale?(sub, origin) ? "stale" : nil, sub.last_error].compact.join(", ")]
175
+ end
176
+ rows.unshift(["ID", "LABEL", "ORIGIN", "ADDED", "LAST OK", "DONE", "NOTE"])
177
+ widths = rows.transpose.map { |column| column.map(&:length).max }
178
+ rows.each { |row| @stdout.puts(row.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip) }
179
+ end
180
+
181
+ # An iso time as local "YYYY-MM-DD HH:MM"; nil for none.
182
+ def when_text(iso)
183
+ iso && Time.iso8601(iso).localtime.strftime("%Y-%m-%d %H:%M")
184
+ rescue ArgumentError
185
+ iso
186
+ end
187
+ end
188
+ end
@@ -154,6 +154,7 @@ module Samagotchi
154
154
  chi bootstrap [HOST[:PORT]|URL] first setup: find the model server, write config.yml
155
155
  chi scratch [options] a one-time session in this terminal: nothing is kept
156
156
  chi web [--port PORT] [--open] [--scope=all] the web UI
157
+ chi web push <ls|rm|test> the phones chi web pushes notifications to
157
158
  chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats>
158
159
  chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
159
160
  chi broadcast [-m TEXT] [--all] [--dry-run] | log | deliver a note for every session it may concern
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.111.1"
4
+ VERSION = "0.113.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,11 @@ 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],
276
+ ["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
274
277
  ["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
278
+ ["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
275
279
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
276
280
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
277
281
  ].freeze
@@ -614,16 +618,58 @@ module Samagotchi
614
618
  log_refused(refused_peer(req), req.path_info, reason)
615
619
  return error_response(401, "unauthorized", unauthorized_detail(req, reason)) if req.path_info.start_with?("/api/")
616
620
 
617
- body = @public_url ? PUBLIC_UNAUTHORIZED_PAGE : UNAUTHORIZED_PAGE
621
+ body = unauthorized_page(req)
618
622
  [401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
619
623
  [body]]
620
624
  end
621
625
 
626
+ # Which page a refused page request gets: the LAN page with no
627
+ # web.public_url, the proxy page for a request that came through it,
628
+ # and for a browser on this machine the one pointing at the public
629
+ # address (#direct_public?): there the paste box can't help.
630
+ def unauthorized_page(req)
631
+ return direct_public_page if direct_public?(req)
632
+ return PUBLIC_UNAUTHORIZED_PAGE if @public_url
633
+
634
+ UNAUTHORIZED_PAGE
635
+ end
636
+
637
+ # A browser on this machine, with web.public_url set: the gate refuses
638
+ # it like every other request, and no trade can fix it here — loopback
639
+ # takes the token as Bearer only, so there is no cookie for it to keep
640
+ # (a cookie for 127.0.0.1 reaches every server on any local port). The
641
+ # page says where to open chi web instead. The origin is this machine's
642
+ # config, not a secret; it is escaped all the same, and no token or
643
+ # link code is in it.
644
+ def direct_public_page
645
+ origin = CGI.escapeHTML(@public_url.origin)
646
+ <<~HTML
647
+ <!doctype html>
648
+ <html lang="en"><head><meta charset="utf-8">
649
+ <meta name="viewport" content="width=device-width, initial-scale=1">
650
+ <title>chi web: open it at its public address</title>
651
+ <style>
652
+ :root { color-scheme: light dark; }
653
+ body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
654
+ a { color: inherit; }
655
+ </style></head>
656
+ <body>
657
+ <h1>chi web</h1>
658
+ <p>chi web is behind an access proxy, so every request needs its access token, this machine's too.
659
+ A browser here can't trade a token for a cookie on 127.0.0.1, so this page can't let you in: open
660
+ chi web at its public address instead.</p>
661
+ <p><a href="#{origin}/">#{origin}/</a></p>
662
+ <p>chi web prints a link to it in its terminal every time it starts.</p>
663
+ </body></html>
664
+ HTML
665
+ end
666
+
622
667
  def unauthorized_detail(req, reason)
623
668
  return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
624
- return "chi web with web.public_url needs its access token, on this machine too" if @public_url
669
+ return "chi web on the LAN needs its access token" unless @public_url
625
670
 
626
- "chi web on the LAN needs its access token"
671
+ detail = "chi web with web.public_url needs its access token, on this machine too"
672
+ direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
627
673
  end
628
674
 
629
675
  # Who a refusal is logged as: the peer, or behind a proxy (every peer
@@ -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)}
@@ -1181,6 +1183,37 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1181
1183
  .pp-foot{display:flex;align-items:center;gap:8px;justify-content:flex-end;padding-top:4px}
1182
1184
  .pp-view{font-size:12px;color:var(--accent);text-decoration:none}
1183
1185
  .pp-view:hover{text-decoration:underline}
1186
+ /* The Home Screen app's notifications sheet (push_devices.js): the bell opens it; a bottom sheet on a phone. */
1187
+ .push-devices .ctx-pop-head strong{font-family:inherit}
1188
+ .push-devices.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1189
+ .pd-list{list-style:none;display:flex;flex-direction:column;gap:8px;margin:2px 0;padding:0}
1190
+ .pd-row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:2px 10px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1191
+ .pd-who{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text)}
1192
+ .pd-who b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1193
+ .pd-tag{font-size:10.5px;padding:0 6px;border-radius:999px;background:var(--accent-bg);color:var(--accent);white-space:nowrap}
1194
+ .pd-act{grid-column:2;grid-row:1/3;display:flex;align-items:center}
1195
+ .pd-meta{grid-column:1;font-size:11.5px;color:var(--text-muted)}
1196
+ .pd-error{color:var(--danger-text)}
1197
+ .pd-done{grid-column:1/3;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;padding-top:6px;border-top:1px solid var(--border);font-size:12px;color:var(--text-3)}
1198
+ .pd-remove{padding:3px 10px;border-radius:7px;font-size:12px;background:var(--bg-raised);color:var(--danger-text);border:1px solid var(--border-strong);cursor:pointer}
1199
+ .pd-remove:hover{color:var(--text-hover)}
1200
+ .pd-switch{appearance:none;-webkit-appearance:none;position:relative;flex-shrink:0;width:34px;height:20px;margin:0;border-radius:999px;background:var(--bg-raised);border:1px solid var(--border-strong);cursor:pointer;transition:background .15s}
1201
+ .pd-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-muted);transition:transform .15s,background .15s}
1202
+ .pd-switch:checked{background:var(--accent-strong);border-color:var(--accent-strong)}
1203
+ .pd-switch:checked::after{transform:translateX(14px);background:var(--on-solid)}
1204
+ .pd-switch:disabled{opacity:.5;cursor:default}
1205
+ .pd-switch:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px}
1206
+ .pd-done .pd-switch{width:30px;height:18px}
1207
+ .pd-done .pd-switch::after{width:12px;height:12px}
1208
+ .pd-done .pd-switch:checked::after{transform:translateX(12px)}
1209
+ .pd-note{font-size:11.5px;color:var(--text-muted)}
1210
+ .pd-note.pd-error{color:var(--danger-text)}
1211
+ .push-devices.sheet .pd-switch{width:42px;height:26px}
1212
+ .push-devices.sheet .pd-switch::after{width:20px;height:20px}
1213
+ .push-devices.sheet .pd-switch:checked::after{transform:translateX(16px)}
1214
+ .push-devices.sheet .pd-done{font-size:13px}
1215
+ .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1216
+ .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1184
1217
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1185
1218
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1186
1219
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -1300,8 +1333,6 @@ body.has-session #scope .scope-where{display:none}
1300
1333
  #sessionTitleRow{flex-basis:100%;order:10}
1301
1334
  #sessionTitle{font-size:12px}
1302
1335
  .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
1336
  #scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
1306
1337
  #stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1307
1338
  #scope .scope-name{flex-shrink:1;min-width:0}
@@ -1471,6 +1502,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1471
1502
  .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
1503
  .card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
1473
1504
  #allList .card{position:relative}
1505
+ /* Every row keeps its content's height: a card shorter than its rows (seen on iOS) cut the clamped preview and the
1506
+ single-line title and recap through the middle. */
1507
+ #allList .card > *{flex-shrink:0}
1474
1508
  /* A card's tools in the all view: archive (or unarchive) and delete, shown on hover, always under hover:none. */
1475
1509
  #allList .card-tools{position:absolute;top:6px;right:6px;display:flex;gap:2px}
1476
1510
  .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 +1554,14 @@ body.all-view #center, body.all-view #topStrip{display:none}
1520
1554
  #topStrip #allTile{width:auto}
1521
1555
  }
1522
1556
  a{color:var(--accent)}
1557
+ /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back), at any width: every text
1558
+ 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
1559
+ fields in this list (stage.spec.js's phone step checks the visible ones). */
1560
+ @media (max-width:600px),(pointer:coarse){
1561
+ #composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
1562
+ .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ctx-add-input,.ws-add-in,.rename-input,
1563
+ #modelSearch{font-size:16px}
1564
+ }
1523
1565
  </style>
1524
1566
  </head>
1525
1567
  <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