samagotchi 0.116.0 → 0.118.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -2
  3. data/docs/cli.md +28 -1
  4. data/docs/configuration.md +6 -0
  5. data/docs/internals/background-tasks.md +1 -1
  6. data/docs/sessions.md +3 -3
  7. data/lib/samagotchi/bridge.rb +88 -14
  8. data/lib/samagotchi/bridge_client.rb +13 -1
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/config.rb +19 -3
  11. data/lib/samagotchi/hold_stop.rb +89 -0
  12. data/lib/samagotchi/prompt_warmup.rb +8 -3
  13. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  14. data/lib/samagotchi/session_manager.rb +3 -2
  15. data/lib/samagotchi/task_watch.rb +23 -5
  16. data/lib/samagotchi/terminal_ui/attached_loop.rb +130 -8
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +2 -0
  19. data/lib/samagotchi/web/public/app.js +17 -1
  20. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  21. data/lib/samagotchi/web/public/composer_size.js +14 -7
  22. data/lib/samagotchi/web/public/data.js +14 -0
  23. data/lib/samagotchi/web/public/favicon.svg +1 -1
  24. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  25. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  26. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  27. data/lib/samagotchi/web/public/index.html +28 -2
  28. data/lib/samagotchi/web/public/manifest.webmanifest +1 -1
  29. data/lib/samagotchi/web/public/session_actions.js +39 -6
  30. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  31. data/lib/samagotchi/web/public/tokens.css +2 -2
  32. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  33. data/lib/samagotchi/web/session_control.rb +40 -3
  34. data/lib/samagotchi/worker.rb +57 -5
  35. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  36. metadata +3 -1
@@ -1141,11 +1141,12 @@ module Samagotchi
1141
1141
  # there is no live SSE or in-process cancel/answer).
1142
1142
  # @param on_input [#call, nil] called after the Bridge queues a turn
1143
1143
  def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
1144
- exit_discards: nil)
1144
+ exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
1145
1145
  require_relative "bridge"
1146
1146
  Samagotchi::Bridge.new(
1147
1147
  engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
1148
- on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards
1148
+ on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
1149
+ exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
1149
1150
  ).start
1150
1151
  rescue StandardError => e
1151
1152
  Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  require_relative "config"
4
6
  require_relative "log"
5
7
  require_relative "tools/task_runtime"
@@ -182,19 +184,35 @@ module Samagotchi
182
184
  # @return [Array<Entry>]
183
185
  def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
184
186
 
185
- # The #holding tasks still running ({id:, command:}), for /exit
186
- # (WorkerIdleExit's watched_running): one finished meanwhile is noted
187
- # as the worker leaves. One `ps` per task.
188
- # @return [Array<Hash>]
187
+ # A watched task still running, as /exit and the exit ask see it:
188
+ # +age_seconds+, the seconds since it started (nil when its record has
189
+ # no start time).
190
+ Running = Data.define(:id, :command, :age_seconds)
191
+
192
+ # The #holding tasks still running, for /exit (WorkerIdleExit's
193
+ # watched_running): one finished meanwhile is noted as the worker leaves.
194
+ # One `ps` per task.
195
+ # @return [Array<Running>]
189
196
  def running
190
197
  holding.filter_map do |entry|
191
198
  record, = Tools::TaskRuntime.get_record(entry.id)
192
- { id: entry.id, command: record.command.to_s } if record&.running?
199
+ next unless record&.running?
200
+
201
+ Running.new(id: entry.id, command: record.command.to_s, age_seconds: age_seconds(record))
193
202
  end
194
203
  end
195
204
 
196
205
  private
197
206
 
207
+ # Seconds since the task started, from its record's iso8601 +started_at+;
208
+ # nil when that can't be read.
209
+ def age_seconds(record)
210
+ started = Time.parse(record.started_at.to_s)
211
+ [(Time.now - started).round, 0].max
212
+ rescue ArgumentError, TypeError
213
+ nil
214
+ end
215
+
198
216
  # Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
199
217
  # was there. +log+: why a task leaves without a note, at +level+ (debug
200
218
  # by default), with +error+ when there is one.
@@ -57,17 +57,35 @@ module Samagotchi
57
57
  # An exit the worker agreed to in an empty session. Said before it has
58
58
  # left: it checks again once it lets go, and a note or prompt arriving
