samagotchi 0.124.0 → 0.125.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/internals/participants.md +26 -1
  6. data/docs/internals/remote-sessions.md +2 -0
  7. data/docs/sessions.md +2 -2
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/process_group.rb +10 -1
  10. data/lib/samagotchi/reminder_carry.rb +92 -0
  11. data/lib/samagotchi/reminder_store.rb +49 -0
  12. data/lib/samagotchi/session_manager.rb +28 -4
  13. data/lib/samagotchi/sessions_command.rb +1 -1
  14. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  15. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  16. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  20. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  21. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +7 -3
  24. data/lib/samagotchi/web/participants_admin.rb +15 -4
  25. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  26. data/lib/samagotchi/web/public/annotate_bar.js +34 -6
  27. data/lib/samagotchi/web/public/app.js +66 -5
  28. data/lib/samagotchi/web/public/claim_link.js +347 -0
  29. data/lib/samagotchi/web/public/participant.html +1 -0
  30. data/lib/samagotchi/web/public/participant.js +46 -4
  31. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  32. data/lib/samagotchi/web/public/route.js +3 -2
  33. data/lib/samagotchi/web/public/tokens.css +2 -1
  34. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  35. data/lib/samagotchi/worker.rb +57 -3
  36. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  37. metadata +11 -1
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tools/task_record"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # The /exit ask and its reply lines: pure text from a reply, holds, and
8
+ # the invocation used to spell commands.
9
+ module ExitLines
10
+ module_function
11
+
12
+ # Why a request the Bridge read after its deadline didn't go through.
13
+ LATE = "worker not answering in time"
14
+ # An exit the worker agreed to in an empty session. Said before it has
15
+ # left: it checks again once it lets go, and a note or prompt arriving
16
+ # first keeps the session (SessionManager.discard_left_session).
17
+ EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
18
+ EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
19
+ # Why the worker stayed up after /exit, by the reason it gave.
20
+ HELD_REASONS = {
21
+ "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
22
+ "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
23
+ "reminders" => "reminders are set", "starting" => "the worker is still starting",
24
+ "background_tasks" => "background tasks it started are still running",
25
+ "delegates" => "delegates of this session are still running"
26
+ }.freeze
27
+ # How to end a background task that holds the worker up (an older
28
+ # worker's 409 has no hint of its own).
29
+ TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
30
+ # The /exit ask, when every hold is one the user can stop: what still
31
+ # runs, then the three answers. Each kind, singular and plural.
32
+ EXIT_HOLD_WORDS = {
33
+ "reminders" => %w[reminder reminders],
34
+ "background_tasks" => ["background task", "background tasks"],
35
+ "delegates" => %w[delegate delegates]
36
+ }.freeze
37
+ # The 200's +stopped+ keys, by the kind the ask lists them under.
38
+ STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
39
+ EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
40
+ # How the ask reads an answer.
41
+ EXIT_ANSWERS = {
42
+ "1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
43
+ "2" => :stop, "stop" => :stop, "exit" => :stop,
44
+ "3" => :back, "back" => :back, "stay" => :back
45
+ }.freeze
46
+
47
+ # The ask: what still runs, then the three answers.
48
+ def ask_lines(holds)
49
+ lines = holds.map do |hold|
50
+ items = Array(hold["items"])
51
+ singular, plural = hold_words(hold["kind"])
52
+ " • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
53
+ end
54
+ lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
55
+ (["This session still has:"] + lines).join("\n")
56
+ end
57
+
58
+ # One entry of a hold: the command or name, with its detail in
59
+ # brackets; a delegate leads with its short id and its title in quotes.
60
+ def item_word(item, kind)
61
+ label = item["label"].to_s
62
+ label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
63
+ detail = item["detail"].to_s
64
+ detail.empty? ? label : "#{label} (#{detail})"
65
+ end
66
+
67
+ # 1: detach, everything left running.
68
+ def kept_line(holds, via:, session_id:)
69
+ "Detached; the session keeps running (#{hold_words_list(holds)}). " \
70
+ "Re-attach with: #{via.attach(session_id)}"
71
+ end
72
+
73
+ # "2 reminders, 1 background task, 1 delegate" from the ask's holds.
74
+ def hold_words_list(holds)
75
+ holds.map do |hold|
76
+ items = Array(hold["items"])
77
+ singular, plural = hold_words(hold["kind"])
78
+ "#{items.size} #{items.size == 1 ? singular : plural}"
79
+ end.join(", ")
80
+ end
81
+
82
+ def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
83
+
84
+ # What a stop-all exit stopped: "stopped 2 reminders (api_health, git
85
+ # push), 1 background task (rails s)"; nil when it stopped nothing.
86
+ def stopped_words(stopped)
87
+ return nil unless stopped.is_a?(Hash)
88
+
89
+ parts = STOPPED_KEYS.filter_map do |key, kind|
90
+ names = Array(stopped[key])
91
+ next if names.empty?
92
+
93
+ singular, plural = hold_words(kind)
94
+ "#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
95
+ end
96
+ parts.empty? ? nil : "stopped #{parts.join(", ")}"
97
+ end
98
+
99
+ def exit_line(reply, via:, session_id:)
100
+ error = reply.json&.fetch("error", nil)
101
+ case reply.status
102
+ when 200
103
+ stopped = stopped_words(reply.json&.fetch("stopped", nil))
104
+ return EMPTY_EXIT if discards?(reply) && stopped.nil?
105
+ return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
106
+
107
+ "Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(session_id)}"
108
+ when 409
109
+ reason = reply.json&.fetch("reason", nil).to_s
110
+ "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
111
+ "Re-attach with: #{via.attach(session_id)}"
112
+ when 0 then exit_failed_line("no reply", via: via, session_id: session_id)
113
+ when 408 then exit_failed_line(LATE, via: via, session_id: session_id)
114
+ else
115
+ # A worker from before the route answers the Bridge's plain 404.
116
+ if error == "not_found"
117
+ return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{session_id}")}"
118
+ end
119
+
120
+ exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "),
121
+ via: via, session_id: session_id)
122
+ end
123
+ end
124
+
125
+ # Why the worker stayed up; the background tasks it waits for by
126
+ # their short id and command (a finish wake's, D3), and how to stop one.
127
+ def held_words(reason, tasks)
128
+ words = HELD_REASONS.fetch(reason, reason)
129
+ listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
130
+ listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
131
+ end
132
+
133
+ # An older worker leaves the field out.
134
+ def discards?(reply) = reply.json&.fetch("discard", nil) == true
135
+
136
+ def exit_failed_line(why, via:, session_id:)
137
+ "Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(session_id)}"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_note"
4
+ require_relative "../image_store"
5
+ require_relative "../log"
6
+ require_relative "../message_parts"
7
+ require_relative "../output_formatter"
8
+ require_relative "../steer"
9
+ require_relative "../tool_activity"
10
+ require_relative "../tool_view"
11
+ require_relative "../turn_note"
12
+ require_relative "event_renderer"
13
+ require_relative "formatting"
14
+
15
+ module Samagotchi
16
+ class TerminalUI
17
+ # Draws the saved part of a worker snapshot when an attached TUI joins or
18
+ # resyncs. The live loop owns the collaborators; this owns replay only.
19
+ class JoinReplay
20
+ include Formatting
21
+
22
+ # How much of the last answer a join shows.
23
+ JOIN_ANSWER_LINES = 12
24
+ JOIN_ANSWER_CHARS = 1200
25
+
26
+ def initialize(screen:, view:, link:, gaps:, renderer:)
27
+ @screen = screen
28
+ @view = view
29
+ @link = link
30
+ @gaps = gaps
31
+ @renderer = renderer
32
+ end
33
+
34
+ def render_join(snapshot, saved)
35
+ render_join_header(Array(snapshot[:messages]))
36
+ render_join_gap(snapshot, saved)
37
+ end
38
+
39
+ # The snapshot's cards and between-turns notices (Bridge CardStore):
40
+ # on join the ones since the last turn (and the running turn's), as
41
+ # the join header shows the last exchange; on a resync only cards not
42
+ # shown here yet, and not the ones an earlier worker saved (a resync
43
+ # onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
44
+ # the running turn's come with its parts, and the join shows no older
45
+ # turn's steps.
46
+ def render_snapshot_cards(entries, joining:)
47
+ entries.each do |entry|
48
+ case entry[:type].to_s
49
+ when "hook_notice"
50
+ next if entry[:in_turn]
51
+
52
+ @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
53
+ when "card"
54
+ next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
55
+
56
+ @renderer.render_card(entry, updated: entry[:updated] == true)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Where the session's last activity was: its newest stamped entry,
64
+ # else its saved recap's time. A gap ago (and no turn running): one
65
+ # line after the join block says when.
66
+ def render_join_gap(snapshot, saved)
67
+ @gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
68
+ return if snapshot[:current_turn]
69
+
70
+ line = @gaps.join_line
71
+ @screen.commit(gap_divider(line)) if line
72
+ end
73
+
74
+ def render_join_header(messages)
75
+ exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
76
+ # The id stays findable: the detach line and chi sessions list show it.
77
+ Log.session_id = @link.client.session_id
78
+ Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
79
+ notes: messages.count { |m| ContextNote.note?(m) })
80
+ last_user = exchange.rindex { |m| Steer.prompt?(m) }
81
+ render_join_notes(messages)
82
+ return unless last_user
83
+
84
+ @screen.commit(join_prompt_line(exchange[last_user]))
85
+ # An image-only turn's text names its images already.
86
+ unless ImageRef.placeholder_only?(exchange[last_user][:content])
87
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
88
+ end
89
+ from = messages.index { |m| m.equal?(exchange[last_user]) }
90
+ render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
91
+ record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
92
+ failed = join_failed_record(record)
93
+ # A turn that ended with no answer: its notice, as live.
94
+ empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
95
+ if empty
96
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
97
+ elsif failed
98
+ # A failed turn whose steps stayed: the line the live view left,
99
+ # and no step text as if it were the answer (the web keeps a
100
+ # failed turn's texts as steps too).
101
+ failure = failed["failure"]
102
+ @screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
103
+ @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
104
+ elsif record && record["status"] == "canceled"
105
+ # A canceled turn (a Stop, Ctrl-C): its line, as live, and no
106
+ # text it streamed before the cancel as if it were the answer.
107
+ @screen.commit(@view.turn_canceled_line(record["cancellation_reason"], record["duration_ms"], by: record["cancelled_by"]))
108
+ @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) unless record["continue"]
109
+ else
110
+ # The saved answer is raw: the latest keeps its thinking, and one may
111
+ # be only a tool call.
112
+ answer = exchange[(last_user + 1)..].reverse_each
113
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
114
+ .find { |text| !text.empty? }
115
+ @screen.commit(last_lines(answer)) if answer
116
+ end
117
+ render_join_notes(messages, after_exchange: true)
118
+ end
119
+
120
+ # What the last exchange did, as the live turn drew it: each saved
121
+ # call's tool row (action, params, status from its result, and its
122
+ # duration from +durations+, the turn's saved tool records) and a
123
+ # plugin's steer where it came. The step texts stay out, as live (the
124
+ # ticker's).
125
+ def render_join_steps(messages, from, durations: [])
126
+ records = durations.dup
127
+ messages.each_with_index.drop(from + 1).each do |m, i|
128
+ if Steer.steer?(m)
129
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
130
+ elsif %w[model assistant].include?(m[:role].to_s)
131
+ responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
132
+ Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
133
+ record = join_record(records, part)
134
+ duration = record&.dig("duration_ms")
135
+ @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
136
+ saved_status: record&.dig("status"),
137
+ auto_allowed: record&.dig("guardrail")))
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ # The prompt's turn's saved tool records (AttachLink: SessionMetrics,
144
+ # in this machine's state dir, as #attach_images): none for a prompt
145
+ # with no turn id (an older session) or a session with no analytics.
146
+ def join_tool_records(prompt)
147
+ @link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
148
+ end
149
+
150
+ # The turn's saved +record+ when it failed with the fields its line is
151
+ # made of (SessionMetrics#failure_fields), else nil: a completed or
152
+ # older turn — or one saved before the failure field — draws its
153
+ # answer as before (a canceled one its line).
154
+ def join_failed_record(record)
155
+ return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
156
+
157
+ failure = record["failure"]
158
+ return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
159
+
160
+ record
161
+ end
162
+
163
+ # The saved record of +part+'s call (its duration and status): the
164
+ # next record of its tool, in call order, consumed with the ones it
165
+ # skipped. By tool and order, not by iteration: an empty retry spends
166
+ # an iteration that left no message (as the web's timing.js
167
+ # takeRecord). nil for a call that left none: it never finished, or a
168
+ # worker before turn ids saved it.
169
+ def join_record(records, part)
170
+ return nil if part[:output].nil?
171
+
172
+ at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
173
+ return nil unless at
174
+
175
+ records.shift(at + 1).last
176
+ end
177
+
178
+ # A saved call's row; "no result" for a call the turn never answered
179
+ # (it ended first). Its status is the saved record's, as live and the
180
+ # web's reload (a guardrail's "blocked" is no error), else read from
181
+ # its result (a guardrail's deny text reads blocked too). A record that
182
+ # System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
183
+ # as the live row does.
184
+ def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
185
+ output = part[:output]
186
+ command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
187
+ text = ToolActivity.saved_result_text(output) unless output.nil?
188
+ status = if output.nil?
189
+ "no result"
190
+ elsif !saved_status.to_s.empty?
191
+ saved_status.to_s
192
+ elsif ToolActivity.guardrail_denied?(text)
193
+ "blocked"
194
+ else
195
+ ToolActivity.tool_activity_status(text, part[:tool], command: command)
196
+ end
197
+ activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
198
+ params: part[:params], status: status,
199
+ description: ToolView.description_title(part[:view] || {}) }
200
+ "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
201
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
202
+ "#{auto_allowed_suffix(auto_allowed)}"
203
+ end
204
+
205
+ # The live row's System 1 suffix for a saved record's {rule, p}.
206
+ def auto_allowed_suffix(saved)
207
+ return "" unless saved.is_a?(Hash)
208
+
209
+ format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
210
+ end
211
+
212
+ # The context notes that came since the last prompt (all of them in a
213
+ # session with none), after the exchange the header shows.
214
+ def render_join_notes(messages, after_exchange: false)
215
+ last_user = messages.rindex { |m| Steer.prompt?(m) }
216
+ return if after_exchange != !last_user.nil?
217
+
218
+ messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
219
+ @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
220
+ end
221
+ end
222
+
223
+ # The end of a long answer; the whole of it is in the session.
224
+ def last_lines(text)
225
+ lines = text.split("\n", -1)
226
+ if lines.size > JOIN_ANSWER_LINES
227
+ text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
228
+ end
229
+ return text if text.length <= JOIN_ANSWER_CHARS
230
+
231
+ "(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
232
+ end
233
+ end
234
+ end
235
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.124.0"
4
+ VERSION = "0.125.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1878,9 +1878,10 @@ module Samagotchi
1878
1878
  def home_label(dir) = Paths.tilde(dir)
