samagotchi 0.126.0 → 0.128.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 +50 -1
- data/docs/architecture.md +4 -0
- data/docs/cli.md +1 -1
- data/docs/configuration.md +2 -2
- data/docs/guardrails.md +5 -0
- data/docs/internals/participants.md +17 -7
- data/docs/sessions.md +3 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +56 -0
- data/lib/samagotchi/guardrails/read_only_shell.rb +48 -1
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/session_participants.rb +14 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
- data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
- data/lib/samagotchi/verify_anchor.rb +8 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -0
- data/lib/samagotchi/web/participant_access.rb +16 -1
- data/lib/samagotchi/web/participant_review.rb +4 -2
- data/lib/samagotchi/web/project_list.rb +52 -0
- data/lib/samagotchi/web/public/annotate_bar.js +14 -2
- data/lib/samagotchi/web/public/app.js +25 -4
- data/lib/samagotchi/web/public/composer.css +13 -1
- data/lib/samagotchi/web/public/index.html +35 -16
- data/lib/samagotchi/web/public/notify.js +4 -1
- data/lib/samagotchi/web/public/participant.html +33 -32
- data/lib/samagotchi/web/public/participant.js +16 -6
- data/lib/samagotchi/web/public/participant_model.js +3 -2
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/project_picker.js +115 -0
- data/lib/samagotchi/web/public/question_card.js +4 -1
- data/lib/samagotchi/web/public/restore.js +7 -4
- data/lib/samagotchi/web/public/scope.js +8 -10
- data/lib/samagotchi/web/public/send_turn.js +3 -1
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/verdict_card.js +18 -3
- data/lib/samagotchi/web/public/verdict_fresh.js +11 -0
- data/lib/samagotchi/web/public/verify_card.css +1 -0
- data/lib/samagotchi/web/public/verify_preset.js +6 -0
- data/lib/samagotchi/web/push/attention.rb +24 -5
- data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
- data/lib/samagotchi/worker.rb +7 -4
- metadata +4 -1
|
@@ -10,19 +10,16 @@ require_relative "../image_input"
|
|
|
10
10
|
module Samagotchi
|
|
11
11
|
class TerminalUI
|
|
12
12
|
class AttachedLoop
|
|
13
|
-
# Bridge sending
|
|
14
|
-
# Reads @as_parent, @client, @client_id, @
|
|
15
|
-
# @
|
|
16
|
-
# @
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# #
|
|
23
|
-
# #persist_recent_history, #prompt_line, #put_back_busy_command, #prefill_confirm,
|
|
24
|
-
# #queue_needs_newer_worker, #send_command, #send_prompt, #stale_worker,
|
|
25
|
-
# #steer_cut_note, #too_late?, #via, and #worker_down.
|
|
13
|
+
# Bridge sending and first-command launch.
|
|
14
|
+
# Reads @as_parent, @client, @client_id, @first_command_id, @first_command_line,
|
|
15
|
+
# @first_commands, @first_prompt, @link, @my_queued, @no_interrupt, @own_turns,
|
|
16
|
+
# @queued_ids, @reader, @running, @screen, @status;
|
|
17
|
+
# writes @first_command_id, @first_command_line, @first_commands, @first_prompt,
|
|
18
|
+
# @my_queued, @queued_ids.
|
|
19
|
+
# Calls the loop helpers #command_registry, #default_model_name, #detach,
|
|
20
|
+
# #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
|
|
21
|
+
# #persist_recent_history, #prompt_line, #queue_needs_newer_worker,
|
|
22
|
+
# #stale_worker, #steer_cut_note, #too_late?, #via, and #worker_down.
|
|
26
23
|
module Sending
|
|
27
24
|
private
|
|
28
25
|
|
|
@@ -33,7 +30,7 @@ module Samagotchi
|
|
|
33
30
|
if reply.status == 202
|
|
34
31
|
# Input from a pipe ends once its output is in (#command_ran).
|
|
35
32
|
command_id = reply.json&.fetch("command_id", nil)
|
|
36
|
-
@
|
|
33
|
+
@own_turns.command_sent(command_id) if command_id
|
|
37
34
|
return
|
|
38
35
|
end
|
|
39
36
|
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
@@ -49,7 +46,7 @@ module Samagotchi
|
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
def command_ran(event)
|
|
52
|
-
@
|
|
49
|
+
@own_turns.command_ran(event[:command_id])
|
|
53
50
|
@queued_ids.delete(event[:command_id])
|
|
54
51
|
unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
|
|
55
52
|
@screen.commit(prompt_line(event[:client_id], event[:line]))
|
|
@@ -147,8 +144,7 @@ module Samagotchi
|
|
|
147
144
|
reply = @client.post_turn(**options)
|
|
148
145
|
if reply.status == 202
|
|
149
146
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
150
|
-
@
|
|
151
|
-
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
147
|
+
@own_turns.sent(enqueued_id) if enqueued_id
|
|
152
148
|
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
153
149
|
note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
|
|
154
150
|
@screen.commit(note) if note
|
|
@@ -172,21 +168,15 @@ module Samagotchi
|
|
|
172
168
|
# Whether the input's end must wait: the -p prompt isn't sent yet, a
|
|
173
169
|
# prompt this run sent hasn't had its turn, or its turn's after_turn
|
|
174
170
|
# hooks still run (their notices, source-links' sources:, come next).
|
|
175
|
-
def own_turns_pending? = !@first_prompt.to_s.strip.empty? ||
|
|
171
|
+
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || @own_turns.pending?
|
|
176
172
|
|
|
177
173
|
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
178
174
|
def own_turn_ended(event)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
185
|
-
# The worker sends answer_display once its after_turn hooks ran.
|
|
186
|
-
@display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
|
|
187
|
-
@open_ids.delete(id)
|
|
188
|
-
@open_ids.subtract(@merged_ids)
|
|
189
|
-
@merged_ids.clear
|
|
175
|
+
completed = event[:type] == :turn_completed
|
|
176
|
+
@own_turns.ended(origin_of(event[:origin]).enqueued_id,
|
|
177
|
+
failed: event[:type] == :turn_failed,
|
|
178
|
+
empty: completed && event.dig(:turn_summary, :empty_answer),
|
|
179
|
+
display_pending: completed && event[:display_pending])
|
|
190
180
|
end
|
|
191
181
|
|
|
192
182
|
# Ours merged into the running turn: they end with it.
|
|
@@ -194,22 +184,20 @@ module Samagotchi
|
|
|
194
184
|
Array(event[:origins]).each do |value|
|
|
195
185
|
origin = origin_of(value)
|
|
196
186
|
id = origin.enqueued_id
|
|
197
|
-
@
|
|
187
|
+
@own_turns.merged(id) if own?(origin.client_id)
|
|
198
188
|
end
|
|
199
189
|
nil
|
|
200
190
|
end
|
|
201
191
|
|
|
202
192
|
def own_prompt_failed
|
|
203
|
-
@
|
|
193
|
+
@own_turns.prompt_failed
|
|
204
194
|
nil
|
|
205
195
|
end
|
|
206
196
|
|
|
207
197
|
# The input ended and nothing of ours is left to wait for.
|
|
208
198
|
def end_of_input
|
|
209
|
-
detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
@own_empty ? :empty_answer : :detached
|
|
199
|
+
detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @own_turns.input_ended?
|
|
200
|
+
@own_turns.outcome
|
|
213
201
|
end
|
|
214
202
|
|
|
215
203
|
# The prompt's `@path` images, stored where the worker reads them
|
|
@@ -14,6 +14,7 @@ require_relative "image_input"
|
|
|
14
14
|
require_relative "line_reader"
|
|
15
15
|
require_relative "question_prompt"
|
|
16
16
|
require_relative "join_replay"
|
|
17
|
+
require_relative "own_turns"
|
|
17
18
|
require_relative "exit_lines"
|
|
18
19
|
require_relative "attached_loop/exit_commands"
|
|
19
20
|
require_relative "attached_loop/questions"
|
|
@@ -68,8 +69,7 @@ module Samagotchi
|
|
|
68
69
|
attr_reader :client_id
|
|
69
70
|
|
|
70
71
|
# @return [String, nil] the model the worker's turns run on, as the
|
|
71
|
-
# last
|
|
72
|
-
# @return [String, nil] the worker's model
|
|
72
|
+
# last snapshot or model change said (the status row's)
|
|
73
73
|
def model_name = @status[:model]
|
|
74
74
|
|
|
75
75
|
# @return [QuestionPrompt, nil] the question waiting for an answer
|
|
@@ -152,21 +152,12 @@ module Samagotchi
|
|
|
152
152
|
@join_replay = JoinReplay.new(screen: @screen, view: @view, link: @link, gaps: @gaps, renderer: @renderer)
|
|
153
153
|
@last_idle_interrupt_at = nil
|
|
154
154
|
@next_input_prefill = nil
|
|
155
|
-
# Prompts this run sent, by enqueued_id: only those come back into
|
|
156
|
-
# the input when their turn fails (a replayed event must not).
|
|
157
|
-
@sent_ids = Set.new
|
|
158
155
|
# This run's prompts queued for after the running turn (their ack
|
|
159
156
|
# said queue), oldest first, until their turn starts or they are
|
|
160
157
|
# removed: /unqueue takes back the newest.
|
|
161
158
|
@my_queued = []
|
|
162
159
|
@wait_at_eof = wait_at_eof
|
|
163
|
-
|
|
164
|
-
# running turn, whether the input has ended, and whether one of this
|
|
165
|
-
# run's prompts failed (wait_at_eof only).
|
|
166
|
-
@open_ids = Set.new
|
|
167
|
-
@merged_ids = Set.new
|
|
168
|
-
@input_ended = false
|
|
169
|
-
@own_failed = false
|
|
160
|
+
@own_turns = OwnTurns.new(active: wait_at_eof)
|
|
170
161
|
# A continue offer is pending: the prompt asks for the answer.
|
|
171
162
|
@continue_offer = nil
|
|
172
163
|
# The /exit ask is open: the next line is its answer (#answer_exit_ask).
|
|
@@ -188,7 +179,8 @@ module Samagotchi
|
|
|
188
179
|
# :failed when the first command (--model) didn't go through, or
|
|
189
180
|
# (wait_at_eof) :turn_failed when one of this run's prompts failed,
|
|
190
181
|
# :empty_answer when one ended with no answer, and :unanswered when
|
|
191
|
-
# a question came after the input ended
|
|
182
|
+
# a question came after the input ended; :removed when this run took
|
|
183
|
+
# back its only queued prompt before it ran
|
|
192
184
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
193
185
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
194
186
|
def run(input: nil)
|
|
@@ -225,7 +217,7 @@ module Samagotchi
|
|
|
225
217
|
|
|
226
218
|
ended = take_early_lines
|
|
227
219
|
return ended if ended
|
|
228
|
-
return end_of_input if @input_ended && !own_turns_pending?
|
|
220
|
+
return end_of_input if @own_turns.input_ended? && !own_turns_pending?
|
|
229
221
|
when :line
|
|
230
222
|
# A pipe is read at once: its lines wait for the joining
|
|
231
223
|
# snapshot, which may bring the question they answer.
|
|
@@ -256,7 +248,7 @@ module Samagotchi
|
|
|
256
248
|
# has nothing left to answer it.
|
|
257
249
|
return unanswered_question(@question_pending) if @question
|
|
258
250
|
|
|
259
|
-
@
|
|
251
|
+
@own_turns.end_input
|
|
260
252
|
return nil
|
|
261
253
|
end
|
|
262
254
|
end_of_input if submit(line) == :detach
|
|
@@ -303,7 +295,7 @@ module Samagotchi
|
|
|
303
295
|
own_turn_ended(event)
|
|
304
296
|
when :prompt_restored then restore_prompt(event)
|
|
305
297
|
# The after_turn hooks are done (their notices came before it).
|
|
306
|
-
when :answer_display then @
|
|
298
|
+
when :answer_display then @own_turns.displayed
|
|
307
299
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
308
300
|
when :participants_changed then participants_changed(event)
|
|
309
301
|
# Another client's anytime command: its line now, before the cards
|
|
@@ -333,7 +325,7 @@ module Samagotchi
|
|
|
333
325
|
refresh_children if Array(event[:origins]).any? { |origin| report_origin?(origin_of(origin)) }
|
|
334
326
|
when :question_requested
|
|
335
327
|
# Nothing is left to answer it with.
|
|
336
|
-
return unanswered_question(event[:pending_question]) if @input_ended
|
|
328
|
+
return unanswered_question(event[:pending_question]) if @own_turns.input_ended?
|
|
337
329
|
|
|
338
330
|
ask(event[:pending_question])
|
|
339
331
|
when :question_answered then question_answered(event)
|
|
@@ -515,7 +507,7 @@ module Samagotchi
|
|
|
515
507
|
# goes back into the input, as the REPL restores it for a retry.
|
|
516
508
|
def restore_prompt(event)
|
|
517
509
|
origin = origin_of(event[:origin])
|
|
518
|
-
return unless own?(origin.client_id) && @
|
|
510
|
+
return unless own?(origin.client_id) && @own_turns.sent?(origin.enqueued_id)
|
|
519
511
|
# Lines from a pipe: nobody to retry it (the REPL's rule).
|
|
520
512
|
return if @wait_at_eof
|
|
521
513
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class TerminalUI
|
|
5
|
+
# This run's own prompts and commands. Sent prompts are always recorded so
|
|
6
|
+
# only this run's failed prompts can return to the input. With pipe input
|
|
7
|
+
# (+active+), this also tracks turns still open, prompts merged into a
|
|
8
|
+
# running turn, whether input ended, and whether a turn failed, was empty, was removed,
|
|
9
|
+
# or still has an answer display pending. #outcome is what #run returns
|
|
10
|
+
# once those turns finish.
|
|
11
|
+
class OwnTurns
|
|
12
|
+
def initialize(active:)
|
|
13
|
+
@active = active
|
|
14
|
+
@sent = Set.new
|
|
15
|
+
@open = Set.new
|
|
16
|
+
@merged = Set.new
|
|
17
|
+
@input_ended = false
|
|
18
|
+
@failed = false
|
|
19
|
+
@empty = false
|
|
20
|
+
@removed = false
|
|
21
|
+
@display_pending = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sent(enqueued_id)
|
|
25
|
+
@sent << enqueued_id
|
|
26
|
+
@open << enqueued_id if @active
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sent?(enqueued_id) = @sent.include?(enqueued_id)
|
|
30
|
+
|
|
31
|
+
def command_sent(command_id)
|
|
32
|
+
@open << command_id if @active
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def command_ran(command_id) = @open.delete(command_id)
|
|
36
|
+
|
|
37
|
+
def merged(enqueued_id)
|
|
38
|
+
@merged << enqueued_id if @open.include?(enqueued_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ended(enqueued_id, failed:, empty:, display_pending:)
|
|
42
|
+
return unless @active
|
|
43
|
+
|
|
44
|
+
ours = @open.include?(enqueued_id) || !@merged.empty?
|
|
45
|
+
@failed = true if ours && failed
|
|
46
|
+
@empty = true if ours && empty
|
|
47
|
+
@display_pending = true if ours && display_pending
|
|
48
|
+
@open.delete(enqueued_id)
|
|
49
|
+
@open.subtract(@merged)
|
|
50
|
+
@merged.clear
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def displayed = (@display_pending = false)
|
|
54
|
+
|
|
55
|
+
def prompt_failed
|
|
56
|
+
@failed = true if @active
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def dequeued(enqueued_id, by_us:)
|
|
60
|
+
return false unless @open.delete?(enqueued_id)
|
|
61
|
+
|
|
62
|
+
by_us ? (@removed = true) : (@failed = true)
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def end_input = (@input_ended = true)
|
|
67
|
+
|
|
68
|
+
def input_ended? = @input_ended
|
|
69
|
+
|
|
70
|
+
def pending? = !@open.empty? || @display_pending
|
|
71
|
+
|
|
72
|
+
def outcome
|
|
73
|
+
return :turn_failed if @failed
|
|
74
|
+
return :empty_answer if @empty
|
|
75
|
+
return :removed if @removed
|
|
76
|
+
|
|
77
|
+
:detached
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -25,4 +25,12 @@ module Samagotchi
|
|
|
25
25
|
|
|
26
26
|
def to_file = { "turn_id" => turn_id, "quote" => quote }
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
VerifyAnchor::FEATURE = "verify"
|
|
30
|
+
VerifyAnchor::UNAVAILABLE = {
|
|
31
|
+
"error" => "verify_unavailable",
|
|
32
|
+
"detail" => "this session's worker predates Verify turns; restart this session to use Verify"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def VerifyAnchor.worker_unavailable?(features) = !features.nil? && !features.include?(VerifyAnchor::FEATURE)
|
|
28
36
|
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -60,6 +60,7 @@ require_relative "editor"
|
|
|
60
60
|
require_relative "../message_parts"
|
|
61
61
|
require_relative "../launch_mode"
|
|
62
62
|
require_relative "session_summary"
|
|
63
|
+
require_relative "project_list"
|
|
63
64
|
require_relative "session_access_routes"
|
|
64
65
|
require_relative "principal"
|
|
65
66
|
require_relative "participant_access"
|
|
@@ -240,6 +241,7 @@ module Samagotchi
|
|
|
240
241
|
ROUTES = [
|
|
241
242
|
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
242
243
|
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
244
|
+
["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
|
|
243
245
|
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
244
246
|
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
|
|
245
247
|
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
@@ -1046,6 +1048,16 @@ module Samagotchi
|
|
|
1046
1048
|
[200, headers, [body]]
|
|
1047
1049
|
end
|
|
1048
1050
|
|
|
1051
|
+
def handle_projects(req)
|
|
1052
|
+
return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
|
|
1053
|
+
|
|
1054
|
+
dir, error = scope_dir(req.params["dir"])
|
|
1055
|
+
return error if error
|
|
1056
|
+
|
|
1057
|
+
current_root = dir && ProjectScope.root_for(dir)
|
|
1058
|
+
json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1049
1061
|
# A scope folder from the page: [dir, nil] (dir nil when none was
|
|
1050
1062
|
# given), or [nil, a 400 response] when it isn't an existing absolute
|
|
1051
1063
|
# folder (a bookmarked worktree deleted since, a typo).
|
|
@@ -490,7 +490,22 @@ module Samagotchi
|
|
|
490
490
|
<style>#{PAGE_STYLE}</style></head>
|
|
491
491
|
<body>
|
|
492
492
|
<h1>chi</h1>
|
|
493
|
-
<p>{sentence}</p>
|
|
493
|
+
<p id="ended">{sentence}</p>
|
|
494
|
+
<script>
|
|
495
|
+
if (/[#&]code=/.test(location.hash)) document.getElementById("ended").hidden = true;
|
|
496
|
+
</script>
|
|
497
|
+
<p id="trade" hidden></p>
|
|
498
|
+
<script type="module">
|
|
499
|
+
const ended = document.getElementById("ended");
|
|
500
|
+
import("/token_fragment.js").then(({ tradeFragmentCode }) => tradeFragmentCode({
|
|
501
|
+
onRefused: (words) => {
|
|
502
|
+
const line = document.getElementById("trade");
|
|
503
|
+
line.textContent = words;
|
|
504
|
+
line.hidden = false;
|
|
505
|
+
ended.hidden = false;
|
|
506
|
+
}
|
|
507
|
+
})).catch(() => { ended.hidden = false; });
|
|
508
|
+
</script>
|
|
494
509
|
</body></html>
|
|
495
510
|
HTML
|
|
496
511
|
BOUND_PAGE = ENDED_PAGE.sub("<title>chi: review link ended</title>", "<title>chi: review link for another account</title>")
|
|
@@ -51,7 +51,8 @@ module Samagotchi
|
|
|
51
51
|
# current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
|
|
52
52
|
def handle_review(req, id)
|
|
53
53
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
54
|
-
|
|
54
|
+
features = ParticipantMode.live_features(session, default_state_dir)
|
|
55
|
+
mode = ParticipantMode.of(session, features: features).to_s
|
|
55
56
|
viewer = participant_viewer(req, session)
|
|
56
57
|
live = bridge_get_json(id, "snapshot")
|
|
57
58
|
turn_snapshot = live && live["snapshot"]
|
|
@@ -67,7 +68,8 @@ module Samagotchi
|
|
|
67
68
|
status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
|
|
68
69
|
json_response(200, {
|
|
69
70
|
viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
|
|
70
|
-
session: { id: session.id, title: participant_title(session), status: status
|
|
71
|
+
session: { id: session.id, title: participant_title(session), status: status,
|
|
72
|
+
verify_unavailable: VerifyAnchor.worker_unavailable?(features) },
|
|
71
73
|
subjects: review_subjects(session, viewer),
|
|
72
74
|
participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
|
|
73
75
|
messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../paths"
|
|
4
|
+
require_relative "../project_scope"
|
|
5
|
+
require_relative "push/attention"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Web
|
|
9
|
+
# Projects represented by the session hub, plus the start project even
|
|
10
|
+
# when it has no sessions.
|
|
11
|
+
module ProjectList
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(summaries, start_dir:, current_root:)
|
|
15
|
+
start_dir = File.expand_path(start_dir.to_s)
|
|
16
|
+
default_root = ProjectScope.root_for(start_dir)
|
|
17
|
+
groups = {}
|
|
18
|
+
summaries.each do |summary|
|
|
19
|
+
next unless summary.is_a?(Hash) && !summary[:archived]
|
|
20
|
+
|
|
21
|
+
root = summary[:project_root]
|
|
22
|
+
next unless valid_root?(root)
|
|
23
|
+
|
|
24
|
+
(groups[root] ||= []) << summary
|
|
25
|
+
end
|
|
26
|
+
groups[default_root] ||= [] if valid_root?(default_root)
|
|
27
|
+
|
|
28
|
+
rows = groups.map do |root, sessions|
|
|
29
|
+
default = root == default_root
|
|
30
|
+
{
|
|
31
|
+
name: File.basename(root),
|
|
32
|
+
root: root,
|
|
33
|
+
root_label: Paths.tilde(root),
|
|
34
|
+
dir: default ? start_dir : root,
|
|
35
|
+
sessions: sessions.size,
|
|
36
|
+
waiting: sessions.count { |summary| Push::Attention.waits?(summary) },
|
|
37
|
+
updated_at: sessions.map { |summary| summary[:updated_at] }.compact.max_by(&:to_s),
|
|
38
|
+
default: default,
|
|
39
|
+
current: root == current_root
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
defaults, others = rows.partition { |row| row[:default] }
|
|
43
|
+
defaults + others.sort_by { |row| [row[:updated_at].to_s, row[:root]] }.reverse
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid_root?(root)
|
|
47
|
+
root && File.directory?(root) && ProjectScope.root_for(root) == root
|
|
48
|
+
end
|
|
49
|
+
private_class_method :valid_root?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -32,6 +32,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
32
32
|
// until its step closes)
|
|
33
33
|
// @param onQuote (text) runs with the quote (and a preset's note) to append
|
|
34
34
|
// to the composer; the selection is cleared and the bar hidden first
|
|
35
|
+
// @param disabledPreset (preset) → a reason to disable it, else null
|
|
35
36
|
// @param actions () => the open session's annotation actions
|
|
36
37
|
// (annotate_actions.js annotationActions), read on every selection
|
|
37
38
|
// @param running () => whether a turn runs (a send pill may wait for it)
|
|
@@ -56,7 +57,8 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
56
57
|
// @return {schedule(), hide(), el}; schedule() re-reads the selection on the
|
|
57
58
|
// next frame (call it on scroll)
|
|
58
59
|
export function createAnnotateBar({
|
|
59
|
-
presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {},
|
|
60
|
+
presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
|
|
61
|
+
actions = () => [], running = () => false, onAction = () => {},
|
|
60
62
|
dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
|
|
61
63
|
doc = globalThis.document, win = globalThis.window,
|
|
62
64
|
}) {
|
|
@@ -80,6 +82,14 @@ export function createAnnotateBar({
|
|
|
80
82
|
btn.title = text;
|
|
81
83
|
bar.appendChild(btn);
|
|
82
84
|
});
|
|
85
|
+
const presetButtons = [...bar.children].slice(1, presets.length + 1);
|
|
86
|
+
function markPresets() {
|
|
87
|
+
presetButtons.forEach((btn, i) => {
|
|
88
|
+
const reason = disabledPreset(presets[i]);
|
|
89
|
+
btn.disabled = !!reason;
|
|
90
|
+
btn.title = reason || presetParts(presets[i]).text;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
83
93
|
// The plugin pills: a separator, the inline ones, then ⋯ and the list
|
|
84
94
|
// it opens. Redrawn only when the session's actions change.
|
|
85
95
|
const group = doc.createElement("span");
|
|
@@ -175,6 +185,7 @@ export function createAnnotateBar({
|
|
|
175
185
|
linkBtn.title = pendingLink?.title || "";
|
|
176
186
|
const list = actions() || [];
|
|
177
187
|
if (!sameActions(list, drawn)) drawActions(list);
|
|
188
|
+
markPresets();
|
|
178
189
|
markActions();
|
|
179
190
|
bar.hidden = false;
|
|
180
191
|
if (touch?.matches) return placeDocked();
|
|
@@ -248,6 +259,7 @@ export function createAnnotateBar({
|
|
|
248
259
|
return;
|
|
249
260
|
}
|
|
250
261
|
const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
|
|
262
|
+
if (btn.disabled || (preset && disabledPreset(preset))) return;
|
|
251
263
|
const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
|
|
252
264
|
const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
|
|
253
265
|
win.getSelection()?.removeAllRanges();
|
|
@@ -256,7 +268,7 @@ export function createAnnotateBar({
|
|
|
256
268
|
onQuote(text);
|
|
257
269
|
});
|
|
258
270
|
|
|
259
|
-
return { schedule, hide, el: bar };
|
|
271
|
+
return { schedule, hide, refresh: markPresets, el: bar };
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
// A preset's button label and note: a text is both.
|
|
@@ -59,12 +59,12 @@ import { createTaskStopper } from "./task_stop.js";
|
|
|
59
59
|
import { createSystemOneWrongAction } from "./system_one_wrong.js";
|
|
60
60
|
import { appendQuote } from "./annotations.js";
|
|
61
61
|
import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
|
|
62
|
-
import { nameVerdictCards } from "./verdict_card.js";
|
|
62
|
+
import { CHECK_NOT_ON_PAGE, nameVerdictCards, ownerCheckHash } from "./verdict_card.js";
|
|
63
63
|
import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
|
|
64
64
|
import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
|
|
65
65
|
import { parsePresets } from "./annotate_presets.js";
|
|
66
66
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
67
|
-
import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
|
|
67
|
+
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
|
|
68
68
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
69
69
|
import { copyText, installCopy } from "./copy.js";
|
|
70
70
|
import { createQuestionCards } from "./question_cards.js";
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
claimFromHash, ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf,
|
|
79
79
|
} from "./claim_link.js";
|
|
80
80
|
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
81
|
+
import { createProjectPicker } from "./project_picker.js";
|
|
81
82
|
import { html, render } from "./vendor/lit-html.js";
|
|
82
83
|
import { shouldFollowScroll } from "./scroll.js";
|
|
83
84
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
@@ -234,6 +235,7 @@ const composeHintEl = $("#composeHint");
|
|
|
234
235
|
|
|
235
236
|
let selected = null;
|
|
236
237
|
let pendingVerifyAnchor = null;
|
|
238
|
+
let annotateBar = null;
|
|
237
239
|
// The session the composer's text was last written in ("" the start page,
|
|
238
240
|
// undefined unknown): the composer is one for every session, and continue →
|
|
239
241
|
// keeps only a draft of its own (fillOpener).
|
|
@@ -589,6 +591,7 @@ function followSelectedSession(sess) {
|
|
|
589
591
|
current.owner = sess.owner || null;
|
|
590
592
|
current.workerVersion = sess.worker_version || null;
|
|
591
593
|
current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
|
|
594
|
+
current.bridgeUp = sess.bridge_up === true;
|
|
592
595
|
const streaming = !!(streamControl && streamControl.live);
|
|
593
596
|
if (!streaming) current.status = sess.status;
|
|
594
597
|
updateInfoBar();
|
|
@@ -609,6 +612,11 @@ function renderScope() {
|
|
|
609
612
|
updateSessionTitle();
|
|
610
613
|
}
|
|
611
614
|
|
|
615
|
+
createProjectPicker({
|
|
616
|
+
el: $("#scope"), scope, scopeError: () => scopeError, api,
|
|
617
|
+
where: () => ({ search: location.search }),
|
|
618
|
+
});
|
|
619
|
+
|
|
612
620
|
// The session lists (lists_view.js): the strip, all sessions, their cards,
|
|
613
621
|
// select mode, the family and chain popovers.
|
|
614
622
|
const lists = createListsView({
|
|
@@ -1338,6 +1346,11 @@ function placeVerdict(record) {
|
|
|
1338
1346
|
placeVerdictWithFresh(historyEl, record, {
|
|
1339
1347
|
who: badge ? badge.name : "You",
|
|
1340
1348
|
author: record.by || null,
|
|
1349
|
+
onMissingCheck: (missing) => {
|
|
1350
|
+
const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
|
|
1351
|
+
if (hash) location.hash = hash;
|
|
1352
|
+
else showToast(CHECK_NOT_ON_PAGE);
|
|
1353
|
+
},
|
|
1341
1354
|
}, verifyFreshState());
|
|
1342
1355
|
}
|
|
1343
1356
|
|
|
@@ -1345,11 +1358,13 @@ function placeVerdict(record) {
|
|
|
1345
1358
|
// always theirs to start; its running check links to the child's session.
|
|
1346
1359
|
function verifyFreshState() {
|
|
1347
1360
|
const id = selected;
|
|
1361
|
+
const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
|
|
1348
1362
|
if (!Array.isArray(current.verifyFresh)) current.verifyFresh = [];
|
|
1349
1363
|
return {
|
|
1350
1364
|
verdicts: current.verdicts || [],
|
|
1351
1365
|
running: current.verifyFresh,
|
|
1352
|
-
canStart: !!id,
|
|
1366
|
+
canStart: !!id && !unavailable,
|
|
1367
|
+
unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
|
|
1353
1368
|
start: async (of) => {
|
|
1354
1369
|
try {
|
|
1355
1370
|
const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
|
|
@@ -1415,6 +1430,7 @@ async function restartWorker() {
|
|
|
1415
1430
|
// The session bar (info_bar_model.js sessionBar): its buttons, and the
|
|
1416
1431
|
// info line rendered by info_bar.js.
|
|
1417
1432
|
function updateInfoBar() {
|
|
1433
|
+
annotateBar?.refresh();
|
|
1418
1434
|
const { buttons, bar } = sessionBar({
|
|
1419
1435
|
id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
|
|
1420
1436
|
continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
|
|
@@ -1568,6 +1584,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
1568
1584
|
if (!stopped) setLiveStatus("idle");
|
|
1569
1585
|
}
|
|
1570
1586
|
if (current.owner === "worker") current.owner = null;
|
|
1587
|
+
current.bridgeUp = false;
|
|
1571
1588
|
// Its card says "live" (the list's owner, or setLiveStatus's) until the
|
|
1572
1589
|
// hub's event says otherwise: the worker is gone now, as far as this
|
|
1573
1590
|
// tab can tell.
|
|
@@ -1604,6 +1621,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1604
1621
|
participantsControl,
|
|
1605
1622
|
placeApply,
|
|
1606
1623
|
placeVerdict,
|
|
1624
|
+
restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
|
|
1607
1625
|
sentIds,
|
|
1608
1626
|
earlyRestores,
|
|
1609
1627
|
stream: {
|
|
@@ -1711,6 +1729,7 @@ const sendTurnControl = createSendTurn({
|
|
|
1711
1729
|
api: { createIdleSession, uploadImage, sendTurn },
|
|
1712
1730
|
verify: () => pendingVerifyAnchor,
|
|
1713
1731
|
clearVerify: (anchor) => { if (pendingVerifyAnchor === anchor) pendingVerifyAnchor = null; },
|
|
1732
|
+
restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
|
|
1714
1733
|
dir: scope.dir,
|
|
1715
1734
|
startPage,
|
|
1716
1735
|
takeDelivery: () => deliveryControl.take(),
|
|
@@ -1948,8 +1967,10 @@ function fillComposer(text) {
|
|
|
1948
1967
|
promptEl.scrollTop = promptEl.scrollHeight;
|
|
1949
1968
|
}
|
|
1950
1969
|
|
|
1951
|
-
|
|
1970
|
+
annotateBar = createAnnotateBar({
|
|
1952
1971
|
presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
|
|
1972
|
+
disabledPreset: (preset) => preset === VERIFY_PRESET
|
|
1973
|
+
&& verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
|
|
1953
1974
|
canAnnotate: () => !!selected && current.owner !== "tui",
|
|
1954
1975
|
roots: turnRoots,
|
|
1955
1976
|
isLive: (root) => liveTurn.running && turnDom.containsLive(root),
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
|
|
2
2
|
(the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
|
|
3
3
|
linked after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
|
|
4
|
-
own parts stay in index.html: the card (#dock), the delivery line, Stop turn, the start page's picker and chips,
|
|
4
|
+
own parts stay in index.html: the card's placing (#dock), the delivery line's links, Stop turn, the start page's picker and chips,
|
|
5
5
|
the / list, and the 16px text fields for iOS. */
|
|
6
6
|
/* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
|
|
7
7
|
bar and all view's cards use it too. */
|
|
8
8
|
:root{--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
|
|
9
|
+
/* The composer card: the glass card both pages' composers sit in (the owner's #dock, the stage view's .dock-card, the
|
|
10
|
+
participant page's .p-card), with its focus ring; its footer (the owner's info bar, the participant's usage) under
|
|
11
|
+
a hairline; and the composer's line, the quiet row above the text field (the owner's delivery line, the
|
|
12
|
+
participant's key hint). A page's own rules only place the card. */
|
|
13
|
+
.composer-card{display:flex;flex-direction:column;border-radius:16px;position:relative;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);transition:border-color .12s ease,box-shadow .12s ease}
|
|
14
|
+
.composer-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
|
|
15
|
+
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.composer-card{background:var(--bg-panel)}}
|
|
16
|
+
@media (prefers-reduced-transparency:reduce){.composer-card{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
|
|
17
|
+
body.no-glass .composer-card{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
18
|
+
.composer-card-foot{padding:8px 14px 10px;border-top:1px solid var(--glass-border)}
|
|
19
|
+
.composer-line{flex:1 0 100%;min-width:0;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
|
|
9
20
|
#composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
|
|
10
21
|
#composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
11
22
|
#composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
|
|
@@ -42,6 +53,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
42
53
|
info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
|
|
43
54
|
border and padding, so it never covers a button of the bar. */
|
|
44
55
|
@media(max-width:600px){
|
|
56
|
+
.composer-card-foot{padding-top:4px;padding-bottom:4px}
|
|
45
57
|
#composeGrip{width:96px;height:26px;margin:-19px auto -7px}
|
|
46
58
|
#composeGrip::after{top:17px;opacity:1}
|
|
47
59
|
}
|