59
59
  # first keeps the session (SessionManager.discard_left_session).
60
- EMPTY_EXIT = "Detached; the session is empty: it will be discarded if nothing arrives before its worker stops."
60
+ EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
61
+ EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
61
62
  # Why the worker stayed up after /exit, by the reason it gave.
62
63
  HELD_REASONS = {
63
64
  "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
64
65
  "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
65
66
  "reminders" => "reminders are set", "starting" => "the worker is still starting",
66
- "background_tasks" => "background tasks it started are still running"
67
+ "background_tasks" => "background tasks it started are still running",
68
+ "delegates" => "delegates of this session are still running"
67
69
  }.freeze
68
70
  # How to end a background task that holds the worker up (an older
69
71
  # worker's 409 has no hint of its own).
70
72
  TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
73
+ # The /exit ask, when every hold is one the user can stop: what still
74
+ # runs, then the three answers. Each kind, singular and plural.
75
+ EXIT_HOLD_WORDS = {
76
+ "reminders" => %w[reminder reminders],
77
+ "background_tasks" => ["background task", "background tasks"],
78
+ "delegates" => %w[delegate delegates]
79
+ }.freeze
80
+ # The 200's +stopped+ keys, by the kind the ask lists them under.
81
+ STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
82
+ EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
83
+ # How the ask reads an answer.
84
+ EXIT_ANSWERS = {
85
+ "1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
86
+ "2" => :stop, "stop" => :stop, "exit" => :stop,
87
+ "3" => :back, "back" => :back, "stay" => :back
88
+ }.freeze
71
89
  # How much of the last answer a join shows.
72
90
  JOIN_ANSWER_LINES = 12
73
91
  JOIN_ANSWER_CHARS = 1200
@@ -175,6 +193,8 @@ module Samagotchi
175
193
  @own_failed = false
176
194
  # A continue offer is pending: the prompt asks for the answer.
177
195
  @continue_offer = nil
196
+ # The /exit ask is open: the next line is its answer (#answer_exit_ask).
197
+ @exit_ask = nil
178
198
  # The status row: the worker's model, ctx, the session's memories.
179
199
  @status = StatusRow.new(screen)
180
200
  # Participants' names by author ("p:<id>"), from the join's state
@@ -470,6 +490,14 @@ module Samagotchi
470
490
 
471
491
  # @return [Symbol, nil] :detach to end the loop
472
492
  def submit(line)
493
+ if @exit_ask
494
+ # The exit ask is open: this line answers it. Ctrl-D and /detach
495
+ # detach without stopping anything, the way its 1 does.
496
+ return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
497
+
498
+ return answer_exit_ask(line)
499
+ end
500
+
473
501
  return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
474
502
 
475
503
  text = line.strip
@@ -599,14 +627,104 @@ module Samagotchi
599
627
 
600
628
  # Detach, and ask the worker to exit now. The worker decides: it stays
601
629
  # up while anything still needs it (a turn, queued input, a continue
602
- # offer, another UI, reminders), and the line says which.
603
- # @return [Symbol] :detach
630
+ # offer, another UI, reminders), and the line says which. When the only
631
+ # holds are ones the user can stop, it asks what to do with them instead
632
+ # of detaching (#answer_exit_ask).
633
+ # @return [Symbol, nil] :detach, or nil to stay at the prompt
604
634
  def exit_worker
