samagotchi 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "../analytics_file"
|
|
|
13
13
|
require_relative "../client_id"
|
|
14
14
|
require_relative "../delivery"
|
|
15
15
|
require_relative "../answer_tail"
|
|
16
|
+
require_relative "../answer_refs"
|
|
16
17
|
require_relative "../bridge_client"
|
|
17
18
|
require_relative "../bridge/bounded_queue"
|
|
18
19
|
require_relative "../bridge/card_store"
|
|
@@ -1695,7 +1696,8 @@ module Samagotchi
|
|
|
1695
1696
|
source = if live
|
|
1696
1697
|
persisted.merge(live_metrics).merge(
|
|
1697
1698
|
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
1698
|
-
"turn_records" => merge_timing_records(persisted["turn_records"], live_metrics
|
|
1699
|
+
"turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
|
|
1700
|
+
live_metrics["turn_records"]),
|
|
1699
1701
|
"tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
|
|
1700
1702
|
)
|
|
1701
1703
|
else
|
|
@@ -1776,6 +1778,18 @@ module Samagotchi
|
|
|
1776
1778
|
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
1777
1779
|
end
|
|
1778
1780
|
|
|
1781
|
+
# The saved turn records without the one the live worker is running:
|
|
1782
|
+
# its turn checkpoint saves that turn's record so far as a killed
|
|
1783
|
+
# turn's (canceled, worker_stopped, SessionMetrics#persist's
|
|
1784
|
+
# running_turn), which is only true once the worker is gone.
|
|
1785
|
+
def without_running_turn(records, live_metrics)
|
|
1786
|
+
active = live_metrics["active_turn"]
|
|
1787
|
+
id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
|
|
1788
|
+
return records if id.nil?
|
|
1789
|
+
|
|
1790
|
+
Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1779
1793
|
def merge_timing_records(persisted, live)
|
|
1780
1794
|
(Array(persisted) + Array(live)).each_with_object({}) do |record, records|
|
|
1781
1795
|
next unless record.is_a?(Hash)
|
|
@@ -1907,6 +1921,10 @@ module Samagotchi
|
|
|
1907
1921
|
message[:display] = shown unless shown.strip.empty?
|
|
1908
1922
|
rendered = message[:display] || stripped
|
|
1909
1923
|
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
1924
|
+
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
1925
|
+
# a stored file is never trusted. Never raises.
|
|
1926
|
+
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
|
|
1927
|
+
message[:display_refs] = refs unless refs.empty?
|
|
1910
1928
|
end
|
|
1911
1929
|
message[:parts] = did if did
|
|
1912
1930
|
filtered << message
|
|
@@ -9,6 +9,7 @@ require_relative "../tools/builtins"
|
|
|
9
9
|
require_relative "../image_store"
|
|
10
10
|
require_relative "../llm_context_edit"
|
|
11
11
|
require_relative "../token_usage"
|
|
12
|
+
require_relative "../row_refs"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
14
15
|
module Web
|
|
@@ -37,7 +38,8 @@ module Samagotchi
|
|
|
37
38
|
# A plugin tool's params are what its live row showed (its preview),
|
|
38
39
|
# saved with the result as tool_params (native: one per call, chat: one
|
|
39
40
|
# per result); its label ("chrome: screenshot") likewise as tool_labels,
|
|
40
|
-
# the part's label
|
|
41
|
+
# the part's label:, and its row refs as tool_refs (RowRefs, checked
|
|
42
|
+
# again here), the part's refs:. The web server has no Engine and runs no plugins, so a
|
|
41
43
|
# result without them (older sessions) falls back to the given
|
|
42
44
|
# registry: the built-ins', and a plugin tool's arguments as key="value"
|
|
43
45
|
# from the parsers' args:, as the live row shows them for a tool
|
|
@@ -106,11 +108,12 @@ module Samagotchi
|
|
|
106
108
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
107
109
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
108
110
|
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
111
|
+
refs = saved_list(responses, :tool_refs, calls.length)
|
|
109
112
|
ids = saved_list(responses, :tool_ids, calls.length)
|
|
110
113
|
images = split_images(responses, calls.length)
|
|
111
114
|
calls.each_with_index.map do |call, i|
|
|
112
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i],
|
|
113
|
-
tool_id: ids[i], marks: marks)
|
|
115
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
|
|
116
|
+
cwd: cwd, tool_id: ids[i], marks: marks)
|
|
114
117
|
end
|
|
115
118
|
end
|
|
116
119
|
|
|
@@ -144,7 +147,8 @@ module Samagotchi
|
|
|
144
147
|
response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
|
|
145
148
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
146
149
|
response && field(response, :images), label: field(response, :tool_labels),
|
|
147
|
-
diff: field(response, :tool_diffs),
|
|
150
|
+
diff: field(response, :tool_diffs),
|
|
151
|
+
refs: field(response, :tool_refs), cwd: cwd,
|
|
148
152
|
tool_id: Array(field(response, :tool_ids)).first, marks: marks)
|
|
149
153
|
end
|
|
150
154
|
end
|
|
@@ -224,10 +228,12 @@ module Samagotchi
|
|
|
224
228
|
|
|
225
229
|
# +shown+ is the saved params line, when it is a String.
|
|
226
230
|
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
227
|
-
# tool_diffs. +
|
|
231
|
+
# tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
|
|
232
|
+
# (RowRefs.for_display checks them again against +cwd+). +cwd+: the
|
|
233
|
+
# session's working directory, for the title.
|
|
228
234
|
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
229
|
-
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil,
|
|
230
|
-
marks: {})
|
|
235
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
|
|
236
|
+
tool_id: nil, marks: {})
|
|
231
237
|
name = call[:name].to_s
|
|
232
238
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
233
239
|
part = { tool: name, params: params.to_s }
|
|
@@ -240,9 +246,11 @@ module Samagotchi
|
|
|
240
246
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|
|
241
247
|
part[:output_truncated] = true if output.length > OUTPUT_MAX
|
|
242
248
|
end
|
|
243
|
-
|
|
244
|
-
part[:images] =
|
|
249
|
+
shots = Array(images).select { |image| image.is_a?(Hash) }
|
|
250
|
+
part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
|
|
245
251
|
part[:diff] = diff if diff.is_a?(Hash)
|
|
252
|
+
row_refs = RowRefs.for_display(refs, cwd: cwd)
|
|
253
|
+
part[:refs] = row_refs unless row_refs.empty?
|
|
246
254
|
if tool_id
|
|
247
255
|
part[:tool_id] = tool_id.to_s
|
|
248
256
|
mark = mark_of(tool_id.to_s, marks, output)
|
|
@@ -45,8 +45,11 @@ export function newActivity() {
|
|
|
45
45
|
// ref: what the row traces to (the server's FileRef: {kind: "file",
|
|
46
46
|
// path, line?, end_line?}, the file a file tool touched), which
|
|
47
47
|
// refs.js makes the row's title and diff lines open.
|
|
48
|
+
// refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
|
|
49
|
+
// label?, actions}]): the first on the row's title, the others in its
|
|
50
|
+
// menu (refs.js bindRowRefs).
|
|
48
51
|
export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
|
|
49
|
-
export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
|
|
52
|
+
export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
|
|
50
53
|
const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
|
|
51
54
|
|
|
52
55
|
// The +fields+ of +source+ that are set, as an object (for a spread).
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The refs a plugin attached to an answer (the message's display_refs,
|
|
2
|
+
// traceable refs P5b): each {match, kind, target, label?, actions} is
|
|
3
|
+
// paired with the answer's bubble, then bound to what it stands for
|
|
4
|
+
// (refs.js bindRef), so its actions join the ref's menu.
|
|
5
|
+
//
|
|
6
|
+
// A bubble knows its message's refs through setAnswerRefs (a reload's
|
|
7
|
+
// render, a turn-end read's re-render); pairAnswerRefs pairs them once per
|
|
8
|
+
// setAnswerRefs. Three passes over all the refs, strongest evidence
|
|
9
|
+
// first, so a weak text match of one ref never takes another's link:
|
|
10
|
+
// 1. a link whose href is one of the ref's action URLs;
|
|
11
|
+
// 2. a link whose text is the match;
|
|
12
|
+
// 3. the match as a whole token inside one plain text node (not in pre,
|
|
13
|
+
// a link, inline code or another ref), or an inline code that is the
|
|
14
|
+
// match alone.
|
|
15
|
+
// A ref binds every occurrence its first finding pass finds; a ref no
|
|
16
|
+
// pass finds is dropped. A label that differs from the element's text is
|
|
17
|
+
// its hover title (unless the element has one: a markdown link's).
|
|
18
|
+
// The match and label are a plugin's text: they only ever go through
|
|
19
|
+
// textContent, setAttribute and bindRef, never markup or a RegExp.
|
|
20
|
+
|
|
21
|
+
import { COPY_SOURCE_ATTR, copySource, showsAnswer } from "./copy.js";
|
|
22
|
+
import { bindRef, boundRef, httpUrl, refKindOk } from "./refs.js";
|
|
23
|
+
|
|
24
|
+
// bubble → {refs, paired}. A WeakMap: a dropped bubble takes its refs with
|
|
25
|
+
// it; the stage hand-off moves the bubble, so they move along.
|
|
26
|
+
const ANSWER_REFS = new WeakMap();
|
|
27
|
+
|
|
28
|
+
// Text never paired: code blocks, links (passes 1 and 2 are theirs), a ref
|
|
29
|
+
// already made, inline code (only as the match alone).
|
|
30
|
+
const SKIP = "pre, a, [data-ref-kind]";
|
|
31
|
+
|
|
32
|
+
// A token boundary, as the `path:line` scanner's (ref_scan.js REF): not
|
|
33
|
+
// after a path-ish character, not before a word character or a slash.
|
|
34
|
+
const BEFORE = /[\w./@+~-]/;
|
|
35
|
+
const AFTER = /[\w/]/;
|
|
36
|
+
|
|
37
|
+
// +bubble+'s refs are +refs+ (a message's display_refs: entries with a
|
|
38
|
+
// match and a kind bindRef binds); a non-array or one without a usable
|
|
39
|
+
// entry clears them. They pair on the next
|
|
40
|
+
// pairAnswerRefs, even when the same ones were paired before.
|
|
41
|
+
export function setAnswerRefs(bubble, refs) {
|
|
42
|
+
if (!bubble) return;
|
|
43
|
+
const list = (Array.isArray(refs) ? refs : []).filter(
|
|
44
|
+
(ref) => ref && typeof ref === "object" && refKindOk(ref.kind) && typeof ref.match === "string" && ref.match.trim(),
|
|
45
|
+
);
|
|
46
|
+
if (list.length) ANSWER_REFS.set(bubble, { refs: list, paired: false });
|
|
47
|
+
else ANSWER_REFS.delete(bubble);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// +bubble+'s refs (setAnswerRefs'), or null.
|
|
51
|
+
export function answerRefsOf(bubble) {
|
|
52
|
+
return ANSWER_REFS.get(bubble)?.refs || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The turn's final answer on its bubble (a turn-end read's +messages+):
|
|
56
|
+
// the last assistant message with text, its bubble the last of +bubbles+
|
|
57
|
+
// that shows it (copy.js showsAnswer, with +normalize+). Its rendered html
|
|
58
|
+
// replaces the bubble's when it has one (not with web.markdown off or no
|
|
59
|
+
// renderer: the text stays); its refs are the bubble's either way (a
|
|
60
|
+
// later read without any clears them). The bubble, or null.
|
|
61
|
+
export function applyFinalAnswer(messages, bubbles, normalize) {
|
|
62
|
+
const message = [...(Array.isArray(messages) ? messages : [])].reverse().find(
|
|
63
|
+
(item) => item?.role === "assistant" && (typeof item.html === "string" || String(item.content ?? "").trim()),
|
|
64
|
+
);
|
|
65
|
+
const bubble = message && [...bubbles].reverse().find((item) => showsAnswer(item, message, normalize));
|
|
66
|
+
if (!bubble) return null;
|
|
67
|
+
if (typeof message.html === "string") {
|
|
68
|
+
bubble.classList.add("markdown");
|
|
69
|
+
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
70
|
+
bubble.innerHTML = message.html;
|
|
71
|
+
}
|
|
72
|
+
setAnswerRefs(bubble, message.display_refs);
|
|
73
|
+
return bubble;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Pairs +bubble+'s refs with its links and text and binds them (once per
|
|
77
|
+
// setAnswerRefs; again a no-op).
|
|
78
|
+
export function pairAnswerRefs(bubble) {
|
|
79
|
+
const entry = bubble && ANSWER_REFS.get(bubble);
|
|
80
|
+
if (!entry || entry.paired) return;
|
|
81
|
+
entry.paired = true;
|
|
82
|
+
let left = entry.refs;
|
|
83
|
+
left = left.filter((ref) => !pairByHref(bubble, ref));
|
|
84
|
+
left = left.filter((ref) => !pairByLinkText(bubble, ref));
|
|
85
|
+
pairByText(bubble, left);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The links a ref may take: not inside a code block or another ref, not
|
|
89
|
+
// bound by a plugin's ref already (a link the regex scanner adopted has
|
|
90
|
+
// attributes only: it may be bound).
|
|
91
|
+
function freeLinks(bubble) {
|
|
92
|
+
return [...bubble.querySelectorAll("a[href]")].filter((a) => !boundRef(a) && !a.parentElement?.closest("pre, [data-ref-kind]"));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Pass 1: true when +ref+ took a link to one of its action URLs.
|
|
96
|
+
function pairByHref(bubble, ref) {
|
|
97
|
+
const urls = new Set((Array.isArray(ref.actions) ? ref.actions : []).map((action) => httpUrl(action?.url)).filter(Boolean));
|
|
98
|
+
if (!urls.size) return false;
|
|
99
|
+
const links = freeLinks(bubble).filter((a) => urls.has(httpUrl(a.getAttribute("href"))));
|
|
100
|
+
return bindAll(links, ref);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pass 2: true when +ref+ took a link whose text is its match. The link
|
|
104
|
+
// goes somewhere the ref doesn't name (the model's URL): the ref's actions
|
|
105
|
+
// join its menu, but its label isn't the link's title.
|
|
106
|
+
function pairByLinkText(bubble, ref) {
|
|
107
|
+
const match = ref.match.trim();
|
|
108
|
+
return bindAll(freeLinks(bubble).filter((a) => a.textContent.trim() === match), ref, { label: false });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Pass 3, for all the +refs+ left at once: a ref the path:line scan made
|
|
112
|
+
// (attributes only, no plugin data) whose text is a match is bound in
|
|
113
|
+
// place (the scan ran first on a bubble that wasn't re-rendered: an answer
|
|
114
|
+
// without html); an inline code that is a match alone is wrapped whole;
|
|
115
|
+
// then each text node's hits (refs in list order, a hit overlapping an
|
|
116
|
+
// earlier one dropped) are wrapped in one go.
|
|
117
|
+
function pairByText(bubble, refs) {
|
|
118
|
+
if (!refs.length) return;
|
|
119
|
+
const doc = bubble.ownerDocument;
|
|
120
|
+
const matching = (el) => {
|
|
121
|
+
const text = el.textContent.trim();
|
|
122
|
+
return refs.find((candidate) => candidate.match.trim() === text);
|
|
123
|
+
};
|
|
124
|
+
for (const scanned of [...bubble.querySelectorAll("[data-ref-kind]:not(a)")]) {
|
|
125
|
+
if (boundRef(scanned) || scanned.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
|
|
126
|
+
const ref = matching(scanned);
|
|
127
|
+
if (ref) bindAll([scanned], ref);
|
|
128
|
+
}
|
|
129
|
+
for (const code of [...bubble.querySelectorAll("code")]) {
|
|
130
|
+
if (code.closest(SKIP)) continue;
|
|
131
|
+
const ref = matching(code);
|
|
132
|
+
if (!ref) continue;
|
|
133
|
+
const span = refSpan(doc, ref);
|
|
134
|
+
if (!span) continue;
|
|
135
|
+
code.replaceWith(span);
|
|
136
|
+
span.append(code);
|
|
137
|
+
labelOn(span, ref);
|
|
138
|
+
}
|
|
139
|
+
const walker = doc.createTreeWalker(bubble, 4 /* NodeFilter.SHOW_TEXT */);
|
|
140
|
+
const nodes = [];
|
|
141
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
142
|
+
const skip = node.parentElement?.closest(`${SKIP}, code`);
|
|
143
|
+
if (!skip || !bubble.contains(skip)) nodes.push(node);
|
|
144
|
+
}
|
|
145
|
+
const hits = new Map();
|
|
146
|
+
for (const ref of refs) {
|
|
147
|
+
const match = ref.match.trim();
|
|
148
|
+
for (const node of nodes) {
|
|
149
|
+
for (const [start, end] of tokenHits(node.data, match)) {
|
|
150
|
+
const list = hits.get(node) || [];
|
|
151
|
+
if (list.some(([s, e]) => start < e && end > s)) continue;
|
|
152
|
+
list.push([start, end, ref]);
|
|
153
|
+
hits.set(node, list);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [node, list] of hits) wrap(node, list.sort((a, b) => a[0] - b[0]));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// [start, end] of each whole-token +match+ in +text+ (indexOf: the match
|
|
161
|
+
// is a plugin's text, never a pattern).
|
|
162
|
+
function tokenHits(text, match) {
|
|
163
|
+
const found = [];
|
|
164
|
+
for (let at = text.indexOf(match); at >= 0; at = text.indexOf(match, at + 1)) {
|
|
165
|
+
const end = at + match.length;
|
|
166
|
+
if (at > 0 && BEFORE.test(text[at - 1])) continue;
|
|
167
|
+
if (end < text.length && AFTER.test(text[end])) continue;
|
|
168
|
+
found.push([at, end]);
|
|
169
|
+
at = end - 1;
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function wrap(node, hits) {
|
|
175
|
+
const doc = node.ownerDocument;
|
|
176
|
+
const frag = doc.createDocumentFragment();
|
|
177
|
+
let pos = 0;
|
|
178
|
+
for (const [start, end, ref] of hits) {
|
|
179
|
+
const span = refSpan(doc, ref);
|
|
180
|
+
if (!span) continue;
|
|
181
|
+
if (start > pos) frag.append(node.data.slice(pos, start));
|
|
182
|
+
span.textContent = node.data.slice(start, end);
|
|
183
|
+
labelOn(span, ref);
|
|
184
|
+
frag.append(span);
|
|
185
|
+
pos = end;
|
|
186
|
+
}
|
|
187
|
+
if (!pos) return;
|
|
188
|
+
if (pos < node.data.length) frag.append(node.data.slice(pos));
|
|
189
|
+
node.replaceWith(frag);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A span bound to +ref+, or null when bindRef refuses it (a bad kind).
|
|
193
|
+
function refSpan(doc, ref) {
|
|
194
|
+
const span = doc.createElement("span");
|
|
195
|
+
bindRef(span, ref);
|
|
196
|
+
return boundRef(span) ? span : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Binds each of +els+ to +ref+; true when one took it.
|
|
200
|
+
function bindAll(els, ref, { label = true } = {}) {
|
|
201
|
+
let bound = false;
|
|
202
|
+
for (const el of els) {
|
|
203
|
+
bindRef(el, ref);
|
|
204
|
+
if (!boundRef(el)) continue;
|
|
205
|
+
if (label) labelOn(el, ref);
|
|
206
|
+
bound = true;
|
|
207
|
+
}
|
|
208
|
+
return bound;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The ref's label as the element's hover title, when it says more than
|
|
212
|
+
// the element's text and the element has no title of its own.
|
|
213
|
+
function labelOn(el, ref) {
|
|
214
|
+
const label = typeof ref.label === "string" ? ref.label.trim() : "";
|
|
215
|
+
if (!label || label === el.textContent.trim() || el.hasAttribute("title")) return;
|
|
216
|
+
el.setAttribute("title", label);
|
|
217
|
+
}
|
|
@@ -38,15 +38,15 @@ import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
|
38
38
|
import { createModelPicker } from "./model_picker.js";
|
|
39
39
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
40
40
|
import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
41
|
-
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
41
|
+
import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
|
|
42
42
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
43
|
-
import { createStageView } from "./stage_view.js";
|
|
43
|
+
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
44
44
|
import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
|
|
45
45
|
import { createTaskStopper } from "./task_stop.js";
|
|
46
46
|
import { appendQuote } from "./annotations.js";
|
|
47
47
|
import { parsePresets } from "./annotate_presets.js";
|
|
48
48
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
|
-
import {
|
|
49
|
+
import { copyText, installCopy } from "./copy.js";
|
|
50
50
|
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
51
51
|
import { createQuestionCards } from "./question_cards.js";
|
|
52
52
|
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
@@ -55,7 +55,7 @@ import { createCommandList } from "./command_list.js";
|
|
|
55
55
|
import { createPromptHistory } from "./prompt_history.js";
|
|
56
56
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
57
57
|
import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
|
|
58
|
-
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
58
|
+
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
59
59
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
60
60
|
import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
61
61
|
import { createNotifications } from "./notifications.js";
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
66
66
|
} from "./timing.js";
|
|
67
67
|
import { createLiveTurn } from "./live_turn.js";
|
|
68
|
+
import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
|
|
68
69
|
import { installRefClicks } from "./refs.js";
|
|
69
70
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
70
71
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
@@ -263,6 +264,7 @@ const questionCards = createQuestionCards({
|
|
|
263
264
|
isNearBottom: () => isNearBottom(),
|
|
264
265
|
scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
265
266
|
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
267
|
+
askingText: () => turnDom.askingText(),
|
|
266
268
|
});
|
|
267
269
|
// The event id (or, from an older worker, the event_seq) the selected session
|
|
268
270
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
@@ -904,30 +906,26 @@ async function readTurnEnd() {
|
|
|
904
906
|
} catch(_){}
|
|
905
907
|
}
|
|
906
908
|
|
|
907
|
-
// The turn's answer as its rendered markdown
|
|
908
|
-
// turn-end read, an answer_display re-read
|
|
909
|
+
// The turn's answer as its rendered markdown and with its plugin refs,
|
|
910
|
+
// once it is final (the turn-end read, an answer_display re-read:
|
|
911
|
+
// answer_refs.js applyFinalAnswer); then the refs.
|
|
909
912
|
function applyFinalMarkdown(messages) {
|
|
910
|
-
|
|
911
|
-
(item) => item?.role === "assistant" && typeof item.html === "string",
|
|
912
|
-
);
|
|
913
|
-
const bubble = message && findInTurnRoots(".bubble.output")
|
|
914
|
-
.reverse()
|
|
915
|
-
.find((item) => showsAnswer(item, message, normalize));
|
|
916
|
-
if (bubble) {
|
|
917
|
-
bubble.classList.add("markdown");
|
|
918
|
-
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
919
|
-
bubble.innerHTML = message.html;
|
|
920
|
-
}
|
|
913
|
+
applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
|
|
921
914
|
linkTextRefs();
|
|
922
915
|
}
|
|
923
916
|
|
|
924
|
-
// The
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
917
|
+
// The refs on the answers' and closed steps' texts, on this page: first
|
|
918
|
+
// each answer's own (a plugin's, answer_refs.js), then the `path:line`
|
|
919
|
+
// refs that name a file the session's rows (and cards) touched
|
|
920
|
+
// (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
|
|
921
|
+
// turn's end and after a reload's render. A ref already made stays as it
|
|
922
|
+
// is.
|
|
928
923
|
function linkTextRefs() {
|
|
929
924
|
const roots = turnRoots();
|
|
930
925
|
const touched = touchedFiles(roots);
|
|
926
|
+
for (const root of roots) {
|
|
927
|
+
for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
|
|
928
|
+
}
|
|
931
929
|
if (!touched.size) return;
|
|
932
930
|
for (const root of roots) {
|
|
933
931
|
for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
|
|
@@ -1104,6 +1102,7 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1104
1102
|
details.appendChild(body);
|
|
1105
1103
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1106
1104
|
// Never in the stage: a recap is between turns.
|
|
1105
|
+
turnDom.settleFold?.();
|
|
1107
1106
|
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1108
1107
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
1109
1108
|
}
|
|
@@ -1302,12 +1301,15 @@ function turnAnswerEl(turnEls) {
|
|
|
1302
1301
|
function placeResolvedQuestion(entry, before) {
|
|
1303
1302
|
const pq = entry.pending_question;
|
|
1304
1303
|
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1305
|
-
|
|
1304
|
+
const turnEls = reloadedTurnEls(before);
|
|
1305
|
+
const asked = Number(entry.iteration) || 1;
|
|
1306
|
+
// The reply that asked: its step's text in the reloaded block (the card
|
|
1307
|
+
// keeps it, as live), never the running turn's.
|
|
1308
|
+
const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
|
|
1309
|
+
questionCards.renderQuestion(pq, { said });
|
|
1306
1310
|
const card = questionCards.questionCard();
|
|
1307
1311
|
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1308
1312
|
questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1309
|
-
const turnEls = reloadedTurnEls(before);
|
|
1310
|
-
const asked = Number(entry.iteration) || 1;
|
|
1311
1313
|
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
1312
1314
|
historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
|
|
1313
1315
|
}
|
|
@@ -1512,6 +1514,7 @@ function currentSessionDuration() {
|
|
|
1512
1514
|
|
|
1513
1515
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1514
1516
|
function appendToHistory(el) {
|
|
1517
|
+
// (The stage's place first moves a turn still sliding away into the history.)
|
|
1515
1518
|
if (turnDom.place?.(el)) return;
|
|
1516
1519
|
appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
|
|
1517
1520
|
}
|
|
@@ -2042,15 +2045,51 @@ fitPrompt();
|
|
|
2042
2045
|
// The card floats over the history: keep the history's bottom padding at the
|
|
2043
2046
|
// card's height, and stay pinned to the end if the reader was there, since the
|
|
2044
2047
|
// card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
|
|
2048
|
+
// In the stage view the stage floats above the card, out of the dock's flow
|
|
2049
|
+
// (stage_view.js): the reserve counts it too, with its 10 px gap, while it
|
|
2050
|
+
// shows.
|
|
2045
2051
|
const dockEl = $("#dock");
|
|
2046
2052
|
const centerEl = $("#center");
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2053
|
+
// A leaving stage (the hand-off's slide) no longer counts: the reserve eases
|
|
2054
|
+
// down over the slide (index.html, #center.reserve-easing).
|
|
2055
|
+
const stageEl = view === "stage" ? turnDom.root : null;
|
|
2056
|
+
const STAGE_GAP = 10;
|
|
2057
|
+
function dockReserve() {
|
|
2058
|
+
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
2059
|
+
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
2060
|
+
}
|
|
2061
|
+
const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
|
|
2062
|
+
// A reader at the end stays there when the reserve changes: at once for the
|
|
2063
|
+
// composer card, eased for the stage (+fromStage+: it appeared, grew or
|
|
2064
|
+
// leaves), so the history glides rather than jumps. An eased follow running
|
|
2065
|
+
// counts as at the end.
|
|
2066
|
+
function updateReserve({ fromStage = false } = {}) {
|
|
2067
|
+
const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
|
|
2068
|
+
const leaving = !!stageEl?.classList.contains("leaving");
|
|
2069
|
+
centerEl.classList.toggle("reserve-easing", leaving);
|
|
2070
|
+
const reserve = `${dockReserve()}px`;
|
|
2071
|
+
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
2072
|
+
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
2073
|
+
if (changed && follow) {
|
|
2074
|
+
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
|
|
2075
|
+
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2076
|
+
}
|
|
2052
2077
|
placeToast();
|
|
2053
|
-
}
|
|
2078
|
+
}
|
|
2079
|
+
const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
|
|
2080
|
+
dockObserver.observe(dockEl);
|
|
2081
|
+
if (stageEl) {
|
|
2082
|
+
dockObserver.observe(stageEl);
|
|
2083
|
+
// Only showing, hiding and leaving change the reserve (not the stage's
|
|
2084
|
+
// other classes: waiting, in-use…).
|
|
2085
|
+
let stageShown = "";
|
|
2086
|
+
new MutationObserver(() => {
|
|
2087
|
+
const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
|
|
2088
|
+
if (shown === stageShown) return;
|
|
2089
|
+
stageShown = shown;
|
|
2090
|
+
updateReserve({ fromStage: true });
|
|
2091
|
+
}).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
|
|
2092
|
+
}
|
|
2054
2093
|
window.addEventListener("resize", () => placeToast());
|
|
2055
2094
|
|
|
2056
2095
|
promptEl.addEventListener("paste", (e) => {
|
|
@@ -2263,14 +2302,16 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2263
2302
|
|
|
2264
2303
|
// The toast sits above the session's composer card (measured: it grows with
|
|
2265
2304
|
// a wrapping footer or a taller box), so it never covers the card's
|
|
2266
|
-
// controls; with no card in sight (the
|
|
2267
|
-
// 24 px from the bottom.
|
|
2305
|
+
// controls, and above the stage while it shows; with no card in sight (the
|
|
2306
|
+
// start page, all sessions) the CSS's 24 px from the bottom.
|
|
2268
2307
|
function placeToast() {
|
|
2269
2308
|
const gap = 12;
|
|
2270
2309
|
const dock = document.getElementById("dock");
|
|
2271
2310
|
const r = dock.getBoundingClientRect();
|
|
2272
2311
|
const over = document.body.classList.contains("has-session") && r.height > 0;
|
|
2273
|
-
const
|
|
2312
|
+
const stage = document.getElementById("turnStage");
|
|
2313
|
+
const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
|
|
2314
|
+
const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
|
|
2274
2315
|
const root = document.documentElement.style;
|
|
2275
2316
|
if (root.getPropertyValue("--toast-bottom") !== bottom) {
|
|
2276
2317
|
if (bottom) root.setProperty("--toast-bottom", bottom);
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// through `continues`, forward through the session whose `continues` names
|
|
5
5
|
// this one. The lists fold a chain into its latest listed link's card (a
|
|
6
6
|
// `day N` chip, its earlier links in a popover); the session page links the
|
|
7
|
-
// previous and the next link. Pure:
|
|
7
|
+
// previous and the next link. Pure: lists_view.js places the chip and rows
|
|
8
|
+
// (lit-html templates) and app.js the toast's markup, and they bind them.
|
|
8
9
|
import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
|
|
9
10
|
import { stoppedBadge, waitingBadge } from "./sessions_list.js";
|
|
10
11
|
import { waitingOn } from "./notify.js";
|
|
12
|
+
import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_view.js";
|
|
13
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
11
14
|
|
|
12
15
|
// The opener a new link's composer is filled with (never sent: the user
|
|
13
16
|
// edits or sends it). The same for every chain.
|
|
@@ -148,10 +151,11 @@ export function chainOthers(f) {
|
|
|
148
151
|
// The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
|
|
149
152
|
// later links aren't listed), with a live dot / "waiting" when one of the
|
|
150
153
|
// other links has a worker / waits on the user (typing in an archived link
|
|
151
|
-
// brings it back, but it stays folded here).
|
|
154
|
+
// brings it back, but it stays folded here). nothing for a family in no
|
|
155
|
+
// chain. A lit-html template, as families_view.js's chip.
|
|
152
156
|
// @param open whether the list is open (aria-expanded)
|
|
153
|
-
export function
|
|
154
|
-
if (!f?.chain) return
|
|
157
|
+
export function chainChipTemplate(f, { open = false } = {}) {
|
|
158
|
+
if (!f?.chain) return nothing;
|
|
155
159
|
const { links, at } = f.chain;
|
|
156
160
|
const others = chainOthers(f);
|
|
157
161
|
const live = others.filter(({ session: s }) => s.owner === "worker").length;
|
|
@@ -162,9 +166,10 @@ export function chainChipHtml(f, { open = false } = {}) {
|
|
|
162
166
|
const later = links.length - 1 - at;
|
|
163
167
|
const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
|
|
164
168
|
const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
return `<button type="button" class
|
|
169
|
+
const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
|
|
170
|
+
const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
|
|
171
|
+
return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
|
|
172
|
+
title=${title}><span class="cc-arrow" aria-hidden="true">↩</span>${text}${liveT}${waitingT}</button>`;
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
// The chain's list (the chip's popover): one row per other link, newest
|
|
@@ -172,7 +177,7 @@ export function chainChipHtml(f, { open = false } = {}) {
|
|
|
172
177
|
// its preview), the waiting / stopped badge, archived. A row's click opens
|
|
173
178
|
// that link (families_view.js's popover binds .family-row[data-id]).
|
|
174
179
|
// @param activeId the open session's id (its row is .active)
|
|
175
|
-
export function
|
|
180
|
+
export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
|
|
176
181
|
const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
|
|
177
182
|
const rows = chainOthers(f).map(({ session: s, day }) => {
|
|
178
183
|
const cls = ["family-row", "chain-row"];
|
|
@@ -180,22 +185,15 @@ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
|
|
|
180
185
|
if (s.archived) cls.push("archived");
|
|
181
186
|
const waiting = waitingBadge(s, { inFamily: true });
|
|
182
187
|
const stopped = waiting ? null : stoppedBadge(s);
|
|
183
|
-
const dotWord = waiting ? waiting.title : s.status;
|
|
184
188
|
const date = chainDate(s.created_at);
|
|
185
189
|
const ago = relativeTime(s.updated_at || s.created_at || "", now);
|
|
186
|
-
const parts = [
|
|
187
|
-
`<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
|
|
188
|
-
`<span class="cr-day">day ${day}</span>`,
|
|
189
|
-
date ? `<span class="cr-date">${escapeHtml(date)}</span>` : "",
|
|
190
|
-
`<span class="fr-preview">${escapeHtml(s.recap || chainTitle(s, byId))}</span>`,
|
|
191
|
-
waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
|
|
192
|
-
stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
|
|
193
|
-
s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
|
|
194
|
-
].join("");
|
|
195
190
|
const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
|
|
196
|
-
return `<div class
|
|
191
|
+
return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
|
|
192
|
+
title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">day ${day}</span>${
|
|
193
|
+
date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
|
|
194
|
+
rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
197
195
|
});
|
|
198
|
-
return `<div class="family-rows chain-rows" role="list">${rows
|
|
196
|
+
return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
|
|
199
197
|
}
|
|
200
198
|
|
|
201
199
|
// What the page does with a refused continue (POST /api/sessions
|