samagotchi 0.116.0 → 0.117.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.
@@ -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.117.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.
@@ -1226,6 +1226,22 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1226
1226
  .push-devices.sheet .pd-done{font-size:13px}
1227
1227
  .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1228
1228
  .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1229
+ /* The Stop button's sheet (stop_sheet.js): what still runs, and the three answers; a bottom sheet on a phone. */
1230
+ .stop-sheet .ctx-pop-head strong{font-family:inherit}
1231
+ .stop-sheet.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1232
+ .ss-lines{list-style:none;display:flex;flex-direction:column;gap:6px;margin:4px 0 10px;padding:0}
1233
+ .ss-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1234
+ .ss-line b{color:var(--text-bright)}
1235
+ .ss-items{flex-basis:100%;color:var(--text-2)}
1236
+ .ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
1237
+ .ss-acts{display:flex;gap:8px;flex-wrap:wrap}
1238
+ .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)}
1239
+ .ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
1240
+ .ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
1241
+ .ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
1242
+ .ss-cancel{background:transparent}
1243
+ .stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
1244
+ .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
1229
1245
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1230
1246
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1231
1247
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -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);
@@ -0,0 +1,126 @@
1
+ // The Stop button's sheet (session_actions.js asks the worker first): when
2
+ // the session's worker still has things the user can stop — reminders, a
3
+ // background task that runs, a delegate with a live worker — Stop asks what
4
+ // to do with them before TERMed-ing the worker. Nothing to stop, or no worker
5
+ // to ask, is today's one tap.
6
+ //
7
+ // "Stop all" runs the worker's stop-all step (POST …/holds/stop, the same
8
+ // HoldStop#stop_all the terminal's /exit ask uses) and then today's Stop;
9
+ // "Stop session only" is today's Stop exactly, so what survives keeps running
10
+ // (docs/sessions.md, "Stopping a worker"). Reminders are listed with a note:
11
+ // they live only in the worker, so they end with the session either way.
12
+ //
13
+ // A phone gets it as a bottom sheet, a wider window as a popover at the Stop
14
+ // button (chip_popover.js). Pure model and template plus createStopSheet, so
15
+ // the specs run them under node --test.
16
+
17
+ import { html, nothing, render } from "./vendor/lit-html.js";
18
+ import { createChipPopover } from "./chip_popover.js";
19
+
20
+ // At most this many of a kind named in the sheet; the rest are one "+N more".
21
+ export const MAX_NAMED = 3;
22
+
23
+ // The Stop button the sheet opens at.
24
+ export const STOP_ANCHOR = "#infoStopBtn";
25
+
26
+ const KIND_WORDS = {
27
+ reminders: ["reminder", "reminders"],
28
+ background_tasks: ["background task", "background tasks"],
29
+ delegates: ["delegate", "delegates"],
30
+ };
31
+
32
+ // One entry of a hold as the sheet writes it: the command, the reminder's
33
+ // name with its schedule, a delegate's short id and its title. A long command
34
+ // is cut short: the sheet must fit a phone without scrolling.
35
+ function itemWords(item, kind) {
36
+ const label = String(item?.label ?? item?.id ?? "").trim();
37
+ const detail = String(item?.detail ?? "").trim();
38
+ const cut = label.length > 42 ? `${label.slice(0, 42)}…` : label;
39
+ if (kind === "delegates") {
40
+ const id = String(item?.id ?? "").slice(0, 8);
41
+ return `${id} "${cut || "untitled"}"${detail ? ` ${detail}` : ""}`;
42
+ }
43
+ const name = cut || String(item?.id ?? "").slice(0, 8);
44
+ return detail ? `${name} (${detail})` : name;
45
+ }
46
+
47
+ // What the sheet says from the worker's holds (GET …/holds): null when there
48
+ // is nothing to ask about (no sheet: Stop stays one tap), else its lines, one
49
+ // per kind, and the total.
50
+ // @return {lines: [{kind, count, word, items, more, note}], total} | null
51
+ export function stopSheetModel(holds) {
52
+ const lines = [];
53
+ for (const hold of Array.isArray(holds) ? holds : []) {
54
+ const kind = String(hold?.kind ?? "");
55
+ const items = Array.isArray(hold?.items) ? hold.items : [];
56
+ if (!items.length) continue;
57
+
58
+ const [singular, plural] = KIND_WORDS[kind] ?? [kind || "hold", `${kind || "holds"}s`];
59
+ lines.push({
60
+ kind,
61
+ count: items.length,
62
+ word: items.length === 1 ? singular : plural,
63
+ items: items.slice(0, MAX_NAMED).map((item) => itemWords(item, kind)),
64
+ more: Math.max(0, items.length - MAX_NAMED),
65
+ note: kind === "reminders" ? "they end with the session" : "",
66
+ });
67
+ }
68
+ if (!lines.length) return null;
69
+
70
+ return { lines, total: lines.reduce((sum, line) => sum + line.count, 0) };
71
+ }
72
+
73
+ function lineTemplate(line) {
74
+ return html`<li class="ss-line" data-kind=${line.kind}>
75
+ <b>${line.count} ${line.word}</b>
76
+ ${line.note ? html` <span class="ss-note">(${line.note})</span>` : nothing}
77
+ ${line.items.length ? html` <span class="ss-items">${line.items.join(" · ")}${line.more ? html` <span class="ss-more">+${line.more} more</span>` : nothing}</span>` : nothing}
78
+ </li>`;
79
+ }
80
+
81
+ // The sheet's content (a lit-html template).
82
+ export function stopSheetTemplate(model) {
83
+ const things = model.total === 1 ? "1 thing" : `${model.total} things`;
84
+ return html`<div class="ctx-pop-head"><strong>This session still has</strong
85
+ ><span class="ctx-pop-kind">${things}</span
86
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
87
+ ><ul class="ss-lines">${model.lines.map(lineTemplate)}</ul
88
+ ><div class="ss-acts"><button type="button" class="ss-stop-all" data-act="all">Stop all</button
89
+ ><button type="button" class="ss-stop-only" data-act="session">Stop session only</button
90
+ ><button type="button" class="ss-cancel" data-act="cancel">Cancel</button></div
91
+ ><div class="ss-foot">Stopping the session ends its worker; a background task or a delegate runs on its own, so it stays.</div>`;
92
+ }
93
+
94
+ // The sheet at the Stop button. +stopAll(id)+ stops what the worker can and
95
+ // then the session; +stopSession(id)+ is today's Stop; +open(id, holds)+ shows
96
+ // it only when there is something (false otherwise, so the caller can fall
97
+ // back to the plain confirm). @return {open, close, isOpen, pop}
98
+ export function createStopSheet({ doc = globalThis.document, win = globalThis.window,
99
+ anchor = STOP_ANCHOR, el = doc.createElement("span"),
100
+ stopAll = () => {}, stopSession = () => {} }) {
101
+ let model = null;
102
+ let forId = null;
103
+
104
+ const shell = createChipPopover({
105
+ id: "stopSheet", className: "ctx-popover stop-sheet", width: 400, el, anchor, openers: anchor,
106
+ summary: () => model, sheet: true, label: "Stop the session?", doc, win,
107
+ show: (data) => render(stopSheetTemplate(data), shell.pop),
108
+ onClose: () => { model = null; forId = null; },
109
+ onAct: (act) => {
110
+ const id = forId;
111
+ if (act === "all") { shell.close(); stopAll(id); }
112
+ else if (act === "session") { shell.close(); stopSession(id); }
113
+ else if (act === "cancel") shell.close();
114
+ },
115
+ });
116
+
117
+ function open(id, holds) {
118
+ model = stopSheetModel(holds);
119
+ if (!model) return false;
120
+ forId = id;
121
+ shell.open(doc.querySelector(anchor) ?? el);
122
+ return true;
123
+ }
124
+
125
+ return { open, close: () => shell.close(), isOpen: shell.isOpen, pop: shell.pop };
126
+ }
@@ -304,22 +304,25 @@ module Samagotchi
304
304
  { epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
305
305
  end
306
306
 
307
- # POST /api/sessions/:id/exit {client_id, delete}: a remote attached
307
+ # POST /api/sessions/:id/exit {client_id, delete, stop}: a remote attached
308
308
  # TUI's /exit (and the worker's half of /exit --delete and /archive),
309
309
  # relayed to the live worker's exit (BridgeClient#request_exit; never
310
310
  # wakes one). client_id is passed on only when it is an attached TUI's
311
311
  # (tui:…): any other is dropped, and then every stream counts as a UI,
312
- # which only keeps the worker up. 200 the Bridge's {status: exiting,
313
- # session_id, discard}; 409 held with its {status, reason, tasks,
314
- # hint}; 503 not_live, 501 not_supported (an older worker), 504
315
- # worker_timeout.
312
+ # which only keeps the worker up. stop: true means the ask's "stop them
313
+ # and exit". 200 the Bridge's {status: exiting, session_id, discard,
314
+ # stopped}; 409 held with its {status, reason, tasks, hint, holds};
315
+ # 503 not_live, 501 not_supported (an older worker), 504 worker_timeout.
316
316
  def handle_exit(req, id)
317
317
  body = body_hash(req) or return refusal(400, "invalid_json", "invalid JSON body")
318
318
  @session_class.load(id, state_dir: default_state_dir)
319
319
  client_id = body["client_id"] if body["client_id"].is_a?(String) && body["client_id"].start_with?(ClientId::TUI_PREFIX)
320
320
  delete = body["delete"] == true
321
+ options = { delete: delete }
322
+ options[:stop] = true if body["stop"] == "all"
323
+ options[:ask] = false if body["ask"] == false
321
324
 
322
- request = ->(client) { client.request_exit(client_id: client_id, delete: delete) }
325
+ request = ->(client) { client.request_exit(client_id: client_id, **options) }
323
326
  with_message(relay(id, bridge_client(id), request, what: "the worker was not asked to exit", cant: "exit from here") do |reply|
324
327
  case reply.status
325
328
  when 200 then json_response(200, reply.json || { status: "exiting", session_id: id })