samagotchi 0.170.0 → 0.171.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 +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +37 -23
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +2 -2
- data/docs/internals/bridge.md +5 -4
- data/docs/internals/context-telemetry.md +13 -1
- data/docs/internals/engine.md +4 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +1 -1
- data/docs/internals/participants.md +235 -119
- data/docs/internals/web-frontend.md +164 -33
- data/docs/internals/working-set.md +69 -5
- data/docs/releasing.md +2 -1
- data/docs/sessions.md +6 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
- data/lib/samagotchi/bridge.rb +39 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +13 -0
- data/lib/samagotchi/engine.rb +5 -1
- data/lib/samagotchi/event_fanout.rb +22 -1
- data/lib/samagotchi/grants.rb +5 -0
- data/lib/samagotchi/guardrails/review_paths.rb +2 -2
- data/lib/samagotchi/image_store.rb +51 -12
- data/lib/samagotchi/kernel_loop.rb +7 -1
- data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
- data/lib/samagotchi/llm_context_stale.rb +4 -1
- data/lib/samagotchi/participant_messages.rb +39 -0
- data/lib/samagotchi/participant_revise.rb +61 -0
- data/lib/samagotchi/participant_uploads.rb +98 -0
- data/lib/samagotchi/reply_wait/poll.rb +5 -1
- data/lib/samagotchi/revise_anchor.rb +9 -0
- data/lib/samagotchi/send_command.rb +5 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager/delivery.rb +7 -0
- data/lib/samagotchi/session_stores.rb +2 -0
- data/lib/samagotchi/tool_runner.rb +39 -5
- data/lib/samagotchi/tools/memory.rb +26 -7
- data/lib/samagotchi/turn_anchors.rb +0 -3
- data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
- data/lib/samagotchi/verify_anchor.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -77
- data/lib/samagotchi/web/participant_access.rb +129 -26
- data/lib/samagotchi/web/participant_images.rb +185 -0
- data/lib/samagotchi/web/participant_review.rb +19 -138
- data/lib/samagotchi/web/participant_show.rb +240 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
- data/lib/samagotchi/web/participant_view.rb +331 -24
- data/lib/samagotchi/web/public/annotate_bar.css +1 -2
- data/lib/samagotchi/web/public/annotate_bar.js +1 -2
- data/lib/samagotchi/web/public/app.js +124 -88
- data/lib/samagotchi/web/public/block_host.js +7 -4
- data/lib/samagotchi/web/public/bubbles.js +9 -1
- data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
- data/lib/samagotchi/web/public/chip_popover.js +5 -4
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer.css +4 -6
- data/lib/samagotchi/web/public/composer_mode.js +11 -6
- data/lib/samagotchi/web/public/composer_mount.js +4 -4
- data/lib/samagotchi/web/public/ctx.js +12 -3
- data/lib/samagotchi/web/public/delivery_line.js +34 -5
- data/lib/samagotchi/web/public/history_shape.js +5 -1
- data/lib/samagotchi/web/public/images.js +7 -1
- data/lib/samagotchi/web/public/index.html +40 -4
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +5 -4
- data/lib/samagotchi/web/public/owner_claim.js +3 -3
- data/lib/samagotchi/web/public/owner_verify.js +0 -2
- data/lib/samagotchi/web/public/participant_bar.js +108 -0
- data/lib/samagotchi/web/public/participant_main.js +289 -0
- data/lib/samagotchi/web/public/participant_model.js +5 -292
- data/lib/samagotchi/web/public/participant_names.js +6 -1
- data/lib/samagotchi/web/public/participant_revise.js +66 -0
- data/lib/samagotchi/web/public/participants_panel.js +3 -12
- data/lib/samagotchi/web/public/question_cards.js +14 -3
- data/lib/samagotchi/web/public/refs.js +53 -36
- data/lib/samagotchi/web/public/reload_check.js +68 -11
- data/lib/samagotchi/web/public/revise_stack.js +21 -10
- data/lib/samagotchi/web/public/send_turn.js +24 -6
- data/lib/samagotchi/web/public/session_fields.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +12 -4
- data/lib/samagotchi/web/public/session_sync.js +9 -2
- data/lib/samagotchi/web/public/session_view.js +17 -7
- data/lib/samagotchi/web/public/stage_asking.js +6 -4
- data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
- data/lib/samagotchi/web/public/stage_view.js +18 -4
- data/lib/samagotchi/web/public/stage_waiting.js +74 -6
- data/lib/samagotchi/web/public/storage.js +22 -0
- data/lib/samagotchi/web/public/stream_handlers.js +2 -1
- data/lib/samagotchi/web/public/tokens.css +1 -1
- data/lib/samagotchi/web/public/turn_block.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -6
- data/lib/samagotchi/web/public/turn_html.js +9 -2
- data/lib/samagotchi/web/public/turn_range.js +1 -2
- data/lib/samagotchi/web/public/verify_target.js +2 -4
- data/lib/samagotchi/web/public/viewer.js +59 -0
- data/lib/samagotchi/web/public/viewer_self.js +53 -0
- data/lib/samagotchi/web/public/working_set.js +11 -5
- data/lib/samagotchi/web/public/working_set_view.js +53 -8
- data/lib/samagotchi/web/public/ws_cards.js +89 -0
- data/lib/samagotchi/web/public/ws_peek.js +106 -0
- data/lib/samagotchi/web/public/ws_preview.css +126 -0
- data/lib/samagotchi/web/public/ws_reader.js +319 -0
- data/lib/samagotchi/web/public/ws_timeline.js +200 -0
- data/lib/samagotchi/web/public/ws_way_in.js +25 -0
- data/lib/samagotchi/web/session_control.rb +39 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +75 -28
- data/lib/samagotchi/web/viewer.rb +70 -0
- data/lib/samagotchi/web/working_set_routes.rb +132 -0
- data/lib/samagotchi/worker.rb +19 -14
- data/lib/samagotchi/working_set.rb +260 -8
- metadata +20 -6
- data/lib/samagotchi/web/public/participant.html +0 -218
- data/lib/samagotchi/web/public/participant.js +0 -719
- data/lib/samagotchi/web/public/participant_annotate.js +0 -76
- data/lib/samagotchi/web/public/review_history.js +0 -72
- data/lib/samagotchi/web/public/tools_overview.js +0 -42
|
@@ -169,8 +169,9 @@ export function editorUrl(template, path, line) {
|
|
|
169
169
|
|
|
170
170
|
// A ref's targets, per kind, in order: the first available one is a plain
|
|
171
171
|
// click's (the main target), all of them are the menu's. Each says when
|
|
172
|
-
// it's available for +
|
|
173
|
-
// editorUrl}, the server's capabilities.editor and editor_url
|
|
172
|
+
// it's available for +refEditor+ (where this page can open a ref: {editor,
|
|
173
|
+
// editorUrl}, the server's capabilities.editor and editor_url; not
|
|
174
|
+
// viewer.js's viewer, which says what the viewer may do at all) and
|
|
174
175
|
// +target+ (refTarget's). A secondary one (copy) joins a ref that has
|
|
175
176
|
// another, never alone. A line (a diff or read line) has its click only.
|
|
176
177
|
// A link whose text is the ref (github-pr's PR diff): its href stays the
|
|
@@ -181,8 +182,8 @@ const LINK = Object.freeze({
|
|
|
181
182
|
id: "link",
|
|
182
183
|
label: (target) => target.actions?.find((action) => sameUrl(action.url, target.href))?.label ||
|
|
183
184
|
(/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
|
|
184
|
-
available: (
|
|
185
|
-
run: (target,
|
|
185
|
+
available: (_refEditor, target) => !!target.href,
|
|
186
|
+
run: (target, _refEditor, env) => env.openLink(target.href),
|
|
186
187
|
});
|
|
187
188
|
|
|
188
189
|
// The ref's own actions (a plugin's, bindRef), one target each, for every
|
|
@@ -196,8 +197,8 @@ const URL_ACTIONS = Object.freeze({
|
|
|
196
197
|
.map((action) => Object.freeze({
|
|
197
198
|
id: `url:${action.id}`,
|
|
198
199
|
label: () => action.label,
|
|
199
|
-
available: (
|
|
200
|
-
run: (_target,
|
|
200
|
+
available: (_refEditor, target) => !target.isLine,
|
|
201
|
+
run: (_target, _refEditor, env) => env.openLink(action.url),
|
|
201
202
|
hint: () => action.url,
|
|
202
203
|
})),
|
|
203
204
|
});
|
|
@@ -226,8 +227,8 @@ const MORE_REFS = Object.freeze({
|
|
|
226
227
|
items.push(Object.freeze({
|
|
227
228
|
id: `more:${at}:editor`,
|
|
228
229
|
label: () => `${name} · Open in editor`,
|
|
229
|
-
available: (
|
|
230
|
-
run: (_target,
|
|
230
|
+
available: (refEditor, target) => !target.isLine && refEditor?.editor === true && !!refEditor.editorUrl,
|
|
231
|
+
run: (_target, refEditor, env) => env.open(editorUrl(refEditor.editorUrl, ref.target.path, ref.target.line)),
|
|
231
232
|
hint: () => `Open the current file in your editor${ref.target.line ? ` at line ${ref.target.line}` : ""}`,
|
|
232
233
|
}));
|
|
233
234
|
}
|
|
@@ -237,8 +238,8 @@ const MORE_REFS = Object.freeze({
|
|
|
237
238
|
items.push(Object.freeze({
|
|
238
239
|
id: `more:${at}:${action.id}`,
|
|
239
240
|
label: () => `${name} · ${action.label}`,
|
|
240
|
-
available: (
|
|
241
|
-
run: (_target,
|
|
241
|
+
available: (_refEditor, target) => !target.isLine,
|
|
242
|
+
run: (_target, _refEditor, env) => env.openLink(action.url),
|
|
242
243
|
hint: () => action.url,
|
|
243
244
|
}));
|
|
244
245
|
}
|
|
@@ -253,10 +254,10 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
253
254
|
Object.freeze({
|
|
254
255
|
id: "editor",
|
|
255
256
|
label: () => "Open in editor",
|
|
256
|
-
available: (
|
|
257
|
-
url: (target,
|
|
258
|
-
run(target,
|
|
259
|
-
const url = this.url(target,
|
|
257
|
+
available: (refEditor) => refEditor?.editor === true && !!refEditor.editorUrl,
|
|
258
|
+
url: (target, refEditor) => editorUrl(refEditor.editorUrl, target.path, target.line),
|
|
259
|
+
run(target, refEditor, env) {
|
|
260
|
+
const url = this.url(target, refEditor);
|
|
260
261
|
if (url) env.open(url);
|
|
261
262
|
},
|
|
262
263
|
// The hover hint: it opens the file as it is now.
|
|
@@ -268,17 +269,29 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
268
269
|
Object.freeze({
|
|
269
270
|
id: "show",
|
|
270
271
|
label: () => "Show in turn",
|
|
271
|
-
available: (
|
|
272
|
-
run: (target,
|
|
272
|
+
available: (_refEditor, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
|
|
273
|
+
run: (target, _refEditor, env) => env.show(target),
|
|
273
274
|
hint: () => "Show the row that touched it in the turn",
|
|
274
275
|
}),
|
|
275
276
|
MORE_REFS,
|
|
277
|
+
// What chi has of the file (the working set's reader, ws_reader.js):
|
|
278
|
+
// an owner's page with the reader, for a file of the set (env's
|
|
279
|
+
// whatChiHas). Secondary and after a row's other refs: never a ref's main
|
|
280
|
+
// target, so a click does what it did; it is in the ▾ menu.
|
|
281
|
+
Object.freeze({
|
|
282
|
+
id: "has",
|
|
283
|
+
label: () => "What chi has",
|
|
284
|
+
secondary: true,
|
|
285
|
+
available: (_refEditor, target, env) => !target.isLine && !!target.path && !!env?.whatChiHas?.has(target.path),
|
|
286
|
+
run: (target, _refEditor, env) => env.whatChiHas.open(target.path),
|
|
287
|
+
hint: () => "What chi has of it: its reads and edits, newest first",
|
|
288
|
+
}),
|
|
276
289
|
Object.freeze({
|
|
277
290
|
id: "copy",
|
|
278
291
|
label: () => "Copy path",
|
|
279
292
|
secondary: true,
|
|
280
|
-
available: (
|
|
281
|
-
run: (target,
|
|
293
|
+
available: (_refEditor, target) => !target.isLine && !!target.path,
|
|
294
|
+
run: (target, _refEditor, env) => env.copy(target.path),
|
|
282
295
|
}),
|
|
283
296
|
]),
|
|
284
297
|
// Any other kind (a plugin's issue, commit, …): its link, then its
|
|
@@ -286,14 +299,15 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
286
299
|
"*": Object.freeze([LINK, URL_ACTIONS, MORE_REFS]),
|
|
287
300
|
});
|
|
288
301
|
|
|
289
|
-
// The targets +
|
|
290
|
-
// when only a secondary one is.
|
|
291
|
-
|
|
302
|
+
// The targets +refEditor+ has for +target+ (refTarget's), in order; none
|
|
303
|
+
// when only a secondary one is. +env+: installRefClicks' (an action's
|
|
304
|
+
// availability may ask it: whatChiHas).
|
|
305
|
+
export function refActions(target, refEditor, actions = REF_ACTIONS, env = null) {
|
|
292
306
|
if (!target) return [];
|
|
293
307
|
// Own keys only: a kind named like an Object property ("constructor") is
|
|
294
308
|
// any other kind.
|
|
295
309
|
const table = (Object.hasOwn(actions, target.kind) ? actions[target.kind] : actions["*"]) || [];
|
|
296
|
-
const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(
|
|
310
|
+
const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(refEditor, target, env));
|
|
297
311
|
return found.some((action) => !action.secondary) ? found : [];
|
|
298
312
|
}
|
|
299
313
|
|
|
@@ -475,16 +489,19 @@ function defaultOpenLink(href) {
|
|
|
475
489
|
}
|
|
476
490
|
|
|
477
491
|
// One delegated listener set on +root+ for every ref on the page.
|
|
478
|
-
// +
|
|
492
|
+
// +refEditor+() is read per click, key and hover, so a capabilities change
|
|
479
493
|
// (another session, a reload) is seen. The targets run through: +open+(url)
|
|
480
494
|
// (the editor's URL), +openLink+(href), +show+(target) and +copy+(text).
|
|
481
|
-
// +menu+ is the ▾ and its menu (ref_menu.js).
|
|
482
|
-
|
|
495
|
+
// +menu+ is the ▾ and its menu (ref_menu.js). +whatChiHas+: the working
|
|
496
|
+
// set's reader ({has(path), open(path)}; null: none, the "What chi has"
|
|
497
|
+
// target never shows).
|
|
498
|
+
export function installRefClicks(root, refEditor, {
|
|
483
499
|
open = defaultOpen, openLink = defaultOpenLink, show = (target) => showInTurn(target), copy = copyText,
|
|
484
|
-
selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument),
|
|
500
|
+
selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument), whatChiHas = null,
|
|
485
501
|
} = {}) {
|
|
486
|
-
const env = { open, openLink, show, copy };
|
|
487
|
-
const
|
|
502
|
+
const env = { open, openLink, show, copy, whatChiHas };
|
|
503
|
+
const actionsOf = (target) => refActions(target, refEditor(), REF_ACTIONS, env);
|
|
504
|
+
const run = (action, target) => action.run(target, refEditor(), env);
|
|
488
505
|
const items = (actions, target) => actions.map((action) => ({
|
|
489
506
|
id: action.id, label: action.label(target), hint: action.hint?.(target) || "", run: () => run(action, target),
|
|
490
507
|
}));
|
|
@@ -497,14 +514,14 @@ export function installRefClicks(root, viewer, {
|
|
|
497
514
|
// the link's own click); Tab works out the tab stops before it moves.
|
|
498
515
|
root.addEventListener("keydown", (e) => {
|
|
499
516
|
pointer = "keyboard";
|
|
500
|
-
if (e.key === "Tab") syncRefTabs(root,
|
|
517
|
+
if (e.key === "Tab") syncRefTabs(root, refEditor());
|
|
501
518
|
}, true);
|
|
502
519
|
|
|
503
520
|
root.addEventListener("click", (e) => {
|
|
504
521
|
if (e.button !== 0 || e.defaultPrevented) return;
|
|
505
522
|
const target = refTarget(e.target);
|
|
506
523
|
if (!target) return;
|
|
507
|
-
const actions =
|
|
524
|
+
const actions = actionsOf(target);
|
|
508
525
|
if (!actions.length) return;
|
|
509
526
|
if (selectingIn(target.owner, selection())) return;
|
|
510
527
|
if (pointer === "touch" && !target.isLine && actions.length > 1) {
|
|
@@ -529,7 +546,7 @@ export function installRefClicks(root, viewer, {
|
|
|
529
546
|
if (e.key === "Escape") menu.hideMore();
|
|
530
547
|
if (e.key !== "Enter" && e.key !== "ArrowDown") return;
|
|
531
548
|
const target = refTarget(el);
|
|
532
|
-
const actions =
|
|
549
|
+
const actions = actionsOf(target);
|
|
533
550
|
if (!actions.length) return;
|
|
534
551
|
if (e.key === "Enter") {
|
|
535
552
|
if (actions[0].id === "link") return;
|
|
@@ -547,13 +564,13 @@ export function installRefClicks(root, viewer, {
|
|
|
547
564
|
const mark = (e) => {
|
|
548
565
|
const target = refTarget(e.target);
|
|
549
566
|
if (!target) return;
|
|
550
|
-
const actions =
|
|
567
|
+
const actions = actionsOf(target);
|
|
551
568
|
const el = target.el;
|
|
552
569
|
if (actions.length) el.dataset.refMain = actions[0].id;
|
|
553
570
|
else delete el.dataset.refMain;
|
|
554
571
|
if (!target.isLine) setRefRole(el, actions);
|
|
555
572
|
hint(el, target, actions[0]);
|
|
556
|
-
if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el),
|
|
573
|
+
if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), actionsOf(refTarget(el)), { focus: true }));
|
|
557
574
|
};
|
|
558
575
|
root.addEventListener("mouseover", mark);
|
|
559
576
|
root.addEventListener("focusin", mark);
|
|
@@ -565,12 +582,12 @@ export function installRefClicks(root, viewer, {
|
|
|
565
582
|
root.addEventListener("focusout", leave);
|
|
566
583
|
}
|
|
567
584
|
|
|
568
|
-
// The tab stops under +root+ for +
|
|
585
|
+
// The tab stops under +root+ for +refEditor+: a ref is one (a button, with
|
|
569
586
|
// aria-haspopup when it has a menu) only while it has a target, so a
|
|
570
587
|
// phone's or an editorless viewer's row titles aren't. Worked out before a
|
|
571
588
|
// Tab moves the focus and on a ref's hover or focus; a line never is one.
|
|
572
|
-
export function syncRefTabs(root,
|
|
573
|
-
for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el),
|
|
589
|
+
export function syncRefTabs(root, refEditor) {
|
|
590
|
+
for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), refEditor));
|
|
574
591
|
}
|
|
575
592
|
|
|
576
593
|
function setRefRole(el, actions) {
|
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
// fallback, data-undrawn): 0 unless chi web missed one. Fire-and-forget: no toast, no
|
|
14
14
|
// store write, a failure is dropped.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// Compared: the turn's own cards and questions too. A drawn turn already
|
|
17
|
+
// renders its plugin cards (turn_block.js), and the snapshot's questions and
|
|
18
|
+
// an undrawn turn's entries are placed into the offscreen holder by
|
|
19
|
+
// cards_offscreen.js, the same placer the page uses with inert hooks. Only
|
|
20
|
+
// entries CardStore attributes to this turn (of_turn) are compared; a card
|
|
21
|
+
// the turn doesn't own (another turn's, a between-turn notice) is dropped on
|
|
22
|
+
// both sides, and a question the read attributes to no turn and stamps to no
|
|
23
|
+
// opening (an older worker) is dropped as well, since nothing places it.
|
|
24
|
+
// What a card's equality means: its id and title, or a question's summary line
|
|
25
|
+
// — not its body, its buttons, its fold or the tag it is drawn in.
|
|
26
|
+
// Between-turn cards and notices are out of scope (the e2e reload checks
|
|
27
|
+
// cover them).
|
|
20
28
|
import { compareShapes, historyShape } from "./history_shape.js";
|
|
29
|
+
import { isCard, splitSnapshotCards } from "./card.js";
|
|
21
30
|
import { turnElements } from "./turn_range.js";
|
|
22
31
|
|
|
23
32
|
// The opening of a turn: its prompt, a wake's note, /continue no's line.
|
|
@@ -29,8 +38,42 @@ function openingId(el) {
|
|
|
29
38
|
return el?.dataset?.turnId || el?.dataset?.openingId || null;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
// The snapshot entries this turn owns (CardStore's of_turn): a drawn turn
|
|
42
|
+
// already renders its cards, so only its questions come from the placer; an
|
|
43
|
+
// undrawn turn has all of them placed, as a reload places them. Entries the
|
|
44
|
+
// page shows elsewhere, the running turn's and the ones that came during it
|
|
45
|
+
// are out (splitSnapshotCards), as in session_view.js.
|
|
46
|
+
function turnEntries(cards, capabilities, turnId, drawn) {
|
|
47
|
+
const placed = splitSnapshotCards(cards, capabilities).placed.filter((entry) => entry?.of_turn === turnId);
|
|
48
|
+
return drawn ? placed.filter((entry) => entry?.type === "question") : placed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A question the read attributes to no turn (an older worker: no of_turn, and
|
|
52
|
+
// no opening it came after, or one stamped to this very turn) has nothing to
|
|
53
|
+
// place it from, so its card is left out of the compare. Only entries that
|
|
54
|
+
// could belong to this turn count: one stamped to another turn does not
|
|
55
|
+
// silence questions here.
|
|
56
|
+
function looseQuestions(cards, turnId) {
|
|
57
|
+
return (Array.isArray(cards) ? cards : []).some((entry) => entry?.type === "question"
|
|
58
|
+
&& entry?.of_turn == null && (entry?.opening_id == null || String(entry.opening_id) === String(turnId)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The ids of the snapshot's cards that are not this turn's.
|
|
62
|
+
function foreignIds(cards, turnId) {
|
|
63
|
+
return new Set((Array.isArray(cards) ? cards : [])
|
|
64
|
+
.filter((entry) => isCard(entry) && entry?.of_turn !== turnId)
|
|
65
|
+
.map((entry) => String(entry.id ?? "")));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// This turn's items only: a card of another turn or between turns is dropped
|
|
69
|
+
// by id; a question the worker didn't attribute to a turn (an older worker)
|
|
70
|
+
// has nothing to place it from, so questions are dropped on both sides.
|
|
71
|
+
function turnOnly(shape, foreign, questions) {
|
|
72
|
+
return shape.filter((item) => {
|
|
73
|
+
if (item.kind === "card") return !foreign.has(item.id);
|
|
74
|
+
if (item.kind === "question") return questions;
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
34
77
|
}
|
|
35
78
|
|
|
36
79
|
// @param enabled () => whether the check is on (the body's attribute)
|
|
@@ -39,8 +82,13 @@ function withoutCards(shape) {
|
|
|
39
82
|
// turn started streaming)
|
|
40
83
|
// @param read (id) => the session as a reload reads it (the full GET)
|
|
41
84
|
// @param render (data) => that read drawn as a reload draws it (a fragment)
|
|
85
|
+
// @param place (holder, entries) puts the turn's snapshot entries into that
|
|
86
|
+
// holder as a reload's placement would (cards_offscreen.js); without it,
|
|
87
|
+
// cards and questions are dropped from both sides
|
|
88
|
+
// @param capabilities () => the page's capabilities (which notices it shows
|
|
89
|
+
// elsewhere)
|
|
42
90
|
// @param post (id, body) => the counts to chi web
|
|
43
|
-
export function createReloadCheck({ enabled, sessionId, running, read, render, post }) {
|
|
91
|
+
export function createReloadCheck({ enabled, sessionId, running, read, render, place = null, capabilities = () => null, post }) {
|
|
44
92
|
// The hand-off's pieces (stage_view.js onHandedOff), in their order;
|
|
45
93
|
// +immediate+: another turn took the stage at once.
|
|
46
94
|
async function handedOff({ pieces = [], immediate = false } = {}) {
|
|
@@ -56,15 +104,24 @@ export function createReloadCheck({ enabled, sessionId, running, read, render, p
|
|
|
56
104
|
// failed turn hands off at once, and its timing line takes the turn
|
|
57
105
|
// record's figure when its own re-read lands (refreshTimingFromSession),
|
|
58
106
|
// which started at the turn's end, before this one.
|
|
59
|
-
|
|
107
|
+
// Whether the read had a drawing for the turn (turn drawings, P2): a
|
|
108
|
+
// drawn one already renders its own cards, an undrawn one has them
|
|
109
|
+
// placed after the render.
|
|
110
|
+
const drawn = !!data?.turn_drawings?.[turnId];
|
|
111
|
+
const entries = place ? turnEntries(data.cards, capabilities(), turnId, drawn) : [];
|
|
112
|
+
// A question the read can't place for this turn (see looseQuestions) is
|
|
113
|
+
// left out of the compare on both sides.
|
|
114
|
+
const questions = !!place && !looseQuestions(data.cards, turnId);
|
|
115
|
+
const shapeOf = (shape) => turnOnly(shape, foreignIds(data.cards, turnId), questions);
|
|
116
|
+
const live = shapeOf(historyShape(rest.filter((el) => !el.matches?.(NOT_THE_TURNS))));
|
|
60
117
|
const holder = document.createElement("div");
|
|
61
118
|
holder.append(render(data));
|
|
119
|
+
// The turn's own cards and questions, where a reload would put them.
|
|
120
|
+
if (place) place(holder, entries);
|
|
62
121
|
const reopened = [...holder.querySelectorAll(OPENING)].find((el) => openingId(el) === turnId);
|
|
63
|
-
const reloaded = reopened ?
|
|
122
|
+
const reloaded = reopened ? shapeOf(historyShape(turnElements(reopened, { container: holder }))) : [];
|
|
64
123
|
const { same, kinds, excused, explained } = reopened ? compareShapes(live, reloaded)
|
|
65
124
|
: { same: false, kinds: ["prompt"], excused: [], explained: false };
|
|
66
|
-
// Whether the read had a drawing for the turn (turn drawings, P2).
|
|
67
|
-
const drawn = !!data?.turn_drawings?.[turnId];
|
|
68
125
|
const undrawn = holder.querySelectorAll("[data-undrawn]").length;
|
|
69
126
|
await post(id, { turn_id: turnId, same, kinds, excused, explained, drawn, undrawn });
|
|
70
127
|
} catch (_) {
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// answer's place (revise_switch.js; view only, not stored);
|
|
17
17
|
// - the final answer of an ended turn (verify_target.js's rule, a delegate
|
|
18
18
|
// report's reply excluded), unstacked or a stack's top, and not a Verify
|
|
19
|
-
// turn's, gets a Revise button: it arms the composer (app.js).
|
|
19
|
+
// turn's, gets a Revise button: it arms the composer (app.js). A
|
|
20
|
+
// participant's page asks mayRevise too (participant_revise.js: their own
|
|
21
|
+
// answers only); the stubs, the chip and the switcher are everyone's.
|
|
20
22
|
//
|
|
21
23
|
// The stamps live on the history's prompt bubbles (data-revises: a reload's
|
|
22
24
|
// render, a live turn_completed's revises), so the stacks are read from the
|
|
@@ -111,7 +113,8 @@ export function versionChipText(count, at = count - 1) {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
// The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
|
|
114
|
-
// the worker predates Revise turns
|
|
116
|
+
// the worker predates Revise turns; +text+ its words, a participant's page
|
|
117
|
+
// gives its own: participant_revise.js).
|
|
115
118
|
export const REVISE_UNAVAILABLE = "restart this session to use Revise";
|
|
116
119
|
|
|
117
120
|
// The feature gate (app.js featureGates): a live worker without the "revise"
|
|
@@ -121,16 +124,16 @@ export const REVISE_UNAVAILABLE = "restart this session to use Revise";
|
|
|
121
124
|
export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
|
|
122
125
|
return workerPredates(workerFeatures, bridgeUp, "revise");
|
|
123
126
|
}
|
|
124
|
-
export function reviseChipText(label, { unavailable = false } = {}) {
|
|
125
|
-
if (unavailable) return `Revise: ${
|
|
127
|
+
export function reviseChipText(label, { unavailable = false, text = REVISE_UNAVAILABLE } = {}) {
|
|
128
|
+
if (unavailable) return `Revise: ${text}`;
|
|
126
129
|
return label ? `Revising: ${label}` : "Revising the answer";
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
// The composer's chip (app.js renders it into its own element): the armed
|
|
130
133
|
// Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
|
|
131
|
-
export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
|
|
134
|
+
export function reviseChipTemplate(anchor, { unavailable = false, text = REVISE_UNAVAILABLE, onClear } = {}) {
|
|
132
135
|
if (!anchor) return nothing;
|
|
133
|
-
return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ?
|
|
136
|
+
return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ? text : `This message rewrites that answer: ${anchor.label || ""}`}><span class="revise-chip-text">${reviseChipText(anchor.label, { unavailable, text })}</span><button type="button" class="revise-chip-clear" aria-label="Don't revise" title="Don't revise (send as a plain message)" @click=${onClear}>✕</button></span>`;
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// An answer's label (the stub's, the chip's): its first line.
|
|
@@ -190,12 +193,18 @@ export function historyTurns(historyEl) {
|
|
|
190
193
|
// @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
|
|
191
194
|
// its prompt's data-verify: a verdict's check_turn_id)
|
|
192
195
|
// @param unavailable () => the worker predates Revise (the feature gate)
|
|
196
|
+
// @param unavailableText the button's title while +unavailable+ (a
|
|
197
|
+
// participant's page: its own words, participant_revise.js)
|
|
193
198
|
// @param armed () => the turn id the composer's Revise is armed with, or null
|
|
194
199
|
// @param onRevise ({turn_id, label}) the Revise button
|
|
200
|
+
// @param mayRevise (turn, turns) => whether this viewer may revise +turn+
|
|
201
|
+
// (one of historyTurns' +turns+): false draws no button, the stack's
|
|
202
|
+
// switcher and chip stay
|
|
195
203
|
// @return {decorate()} (the observer runs it; app.js after a change it reads)
|
|
196
204
|
export function installReviseStacks({
|
|
197
205
|
historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
|
|
198
|
-
unavailable = () => false, armed = () => null, onRevise = () => {},
|
|
206
|
+
unavailable = () => false, unavailableText = REVISE_UNAVAILABLE, armed = () => null, onRevise = () => {},
|
|
207
|
+
mayRevise = () => true,
|
|
199
208
|
}) {
|
|
200
209
|
const stubs = new Map();
|
|
201
210
|
const rows = new Map();
|
|
@@ -244,14 +253,14 @@ export function installReviseStacks({
|
|
|
244
253
|
return row;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
|
-
function drawRow(row, { id, answer, chain, openings }) {
|
|
256
|
+
function drawRow(row, { id, answer, chain, openings, may }) {
|
|
248
257
|
const off = unavailable();
|
|
249
258
|
const isArmed = armed() === id;
|
|
250
259
|
const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
|
|
251
260
|
const versions = chain
|
|
252
261
|
? html`<details class="stack-versions"><summary>${versionChipText(chain.length, at)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
|
|
253
262
|
: nothing;
|
|
254
|
-
const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ?
|
|
263
|
+
const button = may ? html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? unavailableText : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>` : nothing;
|
|
255
264
|
row.classList.toggle("stacked", !!chain);
|
|
256
265
|
render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
|
|
257
266
|
rowAnswer.set(row, answer);
|
|
@@ -302,9 +311,11 @@ export function installReviseStacks({
|
|
|
302
311
|
if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
|
|
303
312
|
if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
|
|
304
313
|
if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
|
|
314
|
+
const may = !!mayRevise(turn, turns);
|
|
315
|
+
if (!may && !tops.has(turn.id)) continue;
|
|
305
316
|
const row = rowFor(turn.id, answer);
|
|
306
317
|
keepRows.add(turn.id);
|
|
307
|
-
drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
|
|
318
|
+
drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings, may });
|
|
308
319
|
}
|
|
309
320
|
syncViews(tops, keepRows, new Map(turns.filter((t) => t.id).map((t) => [t.id, t.answer])));
|
|
310
321
|
// A divider between two folded stubs folds into the later one.
|
|
@@ -26,15 +26,22 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
|
|
|
26
26
|
// +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
|
|
27
27
|
// +showNotice(text)+,
|
|
28
28
|
// +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
|
|
29
|
-
// +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
|
|
29
|
+
// +runCommand(command)+ (composer.js send; null: a viewer without session commands, the line goes as a turn), +toast(text, action, options)+;
|
|
30
|
+
// +closesOn(error)+: a participant's page takes a refusal that closes its
|
|
31
|
+
// composer (participant_main.js; true: taken, the line stays typed); null else;
|
|
30
32
|
// +alert+: the browser's.
|
|
33
|
+
// The refused Revise sends whose text and chip stay (a failed line says why):
|
|
34
|
+
// a worker from before Revise turns, and a participant's of an answer that
|
|
35
|
+
// isn't theirs or that someone else's turn came after.
|
|
36
|
+
const REVISE_REFUSALS = ["revise_unavailable", "revise_not_own", "revise_overtaken"];
|
|
37
|
+
|
|
31
38
|
export function createSendTurn({
|
|
32
39
|
session, store, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
|
|
33
40
|
verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
|
|
34
41
|
revise = () => null, clearRevise = () => {}, restoreRevise = () => {},
|
|
35
42
|
historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
|
|
36
43
|
addCreatedSession, select, showNotice, followWokenWorker, refreshHistory,
|
|
37
|
-
leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
|
|
44
|
+
leaveGoneSession, runCommand, toast, closesOn = null, alert = (text) => globalThis.alert(text),
|
|
38
45
|
}) {
|
|
39
46
|
const promptEl = composer.el;
|
|
40
47
|
const actionBtn = composer.button;
|
|
@@ -64,6 +71,10 @@ export function createSendTurn({
|
|
|
64
71
|
alert(e.message);
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
// The line being sent ({to, line}) until its /turn answers: a second
|
|
75
|
+
// Enter on a slow link (the text still in the box) doesn't send it again.
|
|
76
|
+
let inFlight = null;
|
|
77
|
+
|
|
67
78
|
async function send({ delivery: chosen = null } = {}) {
|
|
68
79
|
if (store.current.owner === "tui") return;
|
|
69
80
|
// A fresh link's pick first: its /model is queued in the worker once
|
|
@@ -110,8 +121,10 @@ export function createSendTurn({
|
|
|
110
121
|
composer.clear();
|
|
111
122
|
return;
|
|
112
123
|
}
|
|
113
|
-
if (route.command) return runCommand(route.command);
|
|
114
|
-
|
|
124
|
+
if (route.command && runCommand) return runCommand(route.command);
|
|
125
|
+
// A viewer without session commands (viewer.js): the line goes as a turn, and the worker decides.
|
|
126
|
+
prompt = route.command ?? route.send;
|
|
127
|
+
if (inFlight && inFlight.to === session() && inFlight.line === line) return;
|
|
115
128
|
const isRunning = running() && !!session();
|
|
116
129
|
const delivery = route.delivery || (isRunning ? chosen || armed : null);
|
|
117
130
|
const hint = historyEl.querySelector(".hint");
|
|
@@ -134,6 +147,8 @@ export function createSendTurn({
|
|
|
134
147
|
// The session the line goes to: the page may switch to another while an
|
|
135
148
|
// image uploads or the turn is sent, and what follows stays with this one.
|
|
136
149
|
const sentTo = session();
|
|
150
|
+
const flight = { to: sentTo, line };
|
|
151
|
+
inFlight = flight;
|
|
137
152
|
try {
|
|
138
153
|
await uploadChips(sending, sentTo, { upload: api.uploadImage });
|
|
139
154
|
const verifyAnchor = verify();
|
|
@@ -208,13 +223,16 @@ export function createSendTurn({
|
|
|
208
223
|
// A stale tab sent into a session deleted meanwhile: the start page
|
|
209
224
|
// says so, and the line stays in the composer.
|
|
210
225
|
if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
|
|
226
|
+
if (sentTo === session() && closesOn?.(e)) return;
|
|
211
227
|
// A /queue to a worker that can't queue (it would steer): its words, the text kept.
|
|
212
228
|
if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
213
229
|
// A Revise to a worker from before Revise turns (the page's gate saw
|
|
214
|
-
// none yet)
|
|
215
|
-
|
|
230
|
+
// none yet), or a participant's of an answer not theirs or overtaken
|
|
231
|
+
// since the tap: the server's words, the text and the chip kept.
|
|
232
|
+
if (REVISE_REFUSALS.includes(e.code) && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
216
233
|
toast(cantText("send", e), null, { sticky: true });
|
|
217
234
|
} finally {
|
|
235
|
+
if (inFlight === flight) inFlight = null;
|
|
218
236
|
actionBtn.disabled = store.current.owner === "tui";
|
|
219
237
|
composer.focus();
|
|
220
238
|
}
|
|
@@ -54,7 +54,7 @@ export const FIELDS = {
|
|
|
54
54
|
writers: "owner_verify.js placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
|
|
55
55
|
},
|
|
56
56
|
createdAt: { redraws: ["infoBar"], writers: "the payload" },
|
|
57
|
-
ctxPct: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_chunk, context_status)" },
|
|
57
|
+
ctxPct: { redraws: ["infoBar"], writers: "session_state.js sessionStateFrom (the payload's context_status, else the saved fill), stream_handlers.js (generation_chunk, context_status)" },
|
|
58
58
|
ctxWindow: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
|
|
59
59
|
ctxWindowSource: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
|
|
60
60
|
ctxUnknownAfterSwitch: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
|
|
@@ -109,10 +109,18 @@ export function sessionStateFrom(data) {
|
|
|
109
109
|
bridgeUp: s.bridge_up === true,
|
|
110
110
|
timing: normalizeTiming(data.timing),
|
|
111
111
|
};
|
|
112
|
-
// The
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
// The ctx fill: the live worker's newest estimate (shown in the session
|
|
113
|
+
// JSON as context_status, kept per request by the Engine) beats the saved
|
|
114
|
+
// fill, which is only the last counted turn's; without it, the saved fill.
|
|
115
|
+
const livePct = s.context_status?.est_pct;
|
|
116
|
+
if (typeof livePct === "number" && Number.isFinite(livePct)) {
|
|
117
|
+
state.ctxPct = livePct;
|
|
118
|
+
} else {
|
|
119
|
+
// The saved fill, so a reloaded or stopped session shows it before its
|
|
120
|
+
// next turn.
|
|
121
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
122
|
+
if (savedPct !== null) state.ctxPct = savedPct;
|
|
123
|
+
}
|
|
116
124
|
if (data.timing?.context?.window_tokens) {
|
|
117
125
|
state.ctxWindow = data.timing.context.window_tokens;
|
|
118
126
|
state.ctxWindowSource = data.timing.context.window_source || null;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// historyEl, emptyEl the history and the start page's empty box
|
|
17
17
|
// getSession(id, opts), openStream(id, fromSeq, handlers, opts) data.js
|
|
18
18
|
// liveTurn, turnDom, questionCards, contextChips, participantsControl the page's views
|
|
19
|
+
// (contextChips, participantsControl: null for a viewer without them, viewer.js)
|
|
19
20
|
// view: {forgetSession(opts), renderSessionView(data, opts), endTurnView(kind, endText, opts)}
|
|
20
21
|
// the session view (session_view.js)
|
|
21
22
|
// page: {clearNotice(), setSessionHash(id), setTurnRunning(running), setLiveStatus(status),
|
|
@@ -37,6 +38,8 @@ import { withoutWorker } from "./format.js";
|
|
|
37
38
|
import { resyncRetry, streamDropEnd } from "./turn_events.js";
|
|
38
39
|
import { createResyncReach } from "./resync_reach.js";
|
|
39
40
|
import { keptCommandBubbles } from "./commands.js";
|
|
41
|
+
import { foldKey } from "./stage_waiting.js";
|
|
42
|
+
import { removeSession } from "./storage.js";
|
|
40
43
|
|
|
41
44
|
// +opened+ false: the stream never delivered a frame (the proxy answered
|
|
42
45
|
// 503, or the bridge refused it), so a retry waits a moment rather than
|
|
@@ -83,6 +86,10 @@ export function createSessionSync(deps) {
|
|
|
83
86
|
async function select(id) {
|
|
84
87
|
page.clearNotice();
|
|
85
88
|
const same = store.selected === id;
|
|
89
|
+
// Leaving for another session: the fold this tab kept for the one left
|
|
90
|
+
// has no page to come back to (stage_waiting.js). A reselect of the same
|
|
91
|
+
// session keeps it: the page is redrawn, not left.
|
|
92
|
+
if (!same) removeSession(foldKey(store.selected));
|
|
86
93
|
// Its synchronous part in one batch: the active card, the composer and
|
|
87
94
|
// the bar redraw once, at its end (never across the await).
|
|
88
95
|
store.batch(() => {
|
|
@@ -93,8 +100,8 @@ export function createSessionSync(deps) {
|
|
|
93
100
|
emptyEl.classList.add("hidden");
|
|
94
101
|
resyncAttempts = 0; // this session has its own re-read budget
|
|
95
102
|
deps.view.forgetSession({ keepVerify: same });
|
|
96
|
-
contextChips
|
|
97
|
-
participantsControl
|
|
103
|
+
contextChips?.load(id);
|
|
104
|
+
participantsControl?.load(id);
|
|
98
105
|
});
|
|
99
106
|
await resync();
|
|
100
107
|
// A folded composer stays folded: typing on the page opens it.
|