samagotchi 0.168.0 → 0.169.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 +38 -1
- data/docs/architecture.md +1 -1
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/block-renderers.md +1 -1
- data/docs/internals/bridge.md +16 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +2 -1
- data/docs/internals/remote-sessions.md +1 -1
- data/docs/internals/web-frontend.md +62 -22
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +10 -6
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge.rb +12 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/command_intake.rb +7 -7
- data/lib/samagotchi/compat.rb +14 -5
- data/lib/samagotchi/engine/objectives.rb +10 -1
- data/lib/samagotchi/guardrails/approvals.rb +8 -3
- data/lib/samagotchi/session_continue.rb +8 -11
- data/lib/samagotchi/session_manager/removal.rb +3 -3
- data/lib/samagotchi/session_stores.rb +79 -0
- data/lib/samagotchi/turn_stages/objectives.rb +6 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/block_watch.js +1 -1
- data/lib/samagotchi/web/public/cards_place.js +5 -1
- data/lib/samagotchi/web/public/index.html +58 -39
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +20 -6
- data/lib/samagotchi/web/public/stage_asking.js +283 -0
- data/lib/samagotchi/web/public/stage_frame.js +7 -11
- data/lib/samagotchi/web/public/stage_model.js +37 -11
- data/lib/samagotchi/web/public/stage_opened.js +28 -18
- data/lib/samagotchi/web/public/stage_slots.js +13 -89
- data/lib/samagotchi/web/public/stage_view.js +202 -160
- data/lib/samagotchi/web/public/stage_waiting.js +180 -0
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/public/turn_model.js +1 -1
- data/lib/samagotchi/web/session_show.rb +14 -4
- data/lib/samagotchi/worker.rb +3 -4
- metadata +4 -1
|
@@ -24,6 +24,7 @@ import { clientLabel, isOwn } from "./turn_events.js";
|
|
|
24
24
|
import { continueLine } from "./commands.js";
|
|
25
25
|
import { plainText } from "./plain_line.js";
|
|
26
26
|
import { noContactFill } from "./no_contact_fill.js";
|
|
27
|
+
import { CONTINUE_KEY, questionKey } from "./stage_waiting.js";
|
|
27
28
|
|
|
28
29
|
// A card's text fields' name: not a contact field (no_contact_fill.js).
|
|
29
30
|
const CARD_NOTE = "chi-card-note";
|
|
@@ -35,8 +36,9 @@ const CARD_NOTE = "chi-card-note";
|
|
|
35
36
|
// @param isNearBottom () => whether the history follows its end
|
|
36
37
|
// @param scrollToEnd () scrolls the history to its end
|
|
37
38
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
38
|
-
// @param askingText () => the running turn's
|
|
39
|
-
//
|
|
39
|
+
// @param askingText (pq) => the running turn's reply that asked +pq+
|
|
40
|
+
// (stage_asking.js askedText via the page's view), kept by a question or
|
|
41
|
+
// own approval card
|
|
40
42
|
// @param renderMarkdown (text) => Promise<html|null>: the kept reply's
|
|
41
43
|
// markdown (the page's POST /api/render; null keeps the raw text)
|
|
42
44
|
// @param linkRefs (el, refs) the page's ref pass on it: a plugin's +refs+
|
|
@@ -48,6 +50,9 @@ const CARD_NOTE = "chi-card-note";
|
|
|
48
50
|
// @param onPending (pending|null) called whenever the open question changes:
|
|
49
51
|
// the page touches the redraws that read pendingQuestion() (the session
|
|
50
52
|
// bar's waiting chip, docs/internals/web-frontend.md write -> redraw)
|
|
53
|
+
// @param onSettled (key) a card stopped waiting (answered, cancelled,
|
|
54
|
+
// replaced or removed; the continue card resolved or dropped), by its
|
|
55
|
+
// stage_waiting.js key: the stage's waiting cards as data
|
|
51
56
|
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
52
57
|
// questionCard() (the live card or null), pendingQuestion() (or null),
|
|
53
58
|
// isResolved(id) (this page already saw that question answered),
|
|
@@ -56,7 +61,7 @@ export function createQuestionCards({
|
|
|
56
61
|
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
57
62
|
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
|
|
58
63
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
59
|
-
onPending = () => {},
|
|
64
|
+
onPending = () => {}, onSettled = () => {},
|
|
60
65
|
}) {
|
|
61
66
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
62
67
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -99,7 +104,9 @@ export function createQuestionCards({
|
|
|
99
104
|
// stage_view.js): the kind, and a delegate's question relayed here.
|
|
100
105
|
card.dataset.kind = pq.kind || "question";
|
|
101
106
|
if (relayView(pq)) card.dataset.relay = "1";
|
|
102
|
-
|
|
107
|
+
// An approval says the tool it approves (the stage's replay rule for its lead-in).
|
|
108
|
+
if (approval && pq.approval?.tool) card.dataset.tool = String(pq.approval.tool);
|
|
109
|
+
const asked = keepsAskingText(pq) ? String(said ?? askingText(pq) ?? "").trim() : "";
|
|
103
110
|
if (asked) saidOf.set(card, asked);
|
|
104
111
|
// Open while pending: the question must be actionable.
|
|
105
112
|
card.open = true;
|
|
@@ -110,8 +117,10 @@ export function createQuestionCards({
|
|
|
110
117
|
summary.textContent = summaryText(pq);
|
|
111
118
|
card.appendChild(summary);
|
|
112
119
|
// A hand toggle: the resolve's auto-collapse then leaves it as the user
|
|
113
|
-
// left it (rememberToggle-style, as turn_block.js does).
|
|
114
|
-
|
|
120
|
+
// left it (rememberToggle-style, as turn_block.js does). Not a click whose
|
|
121
|
+
// toggle was refused: a card that waits in the stage has none of its own
|
|
122
|
+
// (stage_view.js).
|
|
123
|
+
summary.addEventListener("click", (e) => { if (!e.defaultPrevented) card.dataset.toggled = "1"; });
|
|
115
124
|
|
|
116
125
|
// Relayed to the parent: say where else it waits (QuestionDesk#annotate).
|
|
117
126
|
card.appendChild(renderRelayedTo(pq));
|
|
@@ -502,6 +511,7 @@ export function createQuestionCards({
|
|
|
502
511
|
// After the collapse: a card left open by hand renders at once.
|
|
503
512
|
if (full) renderSaidOnOpen(questionEl, full, asked, refs);
|
|
504
513
|
resolvedId = pending.id;
|
|
514
|
+
onSettled(questionKey(questionEl.dataset.qid));
|
|
505
515
|
setPending(null);
|
|
506
516
|
}
|
|
507
517
|
|
|
@@ -567,8 +577,10 @@ export function createQuestionCards({
|
|
|
567
577
|
function replaceQuestionCard() {
|
|
568
578
|
if (!questionEl) return;
|
|
569
579
|
|
|
580
|
+
const key = questionKey(questionEl.dataset.qid);
|
|
570
581
|
questionEl.remove();
|
|
571
582
|
questionEl = null;
|
|
583
|
+
onSettled(key);
|
|
572
584
|
}
|
|
573
585
|
|
|
574
586
|
// ── continue card ──────────────────────────────────────────────────────────
|
|
@@ -587,6 +599,7 @@ export function createQuestionCards({
|
|
|
587
599
|
// The old card goes: the step-limit question asks the same (a worker that
|
|
588
600
|
// posts it announces continue_offered too, for older pages).
|
|
589
601
|
function dropContinueCard() {
|
|
602
|
+
if (continueEl) onSettled(CONTINUE_KEY);
|
|
590
603
|
continueEl?.remove();
|
|
591
604
|
continueEl = null;
|
|
592
605
|
}
|
|
@@ -656,6 +669,7 @@ export function createQuestionCards({
|
|
|
656
669
|
continueEl = null;
|
|
657
670
|
if (!card) return;
|
|
658
671
|
card.classList.add("resolved");
|
|
672
|
+
onSettled(CONTINUE_KEY);
|
|
659
673
|
card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
|
|
660
674
|
const note = doc.createElement("div");
|
|
661
675
|
note.className = "question-result";
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// The stage's docked card and its fold (stage_view.js wires it): a card that
|
|
2
|
+
// waits for the user (stage_cards.js PENDING but the step limit, or ASKS)
|
|
3
|
+
// opens the running turn in the history (stage_opened.js) and docks alone
|
|
4
|
+
// in the stage; chi's own question or approval after a reply gets that
|
|
5
|
+
// reply as its lead-in bubble. A waiting card has no toggle of its own (the
|
|
6
|
+
// stage's fold is the one); and the fold itself: the user's choice, a ref's
|
|
7
|
+
// reveal, and the docked card's own fold.
|
|
8
|
+
import { handOffOrder, stageCollapsed } from "./stage_model.js";
|
|
9
|
+
import { readStored, writeStored } from "./storage.js";
|
|
10
|
+
import { ASKS, PENDING, waitsInStage } from "./stage_cards.js";
|
|
11
|
+
import { cardIn, waitingKey, waitingKind } from "./stage_waiting.js";
|
|
12
|
+
|
|
13
|
+
const COLLAPSE_KEY = "chi_stage_collapsed";
|
|
14
|
+
// The cards that get a lead-in bubble when their step wrote text: chi's own
|
|
15
|
+
// question and its own approval (question_cards.js data-kind; not a
|
|
16
|
+
// delegate's relayed one, a hook's question or the step limit).
|
|
17
|
+
const LEAD_IN_CARD = '.bubble.question[data-kind="question"]:not([data-relay]), .bubble.question[data-kind="approval"]:not([data-relay])';
|
|
18
|
+
// The step limit: its card goes under its ended turn (stage_view.js place),
|
|
19
|
+
// it never docks.
|
|
20
|
+
const STEP_LIMIT = '.bubble.continue, .bubble.question.step-limit, .bubble.question[data-kind="continue"]';
|
|
21
|
+
|
|
22
|
+
function readCollapsed() {
|
|
23
|
+
return readStored(COLLAPSE_KEY) === "1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// +stage+, +extras+, +tail+, +scroller+ and +fold+: the stage's root, its
|
|
27
|
+
// two card slots, its scroller and its fold button. +view+: the turn block
|
|
28
|
+
// (turn_block.js); +opener+: the opened turn (stage_opened.js); +frame()+:
|
|
29
|
+
// the stage's frame (stage_frame.js, made after this). +isOpened()+ /
|
|
30
|
+
// +setOpened(v)+: whether the turn is opened in the history; +isEnded()+:
|
|
31
|
+
// the turn has ended; +promptEl()+: the turn's prompt bubble; +setCloud(open)+:
|
|
32
|
+
// the cloud; +scheduleRefresh()+; +pendingCard()+: a card waits in the card
|
|
33
|
+
// slots; +waiting+: the stage's waiting cards as data (stage_waiting.js),
|
|
34
|
+
// which say the asking form's card and hold its fold.
|
|
35
|
+
export function createStageAsking({
|
|
36
|
+
stage, extras, tail, scroller, fold, view, opener, frame, isOpened, setOpened, isEnded, promptEl, setCloud,
|
|
37
|
+
scheduleRefresh, pendingCard, waiting,
|
|
38
|
+
}) {
|
|
39
|
+
// The user's own fold (chi_stage_collapsed), in memory: written on a
|
|
40
|
+
// hand toggle, never read back from storage to decide (another tab's
|
|
41
|
+
// write doesn't apply live).
|
|
42
|
+
let choice = readCollapsed();
|
|
43
|
+
// A ref's "show in turn" opened a collapsed stage (not kept).
|
|
44
|
+
let revealed = false;
|
|
45
|
+
// The docked card's fold is +waiting+'s (fold, trackFold): the waiting
|
|
46
|
+
// card the user folded, by its key. For that card only, never saved: the
|
|
47
|
+
// next one arrives open, and when the docked card goes the stage is as the
|
|
48
|
+
// choice and +revealed+ say. One more card waiting since the fold (an
|
|
49
|
+
// approval, a relayed or hook question) drops it, so the stage opens on it.
|
|
50
|
+
|
|
51
|
+
// ── the docked card ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
// The step that asked +what+ ({kind, tool}: the card's data-kind and, for
|
|
54
|
+
// an approval, the tool it approves): live, the step whose call runs
|
|
55
|
+
// (turn_model.js askingText). In a replay (a reload, a resync) the asking
|
|
56
|
+
// call may have completed before its question_requested comes
|
|
57
|
+
// (TurnAccumulator.join_events: a question's always, an approval's once
|
|
58
|
+
// it is answered), so the last step when one of its calls is the one
|
|
59
|
+
// that asks (ask_user_question; the approved tool: a parallel call after
|
|
60
|
+
// it doesn't hide it) and no card of this page took its reply yet. Live or replayed, a step whose reply a card already
|
|
61
|
+
// took (+asked+) gives no other card its reply: a second approval or a
|
|
62
|
+
// question after it in the same step has no lead-in. null: none.
|
|
63
|
+
function askingGen({ kind = "question", tool = null } = {}) {
|
|
64
|
+
const { turn } = view.current();
|
|
65
|
+
const gen = turn && !turn.ended ? turn.gens[turn.gens.length - 1] : null;
|
|
66
|
+
if (!gen?.text || asked.has(gen)) return null;
|
|
67
|
+
if (view.askingText()) return gen;
|
|
68
|
+
const call = kind === "question" ? "ask_user_question" : kind === "approval" ? tool : null;
|
|
69
|
+
return call && gen.tools.some((row) => row.tool === call) ? gen : null;
|
|
70
|
+
}
|
|
71
|
+
// What a card asks, for askingGen.
|
|
72
|
+
const asksOf = (el) => ({ kind: el?.dataset?.kind || "question", tool: el?.dataset?.tool || null });
|
|
73
|
+
// The steps whose reply a question card took (place: question_cards.js
|
|
74
|
+
// reads askingText right before), for askingGen's replay rule.
|
|
75
|
+
const asked = new WeakSet();
|
|
76
|
+
function askedFor(el) {
|
|
77
|
+
const gen = askingGen(asksOf(el));
|
|
78
|
+
if (gen) asked.add(gen);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The reply that asked +pq+ (a pending question: its kind, an approval's
|
|
82
|
+
// tool), "" for none.
|
|
83
|
+
function askedText(pq = null) {
|
|
84
|
+
return String(askingGen({ kind: pq?.kind || "question", tool: pq?.approval?.tool || null })?.text || "").trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// +el+ docks: a card that waits for the user (a pending question,
|
|
88
|
+
// approval, relayed or hook card; an asking card), not the step limit,
|
|
89
|
+
// placed while the turn runs.
|
|
90
|
+
function docks(el) {
|
|
91
|
+
if (isEnded() || !el?.matches) return false;
|
|
92
|
+
if (el.matches(PENDING)) return !el.matches(STEP_LIMIT);
|
|
93
|
+
return !!el.querySelector(ASKS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The lead-in for +el+: the reply that asked it when +el+ is chi's own
|
|
97
|
+
// question or approval (LEAD_IN_CARD) and its step wrote text; "" otherwise.
|
|
98
|
+
function leadInText(el) {
|
|
99
|
+
if (!el.matches?.(LEAD_IN_CARD) || !el.matches(PENDING)) return "";
|
|
100
|
+
return String(askingGen(asksOf(el))?.text || "").trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The turn opens in the history (once): the prompt, the block (closed;
|
|
104
|
+
// its steps in order from now on) and the rows the extras hold, in the
|
|
105
|
+
// hand-off's order. What waits stays. +follow+: a reader who was at the
|
|
106
|
+
// history's end follows it (a docked card with no lead-in; a lead-in's
|
|
107
|
+
// read follows instead).
|
|
108
|
+
function openTurn({ follow = false } = {}) {
|
|
109
|
+
if (isOpened()) return;
|
|
110
|
+
const { blockEl } = view.current();
|
|
111
|
+
view.inOrder?.();
|
|
112
|
+
setCloud(false);
|
|
113
|
+
if (blockEl) {
|
|
114
|
+
blockEl.open = false;
|
|
115
|
+
// A lone step's block keeps its chrome there (index.html): its summary
|
|
116
|
+
// is the way to the step's text, which no lead-in shows.
|
|
117
|
+
blockEl.dataset.openedTurn = "";
|
|
118
|
+
}
|
|
119
|
+
const rows = [...extras.children].filter((el) => !waitsInStage(el));
|
|
120
|
+
setOpened(true);
|
|
121
|
+
opener.open(handOffOrder({ prompt: promptEl(), block: blockEl, extras: rows }), { follow });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The waiting cards as data, checked against the slots (stage_waiting.js
|
|
125
|
+
// reconcile, its second feed: it only settles): a card gone from the
|
|
126
|
+
// extras and the tail, or no longer waiting, settles, also one answered
|
|
127
|
+
// outside question_cards.js's hook.
|
|
128
|
+
function reconcileWaiting() {
|
|
129
|
+
waiting.reconcile((key) => !!waitingKind(cardIn(extras, key) || cardIn(tail, key)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// An opened turn's cards that stopped waiting go into the history: one
|
|
133
|
+
// with a lead-in under it (also after the turn's end, so a card
|
|
134
|
+
// cancelled by Stop turn sits there), another one at the history's end
|
|
135
|
+
// while the turn runs.
|
|
136
|
+
function settleCards() {
|
|
137
|
+
if (!isOpened()) return;
|
|
138
|
+
for (const el of [...extras.children]) {
|
|
139
|
+
if (waitsInStage(el)) continue;
|
|
140
|
+
// Out of the stage it waits behind nothing (stage_view.js drawDocked).
|
|
141
|
+
el.classList.remove("ts-behind");
|
|
142
|
+
const key = waitingKey(el);
|
|
143
|
+
if (opener.has(key)) opener.settle(key, el);
|
|
144
|
+
else if (!isEnded()) opener.add(el);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The docked card the stage shows, by its key (the asking form: one at a
|
|
149
|
+
// time, stage_waiting.js shown); null: none docked.
|
|
150
|
+
function askingKey() {
|
|
151
|
+
return waiting.shown();
|
|
152
|
+
}
|
|
153
|
+
function askingCard() {
|
|
154
|
+
return cardIn(extras, askingKey());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The folded row's text for the docked +card+ (stage_model.js
|
|
158
|
+
// foldedRowText): a question's text, an approval's summary and its
|
|
159
|
+
// command's first line, a plugin card's title; "" for none.
|
|
160
|
+
function foldedText(card) {
|
|
161
|
+
if (!card) return "";
|
|
162
|
+
if (card.matches(".plugin-card")) return card.querySelector(".card-title")?.textContent || "";
|
|
163
|
+
if (card.dataset.kind !== "approval") return card.querySelector(".question-text")?.textContent || "";
|
|
164
|
+
// The summary's own words, not its "↑ its reason" link.
|
|
165
|
+
const summary = card.querySelector(":scope > summary");
|
|
166
|
+
const head = [...(summary?.childNodes || [])].filter((n) => n.nodeType === 3).map((n) => n.textContent).join("").trim();
|
|
167
|
+
const what = (card.querySelector(".approval-what") || card.querySelector(".approval-tool"))?.textContent || "";
|
|
168
|
+
return [head, what.trim().split("\n")[0].trim()].filter(Boolean).join(" ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The stage's card element for +key+ (its extras or its tail), null: none.
|
|
172
|
+
function cardOf(key) {
|
|
173
|
+
return cardIn(extras, key) || cardIn(tail, key);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A card that waits in the stage has no toggle of its own: the stage's
|
|
177
|
+
// fold is the one (a closed card left an empty box). A click on its
|
|
178
|
+
// summary (Enter and Space on one arrive as a click) doesn't toggle it,
|
|
179
|
+
// and question_cards.js reads the refusal (defaultPrevented): no
|
|
180
|
+
// data-toggled. Not stopped: the page's outside-click closers still see
|
|
181
|
+
// it. A button or link in the summary ("↑ its reason") works as ever.
|
|
182
|
+
// The test is "waits now", at the click: an answered card toggles.
|
|
183
|
+
stage.addEventListener("click", (e) => {
|
|
184
|
+
const summary = e.target.closest?.("summary");
|
|
185
|
+
if (!summary || summary.contains(e.target.closest("button, a"))) return;
|
|
186
|
+
if (summary.parentElement?.matches(PENDING)) e.preventDefault();
|
|
187
|
+
}, true);
|
|
188
|
+
// Its summary isn't a control meanwhile: out of the tab order (the CSS
|
|
189
|
+
// drops its mark and pointer). Not aria-disabled: that would disable the
|
|
190
|
+
// buttons in it too. +inert+: the ones marked, given back once their card
|
|
191
|
+
// stops waiting or leaves.
|
|
192
|
+
const inert = new Set();
|
|
193
|
+
function markOwnToggles() {
|
|
194
|
+
const waits = new Set();
|
|
195
|
+
for (const key of waiting.blocking()) {
|
|
196
|
+
const summary = cardOf(key)?.querySelector(":scope > summary");
|
|
197
|
+
if (summary) waits.add(summary);
|
|
198
|
+
}
|
|
199
|
+
for (const summary of inert) {
|
|
200
|
+
if (waits.has(summary)) continue;
|
|
201
|
+
summary.removeAttribute("tabindex");
|
|
202
|
+
inert.delete(summary);
|
|
203
|
+
}
|
|
204
|
+
for (const summary of waits) {
|
|
205
|
+
if (inert.has(summary)) continue;
|
|
206
|
+
summary.tabIndex = -1;
|
|
207
|
+
inert.add(summary);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── the fold ─────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
// Whether the stage is collapsed now (stage_model.js stageCollapsed).
|
|
214
|
+
// The registry is checked against the slots first: a card answered since
|
|
215
|
+
// the last refresh no longer holds the fold.
|
|
216
|
+
function isCollapsed() {
|
|
217
|
+
reconcileWaiting();
|
|
218
|
+
return stageCollapsed({ choice, revealed, asking: askingKey(), askingFold: waiting.folded() });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Its fold is that card's only; the form's start opens a stage saved as
|
|
222
|
+
// collapsed and its end gives the choice back, in this task (the card's
|
|
223
|
+
// focus lands right after the start: question_cards.js focusWhenIdle).
|
|
224
|
+
// Another card that comes to wait meanwhile opens it: nothing waits
|
|
225
|
+
// unseen behind the folded question.
|
|
226
|
+
// A card answered meanwhile lowers the mark, so the next one opens it too.
|
|
227
|
+
function trackFold() {
|
|
228
|
+
waiting.trackFold(askingKey());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The fold as isCollapsed says, only when it changed (+force+: always):
|
|
232
|
+
// idempotent, and no refresh of its own (refresh calls it).
|
|
233
|
+
function drawCollapsed({ force = false } = {}) {
|
|
234
|
+
const collapsed = isCollapsed();
|
|
235
|
+
if (!force && fold.textContent && stage.classList.contains("collapsed") === collapsed) return;
|
|
236
|
+
stage.classList.toggle("collapsed", collapsed);
|
|
237
|
+
scroller.hidden = collapsed;
|
|
238
|
+
fold.textContent = collapsed ? "▴" : "▾";
|
|
239
|
+
fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
|
|
240
|
+
fold.setAttribute("aria-expanded", String(!collapsed));
|
|
241
|
+
frame().fitCap();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function applyCollapsed() {
|
|
245
|
+
drawCollapsed({ force: true });
|
|
246
|
+
scheduleRefresh();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A hand toggle (the fold button, a tap on the folded row). In the
|
|
250
|
+
// asking form it folds that question only and writes nothing; else it is
|
|
251
|
+
// the user's choice, kept.
|
|
252
|
+
function setCollapsed(value) {
|
|
253
|
+
reconcileWaiting();
|
|
254
|
+
const asking = askingKey();
|
|
255
|
+
if (asking) {
|
|
256
|
+
waiting.fold(value ? asking : null);
|
|
257
|
+
} else {
|
|
258
|
+
choice = value;
|
|
259
|
+
revealed = false;
|
|
260
|
+
writeStored(COLLAPSE_KEY, value ? "1" : "0");
|
|
261
|
+
}
|
|
262
|
+
applyCollapsed();
|
|
263
|
+
// Opened on a waiting card: it shows as it does on arrival.
|
|
264
|
+
const front = !value && pendingCard() ? cardOf(waiting.front()) : null;
|
|
265
|
+
if (front) frame().afterGrowth(() => frame().reveal(front.parentElement));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
askingGen, asksOf, askedFor, askedText, docks, leadInText, openTurn, reconcileWaiting, settleCards, askingCard, cardOf, foldedText, markOwnToggles,
|
|
270
|
+
isCollapsed, trackFold, drawCollapsed, applyCollapsed, setCollapsed,
|
|
271
|
+
// A ref's "show in turn" opens a collapsed stage for this turn only.
|
|
272
|
+
reveal() {
|
|
273
|
+
revealed = true;
|
|
274
|
+
applyCollapsed();
|
|
275
|
+
},
|
|
276
|
+
// The stage cleared: a reveal ends with it.
|
|
277
|
+
unreveal() {
|
|
278
|
+
if (!revealed) return;
|
|
279
|
+
revealed = false;
|
|
280
|
+
applyCollapsed();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// until the next step), and never above the session panel. The frame: held
|
|
5
5
|
// and grow-only while the stage holds a turn (stage_model.js frameTarget),
|
|
6
6
|
// its growth eased; it lets go in two places only (forgetContent): the
|
|
7
|
-
// asking form (a turn opened in the history: the card
|
|
8
|
-
// place) and the next step after an answered card (releaseHeldWait). The stage's state comes
|
|
7
|
+
// asking form (a card docked, its turn opened in the history: the card
|
|
8
|
+
// alone, stage_view.js place) and the next step after an answered card (releaseHeldWait). The stage's state comes
|
|
9
9
|
// in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
|
|
10
10
|
// what the stage changes of the frame's goes through resetHeld and
|
|
11
11
|
// forgetContent. +waitingCard+: the card blocking the turn, or null.
|
|
@@ -35,7 +35,7 @@ export function reducedMotion() {
|
|
|
35
35
|
|
|
36
36
|
// +observed+: the elements whose size changes refit the frame (the stage's
|
|
37
37
|
// slots, in the order they are observed).
|
|
38
|
-
export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl,
|
|
38
|
+
export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, extras, dockEl, centerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false, waitingCard = () => null }) {
|
|
39
39
|
// The frame (fitFrame): the height held while the turn runs and the
|
|
40
40
|
// tallest content seen.
|
|
41
41
|
let held = null;
|
|
@@ -65,16 +65,13 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
|
|
|
65
65
|
// would push the stage's top out) ──────────────────────────────────────
|
|
66
66
|
|
|
67
67
|
// Where the stage's scroll goes so +slot+ (the scroller's child) shows:
|
|
68
|
-
// its end in sight
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// view reads from its top, never from its options: the question and what
|
|
72
|
-
// it approves are what the answer is about; the options are a scroll away.
|
|
68
|
+
// its end in sight. A waiting card in +slot+ taller than the view reads
|
|
69
|
+
// from its top, never from its options: the question and what it
|
|
70
|
+
// approves are what the answer is about; the options are a scroll away.
|
|
73
71
|
// null: nothing to show.
|
|
74
72
|
function revealTop(slot) {
|
|
75
73
|
if (stage.hidden || slot.hidden) return null;
|
|
76
74
|
let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
|
|
77
|
-
if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
|
|
78
75
|
const card = waitingCard();
|
|
79
76
|
if (card && slot.contains(card)) top = Math.min(top, Math.max(0, card.offsetTop - CARD_TOP_GAP));
|
|
80
77
|
return top;
|
|
@@ -138,8 +135,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
|
|
|
138
135
|
stage.style.setProperty("--stage-max", `${cap}px`);
|
|
139
136
|
fitFrame();
|
|
140
137
|
// A smaller cap (a phone's keyboard came up) keeps a waiting card in
|
|
141
|
-
// sight
|
|
142
|
-
// leaves the reader's scroll alone.
|
|
138
|
+
// sight. A larger one leaves the reader's scroll alone.
|
|
143
139
|
if (keep) requestAnimationFrame(keepCardInSight);
|
|
144
140
|
}
|
|
145
141
|
// The whole dock, not only its composer card: the info bar under it
|
|
@@ -6,7 +6,6 @@ import { toolName } from "./activity.js";
|
|
|
6
6
|
import { splitSentences } from "./sentences.js";
|
|
7
7
|
import { steerSender } from "./format.js";
|
|
8
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
9
|
-
import { askingRefs, askingText } from "./turn_model.js";
|
|
10
9
|
import { plainLine } from "./plain_line.js";
|
|
11
10
|
import { turnPieces } from "./turn_pieces.js";
|
|
12
11
|
import { waitsInStage } from "./stage_cards.js";
|
|
@@ -120,6 +119,18 @@ export function stepSlotText({ step, ended, chipHidden }) {
|
|
|
120
119
|
return !ended ? `step ${step}` : chipHidden ? stepsText(step) : "";
|
|
121
120
|
}
|
|
122
121
|
|
|
122
|
+
// Whether the stage is collapsed to its status row. In the asking form
|
|
123
|
+
// (+asking+: the key of the card that waits there, stage_waiting.js; null:
|
|
124
|
+
// none) only when the user folded that card (+askingFold+: the folded key,
|
|
125
|
+
// null: none), so a
|
|
126
|
+
// question always arrives open and a new one never matches an old fold;
|
|
127
|
+
// else the user's +choice+ (chi_stage_collapsed, as set by hand), unless a
|
|
128
|
+
// ref's "show in turn" opened it for this turn (+revealed+).
|
|
129
|
+
export function stageCollapsed({ choice, revealed, asking = null, askingFold = null }) {
|
|
130
|
+
if (asking) return askingFold === asking;
|
|
131
|
+
return revealed ? false : !!choice;
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
// The collapsed stage's mini tool in the status row: the running call's
|
|
124
135
|
// name and title, its full command as the hover; "" for both otherwise.
|
|
125
136
|
export function miniToolText({ collapsed, tool }) {
|
|
@@ -129,6 +140,28 @@ export function miniToolText({ collapsed, tool }) {
|
|
|
129
140
|
};
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
// The collapsed stage's status row text: with the asking form folded, what
|
|
144
|
+
// the docked card asks (+question+: a question's text, an approval's
|
|
145
|
+
// summary and command, a plugin card's title; its first line, the whole as
|
|
146
|
+
// the hover, in the UI font: +question+ true), else miniToolText's running
|
|
147
|
+
// call: folded, or with +running+ (check-in docked while nothing blocks:
|
|
148
|
+
// the row says what chi runs now).
|
|
149
|
+
export function foldedRowText({ collapsed, question = "", tool = null, running = false }) {
|
|
150
|
+
const whole = String(question || "").trim();
|
|
151
|
+
if (collapsed && whole) return { text: whole.split("\n")[0].trim(), title: whole, question: true };
|
|
152
|
+
return { ...miniToolText({ collapsed: collapsed || running, tool }), question: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The status row's word while check-in's card is docked and nothing blocks
|
|
156
|
+
// (+asksFront+): "writing" while the live step's text streams (the page
|
|
157
|
+
// can't tell narration before a tool call from the answer), else the
|
|
158
|
+
// phase's own word (+phase+: liveSlots's; it stays "waiting for you").
|
|
159
|
+
// +streaming+: the phase the step would have with no card (liveSlots with
|
|
160
|
+
// pending false).
|
|
161
|
+
export function phaseLabel({ phase, asksFront = false, streaming = "" }) {
|
|
162
|
+
return asksFront && (streaming === "writing" || streaming === "writing the answer") ? "writing" : phase;
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
// While the first step has no thinking and no tool rows the turn is plain:
|
|
133
166
|
// its text streams in the stage's answer slot, as the turn block's
|
|
134
167
|
// chrome-less block streams like a bubble.
|
|
@@ -156,16 +189,11 @@ const NO_ANSWER = "no answer";
|
|
|
156
189
|
// ended (nothing happens now);
|
|
157
190
|
// trail the last three finished calls, newest last (a file's title
|
|
158
191
|
// without its dir; command as for tool);
|
|
159
|
-
// ticks one per call: {kind, status, step (the gen's index)}
|
|
160
|
-
//
|
|
161
|
-
// (turn_model.js askingText), "" when none is blocked;
|
|
162
|
-
// askingRefs a plugin's refs on it (turn_model.js askingRefs), or null.
|
|
163
|
-
// +pending+: a card waits for the user; +blocked+: a card that blocks the
|
|
164
|
-
// turn (a question, an approval; not an action card) waits; +ended+: the
|
|
165
|
-
// turn's end kind;
|
|
192
|
+
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
193
|
+
// +pending+: a card waits for the user; +ended+: the turn's end kind;
|
|
166
194
|
// +emptyAnswer+: a completed turn ended with no answer
|
|
167
195
|
// (turn_summary.empty_answer), so it reads "no answer", not "answered".
|
|
168
|
-
export function liveSlots(turn, { narration = "", thinking = "", pending = false,
|
|
196
|
+
export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
|
|
169
197
|
const gens = turn?.gens || [];
|
|
170
198
|
const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
|
|
171
199
|
const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
|
|
@@ -193,8 +221,6 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
193
221
|
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
194
222
|
trail,
|
|
195
223
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
196
|
-
asking: blocked && !ended ? askingText(turn) : "",
|
|
197
|
-
askingRefs: blocked && !ended ? askingRefs(turn) : null,
|
|
198
224
|
};
|
|
199
225
|
}
|
|
200
226
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// A running turn opened in the history (stage_view.js). When
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// A running turn opened in the history (stage_view.js). When a card waits
|
|
2
|
+
// for the user (stage_asking.js docks), the turn's prompt, its steps block
|
|
3
|
+
// and the rows that arrive are the history's from then to the turn's end;
|
|
4
|
+
// only what waits for the user (the card), the live slots and the answer
|
|
5
|
+
// stay in the stage. chi's own question or approval after a reply gets that
|
|
6
|
+
// reply (the reason, its lead-in) as a conversation bubble at the history's
|
|
7
|
+
// end. This module draws the reason
|
|
7
8
|
// bubble and moves things into the history (history_end.js), following a
|
|
8
9
|
// reader who was at the history's end (a reader scrolled up is never
|
|
9
10
|
// moved). At the hand-off the reasons go (end): each answered card's own
|
|
@@ -32,7 +33,8 @@ export function createOpenedTurn({
|
|
|
32
33
|
historyEl, atHistoryEnd, renderMarkdown = () => null, linkRefs = () => {}, reduce = () => false,
|
|
33
34
|
afterSettle = (fn) => requestAnimationFrame(() => requestAnimationFrame(fn)),
|
|
34
35
|
}) {
|
|
35
|
-
// card → its reason bubble, for the turn
|
|
36
|
+
// card key (stage_waiting.js waitingKey) → its reason bubble, for the turn
|
|
37
|
+
// in the stage.
|
|
36
38
|
const reasons = new Map();
|
|
37
39
|
// The asking steps whose text a reason shows (data-said: their text is
|
|
38
40
|
// hidden in the block meanwhile, index.html), to clear at the end.
|
|
@@ -141,18 +143,20 @@ export function createOpenedTurn({
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
return {
|
|
144
|
-
// The turn's pieces so far (handOffOrder's order) into the history;
|
|
145
|
-
// reason's read (ask) does the follow
|
|
146
|
-
|
|
146
|
+
// The turn's pieces so far (handOffOrder's order) into the history; a
|
|
147
|
+
// reason's read (ask) does the follow, else +follow+ (a reader who was at
|
|
148
|
+
// the end before the move).
|
|
149
|
+
open: (pieces, { follow = false } = {}) => add(pieces, { follow }),
|
|
147
150
|
// A row of the opened turn: to the history's end.
|
|
148
151
|
add: (el) => add([el]),
|
|
149
152
|
// +card+ blocks the turn: its reason's bubble at the history's end, in
|
|
150
153
|
// the card's task; a reader at the end reads it from its first line.
|
|
151
154
|
// +follow+: the reader was at the end before the turn opened.
|
|
152
|
-
|
|
155
|
+
// +key+: the card's key, the bubble's for has and settle.
|
|
156
|
+
ask({ card, key, text, refs = null, step = 0, turnId = null, questionId = null, follow = atHistoryEnd() }) {
|
|
153
157
|
const bubble = reasonBubble({ text, refs, step, turnId, questionId });
|
|
154
158
|
moveToHistoryEnd(historyEl, [bubble]);
|
|
155
|
-
reasons.set(
|
|
159
|
+
reasons.set(key, bubble);
|
|
156
160
|
// The step's own text is the reason: not twice (and raw while the
|
|
157
161
|
// step runs). data-said, not a class: lit owns the step's class.
|
|
158
162
|
const stepEl = askingStep();
|
|
@@ -174,12 +178,18 @@ export function createOpenedTurn({
|
|
|
174
178
|
}
|
|
175
179
|
return bubble;
|
|
176
180
|
},
|
|
177
|
-
has: (
|
|
178
|
-
// +card+
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
has: (key) => key != null && reasons.has(key),
|
|
182
|
+
// +card+ drawn again with +key+ (the same question, a new element): its
|
|
183
|
+
// way back to the reason it already has.
|
|
184
|
+
relink(key, card) {
|
|
185
|
+
const bubble = reasons.get(key);
|
|
186
|
+
if (bubble && !card.querySelector(":scope > summary > .question-reason-link")) reasonLink(card, bubble);
|
|
187
|
+
},
|
|
188
|
+
// +card+ (its +key+) no longer waits (answered, cancelled): it goes right
|
|
189
|
+
// under its reason, which stops saying "asking you"; another card of the
|
|
190
|
+
// turn to the history's end. A reader at the end follows.
|
|
191
|
+
settle(key, card) {
|
|
192
|
+
const bubble = reasons.get(key);
|
|
183
193
|
if (!bubble?.isConnected) return add([card]);
|
|
184
194
|
const follow = atHistoryEnd();
|
|
185
195
|
bubble.classList.remove("pending");
|