samagotchi 0.17.0 → 0.18.1
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 +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +9 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -9,11 +9,13 @@ require "uri"
|
|
|
9
9
|
require "rack"
|
|
10
10
|
require "rack/request"
|
|
11
11
|
|
|
12
|
+
require_relative "../answer_tail"
|
|
12
13
|
require_relative "../bridge_client"
|
|
13
14
|
require_relative "../bridge/bounded_queue"
|
|
14
15
|
require_relative "../bridge/card_store"
|
|
15
16
|
require_relative "../session"
|
|
16
17
|
require_relative "../session_manager"
|
|
18
|
+
require_relative "../session_metrics"
|
|
17
19
|
require_relative "../session_commands"
|
|
18
20
|
require_relative "../steer"
|
|
19
21
|
require_relative "../host_registry"
|
|
@@ -177,14 +179,18 @@ module Samagotchi
|
|
|
177
179
|
|
|
178
180
|
# API requests at debug level (not the page and its assets): method,
|
|
179
181
|
# path, status, time. Never a body or the query; only a tail=1 read
|
|
180
|
-
# (the page's end-of-turn re-read)
|
|
182
|
+
# (the page's end-of-turn re-read) and a timing=1 one (its fallback
|
|
183
|
+
# when the merge came up short) are marked, to tell them from a full one.
|
|
181
184
|
def log_request(env, response, started)
|
|
182
185
|
path = env["PATH_INFO"].to_s
|
|
183
186
|
return unless path.start_with?("/api/") && Log.level?(:debug)
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
params = env["QUERY_STRING"].to_s.split("&")
|
|
189
|
+
marks = {}
|
|
190
|
+
marks[:tail] = true if params.include?("tail=1")
|
|
191
|
+
marks[:timing] = true if params.include?("timing=1")
|
|
186
192
|
Log.debug(:web, "request", sid: path[%r{\A/api/sessions/([^/]+)}, 1], method: env["REQUEST_METHOD"], path: path,
|
|
187
|
-
status: response[0], **
|
|
193
|
+
status: response[0], **marks,
|
|
188
194
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
189
195
|
end
|
|
190
196
|
|
|
@@ -518,6 +524,11 @@ module Samagotchi
|
|
|
518
524
|
end
|
|
519
525
|
|
|
520
526
|
def handle_show(req, id)
|
|
527
|
+
# The page's light re-reads: never the whole conversation.
|
|
528
|
+
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
529
|
+
return handle_tail_read(req, id) if req.params["tail"] == "1"
|
|
530
|
+
return handle_timing_read(id) if req.params["timing"] == "1"
|
|
531
|
+
|
|
521
532
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
522
533
|
# Read-only preview: selecting a session never spawns a worker.
|
|
523
534
|
# The worker is woken only on POST /turn (handle_turn) via
|
|
@@ -531,11 +542,6 @@ module Samagotchi
|
|
|
531
542
|
# behind it, all at one event_seq the client streams on from.
|
|
532
543
|
live = bridge_get_json(id, "snapshot")
|
|
533
544
|
turn_snapshot = live && live["snapshot"]
|
|
534
|
-
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
535
|
-
# body (the stream carries the card's plain text only).
|
|
536
|
-
if req.params["cards"] == "1"
|
|
537
|
-
return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
|
|
538
|
-
end
|
|
539
545
|
snapshot = live && live["session_state_snapshot"]
|
|
540
546
|
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
541
547
|
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
@@ -543,8 +549,9 @@ module Samagotchi
|
|
|
543
549
|
# The file's question only while a worker owns the session: one a
|
|
544
550
|
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
545
551
|
# REPL's (it shares nothing with the web).
|
|
552
|
+
# A question between turns (the step-limit one) is the snapshot's own.
|
|
546
553
|
pending = if turn_snapshot
|
|
547
|
-
current_turn
|
|
554
|
+
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
548
555
|
elsif owner&.worker?
|
|
549
556
|
session.pending_question
|
|
550
557
|
end
|
|
@@ -557,19 +564,6 @@ module Samagotchi
|
|
|
557
564
|
end
|
|
558
565
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
559
566
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
|
|
560
|
-
# ?tail=1: the page's re-read at the end of a turn wants the final
|
|
561
|
-
# answer's rendered markdown, the status and the timing, not the
|
|
562
|
-
# whole history again (nor a render of every earlier answer).
|
|
563
|
-
if req.params["tail"] == "1"
|
|
564
|
-
return json_response(200, {
|
|
565
|
-
tail: true,
|
|
566
|
-
session: session_json,
|
|
567
|
-
messages: last_assistant_for_display(raw_messages),
|
|
568
|
-
markdown_warning: @markdown_renderer.warning,
|
|
569
|
-
timing: timing
|
|
570
|
-
})
|
|
571
|
-
end
|
|
572
|
-
|
|
573
567
|
json_response(200, {
|
|
574
568
|
session: session_json,
|
|
575
569
|
history: read_history(id),
|
|
@@ -585,7 +579,7 @@ module Samagotchi
|
|
|
585
579
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
586
580
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
587
581
|
init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
|
|
588
|
-
cards: cards_for_display(turn_snapshot
|
|
582
|
+
cards: cards_for_display(turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)),
|
|
589
583
|
# The composer's / autocomplete; the built-ins until a worker
|
|
590
584
|
# names its plugins' too.
|
|
591
585
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
@@ -601,14 +595,101 @@ module Samagotchi
|
|
|
601
595
|
error_response(404, "not_found", e.message)
|
|
602
596
|
end
|
|
603
597
|
|
|
598
|
+
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
599
|
+
# body (the stream carries the card's plain text only). The worker's
|
|
600
|
+
# cards from its light GET tail, else the saved ones; the session file
|
|
601
|
+
# is only stat'ed (the 404), never parsed.
|
|
602
|
+
def handle_cards_read(id)
|
|
603
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
604
|
+
|
|
605
|
+
tail = live_tail(id)
|
|
606
|
+
json_response(200, { cards: cards_for_display(tail ? tail["cards"] : saved_cards(id)) })
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# ?tail=1: the page's re-read at the end of a turn wants the final
|
|
610
|
+
# answer's rendered markdown, the status and the timing, not the whole
|
|
611
|
+
# history again (nor a render of every earlier answer). Live: the
|
|
612
|
+
# worker's GET tail alone (no session file read); else the file. The
|
|
613
|
+
# session part is what the page reads of it (id, status, used memory
|
|
614
|
+
# names). ?turn_id=: that turn's answer (a queued turn's page re-reads
|
|
615
|
+
# after the next one started). ?recent=1 (the page since this
|
|
616
|
+
# release): the timing's records are the newest turn's (#recent_timing),
|
|
617
|
+
# which the page merges by id; without it, the whole timing as before.
|
|
618
|
+
def handle_tail_read(req, id)
|
|
619
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
620
|
+
|
|
621
|
+
turn_id = req.params["turn_id"].to_s.strip
|
|
622
|
+
turn_id = nil if turn_id.empty?
|
|
623
|
+
tail = live_tail(id, turn_id: turn_id)
|
|
624
|
+
state = tail && tail["session_state_snapshot"]
|
|
625
|
+
if state.is_a?(Hash)
|
|
626
|
+
session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]) }
|
|
627
|
+
messages = last_assistant_for_display([tail["answer"]].compact)
|
|
628
|
+
else
|
|
629
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
630
|
+
state = nil
|
|
631
|
+
session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names) }
|
|
632
|
+
messages = last_assistant_for_display(session.messages, turn_id: turn_id)
|
|
633
|
+
end
|
|
634
|
+
live_metrics = state && state["metrics"]
|
|
635
|
+
# Without recent=1: a tab opened before this release (it replaces its
|
|
636
|
+
# timing with the reply's). Remove this branch after the next release.
|
|
637
|
+
timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
|
|
638
|
+
json_response(200, {
|
|
639
|
+
tail: true,
|
|
640
|
+
session: session_json,
|
|
641
|
+
messages: messages,
|
|
642
|
+
markdown_warning: @markdown_renderer.warning,
|
|
643
|
+
timing: timing
|
|
644
|
+
})
|
|
645
|
+
rescue ArgumentError => e
|
|
646
|
+
error_response(404, "not_found", e.message)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# ?timing=1: the whole timing (every record, analytics.json merged with
|
|
650
|
+
# the live ones) and turn_count, when the page's merge comes up short.
|
|
651
|
+
# The worker's GET state, never its messages.
|
|
652
|
+
def handle_timing_read(id)
|
|
653
|
+
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
654
|
+
|
|
655
|
+
state = bridge_get_json(id, "state")
|
|
656
|
+
json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def session_exists?(id)
|
|
660
|
+
@session_class.exist?(id, state_dir: default_state_dir)
|
|
661
|
+
rescue ArgumentError
|
|
662
|
+
false
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
666
|
+
# "answer", "cards", "event_seq", "event_id"}. A worker from before the
|
|
667
|
+
# route answers 404 not_found: its GET snapshot, in the same shape (the
|
|
668
|
+
# answer by AnswerTail). Anything else (no worker, a refused connect, a
|
|
669
|
+
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
670
|
+
# try at a worker that failed once.
|
|
671
|
+
# @return [Hash, nil]
|
|
672
|
+
def live_tail(id, turn_id: nil)
|
|
673
|
+
status, body = bridge_get(id, turn_id ? "tail?turn_id=#{URI.encode_www_form_component(turn_id)}" : "tail")
|
|
674
|
+
return body if status == 200 && body.is_a?(Hash)
|
|
675
|
+
return nil unless status == 404 && body.is_a?(Hash) && body["error"] == "not_found"
|
|
676
|
+
|
|
677
|
+
status, live = bridge_get(id, "snapshot")
|
|
678
|
+
return nil unless status == 200 && live.is_a?(Hash) && live["snapshot"].is_a?(Hash)
|
|
679
|
+
|
|
680
|
+
snap = live["snapshot"]
|
|
681
|
+
{ "session_state_snapshot" => live["session_state_snapshot"],
|
|
682
|
+
"answer" => AnswerTail.find(snap["messages"], turn_id: turn_id),
|
|
683
|
+
"cards" => snap["cards"], "event_seq" => snap["event_seq"], "event_id" => snap["event_id"] }
|
|
684
|
+
end
|
|
685
|
+
|
|
604
686
|
# The worker's last cards and between-turns notices (Bridge
|
|
605
|
-
#
|
|
606
|
-
# saved in the session's folder (
|
|
687
|
+
# cards: CardStore#list), else (no live worker) the ones the last
|
|
688
|
+
# worker saved in the session's folder (#saved_cards), a card's body as
|
|
607
689
|
# body_html: rendered markdown when the renderer is on, else the
|
|
608
690
|
# escaped text in a <pre>.
|
|
609
691
|
# @return [Array<Hash>]
|
|
610
|
-
def cards_for_display(
|
|
611
|
-
cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
|
|
692
|
+
def cards_for_display(cards)
|
|
612
693
|
Array(cards).filter_map do |card|
|
|
613
694
|
next unless card.is_a?(Hash)
|
|
614
695
|
next card unless card["type"].to_s == "card"
|
|
@@ -979,7 +1060,8 @@ module Samagotchi
|
|
|
979
1060
|
end
|
|
980
1061
|
|
|
981
1062
|
# GET /api/events: the session list as one SSE stream per tab. The
|
|
982
|
-
# first frame is the hub's snapshot (scoped by ?dir= as the list is
|
|
1063
|
+
# first frame is the hub's snapshot (scoped by ?dir= as the list is,
|
|
1064
|
+
# with chi's version),
|
|
983
1065
|
# then `session` for an upsert and `session_gone` for a removal, with
|
|
984
1066
|
# a `: ping` while idle. Frame ids are the hub's seq, for the log's
|
|
985
1067
|
# sake: there is no replay, a reconnect starts with a fresh snapshot.
|
|
@@ -1066,6 +1148,15 @@ module Samagotchi
|
|
|
1066
1148
|
bridge_client(session_id)&.get_json(path)
|
|
1067
1149
|
end
|
|
1068
1150
|
|
|
1151
|
+
# GET a read path of the session's live Bridge with its status
|
|
1152
|
+
# (BridgeClient#get).
|
|
1153
|
+
# @return [Array(Integer, Object)] [status, parsed body]; [nil, nil]
|
|
1154
|
+
# with no live bridge or no reply
|
|
1155
|
+
def bridge_get(session_id, path)
|
|
1156
|
+
client = bridge_client(session_id)
|
|
1157
|
+
client ? client.get(path) : [nil, nil]
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1069
1160
|
# Monotonic SSE cursor for a live bridge session, else nil.
|
|
1070
1161
|
def bridge_event_seq(session_id)
|
|
1071
1162
|
bridge_client(session_id)&.event_seq
|
|
@@ -1138,9 +1229,12 @@ module Samagotchi
|
|
|
1138
1229
|
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
|
|
1139
1230
|
end
|
|
1140
1231
|
|
|
1232
|
+
# The whole timing: analytics.json's records merged with a live
|
|
1233
|
+
# worker's (by id), and turn_count, the finished turns (#turn_count).
|
|
1141
1234
|
def timing_payload(session_id, live_metrics: nil)
|
|
1142
1235
|
persisted = read_analytics(session_id)
|
|
1143
|
-
|
|
1236
|
+
live = live_metrics.is_a?(Hash)
|
|
1237
|
+
source = if live
|
|
1144
1238
|
persisted.merge(live_metrics).merge(
|
|
1145
1239
|
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
1146
1240
|
"turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
|
|
@@ -1149,14 +1243,66 @@ module Samagotchi
|
|
|
1149
1243
|
else
|
|
1150
1244
|
persisted
|
|
1151
1245
|
end
|
|
1246
|
+
turns = Array(source["turn_records"])
|
|
1247
|
+
timing_from(source, turns, Array(source["tool_records"]), live: live,
|
|
1248
|
+
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# The timing a ?tail=1&recent=1 read sends: a live worker's recent
|
|
1252
|
+
# records as its metrics hold them (the newest finished turn's and
|
|
1253
|
+
# the unsaved ones, the running turn's finished calls), no
|
|
1254
|
+
# analytics.json read; with no worker the disk's, trimmed to the
|
|
1255
|
+
# newest turn. turn_count tells the page whether its merge missed one
|
|
1256
|
+
# (it then reads ?timing=1).
|
|
1257
|
+
def recent_timing(session_id, live_metrics)
|
|
1258
|
+
if live_metrics.is_a?(Hash)
|
|
1259
|
+
count = turn_count(live_metrics)
|
|
1260
|
+
# A metrics hash without the count: the whole timing.
|
|
1261
|
+
return timing_payload(session_id, live_metrics: live_metrics) unless count
|
|
1262
|
+
|
|
1263
|
+
turns = Array(live_metrics["turn_records"])
|
|
1264
|
+
tools = Array(live_metrics["tool_records"])
|
|
1265
|
+
# An older worker's metrics hold every record (before they held the
|
|
1266
|
+
# recent ones only, never more than LIVE_RECORDS_CAP turns).
|
|
1267
|
+
turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
|
|
1268
|
+
return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
persisted = read_analytics(session_id)
|
|
1272
|
+
all = Array(persisted["turn_records"])
|
|
1273
|
+
turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
|
|
1274
|
+
timing_from(persisted, turns, tools, live: false, turn_count: all.size)
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
# The finished turns a live worker counts: its metrics' turns (the
|
|
1278
|
+
# loaded history, its own and the running one) less the running one.
|
|
1279
|
+
# @return [Integer, nil]
|
|
1280
|
+
def turn_count(metrics)
|
|
1281
|
+
turns = metrics["turns"]
|
|
1282
|
+
return nil unless turns.is_a?(Integer)
|
|
1283
|
+
|
|
1284
|
+
[turns - (metrics["active_turn"] ? 1 : 0), 0].max
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# The newest turn record and its tool records (and the running turn's
|
|
1288
|
+
# finished calls).
|
|
1289
|
+
def newest_turn_records(turns, tools, active_turn)
|
|
1290
|
+
kept = turns.last(1)
|
|
1291
|
+
ids = kept.filter_map { |r| r["id"] if r.is_a?(Hash) }
|
|
1292
|
+
ids << active_turn["id"] if active_turn.is_a?(Hash) && active_turn["id"]
|
|
1293
|
+
[kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def timing_from(source, turn_records, tool_records, live:, turn_count:)
|
|
1152
1297
|
started_at = source["started_at"]
|
|
1153
1298
|
last_activity_at = source["last_activity_at"]
|
|
1154
1299
|
{
|
|
1155
1300
|
started_at: started_at,
|
|
1156
1301
|
last_activity_at: last_activity_at,
|
|
1157
|
-
session_duration_ms: session_duration_ms(started_at, last_activity_at, active:
|
|
1158
|
-
turn_records:
|
|
1159
|
-
tool_records:
|
|
1302
|
+
session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
|
|
1303
|
+
turn_records: turn_records,
|
|
1304
|
+
tool_records: tool_records,
|
|
1305
|
+
turn_count: turn_count,
|
|
1160
1306
|
active_turn: source["active_turn"],
|
|
1161
1307
|
active_tools: Array(source["active_tools"]),
|
|
1162
1308
|
# The context last seen and the token sums, for the ctx meter
|
|
@@ -1332,14 +1478,10 @@ module Samagotchi
|
|
|
1332
1478
|
MessageParts.for_message(list[index], responses, cwd: cwd)
|
|
1333
1479
|
end
|
|
1334
1480
|
|
|
1335
|
-
# The last message messages_for_display shows as an answer
|
|
1336
|
-
# at most one:
|
|
1337
|
-
def last_assistant_for_display(msgs)
|
|
1338
|
-
|
|
1339
|
-
shown = messages_for_display([m]).first
|
|
1340
|
-
return [shown] if shown && shown[:role] == "assistant"
|
|
1341
|
-
end
|
|
1342
|
-
[]
|
|
1481
|
+
# The last message messages_for_display shows as an answer
|
|
1482
|
+
# (AnswerTail), as a list of at most one: only that one is rendered.
|
|
1483
|
+
def last_assistant_for_display(msgs, turn_id: nil)
|
|
1484
|
+
messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact)
|
|
1343
1485
|
end
|
|
1344
1486
|
|
|
1345
1487
|
def parse_json(str)
|
|
@@ -1384,7 +1526,9 @@ module Samagotchi
|
|
|
1384
1526
|
# folder in a repo, that project's name and root. A bad dir adds
|
|
1385
1527
|
# nothing here: the list call answers 400 and the page says so.
|
|
1386
1528
|
def index_data_attributes(req)
|
|
1387
|
-
|
|
1529
|
+
# version: the chi that served the page; the events snapshot names
|
|
1530
|
+
# the one serving now (an upgraded chi web after a reconnect).
|
|
1531
|
+
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
|
|
1388
1532
|
attrs["view"] = view_name(req.params["view"])
|
|
1389
1533
|
# Always there: an empty one means no presets, not the default.
|
|
1390
1534
|
attrs["annotate-presets"] = @annotate_presets
|
|
@@ -1457,7 +1601,8 @@ module Samagotchi
|
|
|
1457
1601
|
end
|
|
1458
1602
|
|
|
1459
1603
|
def each
|
|
1460
|
-
|
|
1604
|
+
# version: the page compares it with the one it was served by.
|
|
1605
|
+
yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION)
|
|
1461
1606
|
last_write = monotonic
|
|
1462
1607
|
loop do
|
|
1463
1608
|
event = @queue.pop([@heartbeat, EVENTS_POLL].min)
|
|
@@ -39,11 +39,12 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
|
39
39
|
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
40
40
|
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
41
41
|
import { createNotifications } from "./notifications.js";
|
|
42
|
+
import { updateNotice } from "./update_notice.js";
|
|
42
43
|
import {
|
|
43
|
-
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
44
|
+
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
44
45
|
} from "./timing.js";
|
|
45
46
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
46
|
-
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, startPageReply, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
48
|
|
|
48
49
|
const $ = (s) => document.querySelector(s);
|
|
49
50
|
const topStripEl = $("#topStrip");
|
|
@@ -101,6 +102,15 @@ function findInTurnRoots(selector) {
|
|
|
101
102
|
let current = emptySessionState();
|
|
102
103
|
let turnRunning = false;
|
|
103
104
|
let liveTurnTimingEl = null;
|
|
105
|
+
// The running turn's id (turn_started's turn_id, the id of its timing
|
|
106
|
+
// record), so a turn-end re-read finishes that turn's line with that
|
|
107
|
+
// turn's record, whatever started since.
|
|
108
|
+
let liveTurnId = null;
|
|
109
|
+
// The turns this page saw end: the records of its last full render, +1 at
|
|
110
|
+
// each turn's end, never fewer than a merge brought. The live line numbers
|
|
111
|
+
// the next one, so a turn queued behind another reads its own number while
|
|
112
|
+
// the other's re-read is still out.
|
|
113
|
+
let turnsEnded = 0;
|
|
104
114
|
// What the running turn waits on, after its live timing line: a provider
|
|
105
115
|
// retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
|
|
106
116
|
// it streams again.
|
|
@@ -169,6 +179,7 @@ async function refresh() {
|
|
|
169
179
|
function openSessionEvents() {
|
|
170
180
|
eventsControl = openEvents({
|
|
171
181
|
snapshot: (data) => {
|
|
182
|
+
noticeUpdate(data.version);
|
|
172
183
|
notifications.noticeSessions("snapshot", data);
|
|
173
184
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
174
185
|
render();
|
|
@@ -199,6 +210,17 @@ function openSessionEvents() {
|
|
|
199
210
|
});
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
// chi web was upgraded while this tab stayed open (a snapshot on the
|
|
214
|
+
// reconnect names another version than the one that served the page):
|
|
215
|
+
// a toast offers the reload, never forces it.
|
|
216
|
+
let updateShownFor = null;
|
|
217
|
+
function noticeUpdate(served) {
|
|
218
|
+
const text = updateNotice(document.body.dataset.version, served, updateShownFor);
|
|
219
|
+
if (!text) return;
|
|
220
|
+
updateShownFor = served;
|
|
221
|
+
showToast(text, { label: "Reload", run: () => location.reload() }, { sticky: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
// The selected session's card changed: its owner and, without a live
|
|
203
225
|
// stream, its status show in the info bar; a worker whose bridge is up
|
|
204
226
|
// (a turn another client sent woke one, or a replacement after a kill)
|
|
@@ -284,7 +306,7 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
284
306
|
const ctx = cardCtxText(s.ctx_pct);
|
|
285
307
|
const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
|
|
286
308
|
const waiting = waitingBadge(s);
|
|
287
|
-
const waitingHtml = waiting ? `<span class="attn
|
|
309
|
+
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
288
310
|
const dotWord = waiting ? waiting.title : s.status;
|
|
289
311
|
return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
290
312
|
}
|
|
@@ -605,6 +627,7 @@ function clearSelection() {
|
|
|
605
627
|
resetSessionState();
|
|
606
628
|
clearTimingInterval();
|
|
607
629
|
liveTurnTimingEl = null;
|
|
630
|
+
liveTurnId = null;
|
|
608
631
|
setTurnRunning(false);
|
|
609
632
|
closeStream();
|
|
610
633
|
setMarkdownWarning(null);
|
|
@@ -658,6 +681,7 @@ function resetTurnView() {
|
|
|
658
681
|
closeStream();
|
|
659
682
|
clearTimingInterval();
|
|
660
683
|
liveTurnTimingEl = null;
|
|
684
|
+
liveTurnId = null;
|
|
661
685
|
questionCards.removeQuestion();
|
|
662
686
|
questionCards.forgetContinue();
|
|
663
687
|
unmatchedMerge = false;
|
|
@@ -715,6 +739,8 @@ function renderSessionView(data) {
|
|
|
715
739
|
current.dir = s.working_directory;
|
|
716
740
|
current.owner = s.owner || null;
|
|
717
741
|
current.timing = normalizeTiming(data.timing);
|
|
742
|
+
turnsEnded = current.timing.turnRecords.length;
|
|
743
|
+
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
718
744
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
719
745
|
// next turn (a running turn's replay below streams over it).
|
|
720
746
|
const savedPct = savedCtxPct(data.timing?.context);
|
|
@@ -767,7 +793,10 @@ function renderSessionView(data) {
|
|
|
767
793
|
replay.forEach((event) => streamHandlers[event.type]?.(event));
|
|
768
794
|
if (!data.current_turn) {
|
|
769
795
|
setTurnRunning(current.status === "running");
|
|
770
|
-
if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at);
|
|
796
|
+
if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at, activeTurnId);
|
|
797
|
+
} else if (turnRunning) {
|
|
798
|
+
// The replayed turn_started carries no id: the running turn's record's.
|
|
799
|
+
liveTurnId = activeTurnId;
|
|
771
800
|
}
|
|
772
801
|
turnDom.flush();
|
|
773
802
|
snapshotCards.current.forEach((card) => upsertCard(card));
|
|
@@ -818,13 +847,18 @@ let turnEndRead = Promise.resolve();
|
|
|
818
847
|
let displayLanded = null;
|
|
819
848
|
async function readTurnEnd() {
|
|
820
849
|
if (!selected) return;
|
|
850
|
+
const id = selected;
|
|
851
|
+
// The line and the turn this read is for, before the await: a turn
|
|
852
|
+
// queued behind this one may start (a new live line) before it lands.
|
|
853
|
+
const line = { el: liveTurnTimingEl, turnId: liveTurnId };
|
|
821
854
|
try {
|
|
822
|
-
const d = await getSession(
|
|
855
|
+
const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
856
|
+
if (id !== selected) return;
|
|
823
857
|
if (d.session) {
|
|
824
858
|
if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
|
|
825
859
|
if (d.session.status) current.status = d.session.status;
|
|
826
|
-
|
|
827
|
-
|
|
860
|
+
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
861
|
+
finishTurnLine(line);
|
|
828
862
|
setMarkdownWarning(d.markdown_warning);
|
|
829
863
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
830
864
|
updateInfoBar();
|
|
@@ -913,6 +947,9 @@ function emptyAnswerEl(emptyAnswer) {
|
|
|
913
947
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
914
948
|
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
915
949
|
setTurnRunning(false);
|
|
950
|
+
// Every ended turn has a record (completed, canceled, failed); a worker
|
|
951
|
+
// gone mid-turn is re-rendered (resync), which counts again.
|
|
952
|
+
if (kind !== "gone") turnsEnded += 1;
|
|
916
953
|
// A completed turn with no answer: its notice where the answer goes
|
|
917
954
|
// (the turn view places it), else after the turn; before the timing.
|
|
918
955
|
const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
|
|
@@ -1356,11 +1393,12 @@ function clearTimingInterval() {
|
|
|
1356
1393
|
|
|
1357
1394
|
let liveTurnNumber = null;
|
|
1358
1395
|
|
|
1359
|
-
function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
1396
|
+
function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
|
|
1360
1397
|
clearTimingInterval();
|
|
1361
|
-
current.timing.activeTurn = { started_at: startedAt };
|
|
1362
|
-
|
|
1363
|
-
|
|
1398
|
+
current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
|
|
1399
|
+
liveTurnId = turnId || null;
|
|
1400
|
+
// The turns ended so far, so this one comes next.
|
|
1401
|
+
liveTurnNumber = turnsEnded + 1;
|
|
1364
1402
|
liveTurnNote = "";
|
|
1365
1403
|
liveTurnTimingEl = document.createElement("div");
|
|
1366
1404
|
liveTurnTimingEl.className = "turn-timing live";
|
|
@@ -1410,11 +1448,46 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
|
1410
1448
|
updateInfoBar();
|
|
1411
1449
|
}
|
|
1412
1450
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1451
|
+
// A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
|
|
1452
|
+
// and turn_count) merged into the page's (mergeTiming); when the merge comes
|
|
1453
|
+
// up short, the whole timing (?timing=1). Never (re)starts live timing: a
|
|
1454
|
+
// turn that started meanwhile keeps its line and its own active turn.
|
|
1455
|
+
// @return false when the session changed meanwhile
|
|
1456
|
+
async function mergeTurnTiming(id, data) {
|
|
1457
|
+
const keepActive = () => (turnRunning ? current.timing.activeTurn : null);
|
|
1458
|
+
const merged = mergeTiming(current.timing, data);
|
|
1459
|
+
current.timing = { ...merged.timing, activeTurn: keepActive() };
|
|
1460
|
+
if (merged.short) {
|
|
1461
|
+
try {
|
|
1462
|
+
const full = await getSession(id, { timing: true });
|
|
1463
|
+
if (id !== selected) return false;
|
|
1464
|
+
current.timing = { ...normalizeTiming(full.timing), activeTurn: keepActive() };
|
|
1465
|
+
} catch (_) {
|
|
1466
|
+
if (id !== selected) return false;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
turnsEnded = Math.max(turnsEnded, current.timing.turnRecords.length);
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Finish the timing line a turn-end read captured (+line+: {el, turnId})
|
|
1474
|
+
// with that turn's record: its place in the records and its duration (a
|
|
1475
|
+
// canceled one says so). No id (an older worker's events): the last
|
|
1476
|
+
// record. No record: the line stays as the turn's end left it. The ticker
|
|
1477
|
+
// stops only if that line is still the live one.
|
|
1478
|
+
function finishTurnLine(line) {
|
|
1479
|
+
const records = current.timing.turnRecords;
|
|
1480
|
+
const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
|
|
1481
|
+
if (line.el === liveTurnTimingEl) {
|
|
1482
|
+
current.timing.activeTurn = null;
|
|
1483
|
+
clearTimingInterval();
|
|
1417
1484
|
}
|
|
1485
|
+
const duration = Number(record?.duration_ms);
|
|
1486
|
+
if (line.el && record && Number.isFinite(duration)) {
|
|
1487
|
+
line.el.classList.remove("live");
|
|
1488
|
+
line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
|
|
1489
|
+
}
|
|
1490
|
+
updateInfoBar();
|
|
1418
1491
|
}
|
|
1419
1492
|
|
|
1420
1493
|
function updateFirstPeek() {
|
|
@@ -1538,7 +1611,7 @@ const streamHandlers = {
|
|
|
1538
1611
|
clearTimingInterval();
|
|
1539
1612
|
liveTurnTimingEl = null;
|
|
1540
1613
|
turnDom.turnStarted({ ...data, promptBubble });
|
|
1541
|
-
startLiveTurnTiming(data.started_at);
|
|
1614
|
+
startLiveTurnTiming(data.started_at, data.turn_id || null);
|
|
1542
1615
|
}),
|
|
1543
1616
|
turn_completed: async (data) => {
|
|
1544
1617
|
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
@@ -1563,7 +1636,7 @@ const streamHandlers = {
|
|
|
1563
1636
|
try {
|
|
1564
1637
|
await turnEndRead;
|
|
1565
1638
|
if (!selected || !data.display) return;
|
|
1566
|
-
const d = await getSession(selected, { tail: true });
|
|
1639
|
+
const d = await getSession(selected, { tail: true, recent: true });
|
|
1567
1640
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1568
1641
|
} catch (_) {
|
|
1569
1642
|
} finally {
|
|
@@ -1620,7 +1693,9 @@ const streamHandlers = {
|
|
|
1620
1693
|
updateInfoBar();
|
|
1621
1694
|
}
|
|
1622
1695
|
if (view.resync) await resync();
|
|
1623
|
-
|
|
1696
|
+
// A card's action that isn't anytime (the step-limit question's
|
|
1697
|
+
// /continue) has no bubble: nothing to remove.
|
|
1698
|
+
const queued = view.anytime ? commandBubble(view.commandId) : null;
|
|
1624
1699
|
if (view.hidden) queued?.remove();
|
|
1625
1700
|
else if (queued) drawCommandBubble(queued, view);
|
|
1626
1701
|
else addCommandBubble(view);
|
|
@@ -1700,11 +1775,13 @@ const streamHandlers = {
|
|
|
1700
1775
|
|
|
1701
1776
|
async function refreshTimingFromSession() {
|
|
1702
1777
|
if (!selected) return;
|
|
1778
|
+
const id = selected;
|
|
1779
|
+
const line = { el: liveTurnTimingEl, turnId: liveTurnId };
|
|
1703
1780
|
try {
|
|
1704
|
-
const data = await getSession(
|
|
1781
|
+
const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
1782
|
+
if (id !== selected) return;
|
|
1705
1783
|
if (data.session?.status) current.status = data.session.status;
|
|
1706
|
-
|
|
1707
|
-
finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
|
|
1784
|
+
if (await mergeTurnTiming(id, data.timing)) finishTurnLine(line);
|
|
1708
1785
|
} catch (_) {}
|
|
1709
1786
|
}
|
|
1710
1787
|
|
|
@@ -1745,6 +1822,13 @@ async function handleSendTurn() {
|
|
|
1745
1822
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1746
1823
|
const typed = promptEl.value.trim() !== "";
|
|
1747
1824
|
if (!selected) {
|
|
1825
|
+
const local = startPageReply(prompt, { images: chips.length });
|
|
1826
|
+
if (local) {
|
|
1827
|
+
promptEl.value = "";
|
|
1828
|
+
fitPrompt();
|
|
1829
|
+
showNotice(local);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1748
1832
|
actionBtn.disabled = true;
|
|
1749
1833
|
try {
|
|
1750
1834
|
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
|
|
@@ -1758,8 +1842,18 @@ async function handleSendTurn() {
|
|
|
1758
1842
|
}
|
|
1759
1843
|
}
|
|
1760
1844
|
// A session command by the session's list (the new one's too: select
|
|
1761
|
-
// read it); an unknown /word is a prompt, as in a terminal
|
|
1845
|
+
// read it); an unknown /word is a prompt, as in a terminal — but a typo
|
|
1846
|
+
// of a command (/modle) gets the hint, not a turn.
|
|
1762
1847
|
if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
|
|
1848
|
+
if (!chips.length) {
|
|
1849
|
+
const typo = unknownCommandHint(prompt, current.commands);
|
|
1850
|
+
if (typo) {
|
|
1851
|
+
addCommandBubble({ label: null, line: prompt, text: typo });
|
|
1852
|
+
promptEl.value = "";
|
|
1853
|
+
fitPrompt();
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1763
1857
|
const hint = historyEl.querySelector(".hint");
|
|
1764
1858
|
if (hint) hint.remove();
|
|
1765
1859
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -2190,17 +2284,20 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2190
2284
|
showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
2191
2285
|
}
|
|
2192
2286
|
|
|
2287
|
+
// sticky: stays until its action or its ✕ (else gone after 6 s).
|
|
2193
2288
|
let toastTimer = null;
|
|
2194
|
-
function showToast(text, action = null) {
|
|
2289
|
+
function showToast(text, action = null, { sticky = false } = {}) {
|
|
2195
2290
|
const el = $("#toast");
|
|
2196
|
-
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}
|
|
2291
|
+
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
|
|
2292
|
+
(sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
|
|
2197
2293
|
el.hidden = false;
|
|
2198
2294
|
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
2199
2295
|
el.hidden = true;
|
|
2200
2296
|
action.run();
|
|
2201
2297
|
});
|
|
2298
|
+
el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
|
|
2202
2299
|
clearTimeout(toastTimer);
|
|
2203
|
-
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2300
|
+
if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2204
2301
|
}
|
|
2205
2302
|
|
|
2206
2303
|
$("#includeArchived").addEventListener("change", () => {
|