605
- detach(exit_line(@client.request_exit(client_id: @client_id)))
635
+ reply = @client.request_exit(client_id: @client_id)
636
+ holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
637
+ return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
638
+
639
+ @exit_ask = holds
640
+ @screen.commit(ask_lines(holds))
641
+ nil
642
+ rescue SystemCallError, IOError => e
643
+ detach(exit_failed_line(e.message))
644
+ end
645
+
646
+ # The ask: what still runs, then the three answers.
647
+ def ask_lines(holds)
648
+ lines = holds.map do |hold|
649
+ items = Array(hold["items"])
650
+ singular, plural = hold_words(hold["kind"])
651
+ " • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
652
+ end
653
+ lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
654
+ (["This session still has:"] + lines).join("\n")
655
+ end
656
+
657
+ # One entry of a hold: the command or name, with its detail in
658
+ # brackets; a delegate leads with its short id and its title in quotes.
659
+ def item_word(item, kind)
660
+ label = item["label"].to_s
661
+ label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
662
+ detail = item["detail"].to_s
663
+ detail.empty? ? label : "#{label} (#{detail})"
664
+ end
665
+
666
+ # The answer to the exit ask.
667
+ # @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
668
+ # ask doesn't understand
669
+ def answer_exit_ask(line)
670
+ choice = EXIT_ANSWERS[line.to_s.strip.downcase]
671
+ unless choice
672
+ @screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
673
+ return nil
674
+ end
675
+
676
+ holds = @exit_ask
677
+ @exit_ask = nil
678
+ case choice
679
+ when :keep then detach(kept_line(holds))
680
+ when :stop then stop_and_exit
681
+ else
682
+ @screen.commit("(nothing stopped; the worker keeps running)")
683
+ nil
684
+ end
685
+ end
686
+
687
+ # 1: detach, everything left running.
688
+ def kept_line(holds)
689
+ "Detached; the session keeps running (#{hold_words_list(holds)}). " \
690
+ "Re-attach with: #{via.attach(@client.session_id)}"
691
+ end
692
+
693
+ # 2: ask the worker to stop them all and exit.
694
+ # @return [Symbol] :detach
695
+ def stop_and_exit
696
+ reply = @client.request_exit(client_id: @client_id, stop: true)
697
+ detach(exit_line(reply))
606
698
  rescue SystemCallError, IOError => e
607
699
  detach(exit_failed_line(e.message))
608
700
  end
609
701
 
702
+ # "2 reminders, 1 background task, 1 delegate" from the ask's holds.
703
+ def hold_words_list(holds)
704
+ holds.map do |hold|
705
+ items = Array(hold["items"])
706
+ singular, plural = hold_words(hold["kind"])
707
+ "#{items.size} #{items.size == 1 ? singular : plural}"
708
+ end.join(", ")
709
+ end
710
+
711
+ def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
712
+
713
+ # What a stop-all exit stopped: "stopped 2 reminders (api_health, git
714
+ # push), 1 background task (rails s)"; nil when it stopped nothing.
715
+ def stopped_words(stopped)
716
+ return nil unless stopped.is_a?(Hash)
717
+
718
+ parts = STOPPED_KEYS.filter_map do |key, kind|
719
+ names = Array(stopped[key])
720
+ next if names.empty?
721
+
722
+ singular, plural = hold_words(kind)
723
+ "#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
724
+ end
725
+ parts.empty? ? nil : "stopped #{parts.join(", ")}"
726
+ end
727
+
610
728
  # /exit --delete: the worker exits as for /exit, then the session is
611
729
  # deleted. When the worker stays up (another UI, queued input, ...),
612
730
  # nothing is deleted.
@@ -656,7 +774,9 @@ module Samagotchi
656
774
  # (SessionManager.archive_session). An empty session: the worker
657
775
  # deletes it as it leaves.
658
776
  def exit_and_archive
659
- reply = @client.request_exit(client_id: @client_id)
777
+ # /archive can't ask about what still runs: a child the archive will
778
+ # stop is not worth a question here.
779
+ reply = @client.request_exit(client_id: @client_id, ask: false)
660
780
  id = @client.session_id
661
781
  return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
662
782
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
@@ -676,9 +796,11 @@ module Samagotchi
676
796
  error = reply.json&.fetch("error", nil)
677
797
  case reply.status
678
798
  when 200
679
- return EMPTY_EXIT if discards?(reply)
799
+ stopped = stopped_words(reply.json&.fetch("stopped", nil))
800
+ return EMPTY_EXIT if discards?(reply) && stopped.nil?
801
+ return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
680
802
 
681
- "Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
803
+ "Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
682
804
  when 409
683
805
  reason = reply.json&.fetch("reason", nil).to_s
684
806
  "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.116.0"
4
+ VERSION = "0.118.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -236,6 +236,8 @@ module Samagotchi
236
236
  ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
237
237
  ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
238
238
  ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
239
+ ["GET", %r{\A/api/sessions/([^/]+)/holds\z}, :handle_session_holds, :owner, :session],
240
+ ["POST", %r{\A/api/sessions/([^/]+)/holds/stop\z}, :handle_stop_holds, :owner, :session],
239
241
  ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
240
242
  ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
241
243
  ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