1879
1879
 
1880
1880
  # <body> data for the page: where sessions are stored, where an
1881
- # all-view new chat starts (the start folder), and, when ?dir names a
1882
- # folder in a repo, that project's name and root. A bad dir adds
1883
- # nothing here: the list call answers 400 and the page says so.
1881
+ # all-view new chat starts (the start folder), the home folder, and,
1882
+ # when ?dir names a folder in a repo, that project's name and root. A
1883
+ # bad dir adds nothing here: the list call answers 400 and the page
1884
+ # says so.
1884
1885
  # Without ?dir (and without ?scope=all) the page is the start folder's
1885
1886
  # project view when that folder is in a repo: data-scope-dir names it
1886
1887
  # for the page (the URL stays bare, so a token's 303, a link code's
@@ -1890,6 +1891,9 @@ module Samagotchi
1890
1891
  # version: the chi that served the page; the events snapshot names
1891
1892
  # the one serving now (an upgraded chi web after a reconnect).
1892
1893
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(start_dir), "version" => Samagotchi::VERSION }
1894
+ # The owner's home: a claim link's quote reads it "~", as the
1895
+ # participant page shows it (claim_link.js).
1896
+ attrs["home"] = Dir.home
1893
1897
  attrs["view"] = view_name(req.params["view"])
