samagotchi 0.100.0 → 0.100.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 +17 -1
- data/docs/architecture.md +4 -1
- data/docs/hooks.md +2 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
- data/lib/samagotchi/hooks/loader.rb +17 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +44 -8
- data/lib/samagotchi/web/public/chains.js +24 -0
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/markdown_place.js +47 -1
- data/lib/samagotchi/web/public/send_turn.js +6 -2
- data/lib/samagotchi/web/public/session_actions.js +10 -5
- data/lib/samagotchi/web/public/turn_view.js +21 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47dbac4f107af7d9c7d9d571b907a1e9e777326732614ed34986f2dce954168a
|
|
4
|
+
data.tar.gz: 4335f033596dc75f3db80b754a347e36862d0fd07f1d72bafcc7a5c50ed539ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2454b93c9271b14ba0349c2991f6b0312dfdee76fd6a90005feb38726025dad71b6b4f1701bcbb2622c3db786d86fe2e58636668aa2f7d43d0ac289cae36e5a
|
|
7
|
+
data.tar.gz: 8fef8d270b58719f286b57c840f1a5fc3a53de10fec01fdcc08ea3de1315de17d267a834759b05ae09c05ca80b8f27009329eb38ff0aaf9a7b6ab59585b3e1d3
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.100.1] - 2026-10-22
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The web's `continue →` no longer takes a draft left over from another session (or the start page) as the new
|
|
16
|
+
link's first message: the opener goes in, and the toast's **Restore draft** brings the draft back. A draft written
|
|
17
|
+
in the continued session still stays.
|
|
18
|
+
- work-cap 0.1.1: a changed `bundles: work-cap:` setting reaches a running session (the caps are resolved again when the
|
|
19
|
+
settings change; the state file still wins per meter), and an empty key (`tool_calls:` with no value) falls through to
|
|
20
|
+
`child:` / `default:` instead of reading as "off"; `off`, `false` and `0` stay an explicit no cap.
|
|
21
|
+
- Web: a closed step's text in the history renders as markdown (with `web.markdown` on) and keeps its source refs,
|
|
22
|
+
live and after a reload, as the answer does.
|
|
23
|
+
- A hook entry in config.yml's `hooks:` that isn't a mapping, or has no `path:`, warns naming it
|
|
24
|
+
(`hooks.before_turn[0]`) instead of vanishing; the event's other entries still load.
|
|
25
|
+
|
|
11
26
|
## [0.100.0] - 2026-10-22
|
|
12
27
|
|
|
13
28
|
### Added
|
|
@@ -3792,7 +3807,8 @@ and long-lived sessions.
|
|
|
3792
3807
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3793
3808
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3794
3809
|
|
|
3795
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.
|
|
3810
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.100.1...HEAD
|
|
3811
|
+
[0.100.1]: https://github.com/dm1try/samagotchi/compare/v0.100.0...v0.100.1
|
|
3796
3812
|
[0.100.0]: https://github.com/dm1try/samagotchi/compare/v0.99.0...v0.100.0
|
|
3797
3813
|
[0.99.0]: https://github.com/dm1try/samagotchi/compare/v0.98.0...v0.99.0
|
|
3798
3814
|
[0.98.0]: https://github.com/dm1try/samagotchi/compare/v0.97.0...v0.98.0
|
data/docs/architecture.md
CHANGED
|
@@ -431,7 +431,10 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
|
|
|
431
431
|
`drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
|
|
432
432
|
question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
|
|
433
433
|
(`turn_view.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
|
|
434
|
-
the turn's end and after a reload. A
|
|
434
|
+
the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
|
|
435
|
+
from its message's `html` (`turn_view.js` `textTemplate`), a live turn's from `POST /api/render` on its step's
|
|
436
|
+
first open after the turn's end (`markdown_place.js` `renderStepTexts`, which pairs its refs again after the
|
|
437
|
+
swap); the text as written stays on it as `data-copy-source` (`stepText`, a reloaded card's kept reply). A file ref outside the
|
|
435
438
|
session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
|
|
436
439
|
session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
|
|
437
440
|
file ref is dropped rather than shown unchecked.
|
data/docs/hooks.md
CHANGED
|
@@ -28,6 +28,8 @@ hooks:
|
|
|
28
28
|
|
|
29
29
|
Each event key holds a list of entries. A `hooks:` that isn't a mapping, or an event key that isn't a list
|
|
30
30
|
(`before_turn: guard.rb`), warns at each start and loads nothing for it; an empty key loads nothing, silently.
|
|
31
|
+
An entry that isn't a mapping (`- guard.rb`) or has no `path:` warns the same way, naming it
|
|
32
|
+
(`hooks.before_turn[0]`, counted from 0), and is skipped alone: the key's other entries load.
|
|
31
33
|
|
|
32
34
|
## Plugin Format
|
|
33
35
|
|
data/docs/sessions.md
CHANGED
|
@@ -237,7 +237,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
237
237
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
238
238
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
239
239
|
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
240
|
-
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft
|
|
240
|
+
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
241
241
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
242
242
|
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
|
|
243
243
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: work-cap
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "A per-session work cap (tool calls, active minutes) across turns: at a cap the turn pauses on Continue (another cap's worth) / Stop with the session's totals, a warning at 80% goes to the user only; /workcap shows or sets it"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:f4ce7ea7f5801490a29e232c41c7bca2007c140d21f1a78bbfb60e6b6fd4f64b
|
|
10
10
|
# ask_user's effects: are in chi 0.92.0; ctx.usage is new in the next chi
|
|
11
11
|
requires_chi: ">= 0.100.0"
|
|
12
12
|
# chi --set work-cap.tool_calls=400 / work-cap.minutes=60: the session's own caps, a number or off
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
# prompt text. cost-cap (the $ cap) is its sibling; the wordings follow it.
|
|
13
13
|
#
|
|
14
14
|
# Settings (config.yml, bundles: work-cap:), from ctx.settings, never at
|
|
15
|
-
# load: the caps are resolved once per session (see State
|
|
16
|
-
#
|
|
17
|
-
# session
|
|
15
|
+
# load: the caps are resolved once per session and settings (see State
|
|
16
|
+
# below), again when ctx.settings changes, so a config change reaches a
|
|
17
|
+
# running session (the state file still wins per meter); warn_at and
|
|
18
|
+
# ignore_tools are read at each check.
|
|
18
19
|
# tool_calls: 400 this session's own tool-call cap (session_settings:,
|
|
19
20
|
# chi --set work-cap.tool_calls=400); 0/off: none
|
|
20
21
|
# minutes: 60 this session's own cap on active minutes, same rules
|
|
@@ -26,8 +27,10 @@
|
|
|
26
27
|
# tools whose calls and time aren't counted (waiting:
|
|
27
28
|
# delegate blocks on its child by default)
|
|
28
29
|
# Each meter resolves on its own: its own value, else child:'s for a
|
|
29
|
-
# delegate, else default:'s.
|
|
30
|
-
#
|
|
30
|
+
# delegate, else default:'s. An empty key (YAML nil) is unset and falls
|
|
31
|
+
# through; off, false and 0 are an explicit "no cap". A value that doesn't
|
|
32
|
+
# read (tool_calls: many) warns the user once and the next one is used,
|
|
33
|
+
# never a silent "no cap".
|
|
31
34
|
#
|
|
32
35
|
# What counts: ctx.usage's tool calls and active time (generations plus
|
|
33
36
|
# finished tool calls, never a question's wait), less ignore_tools'.
|
|
@@ -36,8 +39,8 @@
|
|
|
36
39
|
# "step": 400}, "minutes": {"cap": null}}, written by /workcap and by
|
|
37
40
|
# Continue; a meter's "cap": null is off for the session, over the config;
|
|
38
41
|
# a meter missing from the file comes from the settings. Resolved once per
|
|
39
|
-
# session id (ctx.delegate? parses the session file) and kept
|
|
40
|
-
# /workcap or Continue writes a new
|
|
42
|
+
# session id and settings (ctx.delegate? parses the session file) and kept
|
|
43
|
+
# until /workcap or Continue writes a new file, or the settings change.
|
|
41
44
|
require "json"
|
|
42
45
|
require "fileutils"
|
|
43
46
|
|
|
@@ -57,7 +60,7 @@ class Plugin
|
|
|
57
60
|
|
|
58
61
|
def initialize(_settings)
|
|
59
62
|
@mutex = Mutex.new
|
|
60
|
-
@resolved = nil # {session:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
|
|
63
|
+
@resolved = nil # {session:, settings:, meters: {meter => {cap:, step:, key:}}, unreadable: {name => value}}
|
|
61
64
|
@warned = {} # meter => [session id, cap] the share warning was given for
|
|
62
65
|
@unread_warned = nil # the session id the "isn't a number" notice was given for
|
|
63
66
|
end
|
|
@@ -101,7 +104,8 @@ class Plugin
|
|
|
101
104
|
def self.float_text(text) = text.sub(/\.(?=e|\z)/i, ".0")
|
|
102
105
|
|
|
103
106
|
# Whether +value+ reads as a cap or as none: a number (text too), off,
|
|
104
|
-
# false or nil
|
|
107
|
+
# false or nil (nil is unset, not none: #first_readable skips it); not
|
|
108
|
+
# "many", true or a list.
|
|
105
109
|
def self.readable?(value, meter)
|
|
106
110
|
case value
|
|
107
111
|
when false, nil then true
|
|
@@ -269,16 +273,17 @@ class Plugin
|
|
|
269
273
|
|
|
270
274
|
# The session's caps now: per meter the state file's, else the session's
|
|
271
275
|
# own value (--set), else child:'s for a delegate, else default:'s.
|
|
272
|
-
# Cached per session id.
|
|
276
|
+
# Cached per session id and settings (a config change resolves again).
|
|
273
277
|
def resolve(ctx)
|
|
274
278
|
session = ctx.session_id
|
|
279
|
+
settings = ctx.settings
|
|
275
280
|
cached = @mutex.synchronize { @resolved }
|
|
276
|
-
return cached if cached && cached[:session] == session
|
|
281
|
+
return cached if cached && cached[:session] == session && cached[:settings] == settings
|
|
277
282
|
|
|
278
283
|
saved = state(ctx, session)
|
|
279
284
|
configured = configured(ctx)
|
|
280
285
|
meters = METERS.to_h { |meter| [meter, saved[meter] || configured[:meters][meter]] }
|
|
281
|
-
resolved = { session: session, meters: meters, unreadable: configured[:unreadable] }
|
|
286
|
+
resolved = { session: session, settings: settings, meters: meters, unreadable: configured[:unreadable] }
|
|
282
287
|
@mutex.synchronize { @resolved = resolved }
|
|
283
288
|
end
|
|
284
289
|
|
|
@@ -301,9 +306,9 @@ class Plugin
|
|
|
301
306
|
end
|
|
302
307
|
|
|
303
308
|
# Per meter the first of its own value, child:'s (a delegate's) and
|
|
304
|
-
# default:'s that is set
|
|
305
|
-
# read (and a child:/default: that
|
|
306
|
-
# notice (#unreadable).
|
|
309
|
+
# default:'s that is set (an empty key, nil, isn't) and reads
|
|
310
|
+
# (.readable?); the ones set that don't read (and a child:/default: that
|
|
311
|
+
# isn't a mapping) are kept for the notice (#unreadable).
|
|
307
312
|
def configured(ctx)
|
|
308
313
|
settings = ctx.settings
|
|
309
314
|
unreadable = {}
|
|
@@ -327,6 +332,8 @@ class Plugin
|
|
|
327
332
|
next unless values.key?(meter)
|
|
328
333
|
|
|
329
334
|
value = values[meter]
|
|
335
|
+
next if value.nil?
|
|
336
|
+
|
|
330
337
|
unless self.class.readable?(value, meter)
|
|
331
338
|
unreadable[name] = value
|
|
332
339
|
next
|
|
@@ -360,12 +367,15 @@ class Plugin
|
|
|
360
367
|
{}
|
|
361
368
|
end
|
|
362
369
|
saved = {} unless saved.is_a?(Hash)
|
|
363
|
-
saved[meter] = data
|
|
370
|
+
saved[meter] = data.transform_values { |value| whole(value) }
|
|
364
371
|
Samagotchi::AtomicFile.write(path, "#{JSON.generate(saved)}\n")
|
|
365
372
|
# Resolved again at the next use, from the file now holding the meter.
|
|
366
373
|
@mutex.synchronize { @resolved = nil }
|
|
367
374
|
end
|
|
368
375
|
|
|
376
|
+
# 800.0 as 800 (a whole cap is saved as an Integer), 2.5 as it is.
|
|
377
|
+
def whole(value) = value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
|
|
378
|
+
|
|
369
379
|
def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
|
|
370
380
|
|
|
371
381
|
def count(value) = self.class.count(value)
|
|
@@ -110,8 +110,8 @@ module Samagotchi
|
|
|
110
110
|
next
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
-
configs.
|
|
114
|
-
next unless
|
|
113
|
+
configs.each_with_index do |cfg, index|
|
|
114
|
+
next unless valid_entry?(event_type, cfg, index)
|
|
115
115
|
|
|
116
116
|
definitions << {
|
|
117
117
|
event_type: event_type.to_s,
|
|
@@ -125,6 +125,21 @@ module Samagotchi
|
|
|
125
125
|
definitions
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# A hook entry is a mapping with a path:; anything else warns, naming
|
|
129
|
+
# the entry (hooks.EVENT[N], from 0), and is skipped alone.
|
|
130
|
+
def self.valid_entry?(event_type, cfg, index)
|
|
131
|
+
where = "hooks.#{event_type}[#{index}]"
|
|
132
|
+
unless cfg.is_a?(Hash)
|
|
133
|
+
Log.warn(:hooks, "hooks_entry_invalid", echo: "[samagotchi:hooks] config.yml #{where}: must be a mapping with a path:; ignored", event: event_type.to_s, index: index)
|
|
134
|
+
return false
|
|
135
|
+
end
|
|
136
|
+
return true if cfg["path"].is_a?(String) && !cfg["path"].strip.empty?
|
|
137
|
+
|
|
138
|
+
Log.warn(:hooks, "hooks_entry_no_path", echo: "[samagotchi:hooks] config.yml #{where}: has no path:; ignored", event: event_type.to_s, index: index)
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
private_class_method :valid_entry?
|
|
142
|
+
|
|
128
143
|
# Load a plugin from the hooks directory.
|
|
129
144
|
# Returns an instance of the plugin class. Instances are cached per
|
|
130
145
|
# [path, settings] across Engines: two entries with different
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -38,7 +38,7 @@ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
|
|
|
38
38
|
import { createCardsPlace } from "./cards_place.js";
|
|
39
39
|
import { createStageView } from "./stage_view.js";
|
|
40
40
|
import { createMarkdownPlace } from "./markdown_place.js";
|
|
41
|
-
import {
|
|
41
|
+
import { openerFill, openTitle, restoredComposer } from "./chains.js";
|
|
42
42
|
import { createTaskStopper } from "./task_stop.js";
|
|
43
43
|
import { appendQuote } from "./annotations.js";
|
|
44
44
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
68
68
|
} from "./timing.js";
|
|
69
69
|
import { createLiveTurn } from "./live_turn.js";
|
|
70
|
-
import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
|
|
70
|
+
import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
|
|
71
71
|
import { installRefClicks } from "./refs.js";
|
|
72
72
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
73
73
|
import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
@@ -174,6 +174,15 @@ const infoCancelBtn = $("#infoCancelBtn");
|
|
|
174
174
|
const composeHintEl = $("#composeHint");
|
|
175
175
|
|
|
176
176
|
let selected = null;
|
|
177
|
+
// The session the composer's text was last written in ("" the start page,
|
|
178
|
+
// undefined unknown): the composer is one for every session, and continue →
|
|
179
|
+
// keeps only a draft of its own (fillOpener).
|
|
180
|
+
let draftFrom;
|
|
181
|
+
// Record that the composer's text was written now, in session +id+ (default
|
|
182
|
+
// the open one). Every write of text into the composer comes through here.
|
|
183
|
+
function noteDraft(id) {
|
|
184
|
+
draftFrom = id ?? selected ?? "";
|
|
185
|
+
}
|
|
177
186
|
let streamControl = null;
|
|
178
187
|
// The session list's stream (GET /api/events); null while the page falls
|
|
179
188
|
// back to fetching (a chi web without a hub).
|
|
@@ -213,6 +222,7 @@ const markdownPlace = createMarkdownPlace({
|
|
|
213
222
|
normalize,
|
|
214
223
|
pairAnswerRefs,
|
|
215
224
|
setAnswerRefs,
|
|
225
|
+
answerRefsOf,
|
|
216
226
|
});
|
|
217
227
|
const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
|
|
218
228
|
const turnDom = view === "stage"
|
|
@@ -500,6 +510,7 @@ const sessionActions = createSessionActions({
|
|
|
500
510
|
toast: showToast,
|
|
501
511
|
addCreatedSession,
|
|
502
512
|
fillOpener,
|
|
513
|
+
restoreDraft,
|
|
503
514
|
closeLiveStream: () => streamControl?.close(),
|
|
504
515
|
workerGone,
|
|
505
516
|
});
|
|
@@ -759,6 +770,7 @@ function clearNotice() {
|
|
|
759
770
|
noticeEl.textContent = "";
|
|
760
771
|
}
|
|
761
772
|
promptEl.addEventListener("input", clearNotice);
|
|
773
|
+
promptEl.addEventListener("input", () => noteDraft());
|
|
762
774
|
|
|
763
775
|
async function select(id) {
|
|
764
776
|
clearNotice();
|
|
@@ -1277,6 +1289,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1277
1289
|
// A failed turn's line back: seen, so the composer opens (this visit).
|
|
1278
1290
|
openForVisit();
|
|
1279
1291
|
promptEl.value = text;
|
|
1292
|
+
noteDraft();
|
|
1280
1293
|
fitPrompt();
|
|
1281
1294
|
},
|
|
1282
1295
|
setChips,
|
|
@@ -1372,6 +1385,7 @@ const sendTurnControl = createSendTurn({
|
|
|
1372
1385
|
clear: clearComposer,
|
|
1373
1386
|
openForVisit: () => openForVisit(),
|
|
1374
1387
|
focus: () => focusPrompt(),
|
|
1388
|
+
noteDraft,
|
|
1375
1389
|
},
|
|
1376
1390
|
historyEl,
|
|
1377
1391
|
turnDom,
|
|
@@ -1525,6 +1539,7 @@ const unqueue = createUnqueue({
|
|
|
1525
1539
|
if (promptEl.value.trim() || chips.length) return false;
|
|
1526
1540
|
openForVisit();
|
|
1527
1541
|
promptEl.value = text;
|
|
1542
|
+
noteDraft();
|
|
1528
1543
|
fitPrompt();
|
|
1529
1544
|
promptEl.focus();
|
|
1530
1545
|
return true;
|
|
@@ -1571,7 +1586,10 @@ const commandList = createCommandList({
|
|
|
1571
1586
|
list: $("#commandList"),
|
|
1572
1587
|
prompt: promptEl,
|
|
1573
1588
|
commands: () => (selected && current.owner !== "tui" ? current.commands : []),
|
|
1574
|
-
afterPick: () =>
|
|
1589
|
+
afterPick: () => {
|
|
1590
|
+
noteDraft();
|
|
1591
|
+
fitPrompt();
|
|
1592
|
+
},
|
|
1575
1593
|
});
|
|
1576
1594
|
|
|
1577
1595
|
// ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
|
|
@@ -1588,6 +1606,7 @@ function historyKey(e) {
|
|
|
1588
1606
|
if (!recalled) return false;
|
|
1589
1607
|
e.preventDefault();
|
|
1590
1608
|
promptEl.value = recalled.value;
|
|
1609
|
+
noteDraft();
|
|
1591
1610
|
promptEl.setSelectionRange(recalled.caret, recalled.caret);
|
|
1592
1611
|
fitPrompt();
|
|
1593
1612
|
return true;
|
|
@@ -1620,15 +1639,31 @@ $("#infoContinueBtn").addEventListener("click", () => continueSelected());
|
|
|
1620
1639
|
$("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
|
|
1621
1640
|
|
|
1622
1641
|
// The new link's composer: the opener (openerText: the previous link's own,
|
|
1623
|
-
// else the constant)
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1626
|
-
|
|
1642
|
+
// else the constant). A draft written in this session (or the new link, or
|
|
1643
|
+
// of unknown origin) stays instead; one carried over from another session or
|
|
1644
|
+
// the start page gives way to the opener and is returned, for the toast's
|
|
1645
|
+
// Restore draft (chains.js openerFill). +created+: the continue's 201;
|
|
1646
|
+
// +continuedId+: the session continued.
|
|
1647
|
+
// @return the displaced draft's text, or null
|
|
1648
|
+
function fillOpener(created, continuedId) {
|
|
1649
|
+
const { value, displaced } = openerFill({
|
|
1650
|
+
text: promptEl.value, hasChips: chips.length > 0, draftFrom, continuedId, linkId: created.id, created,
|
|
1651
|
+
});
|
|
1652
|
+
if (value === null) return null;
|
|
1627
1653
|
openForVisit();
|
|
1628
|
-
promptEl.value =
|
|
1654
|
+
promptEl.value = value;
|
|
1655
|
+
noteDraft(created.id);
|
|
1629
1656
|
fitPrompt();
|
|
1630
1657
|
focusPrompt();
|
|
1631
1658
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
1659
|
+
return displaced;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Restore draft (the continue toast's): +draft+ back into the composer,
|
|
1663
|
+
// over the untouched +opener+ or an empty one, else after what is there
|
|
1664
|
+
// (chains.js restoredComposer); in whatever session is open now.
|
|
1665
|
+
function restoreDraft(draft, opener) {
|
|
1666
|
+
fillComposer(restoredComposer({ current: promptEl.value, opener, draft }));
|
|
1632
1667
|
}
|
|
1633
1668
|
|
|
1634
1669
|
const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
|
|
@@ -1642,6 +1677,7 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
|
|
|
1642
1677
|
function fillComposer(text) {
|
|
1643
1678
|
openForVisit();
|
|
1644
1679
|
promptEl.value = text;
|
|
1680
|
+
noteDraft();
|
|
1645
1681
|
fitPrompt();
|
|
1646
1682
|
focusPrompt();
|
|
1647
1683
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
@@ -25,6 +25,30 @@ export function openerText(created) {
|
|
|
25
25
|
return typeof own === "string" && own.trim() ? own : CONTINUE_OPENER;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// What continue → does with the composer as the new link opens (app.js
|
|
29
|
+
// fillOpener): +text+ / +hasChips+ the composer now; +draftFrom+ the session
|
|
30
|
+
// the text was last written in ("" the start page, undefined unknown);
|
|
31
|
+
// +continuedId+ the session continued, +linkId+ the new link. An empty
|
|
32
|
+
// composer gets the opener. A draft written in either session, or of unknown
|
|
33
|
+
// origin, stays (+value+ null). One carried over from another session or the
|
|
34
|
+
// start page gives way to the opener and comes back as +displaced+ (the
|
|
35
|
+
// toast's Restore draft). Its image chips stay either way: only text is
|
|
36
|
+
// replaced.
|
|
37
|
+
export function openerFill({ text, hasChips, draftFrom, continuedId, linkId, created }) {
|
|
38
|
+
const hasText = !!text?.trim();
|
|
39
|
+
if (!hasText && !hasChips) return { value: openerText(created), displaced: null };
|
|
40
|
+
if (draftFrom === undefined || draftFrom === continuedId || draftFrom === linkId) return { value: null, displaced: null };
|
|
41
|
+
return { value: openerText(created), displaced: hasText ? text : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The composer after Restore draft: +draft+ (a displaced draft) replaces
|
|
45
|
+
// +current+ when that is empty or still exactly +opener+; else it goes after
|
|
46
|
+
// it, past a blank line. Never loses text typed since.
|
|
47
|
+
export function restoredComposer({ current, opener, draft }) {
|
|
48
|
+
if (!current.trim() || current === opener) return draft;
|
|
49
|
+
return `${current.replace(/\s+$/, "")}\n\n${draft}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
// Whether +text+ (a title or a first message) is the opener and nothing
|
|
29
53
|
// else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
|
|
30
54
|
export function isOpener(text) {
|
|
@@ -345,7 +345,13 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
345
345
|
--md-strong-color:var(--text-bright);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
|
|
346
346
|
--md-pre-size:12.5px;--md-cell-size:13px
|
|
347
347
|
}
|
|
348
|
-
.md-compact
|
|
348
|
+
.gen-text.md-compact{
|
|
349
|
+
--md-heading-size:14.5px;--md-heading-color:var(--text);
|
|
350
|
+
--md-subheading-size:14px;--md-subheading-color:var(--text-2);
|
|
351
|
+
--md-strong-color:var(--text);--md-blockquote-color:var(--text-muted);--md-code-color:var(--text);
|
|
352
|
+
--md-pre-size:12.5px;--md-cell-size:13px
|
|
353
|
+
}
|
|
354
|
+
.md-compact:is(.ts-asking,.question-said-full,.gen-text){white-space:normal}
|
|
349
355
|
.md-compact :is(h1,h2,h3,h4,h5,h6){font-size:var(--md-heading-size);font-weight:600;line-height:1.4;margin:8px 0 2px;color:var(--md-heading-color)}
|
|
350
356
|
.md-compact :is(h4,h5,h6){font-size:var(--md-subheading-size);color:var(--md-subheading-color)}
|
|
351
357
|
.md-compact :is(p,ul,ol,blockquote,table,pre){margin:0 0 6px}
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
// The texts whose refs linkTextRefs links: the answers and the closed
|
|
8
8
|
// steps' texts.
|
|
9
9
|
const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
|
|
10
|
+
// The closed steps' texts still shown raw: a live turn's (a reload's come
|
|
11
|
+
// rendered, turn_view.js textTemplate).
|
|
12
|
+
const RAW_STEPS = ".gen:not(.live) > .gen-text:not(.markdown)";
|
|
10
13
|
|
|
11
14
|
export function createMarkdownPlace({
|
|
12
15
|
capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
|
|
13
|
-
pairAnswerRefs, setAnswerRefs = () => {},
|
|
16
|
+
pairAnswerRefs, setAnswerRefs = () => {}, answerRefsOf = () => null,
|
|
14
17
|
}) {
|
|
18
|
+
// The raw step texts already waiting for their step's open.
|
|
19
|
+
const waiting = new WeakSet();
|
|
20
|
+
|
|
15
21
|
// A text the page already shows as its markdown HTML (POST /api/render,
|
|
16
22
|
// sanitised there), or null: markdown off here (no request), or a failed
|
|
17
23
|
// read (the raw text stays).
|
|
@@ -55,6 +61,7 @@ export function createMarkdownPlace({
|
|
|
55
61
|
// turn's end and after a reload's render. A ref already made stays as it
|
|
56
62
|
// is.
|
|
57
63
|
function linkTextRefs() {
|
|
64
|
+
renderStepTexts();
|
|
58
65
|
const pageRoots = roots();
|
|
59
66
|
const touched = touchedFiles(pageRoots);
|
|
60
67
|
for (const root of pageRoots) {
|
|
@@ -66,5 +73,44 @@ export function createMarkdownPlace({
|
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
// The closed steps' raw texts as markdown, as the answers' (markdown on
|
|
77
|
+
// only): each from its step's first open (a turn's end would ask once per
|
|
78
|
+
// step, folded or not), as a resolved question card's kept reply. The
|
|
79
|
+
// text stays as its source (stepText); its refs, a question's, pair again
|
|
80
|
+
// on the swap, then the `path:line` ones (linkRefsIn). A null render
|
|
81
|
+
// keeps the text, and the next open asks again.
|
|
82
|
+
function renderStepTexts() {
|
|
83
|
+
if (capabilities().display !== true) return;
|
|
84
|
+
for (const root of roots()) {
|
|
85
|
+
for (const el of root.querySelectorAll(RAW_STEPS)) {
|
|
86
|
+
const step = el.parentElement;
|
|
87
|
+
if (waiting.has(el) || !step?.addEventListener) continue;
|
|
88
|
+
waiting.add(el);
|
|
89
|
+
renderOnOpen(step, el, el.textContent);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderOnOpen(step, el, text) {
|
|
95
|
+
let state = "raw";
|
|
96
|
+
const draw = () => {
|
|
97
|
+
if (!step.open || state !== "raw") return;
|
|
98
|
+
state = "asking";
|
|
99
|
+
renderMarkdown(text).then((html) => {
|
|
100
|
+
if (!html || el.textContent !== text) { state = html ? "done" : "raw"; return; }
|
|
101
|
+
state = "done";
|
|
102
|
+
step.removeEventListener("toggle", draw);
|
|
103
|
+
const refs = answerRefsOf(el);
|
|
104
|
+
el.setAttribute("data-copy-source", text);
|
|
105
|
+
// Sanitised on the server (POST /api/render).
|
|
106
|
+
el.innerHTML = html;
|
|
107
|
+
el.classList.add("markdown", "md-compact");
|
|
108
|
+
linkRefsIn(el, refs);
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
step.addEventListener("toggle", draw);
|
|
112
|
+
draw();
|
|
113
|
+
}
|
|
114
|
+
|
|
69
115
|
return { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs };
|
|
70
116
|
}
|
|
@@ -19,7 +19,8 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
|
|
|
19
19
|
// (start_page.js: pendingPick(), chosenModel(), newChoices(),
|
|
20
20
|
// clearChoices()); +takeDelivery()+ the delivery line's armed choice;
|
|
21
21
|
// +composer+: {el (the text box), button (Send/Start), chips(),
|
|
22
|
-
// setChips(list), fit(), clear(), openForVisit(), focus()
|
|
22
|
+
// setChips(list), fit(), clear(), openForVisit(), focus(), noteDraft(id) (the
|
|
23
|
+
// text was written in session id)}; +historyEl+,
|
|
23
24
|
// +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
|
|
24
25
|
// +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
|
|
25
26
|
// +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
|
|
@@ -128,7 +129,10 @@ export function createSendTurn({
|
|
|
128
129
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
129
130
|
// A restored line must be seen: the composer opens for this visit.
|
|
130
131
|
if (restored?.refill && sentTo === session()) composer.openForVisit();
|
|
131
|
-
|
|
132
|
+
const before = promptEl.value;
|
|
133
|
+
promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
|
|
134
|
+
// A line back from sentTo is a draft of that session, wherever the page is now.
|
|
135
|
+
if (promptEl.value.trim() && promptEl.value !== before) composer.noteDraft(sentTo);
|
|
132
136
|
composer.fit();
|
|
133
137
|
// Only the sent chips go: images added since stay (they go up at their own send).
|
|
134
138
|
const kept = chipsAfterSend(composer.chips(), sending);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
|
|
1
|
+
import { continueRefusal, continuedText, openChildrenTemplate, openerText } from "./chains.js";
|
|
2
2
|
import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
|
|
3
3
|
import { sessionHash } from "./route.js";
|
|
4
4
|
import { inTree } from "./sessions_list.js";
|
|
@@ -14,12 +14,14 @@ import { inTree } from "./sessions_list.js";
|
|
|
14
14
|
// +refresh()+ fetches the list); +lists+ (lists_view.js: render);
|
|
15
15
|
// +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
|
|
16
16
|
// +toast(text, action, options)+, +addCreatedSession(created)+,
|
|
17
|
-
// +fillOpener(created)+ (the new link's composer
|
|
17
|
+
// +fillOpener(created, continuedId)+ (the new link's composer; returns a draft
|
|
18
|
+
// from another session it displaced, or null), +restoreDraft(draft, opener)+
|
|
19
|
+
// (that draft back, never over newer text), +closeLiveStream()+ (the live
|
|
18
20
|
// stream's close, the stream left as it is) and +workerGone(id)+ the page's.
|
|
19
21
|
// +alert+ / +confirm+: the browser's.
|
|
20
22
|
export function createSessionActions({
|
|
21
23
|
selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
|
|
22
|
-
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
|
|
24
|
+
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
|
|
23
25
|
alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
|
|
24
26
|
}) {
|
|
25
27
|
// The session this tab is stopping: its stream may close before the stop
|
|
@@ -105,8 +107,11 @@ export function createSessionActions({
|
|
|
105
107
|
return;
|
|
106
108
|
}
|
|
107
109
|
await select(next);
|
|
108
|
-
|
|
109
|
-
toast
|
|
110
|
+
// A draft carried over from elsewhere gave way to the opener: the
|
|
111
|
+
// toast brings it back, and stays until closed so it can't be lost.
|
|
112
|
+
const displaced = selected() === next ? fillOpener(created, id) : null;
|
|
113
|
+
const restore = displaced ? { label: "Restore draft", run: () => restoreDraft(displaced, openerText(created)) } : null;
|
|
114
|
+
toast(continuedText(id, created), restore, { sticky: sticky || !!displaced });
|
|
110
115
|
} catch (e) {
|
|
111
116
|
const refusal = continueRefusal(e, sessions());
|
|
112
117
|
if (refusal.kind === "open") {
|
|
@@ -71,13 +71,21 @@ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking",
|
|
|
71
71
|
// full text. None when there is nothing to say. A closed step's text is
|
|
72
72
|
// set whole (.textContent): its refs (ref_scan.js) split it into spans
|
|
73
73
|
// that a text part's update would leave behind. +at+: a reloaded step's
|
|
74
|
-
// message index, for its refs (data-answer-at, as an answer's).
|
|
75
|
-
|
|
74
|
+
// message index, for its refs (data-answer-at, as an answer's). A
|
|
75
|
+
// reloaded step's +rendered+ markdown (its message's html, sanitised on the
|
|
76
|
+
// server, as an answer's) shows in place of the text, which it keeps as
|
|
77
|
+
// its source (stepText); a live step's renders once it closed
|
|
78
|
+
// (markdown_place.js renderStepTexts).
|
|
79
|
+
function textTemplate(text, { live, sentences, placeholder, at = null, rendered = null }) {
|
|
76
80
|
if (live && sentences?.length) {
|
|
77
81
|
return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
|
|
78
82
|
}
|
|
79
83
|
if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
|
|
80
|
-
|
|
84
|
+
if (!text) return nothing;
|
|
85
|
+
if (typeof rendered === "string" && rendered) {
|
|
86
|
+
return html`<div class="gen-text markdown md-compact" data-answer-at=${at ?? nothing} data-copy-source=${text} .innerHTML=${rendered}></div>`;
|
|
87
|
+
}
|
|
88
|
+
return html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>`;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
// One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
|
|
@@ -97,7 +105,7 @@ export function stepTemplate(gen, st = {}) {
|
|
|
97
105
|
const failed = !live && model.failedCount(gen.tools) > 0;
|
|
98
106
|
// A step left with its thinking only reads "thinking" once.
|
|
99
107
|
const wrapped = live || !!text || gen.tools.length > 0;
|
|
100
|
-
return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
|
|
108
|
+
return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at, rendered: gen.html })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
// A hook's line (hook_notice) as a step's row. data.line is a whole row of
|
|
@@ -807,10 +815,11 @@ function defaultPlaceAnswer(appendToHistory) {
|
|
|
807
815
|
|
|
808
816
|
// The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
|
|
809
817
|
// cards count), "" when it has none: a reloaded question card's asking
|
|
810
|
-
// reply.
|
|
818
|
+
// reply, as written (a rendered step keeps it as data-copy-source).
|
|
811
819
|
export function stepText(block, iteration) {
|
|
812
820
|
const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
|
|
813
|
-
|
|
821
|
+
const text = step?.querySelector(":scope > .gen-text");
|
|
822
|
+
return text?.getAttribute("data-copy-source") ?? text?.textContent ?? "";
|
|
814
823
|
}
|
|
815
824
|
|
|
816
825
|
// A re-rendered failed turn's line (a .bubble.cancel, as live's
|
|
@@ -885,7 +894,12 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
|
|
|
885
894
|
if (plainThinking) {
|
|
886
895
|
parts.push(thinkingTemplate({ body: only.thinking, cls: "bubble thinking" }));
|
|
887
896
|
} else if (group.steps.length) {
|
|
888
|
-
|
|
897
|
+
// A step's text with its message's rendered markdown (html: web.markdown on).
|
|
898
|
+
const gens = group.steps.map((step) => ({
|
|
899
|
+
text: step.i === null ? "" : String(items[step.i].content ?? ""),
|
|
900
|
+
html: step.i === null ? null : items[step.i].html ?? null,
|
|
901
|
+
thinking: step.thinking || "", tools: step.tools,
|
|
902
|
+
}));
|
|
889
903
|
const steps = gens.map((gen, k) => stepTemplate(gen, {
|
|
890
904
|
// The steers the step answered first, where the live rows went.
|
|
891
905
|
steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
|