samagotchi 0.133.0 → 0.134.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 +68 -1
- data/docs/cli.md +11 -0
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +102 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +6 -5
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -2
- data/docs/testing.md +7 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +13 -0
- data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
- data/lib/samagotchi/guardrails/pair_command.rb +493 -60
- data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
- data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/system_one/self_protect.rb +58 -2
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +18 -1
- data/lib/samagotchi/web/public/cards_place.js +5 -2
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +3 -1
- data/lib/samagotchi/web/public/history_reveal.js +23 -0
- data/lib/samagotchi/web/public/index.html +44 -28
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/public/turn_html.js +12 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/turn_view.js +21 -11
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- metadata +5 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Folding a long answer by hand (per viewer). A completed answer bubble
|
|
2
|
+
// longer than FOLD_LINES lines or FOLD_CHARS characters of its message text
|
|
3
|
+
// gets a fold row: a sibling just before the bubble, never inside it (the
|
|
4
|
+
// bubble's copy text, its claims and a live end's innerHTML stay the
|
|
5
|
+
// bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
|
|
6
|
+
// no inline style: hold.js strips that) and the row reads its first line
|
|
7
|
+
// and its size, "‹first line› · N lines ▸"; unfolded, the row stays as a
|
|
8
|
+
// slim "▾ N lines" header.
|
|
9
|
+
//
|
|
10
|
+
// Which answers are folded is this browser's choice (localStorage through
|
|
11
|
+
// storage.js): one key per session holding the folded answers' keys, the
|
|
12
|
+
// last FOLD_KEEP of them. An answer's key is its turn's id
|
|
13
|
+
// (data-answer-turn), a delegate report's reply its own (data-reply-key);
|
|
14
|
+
// a bubble with neither (an answer before any prompt, a canceled turn's
|
|
15
|
+
// partial) gets no row. The keys of sessions gone from the session list go
|
|
16
|
+
// when the list loads (sweepAnswerFolds).
|
|
17
|
+
//
|
|
18
|
+
// One observer on #history decorates every path that fills it (a reload's
|
|
19
|
+
// render, a live turn's end, the stage's hand-off), as copy.js does. A jump
|
|
20
|
+
// into a folded answer unfolds it (history_reveal.js); the row follows the
|
|
21
|
+
// bubble's class.
|
|
22
|
+
//
|
|
23
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
24
|
+
// export on an old module would blank the page.
|
|
25
|
+
import { bubbleCopyText } from "./copy.js";
|
|
26
|
+
import { clip } from "./format.js";
|
|
27
|
+
import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
|
|
28
|
+
|
|
29
|
+
// The fold threshold: an answer longer than either gets a fold row.
|
|
30
|
+
export const FOLD_LINES = 25;
|
|
31
|
+
export const FOLD_CHARS = 2000;
|
|
32
|
+
// How many folded answers a session's key keeps (the oldest go first).
|
|
33
|
+
export const FOLD_KEEP = 200;
|
|
34
|
+
export const FOLD_PREFIX = "chi_answer_fold:";
|
|
35
|
+
// A folded row's label, at most this long (CSS cuts it to the row's width).
|
|
36
|
+
const LABEL_CHARS = 160;
|
|
37
|
+
|
|
38
|
+
const ROW_CLASS = "answer-fold";
|
|
39
|
+
const ANSWERS = ".bubble.output";
|
|
40
|
+
// Not done yet: still streaming, or held by the answer pop (hold.js).
|
|
41
|
+
const UNFINISHED = ".streaming, .boxed";
|
|
42
|
+
|
|
43
|
+
// +text+'s lines (a trailing newline ends the last one, it adds none).
|
|
44
|
+
export function lineCount(text) {
|
|
45
|
+
const s = String(text ?? "").replace(/\s+$/, "");
|
|
46
|
+
return s ? s.split("\n").length : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Whether an answer of +text+ gets a fold row.
|
|
50
|
+
export function foldable(text) {
|
|
51
|
+
const s = String(text ?? "");
|
|
52
|
+
return lineCount(s) > FOLD_LINES || s.length > FOLD_CHARS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A markdown line as plain words: no heading marks, list or quote lead, no
|
|
56
|
+
// emphasis or code ticks.
|
|
57
|
+
function plainLine(line) {
|
|
58
|
+
return line
|
|
59
|
+
.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, "")
|
|
60
|
+
.replace(/(\*\*|__|`)/g, "")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A folded answer's label: the first line of what a hook presented in its
|
|
65
|
+
// place (+display+: the text is that), else its first markdown heading,
|
|
66
|
+
// else its first text line (a code fence's marks skipped); one line.
|
|
67
|
+
export function foldLabel(text, { display = false } = {}) {
|
|
68
|
+
const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
69
|
+
const texts = lines.filter((l) => l.trim() && !/^\s*(```|~~~)/.test(l));
|
|
70
|
+
const heading = display ? null : texts.find((l) => /^\s{0,3}#{1,6}\s+\S/.test(l));
|
|
71
|
+
return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The row's words: folded, the label and the size; unfolded, the size.
|
|
75
|
+
export function foldRowText(text, { display = false, folded = true } = {}) {
|
|
76
|
+
const n = lineCount(text);
|
|
77
|
+
const size = `${n} line${n === 1 ? "" : "s"}`;
|
|
78
|
+
if (!folded) return { label: "", size };
|
|
79
|
+
const label = foldLabel(text, { display });
|
|
80
|
+
return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- The stored choice ----
|
|
84
|
+
|
|
85
|
+
export function foldStoreKey(sessionId) {
|
|
86
|
+
return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readEntry(key) {
|
|
90
|
+
try {
|
|
91
|
+
const entry = JSON.parse(readStored(key, "null"));
|
|
92
|
+
if (!entry || !Array.isArray(entry.turns)) return null;
|
|
93
|
+
return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns.filter((t) => typeof t === "string") };
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The session's folded answers' keys, oldest first; [] when none or they
|
|
100
|
+
// can't be read.
|
|
101
|
+
export function readFolded(sessionId) {
|
|
102
|
+
const key = foldStoreKey(sessionId);
|
|
103
|
+
return (key && readEntry(key)?.turns) || [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// +list+ with +answerKey+ folded (moved to the end) or not, at most
|
|
107
|
+
// FOLD_KEEP long. Pure.
|
|
108
|
+
export function withFold(list, answerKey, folded) {
|
|
109
|
+
const rest = list.filter((k) => k !== answerKey);
|
|
110
|
+
return (folded ? [...rest, answerKey] : rest).slice(-FOLD_KEEP);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
|
|
114
|
+
// project root, kept for the sweep of a project's list.
|
|
115
|
+
export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
|
|
116
|
+
const key = foldStoreKey(sessionId);
|
|
117
|
+
if (!key || !answerKey) return;
|
|
118
|
+
const turns = withFold(readFolded(sessionId), answerKey, folded);
|
|
119
|
+
if (!turns.length) removeStored(key);
|
|
120
|
+
else writeStored(key, JSON.stringify({ root, turns }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The session list loaded (+sessions+, each {id, project_root}): the fold
|
|
124
|
+
// keys of sessions not in it go. A project's list (+scoped+) only knows its
|
|
125
|
+
// own project's sessions, so there a key goes only when its project root is
|
|
126
|
+
// one the list shows; an unreadable key goes.
|
|
127
|
+
export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
|
|
128
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
129
|
+
const ids = new Set(list.map((s) => s?.id).filter(Boolean));
|
|
130
|
+
const roots = new Set(list.map((s) => s?.project_root).filter(Boolean));
|
|
131
|
+
for (const key of storedKeys(FOLD_PREFIX)) {
|
|
132
|
+
if (ids.has(key.slice(FOLD_PREFIX.length))) continue;
|
|
133
|
+
const entry = readEntry(key);
|
|
134
|
+
if (!entry || !scoped || (entry.root && roots.has(entry.root))) removeStored(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- The rows ----
|
|
139
|
+
|
|
140
|
+
// The key an answer bubble is folded by, or null.
|
|
141
|
+
export function answerKey(bubble) {
|
|
142
|
+
return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function answerText(bubble) {
|
|
146
|
+
return bubbleCopyText(bubble);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawRow(row, bubble) {
|
|
150
|
+
const folded = bubble.classList.contains("folded");
|
|
151
|
+
const text = answerText(bubble);
|
|
152
|
+
const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
|
|
153
|
+
row.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
154
|
+
row.title = folded ? "Show the answer" : "Fold the answer";
|
|
155
|
+
row.classList.toggle("folded", folded);
|
|
156
|
+
const [labelEl, sizeEl] = row.children;
|
|
157
|
+
if (labelEl.textContent !== label) labelEl.textContent = label;
|
|
158
|
+
if (sizeEl.textContent !== size) sizeEl.textContent = size;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeRow(doc) {
|
|
162
|
+
const row = doc.createElement("button");
|
|
163
|
+
row.type = "button";
|
|
164
|
+
row.className = ROW_CLASS;
|
|
165
|
+
const label = doc.createElement("span");
|
|
166
|
+
label.className = "answer-fold-label";
|
|
167
|
+
const size = doc.createElement("span");
|
|
168
|
+
size.className = "answer-fold-size";
|
|
169
|
+
row.append(label, size);
|
|
170
|
+
return row;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Runs +mutate+ and scrolls +scroller+ so +row+ stays where it was on
|
|
174
|
+
// screen (folding a long answer above the view would pull the page up), in
|
|
175
|
+
// sight at least.
|
|
176
|
+
export function keepRowInView(scroller, row, mutate) {
|
|
177
|
+
const before = row.getBoundingClientRect().top;
|
|
178
|
+
mutate();
|
|
179
|
+
const after = row.getBoundingClientRect().top;
|
|
180
|
+
if (scroller && after !== before) scroller.scrollTop += after - before;
|
|
181
|
+
const box = row.getBoundingClientRect();
|
|
182
|
+
const view = scroller?.getBoundingClientRect?.();
|
|
183
|
+
if (view && (box.top < view.top || box.bottom > view.bottom)) row.scrollIntoView?.({ block: "nearest" });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// @param historyEl the history (#history), also its scroller
|
|
187
|
+
// @param sessionId () => the open session's id
|
|
188
|
+
// @param projectRoot () => the open session's project root, or null
|
|
189
|
+
// @return {decorate(root)} (the observer runs it; specs call it)
|
|
190
|
+
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
|
|
191
|
+
// Each bubble's row, and each row's bubble.
|
|
192
|
+
const rows = new WeakMap();
|
|
193
|
+
const bubbleOf = new WeakMap();
|
|
194
|
+
|
|
195
|
+
function decorateBubble(bubble, stored) {
|
|
196
|
+
if (bubble.parentElement !== historyEl) return;
|
|
197
|
+
const key = answerKey(bubble);
|
|
198
|
+
let row = rows.get(bubble);
|
|
199
|
+
if (!key || bubble.matches(UNFINISHED) || (!row && !foldable(answerText(bubble)))) return;
|
|
200
|
+
if (!row) {
|
|
201
|
+
row = makeRow(historyEl.ownerDocument);
|
|
202
|
+
rows.set(bubble, row);
|
|
203
|
+
bubbleOf.set(row, bubble);
|
|
204
|
+
// The stored choice, once: after that the bubble's class is the state
|
|
205
|
+
// (a jump that unfolds it, history_reveal.js, isn't saved).
|
|
206
|
+
if (stored().includes(key)) bubble.classList.add("folded");
|
|
207
|
+
}
|
|
208
|
+
if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
|
|
209
|
+
drawRow(row, bubble);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Rows whose bubble left (a re-render, a replaced bubble) go with it.
|
|
213
|
+
function dropOrphans() {
|
|
214
|
+
for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
|
|
215
|
+
const bubble = bubbleOf.get(row);
|
|
216
|
+
if (!bubble || bubble.parentElement !== historyEl) row.remove();
|
|
217
|
+
else if (row.nextElementSibling !== bubble) historyEl.insertBefore(row, bubble);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function decorate(root) {
|
|
222
|
+
let folded = null;
|
|
223
|
+
const stored = () => (folded ??= readFolded(sessionId()));
|
|
224
|
+
if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
|
|
225
|
+
for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Synchronous (not a frame later, as copy buttons are): the row lands in
|
|
229
|
+
// the same frame as its bubble, so nothing below it shifts after a paint.
|
|
230
|
+
new MutationObserver((records) => {
|
|
231
|
+
const roots = new Set();
|
|
232
|
+
let removed = false;
|
|
233
|
+
for (const r of records) {
|
|
234
|
+
if (r.type === "attributes") {
|
|
235
|
+
if (r.target.matches?.(ANSWERS)) roots.add(r.target);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (r.removedNodes.length) removed = true;
|
|
239
|
+
const answer = r.target.closest?.(ANSWERS);
|
|
240
|
+
if (answer) roots.add(answer);
|
|
241
|
+
for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
|
|
242
|
+
}
|
|
243
|
+
if (removed) dropOrphans();
|
|
244
|
+
for (const el of roots) if (el.isConnected) decorate(el);
|
|
245
|
+
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
246
|
+
decorate(historyEl);
|
|
247
|
+
|
|
248
|
+
historyEl.addEventListener("click", (e) => {
|
|
249
|
+
const row = e.target.closest?.(`.${ROW_CLASS}`);
|
|
250
|
+
const bubble = row && bubbleOf.get(row);
|
|
251
|
+
if (!bubble) return;
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
const folded = !bubble.classList.contains("folded");
|
|
254
|
+
keepRowInView(historyEl, row, () => {
|
|
255
|
+
bubble.classList.toggle("folded", folded);
|
|
256
|
+
drawRow(row, bubble);
|
|
257
|
+
});
|
|
258
|
+
writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return { decorate };
|
|
262
|
+
}
|
|
@@ -67,6 +67,8 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
|
|
|
67
67
|
if (typeof message.html === "string") {
|
|
68
68
|
bubble.classList.add("markdown");
|
|
69
69
|
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
70
|
+
// A hook's display in the answer's place (answer_fold.js's label).
|
|
71
|
+
bubble.toggleAttribute("data-answer-display", typeof message.display === "string");
|
|
70
72
|
// The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
|
|
71
73
|
bubble.innerHTML = message.html;
|
|
72
74
|
}
|
|
@@ -68,6 +68,7 @@ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify
|
|
|
68
68
|
import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
69
69
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
70
70
|
import { copyText, installCopy } from "./copy.js";
|
|
71
|
+
import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
|
|
71
72
|
import { createQuestionCards } from "./question_cards.js";
|
|
72
73
|
import { splitSnapshotCards } from "./card.js";
|
|
73
74
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX } from "./composer_size.js";
|
|
@@ -443,6 +444,14 @@ const scope = {
|
|
|
443
444
|
};
|
|
444
445
|
// The server refused ?dir (a folder deleted since the link was made).
|
|
445
446
|
let scopeError = null;
|
|
447
|
+
// The list's first load sweeps the folded answers of sessions gone since
|
|
448
|
+
// (answer_fold.js), once per page.
|
|
449
|
+
let foldsSwept = false;
|
|
450
|
+
function sweepFoldsOnce() {
|
|
451
|
+
if (foldsSwept) return;
|
|
452
|
+
foldsSwept = true;
|
|
453
|
+
sweepAnswerFolds(allSessions, { scoped: !!scope.dir });
|
|
454
|
+
}
|
|
446
455
|
|
|
447
456
|
// The list by fetching: the fallback when there is no events stream (a
|
|
448
457
|
// chi web without a hub, or ?dir the server refused: the 400 is read here).
|
|
@@ -450,6 +459,7 @@ async function refresh() {
|
|
|
450
459
|
try {
|
|
451
460
|
allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
|
|
452
461
|
notifications.noticeList(allSessions);
|
|
462
|
+
sweepFoldsOnce();
|
|
453
463
|
} catch (e) {
|
|
454
464
|
if (e.status !== 400 || !scope.dir) throw e;
|
|
455
465
|
scopeError = e.message;
|
|
@@ -470,6 +480,7 @@ function openSessionEvents() {
|
|
|
470
480
|
if (data.chi_update) updateFrame(data.chi_update, { snapshot: true });
|
|
471
481
|
notifications.noticeSessions("snapshot", data);
|
|
472
482
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
483
|
+
sweepFoldsOnce();
|
|
473
484
|
lists.render();
|
|
474
485
|
const sess = allSessions.find((s) => s.id === selected);
|
|
475
486
|
if (sess) followSelectedSession(sess);
|
|
@@ -1305,7 +1316,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1305
1316
|
// A completed turn with no answer: its notice where the answer goes
|
|
1306
1317
|
// (the turn view places it), else after the turn; before the timing.
|
|
1307
1318
|
const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
|
|
1308
|
-
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
1319
|
+
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer, turnId: liveTurn.timing.turnId }) || [];
|
|
1309
1320
|
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
1310
1321
|
liveTurn.timing.finish({ canceled: kind === "canceled" });
|
|
1311
1322
|
if (endText) addEndBubble(endText, { stopped });
|
|
@@ -2024,6 +2035,12 @@ historyEl.addEventListener("click", (e) => {
|
|
|
2024
2035
|
});
|
|
2025
2036
|
turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
|
|
2026
2037
|
turnRoots().forEach((root) => installCopy(root));
|
|
2038
|
+
// Long answers fold by hand (answer_fold.js), per viewer and session.
|
|
2039
|
+
installAnswerFold({
|
|
2040
|
+
historyEl,
|
|
2041
|
+
sessionId: () => selected,
|
|
2042
|
+
projectRoot: () => allSessions.find((s) => s.id === selected)?.project_root ?? null,
|
|
2043
|
+
});
|
|
2027
2044
|
|
|
2028
2045
|
// Notifications (notifications.js): an OS notification and a title count
|
|
2029
2046
|
// when a session needs the user while this tab is not in front. On a
|
|
@@ -25,10 +25,13 @@ export function reloadedTurnEls(historyEl, before) {
|
|
|
25
25
|
return turnEls;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// What goes after a reloaded turn's own rows and cards: its answer
|
|
28
|
+
// What goes after a reloaded turn's own rows and cards: its answer (its
|
|
29
|
+
// fold row, answer_fold.js, when it has one: the two stay together), else
|
|
29
30
|
// its timing line; null when it has neither.
|
|
30
31
|
export function turnAnswerEl(turnEls) {
|
|
31
|
-
|
|
32
|
+
const answer = turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop();
|
|
33
|
+
if (answer) return answer.previousElementSibling?.matches(".answer-fold") ? answer.previousElementSibling : answer;
|
|
34
|
+
return turnEls.find((el) => el.matches(".turn-timing")) || null;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// @param historyEl the history
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// click, its × (data-act="close"), a click outside or Esc; focus goes back
|
|
6
6
|
// to the opener. Another session's summary (refresh) closes it. What it
|
|
7
7
|
// draws, and what a Set or another data-act does, is the caller's. With
|
|
8
|
-
// +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet.
|
|
8
|
+
// +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet. An
|
|
9
|
+
// open one is placed again when the window (or a phone's visual viewport:
|
|
10
|
+
// its keyboard) resizes.
|
|
9
11
|
|
|
10
12
|
import { nothing, render } from "./vendor/lit-html.js";
|
|
11
13
|
|
|
@@ -29,11 +31,28 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
|
29
31
|
return height > room ? { ...place, maxHeight: room } : place;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// The share of a phone's window a bottom sheet takes at most (the rest
|
|
35
|
+
// scrolls); a caller's taller view asks for SHEET_TALL.
|
|
36
|
+
export const SHEET_SHARE = 0.6;
|
|
37
|
+
export const SHEET_TALL = 0.85;
|
|
38
|
+
|
|
32
39
|
// A bottom sheet's place in a phone's window: the window's width less its
|
|
33
|
-
// edges, at its bottom, at most
|
|
40
|
+
// edges, at its bottom, at most +share+ of its height. +viewport+: the
|
|
41
|
+
// visual viewport ({height, offsetTop}) when it is shorter than the window
|
|
42
|
+
// (a phone's keyboard over the page): the sheet sits on it, sized to it.
|
|
34
43
|
// @return {left, bottom, width, maxHeight}
|
|
35
|
-
export function sheetPlace({ innerWidth, innerHeight }) {
|
|
36
|
-
|
|
44
|
+
export function sheetPlace({ innerWidth, innerHeight }, { share = SHEET_SHARE, viewport = null } = {}) {
|
|
45
|
+
const height = viewport ? Math.min(innerHeight, viewport.height) : innerHeight;
|
|
46
|
+
const below = viewport ? Math.max(0, innerHeight - viewport.offsetTop - viewport.height) : 0;
|
|
47
|
+
return { left: SHEET_EDGE, bottom: SHEET_EDGE + Math.round(below), width: innerWidth - 2 * SHEET_EDGE,
|
|
48
|
+
maxHeight: Math.round(height * share) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
|
52
|
+
|
|
53
|
+
// The elements Tab reaches in +root+, in order (none hidden or disabled).
|
|
54
|
+
export function tabStops(root) {
|
|
55
|
+
return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
// id, className: the popover's; width: its widest (px); el: where the chip
|
|
@@ -47,27 +66,42 @@ export function sheetPlace({ innerWidth, innerHeight }) {
|
|
|
47
66
|
// sheet: a bottom sheet in a narrow window (sheetPlace). label: the
|
|
48
67
|
// dialog's accessible name (aria-label), none by default. onOpen(): after
|
|
49
68
|
// an open has shown and placed it (where focus can go into it: show runs
|
|
50
|
-
// while it is still hidden).
|
|
69
|
+
// while it is still hidden). expands: every opener carries aria-expanded
|
|
70
|
+
// ("true" while open, "false" once closed; set "false" in the markup too),
|
|
71
|
+
// for openers that are fixed buttons (a lit-drawn chip owns its own).
|
|
72
|
+
// modal: a dialog that holds the focus while open (aria-modal; Tab and
|
|
73
|
+
// Shift+Tab wrap inside it, a Tab from outside goes in). sheetShare: the
|
|
74
|
+
// share of a phone's window the sheet takes at most (SHEET_SHARE).
|
|
51
75
|
// @return {pop, open, close, refresh, isOpen, opener}
|
|
52
76
|
export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
|
|
53
77
|
session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {}, onOpen = () => {},
|
|
54
|
-
sheet = false, label = null,
|
|
78
|
+
sheet = false, label = null, expands = false, modal = false, sheetShare = SHEET_SHARE,
|
|
79
|
+
doc = document, win = window }) {
|
|
55
80
|
const pop = doc.createElement("div");
|
|
56
81
|
pop.id = id;
|
|
57
82
|
pop.className = className;
|
|
58
83
|
pop.setAttribute("role", "dialog");
|
|
59
84
|
if (label) pop.setAttribute("aria-label", label);
|
|
85
|
+
if (modal) pop.setAttribute("aria-modal", "true");
|
|
60
86
|
pop.hidden = true;
|
|
61
87
|
doc.body.appendChild(pop);
|
|
62
|
-
// The session the open form is for,
|
|
88
|
+
// The session the open form is for, the element it is placed at, and the
|
|
89
|
+
// one focus goes back to (the opener clicked).
|
|
63
90
|
let openFor = null;
|
|
64
91
|
let opener = null;
|
|
92
|
+
let returnTo = null;
|
|
93
|
+
|
|
94
|
+
const expanded = (value) => {
|
|
95
|
+
if (expands) doc.querySelectorAll(openers).forEach((e) => e.setAttribute("aria-expanded", value));
|
|
96
|
+
};
|
|
65
97
|
|
|
66
98
|
function place(anchor) {
|
|
67
99
|
const narrow = sheet && win.innerWidth < SHEET_MAX;
|
|
68
100
|
pop.classList.toggle("sheet", narrow);
|
|
69
101
|
if (narrow) {
|
|
70
|
-
const
|
|
102
|
+
const vv = win.visualViewport;
|
|
103
|
+
const at = sheetPlace({ innerWidth: win.innerWidth, innerHeight: win.innerHeight },
|
|
104
|
+
{ share: sheetShare, viewport: vv ? { height: vv.height, offsetTop: vv.offsetTop } : null });
|
|
71
105
|
pop.style.width = `${at.width}px`;
|
|
72
106
|
pop.style.left = `${at.left}px`;
|
|
73
107
|
pop.style.top = "";
|
|
@@ -86,12 +120,16 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
86
120
|
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
// Opens at +anchor+ (open already: drawn anew there)
|
|
90
|
-
|
|
123
|
+
// Opens at +anchor+ (open already: drawn anew there); focus goes back to
|
|
124
|
+
// +from+ on close (an opener that isn't the anchor; the anchor when +from+
|
|
125
|
+
// is gone or hidden by then).
|
|
126
|
+
function open(anchor, { from = anchor } = {}) {
|
|
91
127
|
const data = summary(anchor);
|
|
92
128
|
if (!data) return;
|
|
93
129
|
openFor = session();
|
|
94
130
|
opener = anchor;
|
|
131
|
+
returnTo = from;
|
|
132
|
+
expanded("true");
|
|
95
133
|
show(data, true);
|
|
96
134
|
pop.hidden = false;
|
|
97
135
|
place(anchor);
|
|
@@ -103,10 +141,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
103
141
|
// its opener.
|
|
104
142
|
function close({ refocus = false } = {}) {
|
|
105
143
|
const had = refocus || pop.contains(doc.activeElement);
|
|
106
|
-
const back = opener;
|
|
144
|
+
const back = returnTo?.isConnected && !returnTo.hidden ? returnTo : opener;
|
|
145
|
+
expanded("false");
|
|
107
146
|
pop.hidden = true;
|
|
108
147
|
openFor = null;
|
|
109
148
|
opener = null;
|
|
149
|
+
returnTo = null;
|
|
110
150
|
onClose();
|
|
111
151
|
render(nothing, pop);
|
|
112
152
|
if (had && back?.isConnected) back.focus?.();
|
|
@@ -156,7 +196,29 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
156
196
|
});
|
|
157
197
|
doc.addEventListener("keydown", (e) => {
|
|
158
198
|
if (e.key === "Escape" && isOpen()) close({ refocus: true });
|
|
199
|
+
else if (e.key === "Tab" && modal && isOpen()) holdFocus(e);
|
|
159
200
|
});
|
|
201
|
+
// A resize (a rotation, a phone's keyboard) places an open one again: a
|
|
202
|
+
// sheet's height follows the window, a popover its chip.
|
|
203
|
+
win.addEventListener?.("resize", replace);
|
|
204
|
+
win.visualViewport?.addEventListener?.("resize", replace);
|
|
205
|
+
|
|
206
|
+
// Tab past the last stop goes to the first, Shift+Tab before the first to
|
|
207
|
+
// the last; a Tab from outside (focus lost to a redraw) goes in.
|
|
208
|
+
function holdFocus(e) {
|
|
209
|
+
const stops = tabStops(pop);
|
|
210
|
+
if (!stops.length) return;
|
|
211
|
+
const first = stops[0];
|
|
212
|
+
const last = stops[stops.length - 1];
|
|
213
|
+
const at = doc.activeElement;
|
|
214
|
+
let to = null;
|
|
215
|
+
if (!pop.contains(at)) to = e.shiftKey ? last : first;
|
|
216
|
+
else if (e.shiftKey && at === first) to = last;
|
|
217
|
+
else if (!e.shiftKey && at === last) to = first;
|
|
218
|
+
if (!to) return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
to.focus();
|
|
221
|
+
}
|
|
160
222
|
|
|
161
223
|
return { pop, open, close, refresh, isOpen, replace, opener: () => opener };
|
|
162
224
|
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
// static JS is cached at unversioned URLs, and a new export on an old
|
|
29
29
|
// module would blank the page.
|
|
30
30
|
|
|
31
|
+
import { revealInHistory } from "./history_reveal.js";
|
|
32
|
+
|
|
31
33
|
export const QUOTE_MAX = 200;
|
|
32
34
|
export const PREFIX_MAX = 32;
|
|
33
35
|
export const TURN_CHARS = 8;
|
|
@@ -311,7 +313,7 @@ export function selectionClaim(root, range, opts = {}, doc = globalThis.document
|
|
|
311
313
|
|
|
312
314
|
// Finds the claim +claim+ ({turn, q, p}) in +container+ and shows it: the
|
|
313
315
|
// match's root (or the turn's opening) scrolled to the middle and marked
|
|
314
|
-
// HIT_CLASS,
|
|
316
|
+
// HIT_CLASS, what hides it unfolded (history_reveal.js), the quote highlighted (CSS
|
|
315
317
|
// Highlight API). Without the turn (no opening carries its id: a
|
|
316
318
|
// non-person turn), the quote in any root of the page. @return "quote",
|
|
317
319
|
// "turn" or null (neither the turn nor the quote).
|
|
@@ -323,7 +325,7 @@ export function showClaim(container, claim, { roots: rootSelector, opts = {}, do
|
|
|
323
325
|
const hit = findClaim(texts.map((t) => t.text), claim, opts);
|
|
324
326
|
if (!hit && !turn) return null;
|
|
325
327
|
const el = hit ? roots[hit.at] : turn.anchor;
|
|
326
|
-
|
|
328
|
+
revealInHistory(el);
|
|
327
329
|
el.classList.add(HIT_CLASS);
|
|
328
330
|
el.scrollIntoView?.({ block: "center" });
|
|
329
331
|
if (hit) {
|
|
@@ -77,7 +77,9 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
77
77
|
.set: something under it is set, an accent dot on a phone (the override chips say it on a wider row). */
|
|
78
78
|
#composer .actions-col .icon-btn{order:2;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
|
|
79
79
|
#composer .actions-col .icon-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
80
|
-
#composer .actions-col .icon-btn:hover
|
|
80
|
+
#composer .actions-col .icon-btn:hover{filter:none}
|
|
81
|
+
#composer .actions-col .icon-btn[aria-expanded="true"]{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
|
|
82
|
+
@media(hover:hover){#composer .actions-col .icon-btn:hover{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}}
|
|
81
83
|
#composer .actions-col .icon-btn[hidden],body.has-session #composer .actions-col .icon-btn{display:none}
|
|
82
84
|
#composer .actions-col .settings-btn{--icon:var(--icon-settings)}
|
|
83
85
|
#composer .actions-col .toolkit-btn{--icon:var(--icon-toolkit)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The one way a jump into the history shows what it lands on: whatever
|
|
2
|
+
// hides +el+ unfolds: a closed <details> around it (a turn's steps block, a
|
|
3
|
+
// step, a diff), an answer folded by hand (answer_fold.js: its row follows
|
|
4
|
+
// the bubble's class; the jump's unfold isn't saved). Every jump goes through it (claim_link.js
|
|
5
|
+
// showClaim, refs.js showRow and so the working set's step tags,
|
|
6
|
+
// verdict_card.js "View check"), so a new way to hide history is opened in
|
|
7
|
+
// one place.
|
|
8
|
+
//
|
|
9
|
+
// Pure DOM, so it runs under `node --test` with happy-dom. A module of its
|
|
10
|
+
// own: static JS is cached at unversioned URLs, and a new export on an old
|
|
11
|
+
// module would blank the page.
|
|
12
|
+
|
|
13
|
+
// Opens each closed <details> that holds +el+ (not +el+ itself: a fold the
|
|
14
|
+
// caller means to open it opens itself) and unfolds the folded answer that
|
|
15
|
+
// is or holds it. +el+, or null.
|
|
16
|
+
export function revealInHistory(el) {
|
|
17
|
+
if (!el) return null;
|
|
18
|
+
el.closest?.(".bubble.output.folded")?.classList.remove("folded");
|
|
19
|
+
for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
|
|
20
|
+
details.open = true;
|
|
21
|
+
}
|
|
22
|
+
return el;
|
|
23
|
+
}
|