samagotchi 0.150.0 → 0.152.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 +50 -1
- data/docs/architecture.md +2 -2
- data/docs/bundle-authoring.md +5 -5
- data/docs/cli.md +32 -7
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +4 -0
- data/docs/internals/block-renderers.md +98 -0
- data/docs/internals/bridge.md +53 -17
- data/docs/internals/bundles.md +49 -14
- data/docs/internals/objectives.md +6 -4
- data/docs/internals/web-frontend.md +2 -2
- data/docs/memory.md +7 -2
- data/docs/plugins.md +91 -14
- data/docs/releasing.md +7 -2
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/block_renderers.rb +116 -0
- data/lib/samagotchi/bridge/card_places.rb +93 -0
- data/lib/samagotchi/bridge/card_store.rb +219 -122
- data/lib/samagotchi/bridge.rb +12 -4
- data/lib/samagotchi/bundle_command/check.rb +43 -3
- data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
- data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +88 -10
- data/lib/samagotchi/bundle_needs.rb +4 -6
- data/lib/samagotchi/bundle_settings_check.rb +14 -13
- data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
- data/lib/samagotchi/child_env.rb +15 -0
- data/lib/samagotchi/context_providers.rb +42 -10
- data/lib/samagotchi/engine.rb +47 -6
- data/lib/samagotchi/extension_load.rb +4 -3
- data/lib/samagotchi/guardrail_wiring.rb +28 -19
- data/lib/samagotchi/guardrails/approval.rb +2 -2
- data/lib/samagotchi/guardrails/rules.rb +1 -0
- data/lib/samagotchi/guardrails/verdict.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
- data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
- data/lib/samagotchi/memory_bundle/builder.rb +39 -31
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
- data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
- data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
- data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
- data/lib/samagotchi/memory_bundle/installer.rb +91 -16
- data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
- data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
- data/lib/samagotchi/memory_bundle/merger.rb +7 -8
- data/lib/samagotchi/memory_bundle/profile.rb +3 -2
- data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
- data/lib/samagotchi/memory_bundle/shas.rb +4 -3
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
- data/lib/samagotchi/memory_bundle/status.rb +11 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_config.rb +5 -4
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/system_one/record.rb +10 -2
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/tools/delegate.rb +20 -2
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -3
- data/lib/samagotchi/update_command.rb +13 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +24 -13
- data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
- data/lib/samagotchi/web/markdown_renderer.rb +39 -6
- data/lib/samagotchi/web/message_display.rb +3 -3
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/block_frame.js +202 -0
- data/lib/samagotchi/web/public/block_host.js +502 -0
- data/lib/samagotchi/web/public/block_renderers.css +20 -0
- data/lib/samagotchi/web/public/block_source.js +37 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -5
- data/lib/samagotchi/web/public/card.js +17 -10
- data/lib/samagotchi/web/public/cards_place.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/history_reveal.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +2 -3
- data/lib/samagotchi/web/public/text_roots.js +6 -0
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +6 -0
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +13 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md).
|
|
2
|
+
// One observer per turn root, filtered to TEXTS, as copy.js and answer_fold.js:
|
|
3
|
+
// every path that fills a root (a reload, a session switch, a turn's end, an
|
|
4
|
+
// answer_display, Revise's clones, a step's swap) arrives the same way, and
|
|
5
|
+
// cards, the asking slot and question replies are never touched.
|
|
6
|
+
//
|
|
7
|
+
// The DOM is the state: a <figure class="block-drawn"> right before the <pre>
|
|
8
|
+
// (block_source.js) says it is drawn, so a Revise clone needs nothing, and
|
|
9
|
+
// data-block-failed on the <pre> says it isn't to be tried again. The figure
|
|
10
|
+
// holds an <img src="data:..."> only: the frame's output is never innerHTML, and
|
|
11
|
+
// never a blob: (a blob has this page's origin, and the browser's own "Open
|
|
12
|
+
// Image in New Tab" navigates to it).
|
|
13
|
+
//
|
|
14
|
+
// A module of its own: static JS is cached at unversioned URLs.
|
|
15
|
+
|
|
16
|
+
import { html, render as litRender } from "./vendor/lit-html.js";
|
|
17
|
+
import { createBlockFrame, FRAME_TIMEOUT_MS } from "./block_frame.js";
|
|
18
|
+
import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
|
|
19
|
+
import { copyText } from "./copy.js";
|
|
20
|
+
import { keepFollowing, shouldFollowScroll } from "./scroll.js";
|
|
21
|
+
import { TEXTS } from "./text_roots.js";
|
|
22
|
+
|
|
23
|
+
const BLOCKS = "pre[data-lang]";
|
|
24
|
+
// MessageDisplay::MAX_RENDER_BYTES's number: a source over it stays source.
|
|
25
|
+
export const MAX_SOURCE_BYTES = 64 * 1024;
|
|
26
|
+
export const CACHE_SIZE = 200;
|
|
27
|
+
export const MARK_PREFIX = "chi_block_rendering:";
|
|
28
|
+
const IO_MARGIN = "600px 0px";
|
|
29
|
+
const MAX_RETRIES = 5;
|
|
30
|
+
// The biggest picture a page shows, whatever the renderer: a model-written
|
|
31
|
+
// directive can ask Mermaid for an SVG tens of millions of px tall, which breaks
|
|
32
|
+
// the session's scrolling. Over it the block stays source. The real diagrams of
|
|
33
|
+
// eval 0012 reach about 2500 px a side and 3.5M px: docs/internals/block-renderers.md.
|
|
34
|
+
export const MAX_PICTURE_SIDE = 50000;
|
|
35
|
+
export const MAX_PICTURE_PIXELS = 100_000_000;
|
|
36
|
+
export const DECODE_TIMEOUT_MS = 15000;
|
|
37
|
+
// A render that held on this long keeps its mark: it stalls the page once, not on
|
|
38
|
+
// every load. Real Mermaid's worst is about 1 s.
|
|
39
|
+
export const SLOW_RENDER_MS = 10000;
|
|
40
|
+
const NOT_AN_IMAGE = "the renderer's SVG can't be shown as an image";
|
|
41
|
+
// A very wide diagram scaled to the column would be a sliver: it stays at least
|
|
42
|
+
// this tall and scrolls sideways instead.
|
|
43
|
+
const MIN_SHOWN_HEIGHT = 120;
|
|
44
|
+
|
|
45
|
+
const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M5.5 4 2 8l3.5 4M10.5 4 14 8l-3.5 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
46
|
+
|
|
47
|
+
// The picture's URL and size. The XML parser reads the root, as the image will: a
|
|
48
|
+
// regex over model-influenced XML finds the wrong box or none (an exponent, a comment
|
|
49
|
+
// holding "<svg", a ">" in an attribute, an entity, a prefix). width and height come
|
|
50
|
+
// from the viewBox, as Mermaid's width="100%" gives an <img> a wrong natural size. No
|
|
51
|
+
// usable box: the size is what it decodes to (block_host's draw). Only ever a
|
|
52
|
+
// data: URL (rule 2).
|
|
53
|
+
// @throws Error it isn't an SVG document
|
|
54
|
+
export function pictureOf(svg, win = window) {
|
|
55
|
+
const parsed = new win.DOMParser().parseFromString(svg, "image/svg+xml");
|
|
56
|
+
const root = parsed.documentElement;
|
|
57
|
+
if (root?.localName !== "svg" || parsed.querySelector("parsererror")) throw new Error(NOT_AN_IMAGE);
|
|
58
|
+
const box = String(root.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/).map(Number);
|
|
59
|
+
const sized = box.length === 4 && box[2] > 0 && box[3] > 0;
|
|
60
|
+
if (sized) {
|
|
61
|
+
root.setAttribute("width", box[2]);
|
|
62
|
+
root.setAttribute("height", box[3]);
|
|
63
|
+
}
|
|
64
|
+
const text = sized ? new win.XMLSerializer().serializeToString(parsed) : svg;
|
|
65
|
+
return { url: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text)}`, width: sized ? box[2] : null, height: sized ? box[3] : null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Why a picture of this size isn't shown, or null.
|
|
69
|
+
export function oversize(width, height) {
|
|
70
|
+
if (!width || !height) return null; // no box: its decoded size is checked
|
|
71
|
+
if (width <= MAX_PICTURE_SIDE && height <= MAX_PICTURE_SIDE && width * height <= MAX_PICTURE_PIXELS) return null;
|
|
72
|
+
return `the picture would be ${Math.round(width)} x ${Math.round(height)} px, too large to show`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The width that keeps a picture at least MIN_SHOWN_HEIGHT tall, never past its
|
|
76
|
+
// natural size; null when it needs none of that.
|
|
77
|
+
export function minShownWidth(width, height) {
|
|
78
|
+
if (!width || !height) return null;
|
|
79
|
+
return Math.min(width, Math.ceil((width * MIN_SHOWN_HEIGHT) / height));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A decoded size that isn't a picture: nothing to see, or a size the browser
|
|
83
|
+
// couldn't work out (WebKit's 0 x 150 for a viewBox it can't hold).
|
|
84
|
+
export function emptyPicture(natural) {
|
|
85
|
+
return !(natural?.width > 0 && natural?.height > 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A short name for +source+ in sessionStorage; a collision only leaves a block
|
|
89
|
+
// as source. No crypto.subtle: it's undefined on a plain-http LAN address.
|
|
90
|
+
export function sourceHash(source) {
|
|
91
|
+
let h = 0x811c9dc5;
|
|
92
|
+
for (let i = 0; i < source.length; i++) h = Math.imul(h ^ source.charCodeAt(i), 0x01000193);
|
|
93
|
+
return `${(h >>> 0).toString(36)}.${source.length}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A renderer's file by the page's own fetch (the frame has no network). Not
|
|
97
|
+
// data.js api: that's JSON. The content type keeps a proxy's sign-in HTML from
|
|
98
|
+
// being passed in as code.
|
|
99
|
+
export async function fetchRendererFile(bundle, file) {
|
|
100
|
+
const res = await fetch(`/api/block_renderers/${encodeURIComponent(bundle)}/${encodeURIComponent(file)}`, { headers: { Accept: "text/javascript" } });
|
|
101
|
+
if (!res.ok) throw new Error(`the renderer's ${file} answered ${res.status}`);
|
|
102
|
+
if (!/javascript/i.test(res.headers.get("content-type") || "")) throw new Error(`the renderer's ${file} isn't a script`);
|
|
103
|
+
return res.text();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function fetchRendererList() {
|
|
107
|
+
const res = await fetch("/api/block_renderers", { headers: { Accept: "application/json" } });
|
|
108
|
+
if (!res.ok) throw new Error(`block renderers answered ${res.status}`);
|
|
109
|
+
return (await res.json()).renderers;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The page's tokens as a small generic object; mapping it to a library is the
|
|
113
|
+
// bundle's job.
|
|
114
|
+
export function currentTheme(doc = document, win = window) {
|
|
115
|
+
const style = win.getComputedStyle(doc.documentElement);
|
|
116
|
+
const token = (name) => style.getPropertyValue(name).trim();
|
|
117
|
+
return {
|
|
118
|
+
name: win.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
|
119
|
+
bg: token("--bg"), panel: token("--bg-panel"), text: token("--text"), muted: token("--text-3"),
|
|
120
|
+
border: token("--border-strong"), accent: token("--accent"), font: win.getComputedStyle(doc.body).fontFamily,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The nearest scroller holding +el+, +root+ at the latest. IntersectionObserver
|
|
125
|
+
// needs it named: with root:null a rootMargin doesn't pass an overflow:auto
|
|
126
|
+
// between, so a block just under #history's fold wouldn't count.
|
|
127
|
+
function scrollerOf(el, root, win) {
|
|
128
|
+
for (let at = el.parentElement; at && at !== root; at = at.parentElement) {
|
|
129
|
+
const { overflowY } = win.getComputedStyle(at);
|
|
130
|
+
if (overflowY === "auto" || overflowY === "scroll") return at;
|
|
131
|
+
}
|
|
132
|
+
return root;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One IntersectionObserver per scroller. +el+ moved to another scroller (the
|
|
136
|
+
// stage's hand-off to the history) moves with it.
|
|
137
|
+
function ioWatcher(win) {
|
|
138
|
+
const observers = new Map();
|
|
139
|
+
const watched = new WeakMap();
|
|
140
|
+
return (el, scroller, onNear) => {
|
|
141
|
+
let io = observers.get(scroller);
|
|
142
|
+
if (!io) {
|
|
143
|
+
io = new win.IntersectionObserver((entries) => {
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (!entry.isIntersecting) continue;
|
|
146
|
+
io.unobserve(entry.target);
|
|
147
|
+
const near = watched.get(entry.target);
|
|
148
|
+
watched.delete(entry.target);
|
|
149
|
+
near?.onNear();
|
|
150
|
+
}
|
|
151
|
+
}, { root: scroller, rootMargin: IO_MARGIN });
|
|
152
|
+
observers.set(scroller, io);
|
|
153
|
+
}
|
|
154
|
+
const before = watched.get(el);
|
|
155
|
+
if (before && before.io !== io) before.io.unobserve(el);
|
|
156
|
+
watched.set(el, { io, onNear });
|
|
157
|
+
io.observe(el);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function guardedStorage(get) {
|
|
162
|
+
const use = (fn, fallback) => { try { return fn(get()); } catch (_) { return fallback; } };
|
|
163
|
+
return {
|
|
164
|
+
has: (key) => use((s) => s.getItem(key) !== null, false),
|
|
165
|
+
set: (key) => use((s) => s.setItem(key, "1")),
|
|
166
|
+
remove: (key) => use((s) => s.removeItem(key)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// @param roots the turn roots (app.js turnRoots())
|
|
171
|
+
// @param sessionId () => the open session's id: the frames go on a change, to
|
|
172
|
+
// limit what they hold (docs/internals/block-renderers.md, residual risk)
|
|
173
|
+
// The rest are seams for the specs.
|
|
174
|
+
export function createBlockHost({
|
|
175
|
+
roots, sessionId, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
|
|
176
|
+
decodeImage = async (url) => { const img = new win.Image(); img.src = url; await img.decode(); return { width: img.naturalWidth, height: img.naturalHeight }; },
|
|
177
|
+
decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme, copy = copyText,
|
|
178
|
+
watch = null, doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
|
|
179
|
+
}) {
|
|
180
|
+
const near = watch || ioWatcher(win);
|
|
181
|
+
const cache = new Map();
|
|
182
|
+
const frames = new Map();
|
|
183
|
+
// A language whose renderer didn't load, and why: until the session changes,
|
|
184
|
+
// its blocks don't fetch it again one by one.
|
|
185
|
+
const broken = new Map();
|
|
186
|
+
// Blocks with a draw on its way (not a drawn state: that is the figure): pre =>
|
|
187
|
+
// {target, scroller, start, drawing}. Until it starts, a block whose scroller
|
|
188
|
+
// changed (the stage's hand-off to the history) gets watched there.
|
|
189
|
+
const waiting = new WeakMap();
|
|
190
|
+
// The marks of the renders running now: mark => when it started. Such a mark isn't
|
|
191
|
+
// a leftover from an earlier load.
|
|
192
|
+
const inFlight = new Map();
|
|
193
|
+
// The pictures on their way, by cache key: a bubble rewritten or cloned during a
|
|
194
|
+
// render has its blocks wait for that one, and each draws into whichever is connected.
|
|
195
|
+
const coming = new Map();
|
|
196
|
+
const retries = new WeakMap();
|
|
197
|
+
let list = null;
|
|
198
|
+
let listLoad = null;
|
|
199
|
+
let session = sessionId();
|
|
200
|
+
|
|
201
|
+
function withRenderers(fn) {
|
|
202
|
+
if (list) { fn(list); return; }
|
|
203
|
+
listLoad ??= listRenderers().then(
|
|
204
|
+
(renderers) => { list = new Map((renderers || []).map((r) => [r.lang, r])); return list; },
|
|
205
|
+
() => { list = new Map(); return list; }, // a failed list: no drawing
|
|
206
|
+
);
|
|
207
|
+
listLoad.then(fn);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function dropFrames() {
|
|
211
|
+
for (const frame of frames.values()) frame.drop();
|
|
212
|
+
frames.clear();
|
|
213
|
+
broken.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function noticeSession() {
|
|
217
|
+
const now = sessionId();
|
|
218
|
+
if (now === session) return;
|
|
219
|
+
session = now;
|
|
220
|
+
dropFrames();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function frameFor(lang, renderer) {
|
|
224
|
+
let frame = frames.get(lang);
|
|
225
|
+
if (frame && !frame.dead()) return frame;
|
|
226
|
+
frame = makeFrame({ files: renderer.files, fetchFile: (file) => fetchFile(renderer.bundle, file) });
|
|
227
|
+
frame.ready.catch((e) => { if (!e?.dropped) broken.set(lang, e?.message || "its renderer didn't load"); });
|
|
228
|
+
frames.set(lang, frame);
|
|
229
|
+
return frame;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function remember(key, picture) {
|
|
233
|
+
cache.delete(key);
|
|
234
|
+
cache.set(key, picture);
|
|
235
|
+
if (cache.size > CACHE_SIZE) cache.delete(cache.keys().next().value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const cacheKey = (lang, themeName, source) => `${lang}\u0000${themeName}\u0000${source}`;
|
|
239
|
+
const markKey = (lang, source) => `${MARK_PREFIX}${lang}:${sourceHash(source)}`;
|
|
240
|
+
|
|
241
|
+
function rootOf(el) {
|
|
242
|
+
return roots.find((root) => root.contains(el)) || roots[0];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// +mutate+ with the view held: a viewer at the bottom stays there, and a
|
|
246
|
+
// block above a reader's view keeps what they read where it is. Browsers that
|
|
247
|
+
// anchor scrolling have done it by the second read, and there is nothing left.
|
|
248
|
+
function holdingView(pre, mutate, bottomOf) {
|
|
249
|
+
const scroller = scrollerOf(pre, rootOf(pre), win);
|
|
250
|
+
if (shouldFollowScroll(scroller)) return keepFollowing(scroller, mutate);
|
|
251
|
+
const before = pre.getBoundingClientRect().bottom;
|
|
252
|
+
if (before > scroller.getBoundingClientRect().top) return mutate();
|
|
253
|
+
const result = mutate();
|
|
254
|
+
const after = bottomOf();
|
|
255
|
+
if (after !== before) scroller.scrollTop += after - before;
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function setMark(mark) {
|
|
260
|
+
storage.set(mark);
|
|
261
|
+
inFlight.set(mark, Date.now());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function clearMark(mark) {
|
|
265
|
+
storage.remove(mark);
|
|
266
|
+
inFlight.delete(mark);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A reload during a render isn't a hang: its mark goes. A page truly hung never
|
|
270
|
+
// runs this, and one that was stuck past the frame's timeout has shown it is
|
|
271
|
+
// suspect, so that mark stays.
|
|
272
|
+
win.addEventListener?.("pagehide", () => {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
for (const [mark, started] of inFlight) if (now - started < FRAME_TIMEOUT_MS) clearMark(mark);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The decoded image, bounded: a browser can sit on a vast SVG without ending.
|
|
278
|
+
function decoded(url) {
|
|
279
|
+
let timer;
|
|
280
|
+
const bound = new Promise((_, reject) => {
|
|
281
|
+
timer = setTimeout(() => reject(Object.assign(new Error("the picture took too long to show"), { timedOut: true })), decodeTimeoutMs);
|
|
282
|
+
});
|
|
283
|
+
const image = Promise.resolve().then(() => decodeImage(url)).catch(() => {
|
|
284
|
+
throw new Error(NOT_AN_IMAGE);
|
|
285
|
+
});
|
|
286
|
+
return Promise.race([image, bound]).finally(() => clearTimeout(timer));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function makeFigure(picture, themeName, lang) {
|
|
290
|
+
const figure = doc.createElement("figure");
|
|
291
|
+
figure.className = DRAWN;
|
|
292
|
+
figure.dataset.theme = themeName;
|
|
293
|
+
const scroll = doc.createElement("div");
|
|
294
|
+
scroll.className = "block-scroll";
|
|
295
|
+
const img = doc.createElement("img");
|
|
296
|
+
img.className = "block-img";
|
|
297
|
+
img.alt = `${lang} diagram`;
|
|
298
|
+
showPicture(img, picture);
|
|
299
|
+
scroll.append(img);
|
|
300
|
+
const toggle = doc.createElement("button");
|
|
301
|
+
toggle.type = "button";
|
|
302
|
+
toggle.className = "block-toggle";
|
|
303
|
+
litRender(ICON, toggle);
|
|
304
|
+
figure.append(scroll, toggle);
|
|
305
|
+
return figure;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function showPicture(img, { url, width, height }) {
|
|
309
|
+
if (width && height) {
|
|
310
|
+
img.setAttribute("width", width);
|
|
311
|
+
img.setAttribute("height", height);
|
|
312
|
+
img.style.minWidth = `${minShownWidth(width, height)}px`;
|
|
313
|
+
}
|
|
314
|
+
img.src = url;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function place(pre, picture, themeName, lang) {
|
|
318
|
+
if (!pre.isConnected) return;
|
|
319
|
+
const drawn = figureOf(pre);
|
|
320
|
+
if (drawn) {
|
|
321
|
+
drawn.dataset.theme = themeName;
|
|
322
|
+
showPicture(drawn.querySelector(".block-img"), picture);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const figure = makeFigure(picture, themeName, lang);
|
|
326
|
+
holdingView(pre, () => {
|
|
327
|
+
pre.before(figure);
|
|
328
|
+
setSourceShown(figure, pre, false);
|
|
329
|
+
}, () => figure.getBoundingClientRect().bottom);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The <pre> stays as it is, under one muted line: never markup (the words are
|
|
333
|
+
// a renderer's, driven by the model's input), and shown by CSS, so a bubble's
|
|
334
|
+
// textContent, its dedupe and an annotation's text stay what they were (copy.js's
|
|
335
|
+
// rule for its buttons).
|
|
336
|
+
function fail(pre, lang, message) {
|
|
337
|
+
if (!pre.isConnected) return;
|
|
338
|
+
pre.dataset.blockFailed = "1";
|
|
339
|
+
const note = doc.createElement("div");
|
|
340
|
+
note.className = "block-note";
|
|
341
|
+
const first = String(message ?? "").split("\n")[0].trim();
|
|
342
|
+
const words = `Couldn't draw this ${lang} block${first ? `: ${first}` : ""}`;
|
|
343
|
+
note.dataset.text = words;
|
|
344
|
+
note.setAttribute("role", "note");
|
|
345
|
+
note.setAttribute("aria-label", words);
|
|
346
|
+
note.title = "Click to copy";
|
|
347
|
+
pre.after(note);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function pictureFor(key, lang, source, renderer, now) {
|
|
351
|
+
let picture = coming.get(key);
|
|
352
|
+
if (!picture) {
|
|
353
|
+
picture = rendered(key, lang, source, renderer, now).finally(() => coming.delete(key));
|
|
354
|
+
coming.set(key, picture);
|
|
355
|
+
}
|
|
356
|
+
return picture;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The size that counts is what shows: from the root's box before the decode (a
|
|
360
|
+
// vast one needn't be tried), and what it decoded to in any case.
|
|
361
|
+
async function rendered(key, lang, source, renderer, now) {
|
|
362
|
+
const mark = markKey(lang, source);
|
|
363
|
+
try {
|
|
364
|
+
const svg = await frameFor(lang, renderer).render(source, now, { onStart: () => setMark(mark) });
|
|
365
|
+
const started = inFlight.get(mark);
|
|
366
|
+
const picture = pictureOf(svg, win);
|
|
367
|
+
const tooLarge = oversize(picture.width, picture.height);
|
|
368
|
+
if (tooLarge) throw new Error(tooLarge);
|
|
369
|
+
const natural = await decoded(picture.url);
|
|
370
|
+
if (emptyPicture(natural)) throw new Error(NOT_AN_IMAGE);
|
|
371
|
+
const tooLargeShown = oversize(natural.width, natural.height);
|
|
372
|
+
if (tooLargeShown) throw new Error(tooLargeShown);
|
|
373
|
+
if (Date.now() - started > SLOW_RENDER_MS) inFlight.delete(mark);
|
|
374
|
+
else clearMark(mark);
|
|
375
|
+
const shown = picture.width ? picture : { ...picture, width: natural.width, height: natural.height };
|
|
376
|
+
remember(key, shown);
|
|
377
|
+
return shown;
|
|
378
|
+
} catch (e) {
|
|
379
|
+
// A timeout's mark stays: the block is suspect on the next load too.
|
|
380
|
+
if (e?.timedOut) inFlight.delete(mark);
|
|
381
|
+
else clearMark(mark);
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function draw(pre, lang, source, renderer) {
|
|
387
|
+
const now = theme(doc, win);
|
|
388
|
+
const key = cacheKey(lang, now.name, source);
|
|
389
|
+
const hit = cache.get(key);
|
|
390
|
+
if (hit) { waiting.delete(pre); place(pre, hit, now.name, lang); return; }
|
|
391
|
+
if (broken.has(lang)) { waiting.delete(pre); fail(pre, lang, broken.get(lang)); return; }
|
|
392
|
+
let retry = false;
|
|
393
|
+
try {
|
|
394
|
+
place(pre, await pictureFor(key, lang, source, renderer, now), now.name, lang);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
// A redraw that failed keeps the picture it has.
|
|
397
|
+
if (!e?.dropped && !figureOf(pre)) fail(pre, lang, e?.message);
|
|
398
|
+
retry = !!e?.dropped;
|
|
399
|
+
} finally {
|
|
400
|
+
waiting.delete(pre);
|
|
401
|
+
}
|
|
402
|
+
// Queued behind a frame that died (another block's timeout): a new frame's
|
|
403
|
+
// turn. A session switch's frames go with their blocks, which aren't connected.
|
|
404
|
+
// Each such death uses up one block, so this ends; the cap is for what would
|
|
405
|
+
// otherwise spin the page if that ever stopped being true.
|
|
406
|
+
if (!retry) return;
|
|
407
|
+
const tries = (retries.get(pre) || 0) + 1;
|
|
408
|
+
retries.set(pre, tries);
|
|
409
|
+
if (tries <= MAX_RETRIES) consider(pre);
|
|
410
|
+
else fail(pre, lang, "its renderer keeps stopping");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A copy.js wrap took the <pre> away from a figure placed first: back with it.
|
|
414
|
+
function rejoin(pre) {
|
|
415
|
+
if (figureOf(pre) || !pre.hidden) return;
|
|
416
|
+
const wrap = pre.parentElement;
|
|
417
|
+
const stray = wrap?.classList.contains("code-wrap") && wrap.previousElementSibling;
|
|
418
|
+
if (stray?.classList?.contains(DRAWN)) wrap.insertBefore(stray, pre);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function watchFor(entry) {
|
|
422
|
+
const scroller = scrollerOf(entry.target, rootOf(entry.target), win);
|
|
423
|
+
if (scroller === entry.scroller) return;
|
|
424
|
+
entry.scroller = scroller;
|
|
425
|
+
near(entry.target, scroller, entry.start);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function consider(pre) {
|
|
429
|
+
if (!pre.isConnected || !pre.closest(TEXTS) || pre.dataset.blockFailed) return;
|
|
430
|
+
const pending = waiting.get(pre);
|
|
431
|
+
if (pending) {
|
|
432
|
+
if (!pending.drawing) watchFor(pending);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
rejoin(pre);
|
|
436
|
+
const lang = pre.getAttribute("data-lang");
|
|
437
|
+
const source = pre.textContent;
|
|
438
|
+
if (!source.trim()) return; // an empty fence is left alone
|
|
439
|
+
withRenderers((renderers) => {
|
|
440
|
+
const renderer = renderers.get(lang);
|
|
441
|
+
if (!renderer || !pre.isConnected || waiting.has(pre) || pre.dataset.blockFailed) return;
|
|
442
|
+
const now = theme(doc, win);
|
|
443
|
+
const figure = figureOf(pre);
|
|
444
|
+
if (figure && figure.dataset.theme === now.name) return;
|
|
445
|
+
const hit = cache.get(cacheKey(lang, now.name, source));
|
|
446
|
+
if (hit) { place(pre, hit, now.name, lang); return; } // no flash: this frame
|
|
447
|
+
if (!figure) {
|
|
448
|
+
if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { fail(pre, lang, "it is over 64 KB"); return; }
|
|
449
|
+
const mark = markKey(lang, source);
|
|
450
|
+
if (storage.has(mark) && !inFlight.has(mark)) { fail(pre, lang, "it hung or took too long the last time"); return; }
|
|
451
|
+
}
|
|
452
|
+
// A drawn block's <pre> is hidden, which never intersects: its figure does.
|
|
453
|
+
const entry = {
|
|
454
|
+
target: figure || pre,
|
|
455
|
+
scroller: null,
|
|
456
|
+
drawing: false,
|
|
457
|
+
start: () => { entry.drawing = true; draw(pre, lang, source, renderer); },
|
|
458
|
+
};
|
|
459
|
+
waiting.set(pre, entry);
|
|
460
|
+
watchFor(entry);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function scan(el) {
|
|
465
|
+
if (el.matches?.(BLOCKS)) consider(el);
|
|
466
|
+
for (const pre of el.querySelectorAll?.(BLOCKS) || []) consider(pre);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const root of roots) {
|
|
470
|
+
new win.MutationObserver((records) => {
|
|
471
|
+
noticeSession();
|
|
472
|
+
for (const record of records) {
|
|
473
|
+
for (const node of record.addedNodes) if (node.nodeType === 1) scan(node);
|
|
474
|
+
}
|
|
475
|
+
}).observe(root, { childList: true, subtree: true });
|
|
476
|
+
scan(root);
|
|
477
|
+
|
|
478
|
+
root.addEventListener("mousedown", (e) => {
|
|
479
|
+
if (e.target.closest?.(".block-toggle")) e.preventDefault();
|
|
480
|
+
});
|
|
481
|
+
// Delegated, so a Revise clone's work too.
|
|
482
|
+
root.addEventListener("click", (e) => {
|
|
483
|
+
const toggle = e.target.closest?.(".block-toggle");
|
|
484
|
+
if (toggle) {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
e.stopPropagation();
|
|
487
|
+
const figure = toggle.parentElement;
|
|
488
|
+
setSourceShown(figure, figure.nextElementSibling, !figure.classList.contains("source"));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const note = e.target.closest?.(".block-note");
|
|
492
|
+
if (note) { copy(note.dataset.text); return; }
|
|
493
|
+
if (e.target.closest?.(".block-img")) e.target.closest(`.${DRAWN}`).classList.toggle("natural");
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
|
|
498
|
+
for (const root of roots) scan(root);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return { scan, dropFrames };
|
|
502
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* A drawn fenced block (block_host.js): the picture scales to the column, a click shows its natural size in a scroller; the toggle shows the source, in the block's place. In a .code-wrap the copy button sits top-right, so the toggle sits left of it. */
|
|
2
|
+
.block-drawn{position:relative;margin:0 0 12px;white-space:normal}
|
|
3
|
+
.code-wrap>.block-drawn{margin:0}
|
|
4
|
+
.code-wrap>.block-drawn.source{height:0}
|
|
5
|
+
.block-drawn:last-child{margin-bottom:0}
|
|
6
|
+
.block-scroll{overflow-x:auto;padding:4px 0}
|
|
7
|
+
/* max-height and the natural max-width are block_host.js MAX_PICTURE_SIDE: a backstop, so no parsing or decoding surprise reaches the browser's 33M px. */
|
|
8
|
+
.block-img{display:block;max-width:100%;max-height:50000px;object-fit:contain;height:auto;margin:0 auto;cursor:zoom-in}
|
|
9
|
+
.block-drawn.natural .block-img{max-width:50000px;margin:0;cursor:zoom-out}
|
|
10
|
+
.block-drawn.source .block-scroll{display:none}
|
|
11
|
+
.block-toggle{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
|
|
12
|
+
.code-wrap>.block-drawn>.block-toggle{right:34px}
|
|
13
|
+
.block-toggle svg{display:block}
|
|
14
|
+
.block-drawn:hover>.block-toggle,.code-wrap:hover>.block-drawn>.block-toggle,.block-toggle:focus-visible,.block-drawn.source>.block-toggle{opacity:1}
|
|
15
|
+
.block-toggle:hover:not(:disabled){filter:none}@media(hover:hover){.block-toggle:hover:not(:disabled){color:var(--text-bright)}}
|
|
16
|
+
.block-drawn.source:not(:last-child){margin:0}
|
|
17
|
+
/* Its words are the attribute's, so a bubble's text stays the answer's (block_host.js fail). */
|
|
18
|
+
.block-note{margin:4px 0 12px;font-size:12px;color:var(--text-3);white-space:normal}
|
|
19
|
+
.block-note{cursor:copy}
|
|
20
|
+
.block-note::before{content:attr(data-text)}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A drawn block's two faces (block_host.js): the picture, or its source. The
|
|
2
|
+
// source <pre> stays in the DOM right after the figure, `hidden` while the
|
|
3
|
+
// picture shows, so whatever reads text (a claim's walk, copy) sees what it
|
|
4
|
+
// always did. The DOM is the state: a Revise clone carries both with it.
|
|
5
|
+
//
|
|
6
|
+
// Pure DOM, so it runs under `node --test`. A module of its own, for
|
|
7
|
+
// history_reveal.js too: static JS is cached at unversioned URLs, and a new
|
|
8
|
+
// export on an old module would blank the page.
|
|
9
|
+
|
|
10
|
+
export const DRAWN = "block-drawn";
|
|
11
|
+
|
|
12
|
+
const SHOW = "Show the source";
|
|
13
|
+
const HIDE = "Show the picture";
|
|
14
|
+
|
|
15
|
+
// The figure drawn for +pre+, or null.
|
|
16
|
+
export function figureOf(pre) {
|
|
17
|
+
const before = pre?.previousElementSibling;
|
|
18
|
+
return before?.classList?.contains(DRAWN) ? before : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The source shown (and the picture hidden), or the other way round.
|
|
22
|
+
export function setSourceShown(figure, pre, shown) {
|
|
23
|
+
pre.hidden = !shown;
|
|
24
|
+
figure.classList.toggle("source", shown);
|
|
25
|
+
const toggle = figure.querySelector(":scope > .block-toggle");
|
|
26
|
+
if (!toggle) return;
|
|
27
|
+
toggle.setAttribute("aria-pressed", shown ? "true" : "false");
|
|
28
|
+
toggle.setAttribute("aria-label", shown ? HIDE : SHOW);
|
|
29
|
+
toggle.title = shown ? HIDE : SHOW;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A jump that lands in a drawn block's source shows it (history_reveal.js).
|
|
33
|
+
export function revealBlockSource(el) {
|
|
34
|
+
const pre = el?.closest?.("pre[hidden]");
|
|
35
|
+
const figure = figureOf(pre);
|
|
36
|
+
if (figure) setSourceShown(figure, pre, true);
|
|
37
|
+
}
|
|
@@ -28,12 +28,16 @@ export function removeQueuedBubble(bubble) {
|
|
|
28
28
|
|
|
29
29
|
// A prompt that started a turn: not a line steered into a running one, nor a
|
|
30
30
|
// failed turn's prompt, drawn again after the history (it went back to its
|
|
31
|
-
// sender: the session and the turn count have no such turn).
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// sender: the session and the turn count have no such turn). A wake turn's
|
|
32
|
+
// note opens its turn too. The same turns as Steer.turn_opening? in Ruby, which
|
|
33
|
+
// counts turns_since.
|
|
34
|
+
export const TURN_PROMPT = ".bubble.user:not(.steered):not(.failed), .bubble.note.turn-start";
|
|
35
|
+
|
|
36
|
+
// The history's turn prompts, oldest first (what turns_since counts). Its
|
|
37
|
+
// children, tested one by one: `:scope > ` would reach only TURN_PROMPT's
|
|
38
|
+
// first branch, the comma's other one matching nested elements too.
|
|
35
39
|
export function turnPromptBubbles(historyEl) {
|
|
36
|
-
return [...historyEl.
|
|
40
|
+
return [...historyEl.children].filter((el) => el.matches(TURN_PROMPT));
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
// The history's one line while it has nothing else (loading, an error,
|
|
@@ -35,11 +35,14 @@ export function noticeCardLine(card) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// A notice inside its <details>: the line as the summary, and where it
|
|
38
|
-
// came from when the row is opened (the line wraps then)
|
|
38
|
+
// came from when the row is opened (the line wraps then): the command that
|
|
39
|
+
// showed it (+label+, "from /short"), then the source.
|
|
39
40
|
export function noticeTemplate(card) {
|
|
40
41
|
const title = String(card?.title ?? "").trim();
|
|
41
42
|
const source = String(card?.source ?? "").trim();
|
|
42
|
-
const
|
|
43
|
+
const named = source && source !== title ? html`<span class="card-source">${source}</span>` : nothing;
|
|
44
|
+
const lead = card?.label ? `${card.label}${named === nothing ? "" : " · "}` : "from ";
|
|
45
|
+
const from = card?.label || named !== nothing ? html`<div class="notice-from">${lead}${named}</div>` : nothing;
|
|
43
46
|
return html`<summary>${noticeCardLine(card)}</summary>${from}`;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -64,17 +67,19 @@ export function cardBodyTemplate(card) {
|
|
|
64
67
|
return body.trim() ? html`<pre>${body}</pre>` : null;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
// The card: its head (title,
|
|
68
|
-
//
|
|
70
|
+
// The card: its head (title, the command that showed it after a reload,
|
|
71
|
+
// source), body and one button per action (the command as its tooltip and
|
|
72
|
+
// data), then the line a failed action writes.
|
|
69
73
|
export function cardTemplate(card) {
|
|
70
74
|
const source = card.source ? html`<span class="card-source">${card.source}</span>` : nothing;
|
|
75
|
+
const label = card.label ? html`<span class="card-label">${card.label}</span>` : nothing;
|
|
71
76
|
const body = cardBodyTemplate(card);
|
|
72
77
|
const actions = (card.actions || []).filter((a) => a && a.command);
|
|
73
78
|
const buttons = actions.length
|
|
74
79
|
? html`<div class="card-actions">${actions.map((a) =>
|
|
75
80
|
html`<button type="button" class="card-action ghost" data-command=${a.command} title=${a.command}>${a.label || a.command}</button>`)}</div>`
|
|
76
81
|
: nothing;
|
|
77
|
-
return html`<div class="card-head"><span class="card-title">${card.title || ""}</span>${source}</div>${body ? html`<div class="card-body">${body}</div>` : nothing}${buttons}<div class="card-error hidden"></div>`;
|
|
82
|
+
return html`<div class="card-head"><span class="card-title">${card.title || ""}</span>${label}${source}</div>${body ? html`<div class="card-body">${body}</div>` : nothing}${buttons}<div class="card-error hidden"></div>`;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
// A card element → the card it shows, as drawCard drew it.
|
|
@@ -86,8 +91,10 @@ const DRAWN = new WeakMap();
|
|
|
86
91
|
// redraw writes only what changed: an open notice stays open, a body
|
|
87
92
|
// that didn't change keeps its nodes (a selection in it), and the same
|
|
88
93
|
// body again from the stream (text, no html) keeps the rendered one. A
|
|
89
|
-
// failed action's line goes, as the card is new.
|
|
90
|
-
|
|
94
|
+
// failed action's line goes, as the card is new. A live update has no label
|
|
95
|
+
// (only a reload's listing does): the card keeps the one it had.
|
|
96
|
+
export function drawCard(el, incoming) {
|
|
97
|
+
const card = { ...incoming, label: incoming.label ?? DRAWN.get(el)?.label };
|
|
91
98
|
const notice = isNoticeCard(card);
|
|
92
99
|
if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
|
|
93
100
|
const fresh = el.ownerDocument.createElement(notice ? "details" : "div");
|
|
@@ -124,9 +131,9 @@ export function refreshCardBody(el, card) {
|
|
|
124
131
|
paint(el, { ...drawn, body_html: card.body_html });
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
// Where a snapshot's card or between-turns notice goes: before the
|
|
128
|
-
//
|
|
129
|
-
//
|
|
134
|
+
// Where a snapshot's card or between-turns notice goes: before the bubble that
|
|
135
|
+
// opens the first turn after it (turns_since counts the turn openings the page
|
|
136
|
+
// draws after it, Bridge::CardPlaces), or null for the end of the history.
|
|
130
137
|
export function cardPlace(userBubbles, card) {
|
|
131
138
|
return recapPlace(userBubbles, card?.turns_since);
|
|
132
139
|
}
|
|
@@ -86,11 +86,11 @@ export function createCardsPlace({
|
|
|
86
86
|
// A snapshot's cards and notices, each above the first turn after it; a
|
|
87
87
|
// turn's own notice where it showed live (placeTurnNotice). +end+: what
|
|
88
88
|
// closes the history below them (a failed turn's prompt), for those with
|
|
89
|
-
// no turn after them.
|
|
89
|
+
// no turn after them; one shown after that prompt went back stays below it.
|
|
90
90
|
function placeSnapshotCards(entries, { before: end = null } = {}) {
|
|
91
91
|
const users = turnPromptBubbles(historyEl);
|
|
92
92
|
for (const entry of entries) {
|
|
93
|
-
const before = cardPlace(users, entry) || end;
|
|
93
|
+
const before = cardPlace(users, entry) || (entry.after_failed ? null : end);
|
|
94
94
|
if (isCard(entry)) upsertCard(entry, { before });
|
|
95
95
|
else if (entry.type === "question") placeResolvedQuestion(entry, before);
|
|
96
96
|
else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
|