1894
1898
  # Only when on: the page reads its presence (the turn view ignores it).
1895
1899
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
@@ -26,15 +26,17 @@ module Samagotchi
26
26
  # +identity_header+: the header this chi web's auth proxy names the
27
27
  # signed-in user in (null without one), for the Invite's email field;
28
28
  # +mode+: what a participant invited now gets, "pair" or "review"
29
- # (ReviewStart.share_terms), for the panel's words.
30
- def handle_participants_list(_req, id = nil)
29
+ # (ReviewStart.share_terms), for the panel's words; +link_base+: where
30
+ # a participant's link opens (participant_base), for a claim link
31
+ # (claim_link.js) the participant's browser opens with its cookie.
32
+ def handle_participants_list(req, id = nil)
31
33
  now = @grants.now
32
34
  grants = @grants.list(session_id: id).map { |grant| grant.public_h(now) }
33
35
  return json_response(200, { participants: grants }) unless id
34
36
 
35
37
  terms = ReviewStart.share_terms(Session.load(id, state_dir: default_state_dir), state_dir: default_state_dir)
36
38
  json_response(200, { participants: with_spend(id, grants), warning: terms.warning, mode: terms.mode.to_s,
37
- identity_header: @public_url&.identity_header })
39
+ identity_header: @public_url&.identity_header, link_base: participant_base(req) })
38
40
  rescue Grants::Unwritable => e