@@ -4,6 +4,8 @@ import {
4
4
  sendTurn,
5
5
  cancelTurn,
6
6
  stopSession,
7
+ sessionHolds,
8
+ stopSessionHolds,
7
9
  restartSession,
8
10
  deleteSession,
9
11
  archiveSession,
@@ -98,6 +100,7 @@ import { createToasts } from "./toasts.js";
98
100
  import { createUnqueue } from "./unqueue.js";
99
101
  import { createStartPage } from "./start_page.js";
100
102
  import { createSessionActions } from "./session_actions.js";
103
+ import { createStopSheet } from "./stop_sheet.js";
101
104
  import { createRename } from "./session_rename.js";
102
105
  import { createSendTurn } from "./send_turn.js";
103
106
  import { createMarkdownTip } from "./markdown_tip.js";
@@ -565,12 +568,21 @@ const lists = createListsView({
565
568
 
566
569
  // Continue, Archive, Delete and Stop, and the start page when the open
567
570
  // session is gone (session_actions.js).
571
+ // The Stop sheet first (stop_sheet.js): its buttons are the two ways to stop,
572
+ // and the sheet is only shown when the worker has something the user can stop.
573
+ const stopSheet = createStopSheet({
574
+ doc: document, win: window,
575
+ stopAll: (id) => sessionActions.stopSelected(id, { stopHolds: true }),
576
+ stopSession: (id) => sessionActions.stopSelected(id),
577
+ });
578
+
568
579
  const sessionActions = createSessionActions({
569
580
  selected: () => selected,
570
581
  state: () => current,
571
582
  sessions: () => allSessions,
572
583
  setSessions: (list) => { allSessions = list; },
573
- api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession },
584
+ api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession,
585
+ sessionHolds, stopSessionHolds },
574
586
  eventsLive: () => !!eventsControl,
575
587
  refresh,
576
588
  lists,
@@ -584,6 +596,7 @@ const sessionActions = createSessionActions({
584
596
  restoreDraft,
585
597
  closeLiveStream: () => streamControl?.close(),
586
598
  workerGone,
599
+ stopSheet,
587
600
  });
588
601
  // The title's ✎: /title through the chips' path (session_rename.js), the
589
602
  // input over the title row on screen: the top bar's when it shows the
@@ -1659,6 +1672,9 @@ attachInput.addEventListener("change", () => {
1659
1672
  attachInput.value = "";
1660
1673
  });
1661
1674
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1675
+ // The floor is saved as it was set; fitPrompt holds it to half the window it is
1676
+ // drawn in (composer_size.js), so a floor saved in a bigger window fits this one
1677
+ // and comes back when the window is big again.
1662
1678
  let promptFloor = Number(readStored(GRIP_KEY)) || null;
1663
1679
 
1664
1680
  // The empty box: one line in a session, three on the start page.
@@ -1,9 +1,10 @@
1
1
  // The composer's height and the gestures of the grip on its top edge. The
2
2
  // height grows with the text up to a cap; the grip sets a taller floor (the
3
- // cap then is at least that floor). A release of the grip is one gesture: a
4
- // drag that resizes, a drag far enough down that folds the box away, or a
5
- // click (which, expanded, folds too — after a delay, so a double-click can
6
- // still reset the height).
3
+ // cap then is at least that floor), and no floor — saved in another window or
4
+ // dragged here — takes more than half of the window the box is shown in. A
5
+ // release of the grip is one gesture: a drag that resizes, a drag far enough
6
+ // down that folds the box away, or a click (which, expanded, folds too — after
7
+ // a delay, so a double-click can still reset the height).
7
8
  export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
8
9
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
9
10
  const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
@@ -15,15 +16,21 @@ export const GRIP_CLICK_FOLD_MS = 250; // how long a click on the expanded grip
15
16
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
16
17
  // grip's height, or null; minPx: the empty box's height.
17
18
  export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
18
- const floor = Math.max(minPx, floorPx || 0);
19
+ // The floor, as THIS window can hold it: a floor saved in a bigger window
20
+ // (a rotated phone, a resized window, another device on the same origin) is
21
+ // held down to the share a drag may set here, and comes back whole when the
22
+ // window is big again — only the height shown shrinks, never the saved floor.
23
+ const floor = gripFloor(floorPx, viewportPx, minPx);
19
24
  const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
20
25
  return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
21
26
  }
22
27
 
23
28
  // A grip drag: the floor it asks for, kept between the minimum and the
24
- // grip's share of the window.
29
+ // grip's share of the window. The same rule fits a floor saved earlier (see
30
+ // promptHeight), so the box never takes more of a window than a drag in it
31
+ // could have given it.
25
32
  export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
26
- return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
33
+ return Math.max(minPx, Math.min(px || 0, Math.round(viewportPx * PROMPT_GRIP_SHARE)));
27
34
  }
28
35
 
29
36
  // Whether a grip drag asks to fold the composer: it wants the box at least
@@ -194,6 +194,20 @@ export function stopSession(id, opts = {}) {
194
194
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
195
195
  }
196
196
 
197
+ // What this session's worker still has that the user can stop (reminders, a
198
+ // background task running, a delegate with a live worker), for the Stop
199
+ // button's sheet: 200 {holds: []} always — no worker, or one that can't say,
200
+ // is an empty list, so Stop stays one tap.
201
+ export function sessionHolds(id, opts = {}) {
202
+ return api(`/api/sessions/${encodeURIComponent(id)}/holds`, { ...opts });
203
+ }
204
+
205
+ // The sheet's "Stop all": the worker stops everything the user can before the
206
+ // Stop TERMs it. 200 {status: "stopped", stopped: {reminders, tasks, delegates}}.
207
+ export function stopSessionHolds(id, opts = {}) {
208
+ return api(`/api/sessions/${encodeURIComponent(id)}/holds/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
209
+ }
210
+
197
211
  // Kills a background task the session's conversation started (the
198
212
  // stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
199
213
  // no live worker.
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f6feb"/><stop offset="1" stop-color="#6e40c9"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="1" y="1" width="30" height="30" rx="7.5" fill="#22262f"/><g fill="none" stroke="#0a84ff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
@@ -17,7 +17,7 @@
17
17
  *{box-sizing:border-box;margin:0;padding:0}
18
18
  /* No iOS text autosizing: it grows text in a box sized for the text as written (it clipped the session cards). */
19
19
  html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
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}
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%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
21
21
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
22
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)}
23
23
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}
@@ -1067,7 +1067,11 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1067
1067
  @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
1068
1068
  #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
1069
1069
  #infoBar.visible{display:flex}
1070
- #infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
1070
+ /* The meta row keeps its room: with a plain flex:1 the bar's own buttons (continue, archive, stop session, delete)
1071
+ shrink it to a narrow column in a small window and its items wrap into five lines. With a 640px basis they share
1072
+ the line while they fit; when they don't, the buttons wrap to a line of their own (the bar already wraps) and the
1073
+ meta keeps the width. */
1074
+ #infoBar .info{flex:1 1 640px;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
1071
1075
  #infoBar .meta{font-size:11px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
1072
1076
  #infoBar .meta .id{font-family:ui-monospace,monospace;font-weight:700;font-size:12px;color:var(--accent)}
1073
1077
  #infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
@@ -1082,6 +1086,12 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
1082
1086
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);max-width:48ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
1083
1087
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
1084
1088
  #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
1089
+ /* Where the meta row wraps (above the phone's one scrolling line) the two long items give way so it stays two lines: the
1090
+ folder takes at most a third of the row and a chip at most 30%, cut with "…", their full text in the title. */
1091
+ @media(min-width:601px){
1092
+ #infoBar .meta .dir{max-width:min(48ch,32%)}
1093
+ #infoBar .meta .model{max-width:min(40ch,30%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1094
+ }
1085
1095
  /* The plain chips (spans, links) and the status take the button chips' 1px border, transparent and outside their tint, so every chip is one height: a chip wrapping onto a row of plain ones doesn't grow it. */
1086
1096
  #infoBar .meta :is(span,a).model,#infoBar .meta .status{border:1px solid transparent;background-clip:padding-box}
1087
1097
  /* The chips a running turn rewrites (the session clock each second, the last generation's speed, ctx): tabular digits in a reserved width, so a tick ("9.5s" → "10s" → "1m 00s") or a new number doesn't widen the line and wrap a chip onto a new row. The speed and the clock show from the session's start, a muted placeholder until there is a number. */
@@ -1226,6 +1236,22 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1226
1236
  .push-devices.sheet .pd-done{font-size:13px}
1227
1237
  .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1228
1238
  .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1239
+ /* The Stop button's sheet (stop_sheet.js): what still runs, and the three answers; a bottom sheet on a phone. */
1240
+ .stop-sheet .ctx-pop-head strong{font-family:inherit}
1241
+ .stop-sheet.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1242
+ .ss-lines{list-style:none;display:flex;flex-direction:column;gap:6px;margin:4px 0 10px;padding:0}
1243
+ .ss-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1244
+ .ss-line b{color:var(--text-bright)}
1245
+ .ss-items{flex-basis:100%;color:var(--text-2)}
1246
+ .ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
1247
+ .ss-acts{display:flex;gap:8px;flex-wrap:wrap}
1248
+ .ss-acts button{padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2)}
1249
+ .ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
1250
+ .ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
1251
+ .ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
1252
+ .ss-cancel{background:transparent}
1253
+ .stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
1254
+ .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
1229
1255
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1230
1256
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1231
1257
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -5,7 +5,7 @@
5
5
  "scope": "/",
6
6
  "display": "standalone",
7
7
  "background_color": "#0b0d11",
8
- "theme_color": "#7aa5ff",
8
+ "theme_color": "#5aa9ff",
9
9
  "icons": [
10
10
  { "src": "/icons/chi-192.png", "sizes": "192x192", "type": "image/png" },
11
11
  { "src": "/icons/chi-512.png", "sizes": "512x512", "type": "image/png" }
@@ -10,7 +10,8 @@ import { inTree } from "./sessions_list.js";
10
10
  // Hooks: +selected()+ the open session's id or null; +state()+ its fields
11
11
  // (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
12
12
  // +api+: {continueSession, startReview, archiveSession, unarchiveSession,
13
- // deleteSession, stopSession} (data.js); +eventsLive()+ the hub's stream is open (else
13
+ // deleteSession, stopSession, sessionHolds, stopSessionHolds} (data.js);
14
+ // +eventsLive()+ the hub's stream is open (else
14
15
  // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
15
16
  // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
16
17
  // +toast(text, action, options)+, +addCreatedSession(created)+,
@@ -30,6 +31,10 @@ export function createSessionActions({
30
31
  selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
31
32
  select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
32
33
  alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
34
+ // The Stop sheet (stop_sheet.js): +open(id, holds)+ shows it and answers
35
+ // true; nothing to stop (or no sheet wired) is false, and Stop is the
36
+ // confirm.
37
+ stopSheet = { open: () => false },
33
38
  }) {
34
39
  // The session this tab is stopping: its stream may close before the stop
35
40
  // request returns (workerGone reads it).
@@ -64,11 +69,39 @@ export function createSessionActions({
64
69
  showNotice(goneSessionNotice(id, why));
65
70
  }
66
71
 
67
- // The info bar's Stop: the open session's worker, after a confirm.
68
- async function stopSelected() {
69
- if (!selected()) return;
70
- if (!confirm(`Stop session ${selected()}?`)) return;
71
- const id = selected();
72
+ // The info bar's Stop: the open session's worker. It first asks the worker
73
+ // what still runs that the user can stop (GET …/holds); when there is
74
+ // something, the sheet (stop_sheet.js) asks what to do with it; when there
75
+ // is nothing, or no worker to ask, it is today's confirm and one tap.
76
+ // +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
77
+ // the stop. A delegate or a background task outlives the worker either way
78
+ // (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
79
+ async function stopSelected(id = selected(), { stopHolds = false } = {}) {
80
+ if (!id) return;
81
+
82
+ if (!stopHolds) {
83
+ let holds = [];
84
+ try {
85
+ const answer = await api.sessionHolds(id);
86
+ holds = answer?.holds || [];
87
+ } catch {
88
+ holds = []; // a worker that can't say, or none: stop as before
89
+ }
90
+ if (stopSheet.open(id, holds)) return;
91
+ if (!confirm(`Stop session ${id}?`)) return;
92
+ }
93
+
94
+ // Stop all: the worker stops what it can first. A failure is said and
95
+ // doesn't hold the stop: the session goes either way, and the sheet's
96
+ // other button is exactly that.
97
+ if (stopHolds) {
98
+ try {
99
+ await api.stopSessionHolds(id);
100
+ } catch (e) {
101
+ toast(`${cantText("stop what the session runs", e)} — stopping the session anyway`, null, { sticky: true });
102
+ }
103
+ }
104
+
72
105
  stoppingId = id;
73
106
  try {
74
107
  await api.stopSession(id);