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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -2
- data/docs/cli.md +28 -1
- data/docs/configuration.md +6 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge.rb +88 -14
- data/lib/samagotchi/bridge_client.rb +13 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +19 -3
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +130 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -0
- data/lib/samagotchi/web/public/app.js +17 -1
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/composer_size.js +14 -7
- data/lib/samagotchi/web/public/data.js +14 -0
- data/lib/samagotchi/web/public/favicon.svg +1 -1
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +28 -2
- data/lib/samagotchi/web/public/manifest.webmanifest +1 -1
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +40 -3
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- 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
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))}). " \
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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.
|
|
Binary file
|
|
@@ -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)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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.
|
|
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"><
|
|
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>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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%),
|
|
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
|
-
|
|
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": "#
|
|
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);
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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);
|