samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1059,7 +1059,8 @@ module Samagotchi
|
|
|
1059
1059
|
port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
|
|
1060
1060
|
end
|
|
1061
1061
|
|
|
1062
|
-
#
|
|
1062
|
+
# GET a read path of the session's live Bridge (+path+: "state",
|
|
1063
|
+
# "snapshot", "stats", ...).
|
|
1063
1064
|
# @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
|
|
1064
1065
|
def bridge_get_json(session_id, path)
|
|
1065
1066
|
bridge_client(session_id)&.get_json(path)
|
|
@@ -1252,6 +1253,13 @@ module Samagotchi
|
|
|
1252
1253
|
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
|
|
1253
1254
|
next
|
|
1254
1255
|
end
|
|
1256
|
+
# A turn that ended with no answer (TurnNote.empty's marker): its
|
|
1257
|
+
# empty steps (with ?parts=1) and the item the page draws its
|
|
1258
|
+
# notice from; the note itself stays hidden.
|
|
1259
|
+
if (marker = Samagotchi::TurnNote.empty_answer(m))
|
|
1260
|
+
filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
|
|
1261
|
+
next
|
|
1262
|
+
end
|
|
1255
1263
|
next if role == "system"
|
|
1256
1264
|
next if role == "tool_response"
|
|
1257
1265
|
|
|
@@ -1293,6 +1301,19 @@ module Samagotchi
|
|
|
1293
1301
|
[]
|
|
1294
1302
|
end
|
|
1295
1303
|
|
|
1304
|
+
# A no-answer turn's marker as display items: each empty step that
|
|
1305
|
+
# has something to show (its thinking) as a text-less assistant step
|
|
1306
|
+
# (only with +parts+, as other text-less steps), then
|
|
1307
|
+
# { role: "empty_answer", retries: }.
|
|
1308
|
+
def empty_answer_items(marker, parts:, cwd:)
|
|
1309
|
+
steps = parts ? Array(marker[:steps] || marker["steps"]) : []
|
|
1310
|
+
items = steps.filter_map do |step|
|
|
1311
|
+
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
1312
|
+
{ role: "assistant", content: "", parts: did } if did
|
|
1313
|
+
end
|
|
1314
|
+
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1296
1317
|
# The last turn failed and its prompt went back to the user: the
|
|
1297
1318
|
# prompt (session.last_prompt) and why, for the page to show after the
|
|
1298
1319
|
# conversation as it did live. nil otherwise.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
listSessions,
|
|
3
|
-
createSession,
|
|
4
3
|
getSession,
|
|
5
4
|
sendTurn,
|
|
6
5
|
cancelTurn,
|
|
@@ -21,7 +20,7 @@ import { emptySessionState } from "./session_state.js";
|
|
|
21
20
|
import { createModelPicker } from "./model_picker.js";
|
|
22
21
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
22
|
import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
|
-
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
23
|
+
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
24
|
import { createStageView } from "./stage_view.js";
|
|
26
25
|
import { appendQuote } from "./annotations.js";
|
|
27
26
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -37,14 +36,14 @@ import { createPromptHistory } from "./prompt_history.js";
|
|
|
37
36
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
38
37
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
39
38
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
40
|
-
import { stripColumns, stripShowParts } from "./strip.js";
|
|
39
|
+
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
41
40
|
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
42
41
|
import { createNotifications } from "./notifications.js";
|
|
43
42
|
import {
|
|
44
43
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
45
44
|
} from "./timing.js";
|
|
46
45
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine,
|
|
46
|
+
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
47
|
|
|
49
48
|
const $ = (s) => document.querySelector(s);
|
|
50
49
|
const topStripEl = $("#topStrip");
|
|
@@ -776,6 +775,9 @@ function renderSessionView(data) {
|
|
|
776
775
|
// A question the worker is blocked on (also after a reload).
|
|
777
776
|
if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
|
|
778
777
|
questionCards.renderQuestion(data.pending_question);
|
|
778
|
+
// The turn's replay drew it as asked; the snapshot knows whether it is
|
|
779
|
+
// relayed to the parent now.
|
|
780
|
+
questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
|
|
779
781
|
}
|
|
780
782
|
// A turn ran out of iterations and waits for a yes or no.
|
|
781
783
|
if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
|
|
@@ -897,12 +899,25 @@ function addEndBubble(text) {
|
|
|
897
899
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
898
900
|
}
|
|
899
901
|
|
|
902
|
+
// The notice of a turn that ended with no answer (turn_summary.empty_answer),
|
|
903
|
+
// as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
|
|
904
|
+
function emptyAnswerEl(emptyAnswer) {
|
|
905
|
+
const el = document.createElement("div");
|
|
906
|
+
el.className = EMPTY_ANSWER_CLASS;
|
|
907
|
+
el.textContent = emptyAnswerLine(emptyAnswer);
|
|
908
|
+
return el;
|
|
909
|
+
}
|
|
910
|
+
|
|
900
911
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
901
912
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
902
913
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
903
|
-
function endTurnView(kind, endText = null) {
|
|
914
|
+
function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
|
|
904
915
|
setTurnRunning(false);
|
|
905
|
-
|
|
916
|
+
// A completed turn with no answer: its notice where the answer goes
|
|
917
|
+
// (the turn view places it), else after the turn; before the timing.
|
|
918
|
+
const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
|
|
919
|
+
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
920
|
+
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
906
921
|
finishLiveTurnTiming(null, { canceled: kind === "canceled" });
|
|
907
922
|
if (endText) addEndBubble(endText);
|
|
908
923
|
if (kept.length) {
|
|
@@ -1203,7 +1218,7 @@ function reloadedTurnEls(before) {
|
|
|
1203
1218
|
// What goes after a reloaded turn's own rows and cards: its answer, else
|
|
1204
1219
|
// its timing line; null when it has neither.
|
|
1205
1220
|
function turnAnswerEl(turnEls) {
|
|
1206
|
-
return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1221
|
+
return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
1207
1222
|
}
|
|
1208
1223
|
|
|
1209
1224
|
// A finished turn's question (the snapshot's cards keep it with its
|
|
@@ -1526,7 +1541,7 @@ const streamHandlers = {
|
|
|
1526
1541
|
startLiveTurnTiming(data.started_at);
|
|
1527
1542
|
}),
|
|
1528
1543
|
turn_completed: async (data) => {
|
|
1529
|
-
endTurnView("completed");
|
|
1544
|
+
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
1530
1545
|
const out = turnOutput(data);
|
|
1531
1546
|
if (out) appendChunk(out);
|
|
1532
1547
|
// after_turn hooks are running and may present the answer: its
|
|
@@ -1643,6 +1658,8 @@ const streamHandlers = {
|
|
|
1643
1658
|
question_answered: (data) => {
|
|
1644
1659
|
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
1645
1660
|
},
|
|
1661
|
+
// A delegate's question relayed to its parent's card, or no longer.
|
|
1662
|
+
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
1646
1663
|
question_cancelled: (data) => {
|
|
1647
1664
|
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
1648
1665
|
},
|
|
@@ -1714,37 +1731,19 @@ function addCreatedSession(created) {
|
|
|
1714
1731
|
render();
|
|
1715
1732
|
}
|
|
1716
1733
|
|
|
1717
|
-
// A first message: an idle session, then the turn
|
|
1718
|
-
// later one (handleSendTurn), so a failed first turn's prompt
|
|
1719
|
-
// into the composer too (sentIds).
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
const prompt = promptEl.value.trim();
|
|
1723
|
-
if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
|
|
1724
|
-
if (!prompt) return;
|
|
1725
|
-
actionBtn.disabled = true;
|
|
1726
|
-
try {
|
|
1727
|
-
const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
|
|
1728
|
-
addCreatedSession(s);
|
|
1729
|
-
select(s.id);
|
|
1730
|
-
promptEl.value = "";
|
|
1731
|
-
fitPrompt();
|
|
1732
|
-
refreshHistory();
|
|
1733
|
-
} catch (e) {
|
|
1734
|
-
alert(e.message);
|
|
1735
|
-
} finally {
|
|
1736
|
-
actionBtn.disabled = false;
|
|
1737
|
-
}
|
|
1734
|
+
// A first message: an idle session, then the turn (or the command) into it
|
|
1735
|
+
// as for any later one (handleSendTurn), so a failed first turn's prompt
|
|
1736
|
+
// comes back into the composer too (sentIds).
|
|
1737
|
+
function handleCreate() {
|
|
1738
|
+
return handleSendTurn();
|
|
1738
1739
|
}
|
|
1739
1740
|
|
|
1740
1741
|
async function handleSendTurn() {
|
|
1741
|
-
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1742
1742
|
if (current.owner === "tui") return;
|
|
1743
1743
|
const prompt = turnText(promptEl.value, chips);
|
|
1744
1744
|
if (!prompt) return;
|
|
1745
1745
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1746
1746
|
const typed = promptEl.value.trim() !== "";
|
|
1747
|
-
if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
|
|
1748
1747
|
if (!selected) {
|
|
1749
1748
|
actionBtn.disabled = true;
|
|
1750
1749
|
try {
|
|
@@ -1758,6 +1757,9 @@ async function handleSendTurn() {
|
|
|
1758
1757
|
actionBtn.disabled = false;
|
|
1759
1758
|
}
|
|
1760
1759
|
}
|
|
1760
|
+
// A session command by the session's list (the new one's too: select
|
|
1761
|
+
// read it); an unknown /word is a prompt, as in a terminal.
|
|
1762
|
+
if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
|
|
1761
1763
|
const hint = historyEl.querySelector(".hint");
|
|
1762
1764
|
if (hint) hint.remove();
|
|
1763
1765
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -1777,6 +1779,15 @@ async function handleSendTurn() {
|
|
|
1777
1779
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
1778
1780
|
}
|
|
1779
1781
|
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
|
|
1782
|
+
if (ack?.command_id) {
|
|
1783
|
+
// The worker knew it as a command (its list was newer than ours):
|
|
1784
|
+
// its command_ran shows it, not a prompt bubble.
|
|
1785
|
+
echo.remove();
|
|
1786
|
+
promptEl.value = "";
|
|
1787
|
+
fitPrompt();
|
|
1788
|
+
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1780
1791
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1781
1792
|
refreshHistory();
|
|
1782
1793
|
// A turn that failed fast was restored before this ack: its prompt
|
|
@@ -2229,11 +2240,27 @@ function stripHiddenSaved() {
|
|
|
2229
2240
|
return false;
|
|
2230
2241
|
}
|
|
2231
2242
|
}
|
|
2243
|
+
// A short window hides the strip by itself, so the stage keeps room for a
|
|
2244
|
+
// card that waits (its question and choices); the sessions pill brings it
|
|
2245
|
+
// back until the window is tall again. Not saved: the saved choice is the
|
|
2246
|
+
// user's own hide ▴ / show.
|
|
2247
|
+
const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
|
|
2248
|
+
let stripShownWhileShort = false;
|
|
2249
|
+
function applyStripHidden() {
|
|
2250
|
+
document.body.classList.toggle("strip-hidden", stripAutoHidden({
|
|
2251
|
+
saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
|
|
2252
|
+
}));
|
|
2253
|
+
}
|
|
2232
2254
|
function setStripHidden(hidden) {
|
|
2233
2255
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
2234
|
-
|
|
2256
|
+
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
2257
|
+
applyStripHidden();
|
|
2235
2258
|
}
|
|
2236
|
-
|
|
2259
|
+
applyStripHidden();
|
|
2260
|
+
shortWindow.addEventListener("change", () => {
|
|
2261
|
+
if (!shortWindow.matches) stripShownWhileShort = false;
|
|
2262
|
+
applyStripHidden();
|
|
2263
|
+
});
|
|
2237
2264
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2238
2265
|
|
|
2239
2266
|
$("#filter").addEventListener("input", renderAll);
|
|
@@ -253,6 +253,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
253
253
|
.bubble.question.approval.answered.denied .question-result{color:var(--danger)}
|
|
254
254
|
.bubble.question.approval.answered.denied .question-option.selected{border-color:var(--danger)}
|
|
255
255
|
.approval-details{margin-bottom:8px;font-size:13px;color:var(--text-2)}
|
|
256
|
+
.approval-delegate{font-size:12px;color:var(--text-muted);margin-bottom:4px;overflow-wrap:anywhere}
|
|
257
|
+
.approval-delegate a{color:var(--text);font-weight:600}
|
|
258
|
+
.question-relayed{font-size:12px;color:var(--text-muted);margin-bottom:6px}
|
|
259
|
+
.question-relayed.hidden{display:none}
|
|
260
|
+
.question-relayed a{color:var(--text);font-weight:600}
|
|
256
261
|
.approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
|
|
257
262
|
.approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
|
|
258
263
|
.approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
@@ -429,6 +434,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
429
434
|
.turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
|
|
430
435
|
.turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
|
|
431
436
|
.turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
|
|
437
|
+
.turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
|
|
432
438
|
.turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
|
|
433
439
|
@keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
|
|
434
440
|
.turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
|
|
@@ -441,6 +447,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
441
447
|
.turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
|
|
442
448
|
.turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
|
|
443
449
|
.turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
|
|
450
|
+
.turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
|
|
444
451
|
.turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
|
|
445
452
|
.ts-phase{color:var(--accent);font-weight:600}
|
|
446
453
|
.ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -30,16 +30,29 @@ export function waitingOn(summary) {
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// How long a delegated child's new approval waits before it notifies: its
|
|
34
|
+
// parent relays it when it waits on its children, and then the parent's
|
|
35
|
+
// card notifies instead (one bell, not two). A child the parent isn't
|
|
36
|
+
// waiting on directly is seen by its look at the others (DelegateRelay
|
|
37
|
+
// OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
|
|
38
|
+
// scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
|
|
39
|
+
// wait; a child's approval nobody relays rings this late.
|
|
40
|
+
export const RELAY_HOLD_MS = 3500;
|
|
41
|
+
|
|
33
42
|
// What changed between two summaries of one session that needs the user.
|
|
34
43
|
// @param prev the summary held before (null for a session not seen yet)
|
|
35
44
|
// @param next the new summary
|
|
36
45
|
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
37
|
-
// key names this one event, the same in every tab (the Notification tag)
|
|
46
|
+
// key names this one event, the same in every tab (the Notification tag);
|
|
47
|
+
// holdMs, when there, delays it (a delegated child's approval)
|
|
38
48
|
export function attentionFor(prev, next) {
|
|
39
49
|
if (!next || !next.id) return null;
|
|
40
50
|
const q = next.pending_question;
|
|
41
51
|
if (q && q.id && q.id !== prev?.pending_question?.id) {
|
|
42
|
-
|
|
52
|
+
// Relayed to the parent already: the parent's card notifies.
|
|
53
|
+
if (q.relayed_to) return null;
|
|
54
|
+
const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
|
|
55
|
+
return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
|
|
43
56
|
}
|
|
44
57
|
const card = next.pending_card;
|
|
45
58
|
if (card && card.id && card.id !== prev?.pending_card?.id) {
|
|
@@ -74,7 +87,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
74
87
|
export function closedKeys(prev, next) {
|
|
75
88
|
const keys = [];
|
|
76
89
|
const q = prev?.pending_question;
|
|
77
|
-
|
|
90
|
+
const nq = next?.pending_question;
|
|
91
|
+
// Gone, or relayed to the parent (its card notifies and badges instead).
|
|
92
|
+
if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
|
|
78
93
|
const card = prev?.pending_card;
|
|
79
94
|
if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
|
|
80
95
|
return keys;
|
|
@@ -178,10 +193,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
178
193
|
},
|
|
179
194
|
// A new attention in a tab behind: +deliver+ (notify, badge) unless
|
|
180
195
|
// another tab has it in front. @return whether it may still be delivered
|
|
196
|
+
// An attention's own holdMs (a delegated child's approval) holds it
|
|
197
|
+
// longer, alone too.
|
|
181
198
|
offer(attention, deliver) {
|
|
182
199
|
const key = attention.key;
|
|
183
200
|
if (seen.has(key)) return false;
|
|
184
|
-
if (!channel) {
|
|
201
|
+
if (!channel && !attention.holdMs) {
|
|
185
202
|
deliver();
|
|
186
203
|
return true;
|
|
187
204
|
}
|
|
@@ -189,7 +206,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
|
|
|
189
206
|
pending.set(key, schedule(() => {
|
|
190
207
|
pending.delete(key);
|
|
191
208
|
if (!seen.has(key)) deliver();
|
|
192
|
-
}, holdMs));
|
|
209
|
+
}, Math.max(holdMs, attention.holdMs || 0)));
|
|
193
210
|
return true;
|
|
194
211
|
},
|
|
195
212
|
// Closed before its hold was up (a question answered at once).
|
|
@@ -8,8 +8,26 @@ export function isApproval(pq) {
|
|
|
8
8
|
return !!pq && pq.kind === "approval";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// A delegate's approval relayed to this session's user (the approval
|
|
12
|
+
// relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
|
|
13
|
+
// task, or null.
|
|
14
|
+
// @return {null | {who, childId, childShort, task, more}}
|
|
15
|
+
export function relayView(pq) {
|
|
16
|
+
const r = pq && pq.relay;
|
|
17
|
+
if (!r || typeof r !== "object" || !r.child_id) return null;
|
|
18
|
+
const chain = Array.isArray(r.chain) && r.chain.length ? r.chain : [String(r.child_id).slice(0, 8)];
|
|
19
|
+
return {
|
|
20
|
+
who: [...chain].reverse().join(" → "),
|
|
21
|
+
childId: String(r.child_id),
|
|
22
|
+
childShort: r.child_short || String(r.child_id).slice(0, 8),
|
|
23
|
+
task: r.task ? String(r.task) : "",
|
|
24
|
+
more: Number(r.more) || 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
|
|
29
|
+
// preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
|
|
30
|
+
// is relayView's, for a delegate's approval
|
|
13
31
|
export function approvalView(pq) {
|
|
14
32
|
if (!isApproval(pq)) return null;
|
|
15
33
|
const a = pq.approval || {};
|
|
@@ -33,12 +51,34 @@ export function approvalView(pq) {
|
|
|
33
51
|
where,
|
|
34
52
|
why: `${reason} (${who})`,
|
|
35
53
|
...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
|
|
54
|
+
...(relayView(pq) ? { delegate: relayView(pq) } : {}),
|
|
36
55
|
};
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
// This session's question waits in its parent's card too (the parent
|
|
59
|
+
// relayed it): the parent's id and short id, or null.
|
|
60
|
+
// @return {null | {parentId, parentShort}}
|
|
61
|
+
export function relayedToView(pq) {
|
|
62
|
+
const r = pq && pq.relayed_to;
|
|
63
|
+
if (!r || typeof r !== "object" || !r.parent_id) return null;
|
|
64
|
+
return { parentId: String(r.parent_id), parentShort: r.parent_short || String(r.parent_id).slice(0, 8) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// How a relayed approval's card closed without an answer here: the
|
|
68
|
+
// delegate's question was answered there, its worker went, or this turn
|
|
69
|
+
// stopped (the question still waits in the delegate). A dismiss here
|
|
70
|
+
// denied it.
|
|
71
|
+
function relayClosedText(relay, reason) {
|
|
72
|
+
if (reason === "answered_on_child") return `Answered in ${relay.who}`;
|
|
73
|
+
if (reason === "child_gone") return `${relay.who}'s worker is gone`;
|
|
74
|
+
return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
// The line a resolved card shows.
|
|
40
78
|
export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
41
79
|
const approval = isApproval(pq);
|
|
80
|
+
const relay = relayView(pq);
|
|
81
|
+
if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
|
|
42
82
|
if (cancelled) {
|
|
43
83
|
const label = approval ? "Denied" : "Cancelled";
|
|
44
84
|
return reason ? `${label} (${reason})` : label;
|
|
@@ -70,8 +110,10 @@ export function truncate(text, n = 80) {
|
|
|
70
110
|
export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
71
111
|
if (cancelled || answer) {
|
|
72
112
|
const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
|
|
113
|
+
const relay = relayView(pq);
|
|
73
114
|
const asked = summaryAsked(pq);
|
|
74
|
-
|
|
115
|
+
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
116
|
+
return truncate(relay ? `${relay.who}: ${text}` : text, 120);
|
|
75
117
|
}
|
|
76
118
|
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
|
|
77
119
|
return truncate(pq.header || label);
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
|
-
import { approvalAllowed, approvalView, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
17
|
+
import { approvalAllowed, approvalView, nextCardAction, relayedToView, resultText, summaryText } from "./question_card.js";
|
|
18
|
+
import { sessionHash } from "./route.js";
|
|
18
19
|
import { clientLabel, continueLine, isOwn } from "./turn_events.js";
|
|
19
20
|
|
|
20
21
|
// @param sessionId () => the open session's id (answers go there)
|
|
@@ -64,6 +65,8 @@ export function createQuestionCards({
|
|
|
64
65
|
// left it (rememberToggle-style, as turn_view.js does).
|
|
65
66
|
summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
|
|
66
67
|
|
|
68
|
+
// Relayed to the parent: say where else it waits (QuestionDesk#annotate).
|
|
69
|
+
card.appendChild(renderRelayedTo(pq));
|
|
67
70
|
if (approval) {
|
|
68
71
|
card.appendChild(renderApprovalDetails(approval));
|
|
69
72
|
} else {
|
|
@@ -170,6 +173,23 @@ export function createQuestionCards({
|
|
|
170
173
|
function renderApprovalDetails(view) {
|
|
171
174
|
const box = doc.createElement("div");
|
|
172
175
|
box.className = "approval-details";
|
|
176
|
+
// A delegate's approval: who asks, and its task.
|
|
177
|
+
if (view.delegate) {
|
|
178
|
+
const who = doc.createElement("div");
|
|
179
|
+
who.className = "approval-delegate";
|
|
180
|
+
const label = doc.createElement("span");
|
|
181
|
+
label.className = "approval-label";
|
|
182
|
+
label.textContent = "delegate ";
|
|
183
|
+
who.appendChild(label);
|
|
184
|
+
const name = doc.createElement("a");
|
|
185
|
+
name.className = "approval-delegate-link";
|
|
186
|
+
name.href = sessionHash(view.delegate.childId);
|
|
187
|
+
name.title = "Open the delegate's session";
|
|
188
|
+
name.textContent = view.delegate.who;
|
|
189
|
+
who.appendChild(name);
|
|
190
|
+
if (view.delegate.task) who.appendChild(doc.createTextNode(` · ${view.delegate.task}`));
|
|
191
|
+
box.appendChild(who);
|
|
192
|
+
}
|
|
173
193
|
const tool = doc.createElement("div");
|
|
174
194
|
tool.className = "approval-tool";
|
|
175
195
|
tool.textContent = view.tool;
|
|
@@ -203,6 +223,32 @@ export function createQuestionCards({
|
|
|
203
223
|
return box;
|
|
204
224
|
}
|
|
205
225
|
|
|
226
|
+
// "Waiting for approval in parent ab12: answering here works too", or
|
|
227
|
+
// an empty (hidden) line while the question isn't relayed.
|
|
228
|
+
function renderRelayedTo(pq) {
|
|
229
|
+
const line = doc.createElement("div");
|
|
230
|
+
line.className = "question-relayed";
|
|
231
|
+
const view = relayedToView(pq);
|
|
232
|
+
if (!view) {
|
|
233
|
+
line.classList.add("hidden");
|
|
234
|
+
return line;
|
|
235
|
+
}
|
|
236
|
+
line.appendChild(doc.createTextNode(`Waiting for ${pq.kind === "approval" ? "approval" : "an answer"} in parent `));
|
|
237
|
+
const link = doc.createElement("a");
|
|
238
|
+
link.href = sessionHash(view.parentId);
|
|
239
|
+
link.textContent = view.parentShort;
|
|
240
|
+
line.appendChild(link);
|
|
241
|
+
line.appendChild(doc.createTextNode(": answering here works too"));
|
|
242
|
+
return line;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The parent relayed the open question, or no longer does (question_relay).
|
|
246
|
+
function markRelayed(id, relayedTo) {
|
|
247
|
+
if (!questionEl || !pending || String(pending.id) !== String(id)) return;
|
|
248
|
+
pending = { ...pending, relayed_to: relayedTo || null };
|
|
249
|
+
questionEl.querySelector(".question-relayed")?.replaceWith(renderRelayedTo(pending));
|
|
250
|
+
}
|
|
251
|
+
|
|
206
252
|
function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
207
253
|
if (!questionEl) return;
|
|
208
254
|
if (id && pending && String(pending.id) !== String(id)) return;
|
|
@@ -225,6 +271,7 @@ export function createQuestionCards({
|
|
|
225
271
|
questionEl.appendChild(note);
|
|
226
272
|
questionEl.classList.remove("submitting");
|
|
227
273
|
disableQuestionInputs();
|
|
274
|
+
questionEl.querySelector(".question-relayed")?.classList.add("hidden");
|
|
228
275
|
// The summary becomes the result; the card collapses to that one line
|
|
229
276
|
// unless the user opened or closed it by hand.
|
|
230
277
|
const summary = questionEl.querySelector("summary");
|
|
@@ -343,6 +390,7 @@ export function createQuestionCards({
|
|
|
343
390
|
return {
|
|
344
391
|
renderQuestion: renderQuestionCard,
|
|
345
392
|
resolveQuestion: resolveQuestionCard,
|
|
393
|
+
markRelayed,
|
|
346
394
|
removeQuestion: removeQuestionCard,
|
|
347
395
|
questionCard: () => questionEl,
|
|
348
396
|
pendingQuestion: () => pending,
|
|
@@ -78,7 +78,14 @@ const WAITING_BADGES = {
|
|
|
78
78
|
// @return null | {kind, text, title}
|
|
79
79
|
export function waitingBadge(summary) {
|
|
80
80
|
const waiting = waitingOn(summary);
|
|
81
|
-
|
|
81
|
+
if (!waiting) return null;
|
|
82
|
+
// A delegate's approval waiting in its parent's card (the approval relay).
|
|
83
|
+
const parent = summary.pending_question?.relayed_to;
|
|
84
|
+
if (parent && waiting.reason !== "card") {
|
|
85
|
+
return { kind: waiting.reason, text: `in parent ${parent}`,
|
|
86
|
+
title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
|
|
87
|
+
}
|
|
88
|
+
return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
// What the all view's search matches for a session that waits on the user:
|
|
@@ -88,6 +88,8 @@ export function isPlain(turn) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const END_PHASES = { completed: "answered", canceled: "canceled" };
|
|
91
|
+
// A completed turn whose model returned nothing (the turn view's notice).
|
|
92
|
+
const NO_ANSWER = "no answer";
|
|
91
93
|
|
|
92
94
|
// What the live slots show for +turn+:
|
|
93
95
|
// phase the status row's word;
|
|
@@ -100,14 +102,17 @@ const END_PHASES = { completed: "answered", canceled: "canceled" };
|
|
|
100
102
|
// trail the last three finished calls, newest last (a file's title
|
|
101
103
|
// without its dir);
|
|
102
104
|
// ticks one per call: {kind, status, step (the gen's index)}.
|
|
103
|
-
// +pending+: a card waits for the user; +ended+: the turn's end kind
|
|
104
|
-
|
|
105
|
+
// +pending+: a card waits for the user; +ended+: the turn's end kind;
|
|
106
|
+
// +emptyAnswer+: a completed turn ended with no answer
|
|
107
|
+
// (turn_summary.empty_answer), so it reads "no answer", not "answered".
|
|
108
|
+
export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
|
|
105
109
|
const gens = turn?.gens || [];
|
|
106
110
|
const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
|
|
107
111
|
const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
|
|
108
112
|
const current = gens[gens.length - 1];
|
|
109
113
|
let phase;
|
|
110
|
-
if (ended) phase =
|
|
114
|
+
if (ended === "completed" && emptyAnswer) phase = NO_ANSWER;
|
|
115
|
+
else if (ended) phase = END_PHASES[ended] || "failed";
|
|
111
116
|
else if (pending) phase = "waiting for you";
|
|
112
117
|
else if (running) phase = "running a tool";
|
|
113
118
|
else if (current?.text && isPlain(turn)) phase = "writing the answer";
|
|
@@ -26,7 +26,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
|
|
|
26
26
|
// asks the user too, but the turn goes on: it waits only while the turn runs
|
|
27
27
|
// and never holds the hand-off (a turn cut short can leave it open).
|
|
28
28
|
const ASKS = ".plugin-card[data-in-turn] .card-action";
|
|
29
|
-
const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
|
|
29
|
+
const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
|
|
30
30
|
// The mark of a flash in the trail: a warn notice, a hook's info notice, a
|
|
31
31
|
// plugin's nudge (steer).
|
|
32
32
|
const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
|
|
@@ -93,6 +93,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
93
93
|
// The turn in the stage, from turnStarted until the hand-off (or a reset).
|
|
94
94
|
let holding = false;
|
|
95
95
|
let endKind = null;
|
|
96
|
+
// The ended turn had no answer (turn_summary.empty_answer).
|
|
97
|
+
let emptyAnswer = false;
|
|
96
98
|
let settled = true;
|
|
97
99
|
let settleTimer = null;
|
|
98
100
|
let startedAt = 0;
|
|
@@ -175,7 +177,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
175
177
|
// A question blocks the turn: its card takes the live slots' room. An
|
|
176
178
|
// asking card's turn goes on: the status waits, the slots stay.
|
|
177
179
|
const blocked = pendingCard();
|
|
178
|
-
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
|
|
180
|
+
const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
|
|
179
181
|
|
|
180
182
|
stage.classList.toggle("plain", plain);
|
|
181
183
|
stage.classList.toggle("ended", !!endKind);
|
|
@@ -358,6 +360,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
358
360
|
// never above the session panel (the dock is absolute: a tall composer or
|
|
359
361
|
// a phone's keyboard would push the stage's top out) ────────────────────
|
|
360
362
|
|
|
363
|
+
// Scroll the stage so +slot+'s end shows (it is the scroller's child).
|
|
364
|
+
function revealSlot(slot) {
|
|
365
|
+
if (stage.hidden || slot.hidden) return;
|
|
366
|
+
const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
|
|
367
|
+
if (end > scroller.scrollTop) scroller.scrollTop = end;
|
|
368
|
+
}
|
|
369
|
+
|
|
361
370
|
function fitCap() {
|
|
362
371
|
if (stage.hidden) return;
|
|
363
372
|
const rest = dockEl.offsetHeight - stage.offsetHeight;
|
|
@@ -452,6 +461,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
452
461
|
clearTimeout(settleTimer);
|
|
453
462
|
holding = false;
|
|
454
463
|
endKind = null;
|
|
464
|
+
emptyAnswer = false;
|
|
455
465
|
settled = true;
|
|
456
466
|
view.reset();
|
|
457
467
|
emptySlots();
|
|
@@ -517,6 +527,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
517
527
|
}
|
|
518
528
|
holding = true;
|
|
519
529
|
endKind = null;
|
|
530
|
+
emptyAnswer = false;
|
|
520
531
|
settled = true;
|
|
521
532
|
startedAt = Date.parse(data?.started_at) || Date.now();
|
|
522
533
|
emptySlots();
|
|
@@ -542,6 +553,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
542
553
|
kept.push(...warn);
|
|
543
554
|
view.chronological();
|
|
544
555
|
endKind = args.kind;
|
|
556
|
+
emptyAnswer = !!args.emptyAnswer;
|
|
545
557
|
endedAt = Date.now();
|
|
546
558
|
// A completed turn's end read (markdown, answer_display) is pending
|
|
547
559
|
// until answerReady; the others have none.
|
|
@@ -588,8 +600,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
|
|
|
588
600
|
const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
|
|
589
601
|
slot.appendChild(el);
|
|
590
602
|
slot.hidden = false;
|
|
591
|
-
|
|
603
|
+
const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
|
|
604
|
+
if (stick || waits) slot.scrollTop = slot.scrollHeight;
|
|
592
605
|
scheduleRefresh();
|
|
606
|
+
// A card that waits for the user is brought into the stage's view: in
|
|
607
|
+
// a short window the prompt and headline fill it and the card sat
|
|
608
|
+
// below, out of sight (after the refresh, which hides the live slots).
|
|
609
|
+
if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
|
|
593
610
|
return true;
|
|
594
611
|
},
|
|
595
612
|
// Where the live timing line goes (the status row), null: the history.
|
|
@@ -30,3 +30,14 @@ export function stripShowParts(sessions) {
|
|
|
30
30
|
waiting: waitingN ? `${waitingN} waiting` : "",
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// A window this short hides the strip by itself: at ~500-700 px the stage
|
|
35
|
+
// is capped low and a card that waits there (a question, an approval) is
|
|
36
|
+
// cut off below the strip.
|
|
37
|
+
export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
|
|
38
|
+
|
|
39
|
+
// Whether the strip is hidden: the user's own hide (saved), or a short
|
|
40
|
+
// window unless the user showed it (the pill) since it got short.
|
|
41
|
+
export function stripAutoHidden({ saved, short, shownWhileShort }) {
|
|
42
|
+
return !!saved || (!!short && !shownWhileShort);
|
|
43
|
+
}
|