39
41
  grants_unwritable(e)
40
42
  end
@@ -149,10 +151,19 @@ module Samagotchi
149
151
  return @public_url.link(@link_codes.issue(kind: :grant, token: token, ttl: ttl), "s=#{id}")
150
152
  end
151
153
 
154
+ "#{participant_base(req)}?token=#{token}#s=#{id}"
155
+ end
156
+
157
+ # Where a participant's link opens, no token: web.public_url's origin,
158
+ # else this chi web's LAN address, else the Host asked
159
+ # ("http://192.168.1.5:4567/").
160
+ def participant_base(req)
161
+ return "#{@public_url.origin}/" if @public_url
162
+
152
163
  host = req.get_header("HTTP_HOST").to_s
153
164
  port = host[/:(\d+)\z/, 1] || req.port.to_s
154
165
  name = @lan ? @lan[:ip] : host.sub(/:\d*\z/, "")
155
- "http://#{name}:#{port}/?token=#{token}#s=#{id}"
166
+ "http://#{name}:#{port}/"
156
167
  end
157
168
  end
158
169
  end
@@ -1,6 +1,6 @@
1
- /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), small pills with a gap (they
2
- wrap on a narrow page). Both pages link it: the owner's (app.js) and the participant page (participant.js). Colours
3
- are tokens.css's. */
1
+ /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), Copy link last where the page
2
+ has one, small pills with a gap (they wrap on a narrow page). Both pages link it: the owner's (app.js) and the
3
+ participant page (participant.js). Colours are tokens.css's. */
4
4
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
5
5
  #annotateBar[hidden]{display:none}
