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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +4 -0
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +2 -2
  6. data/docs/guardrails.md +5 -0
  7. data/docs/internals/participants.md +17 -7
  8. data/docs/sessions.md +3 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/engine.rb +56 -0
  13. data/lib/samagotchi/guardrails/read_only_shell.rb +48 -1
  14. data/lib/samagotchi/session_manager.rb +5 -0
  15. data/lib/samagotchi/session_participants.rb +14 -1
  16. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  20. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  21. data/lib/samagotchi/verify_anchor.rb +8 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +12 -0
  24. data/lib/samagotchi/web/participant_access.rb +16 -1
  25. data/lib/samagotchi/web/participant_review.rb +4 -2
  26. data/lib/samagotchi/web/project_list.rb +52 -0
  27. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  28. data/lib/samagotchi/web/public/app.js +25 -4
  29. data/lib/samagotchi/web/public/composer.css +13 -1
  30. data/lib/samagotchi/web/public/index.html +35 -16
  31. data/lib/samagotchi/web/public/notify.js +4 -1
  32. data/lib/samagotchi/web/public/participant.html +33 -32
  33. data/lib/samagotchi/web/public/participant.js +16 -6
  34. data/lib/samagotchi/web/public/participant_model.js +3 -2
  35. data/lib/samagotchi/web/public/participant_names.js +2 -2
  36. data/lib/samagotchi/web/public/project_picker.js +115 -0
  37. data/lib/samagotchi/web/public/question_card.js +4 -1
  38. data/lib/samagotchi/web/public/restore.js +7 -4
  39. data/lib/samagotchi/web/public/scope.js +8 -10
  40. data/lib/samagotchi/web/public/send_turn.js +3 -1
  41. data/lib/samagotchi/web/public/session_state.js +2 -0
  42. data/lib/samagotchi/web/public/stage_view.js +1 -1
  43. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  44. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  45. data/lib/samagotchi/web/public/verdict_fresh.js +11 -0
  46. data/lib/samagotchi/web/public/verify_card.css +1 -0
  47. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  48. data/lib/samagotchi/web/push/attention.rb +24 -5
  49. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  50. data/lib/samagotchi/worker.rb +7 -4
  51. 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, first-command launch, and this run's turn ledger.
14
- # Reads @as_parent, @client, @client_id, @display_pending, @first_command_id,
15
- # @first_command_line, @first_commands, @first_prompt, @input_ended, @link,
16
- # @merged_ids, @my_queued, @no_interrupt, @open_ids, @own_empty, @own_failed,
17
- # @queued_ids, @reader, @running, @screen, @sent_ids, @status, @wait_at_eof;
18
- # writes @display_pending, @first_command_id, @first_command_line,
19
- # @first_commands, @first_prompt, @merged_ids, @my_queued, @open_ids,
20
- # @own_empty, @own_failed, @queued_ids, @sent_ids.
21
- # Calls the loop helpers #client_id_for, #command_registry, #default_model_name,
22
- # #detach, #mark_activity, #older_worker_command, #origin_of, #own?, #paint,
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
- @open_ids << command_id if command_id && @wait_at_eof
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
- @open_ids.delete(event[:command_id])
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
- @sent_ids << enqueued_id if enqueued_id
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? || !@open_ids.empty? || @display_pending == true
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
- return unless @wait_at_eof
180
-
181
- id = origin_of(event[:origin]).enqueued_id
182
- ours = @open_ids.include?(id) || !@merged_ids.empty?
183
- @own_failed = true if ours && event[:type] == :turn_failed
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
- @merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
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
- @own_failed = true if @wait_at_eof
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
- return :turn_failed if @own_failed
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 command said
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
- # Sent and not ended yet (by enqueued_id), the ones merged into the
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
- @input_ended = true
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 @display_pending = false
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) && @sent_ids.include?(origin.enqueued_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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.126.0"
4
+ VERSION = "0.128.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- mode = ParticipantMode.live(session, state_dir: default_state_dir).to_s
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 = () => {}, actions = () => [], running = () => false, onAction = () => {},
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
- const annotateBar = createAnnotateBar({
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
  }