6
6
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
@@ -15,4 +15,8 @@
15
15
  #annotateBar.docked{right:8px;justify-content:center}
16
16
  @media (pointer:coarse){#annotateBar button{font-size:14px;padding:8px 14px}}
17
17
  #annotateBar .annotate-more-list[hidden]{display:none}
18
- #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
18
+ #annotateBar .annotate-preset,#annotateBar .annotate-link{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
19
+ /* A claim link's target (claim_link.js): its bubble (or its turn's opening) ringed, the quote itself highlighted. One
20
+ name for both pages; --claim-mark/--claim-ring follow the palette (warn-bright and warn are set per palette). */
21
+ .claim-hit{outline:2px solid var(--claim-ring);outline-offset:3px;border-radius:12px}
22
+ ::highlight(chi-claim){background-color:var(--claim-mark);color:inherit}
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // A bundle's pills (chi.annotation) come after the presets: each prepares
11
11
  // its command line with the selection (annotate_actions.js), at most 3
12
- // inline, the rest behind "⋯".
12
+ // inline, the rest behind "⋯". Last, "Copy link" where the page has a
13
+ // link for the selection (a claim link, claim_link.js).
13
14
  //
14
15
  // On a touch screen the bar docks above the composer instead: the OS's own
15
16
  // selection menu (Copy, Look Up…) and the handles sit next to the selection,
@@ -21,7 +22,8 @@ import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js"
21
22
  import { presetNote } from "./annotate_presets.js";
22
23
  import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
23
24
 
24
- // @param presets the preset texts (annotate_presets.js parsePresets)
25
+ // @param presets the presets: each a text (annotate_presets.js
26
+ // parsePresets) or {label, text} (verify_preset.js)
25
27
  // @param canAnnotate () => whether the open session takes annotations (one
26
28
  // is open and the composer can send)
27
29
  // @param roots () => the elements a selection may start in (the history,
@@ -45,12 +47,17 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
45
47
  // passes its own, participant_annotate.js)
46
48
  // @param quote (text, source) => the quote block for the selected +text+
47
49
  // (annotations.js quoteBlock by default), or null for no text
50
+ // @param link (source, range) => the selection's link, {url, title}, or
51
+ // null for no "Copy link" (the default); +range+ the selection clipped
52
+ // to +source+'s root
53
+ // @param onLink (link) runs on "Copy link"; the selection is cleared and
54
+ // the bar hidden first
48
55
  // @param doc, win the page's document and window
49
56
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
50
57
  // next frame (call it on scroll)
51
58
  export function createAnnotateBar({
52
59
  presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
53
- dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock,
60
+ dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
54
61
  doc = globalThis.document, win = globalThis.window,
55
62
  }) {
56
63
  const bar = doc.createElement("div");
@@ -66,8 +73,9 @@ export function createAnnotateBar({
66
73
  return btn;
67
74
  }
68
75
  bar.appendChild(button("Annotate", "annotate-main"));
69
- presets.forEach((text, i) => {
70
- const btn = button(text, "annotate-preset");
76
+ presets.forEach((preset, i) => {
77
+ const { label, text } = presetParts(preset);
78
+ const btn = button(label, "annotate-preset");
71
79
  btn.dataset.index = String(i);
72
80
  btn.title = text;
73
81
  bar.appendChild(btn);
@@ -78,10 +86,14 @@ export function createAnnotateBar({
78
86
  group.className = "annotate-actions";
79
87
  group.hidden = true;
80
88
  bar.appendChild(group);
89
+ const linkBtn = button("Copy link", "annotate-link");
90
+ linkBtn.hidden = true;
91
+ bar.appendChild(linkBtn);
81
92
  doc.body.appendChild(bar);
82
93
  const touch = win.matchMedia?.("(pointer: coarse)");
83
94
  let pendingQuote = null;
84
95
  let pendingFields = null;
96
+ let pendingLink = null;
85
97
  let drawn = [];
86
98
  let moreList = null;
87
99
 
@@ -129,6 +141,7 @@ export function createAnnotateBar({
129
141
  function hide() {
130
142
  pendingQuote = null;
131
143
  pendingFields = null;
144
+ pendingLink = null;
132
145
  if (moreList) moreList.hidden = true;
133
146
  bar.hidden = true;
134
147
  }
@@ -151,6 +164,9 @@ export function createAnnotateBar({
151
164
  if (!block) return hide();
152
165
  pendingQuote = block;
153
166
  pendingFields = selectionFields(raw, source);
167
+ pendingLink = link(source, clipped) || null;
168
+ linkBtn.hidden = !pendingLink;
169
+ linkBtn.title = pendingLink?.title || "";
154
170
  const list = actions() || [];
155
171
  if (!sameActions(list, drawn)) drawActions(list);
156
172
  markActions();
@@ -209,6 +225,13 @@ export function createAnnotateBar({
209
225
  moreList.hidden = !moreList.hidden;
210
226
  return;
211
227
  }
228
+ if (btn === linkBtn) {
229
+ const target = pendingLink;
230
+ win.getSelection()?.removeAllRanges();
231
+ hide();
232
+ if (target) onLink(target);
233
+ return;
234
+ }
212
235
  if (btn.dataset.action !== undefined) {
213
236
  const action = drawn[Number(btn.dataset.action)];
214
237
  if (!action || btn.disabled) return;
@@ -219,7 +242,7 @@ export function createAnnotateBar({
219
242
  return;
220
243
  }
221
244
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
222
- const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
245
+ const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
223
246
  win.getSelection()?.removeAllRanges();
224
247
  hide();
225
248
  onQuote(text);
@@ -227,3 +250,8 @@ export function createAnnotateBar({
227
250
 
228
251
  return { schedule, hide, el: bar };
229
252
  }
253
+
254
+ // A preset's button label and note: a text is both.
255
+ function presetParts(preset) {
256
+ return typeof preset === "string" ? { label: preset, text: preset } : { label: preset.label, text: preset.text };
257
+ }