samagotchi 0.134.0 → 0.135.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 +49 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +39 -34
- data/docs/configuration.md +1 -2
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/web-frontend.md +262 -0
- data/docs/sessions.md +2 -0
- data/docs/testing.md +27 -2
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/engine.rb +69 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
- data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
- data/lib/samagotchi/guardrails/pair_command.rb +387 -78
- data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
- data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +13 -15
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +3 -3
- data/lib/samagotchi/web/public/app.js +447 -340
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/composer.css +8 -0
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +8 -4
- data/lib/samagotchi/web/public/index.html +38 -9
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +2 -2
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +27 -92
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +5 -3
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +12 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -18,6 +18,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
|
|
|
18
18
|
import { createChipPopover } from "./chip_popover.js";
|
|
19
19
|
import { authorColor, participantAuthor } from "./participant_model.js";
|
|
20
20
|
import { setParticipantNames } from "./participant_names.js";
|
|
21
|
+
import { stillOn } from "./latest.js";
|
|
21
22
|
|
|
22
23
|
// Seen this recently: here now (the chip's dot). The gate writes
|
|
23
24
|
// last_used_at at most once a minute, so two minutes.
|
|
@@ -298,9 +299,10 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
298
299
|
onChange();
|
|
299
300
|
return;
|
|
300
301
|
}
|
|
302
|
+
const still = stillOn(session, id);
|
|
301
303
|
try {
|
|
302
304
|
const body = await api.list(id);
|
|
303
|
-
if (
|
|
305
|
+
if (!still()) return;
|
|
304
306
|
grants = Array.isArray(body.participants) ? body.participants : [];
|
|
305
307
|
working = typeof body.warning === "string" ? body.warning : null;
|
|
306
308
|
inviteMode = typeof body.mode === "string" ? body.mode : null;
|
|
@@ -309,7 +311,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
309
311
|
loadedFor = id;
|
|
310
312
|
if (setParticipantNames(grants)) onNames();
|
|
311
313
|
} catch {
|
|
312
|
-
if (
|
|
314
|
+
if (!still()) return;
|
|
313
315
|
// Another session's list and warning are not this one's.
|
|
314
316
|
if (loadedFor !== id) {
|
|
315
317
|
grants = [];
|
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
// The project picker opened by the top-bar scope chip. The server supplies
|
|
2
|
-
// the project roots and their current/default marks; links carry
|
|
3
|
-
//
|
|
2
|
+
// the project roots and their current/default marks; links never carry a
|
|
3
|
+
// session hash into another project's list.
|
|
4
4
|
|
|
5
5
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
6
6
|
import { allScopeHref, projectScopeHref } from "./scope.js";
|
|
7
7
|
import { createChipPopover } from "./chip_popover.js";
|
|
8
|
-
|
|
9
|
-
function homeHref(search) {
|
|
10
|
-
const view = new URLSearchParams(search || "").get("view");
|
|
11
|
-
return view ? `/?view=${encodeURIComponent(view)}` : "/";
|
|
12
|
-
}
|
|
8
|
+
import { createLatest } from "./latest.js";
|
|
13
9
|
|
|
14
10
|
function sessionCount(count) {
|
|
15
11
|
const n = Number(count) || 0;
|
|
16
12
|
return `${n} session${n === 1 ? "" : "s"}`;
|
|
17
13
|
}
|
|
18
14
|
|
|
19
|
-
function projectRow(row
|
|
15
|
+
function projectRow(row) {
|
|
20
16
|
return html`<a class=${`project-row${row.default ? " default" : ""}${row.current ? " current" : ""}`}
|
|
21
|
-
href=${row.default ?
|
|
17
|
+
href=${row.default ? "/" : projectScopeHref(row.dir, "")}
|
|
22
18
|
aria-current=${row.current ? "page" : nothing}>
|
|
23
19
|
<span class="project-main"><span class="project-name">${row.name}</span
|
|
24
20
|
>${row.default ? html`<span class="project-default">default</span>` : nothing
|
|
@@ -30,14 +26,14 @@ function projectRow(row, search) {
|
|
|
30
26
|
|
|
31
27
|
// +rows+ is the API's ordered list. When there is no project scope, the
|
|
32
28
|
// all-projects row is current; a bad ?dir= has no current project to mark.
|
|
33
|
-
export function pickerTemplate({ rows = [], loading = false, error = "",
|
|
29
|
+
export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
|
|
34
30
|
allCurrent = false } = {}) {
|
|
35
|
-
const allHref = allScopeHref("", currentDir
|
|
31
|
+
const allHref = allScopeHref("", currentDir);
|
|
36
32
|
return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
|
|
37
33
|
data-act="close" aria-label="Close">×</button></div>
|
|
38
34
|
${loading ? html`<div class="project-note" role="status">Loading projects…</div>` : nothing}
|
|
39
35
|
${error ? html`<div class="project-note project-error" role="alert">${error}</div>` : nothing}
|
|
40
|
-
<nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row
|
|
36
|
+
<nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row))}
|
|
41
37
|
<a id="projectAll" class=${`project-row project-all${allCurrent ? " current" : ""}`}
|
|
42
38
|
href=${allHref} aria-current=${allCurrent ? "page" : nothing}>
|
|
43
39
|
<span class="project-main"><span class="project-name">All projects</span
|
|
@@ -47,20 +43,20 @@ export function pickerTemplate({ rows = [], loading = false, error = "", search
|
|
|
47
43
|
</nav>`;
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
// +scopeError+ returns the page's current folder error, if any.
|
|
51
|
-
|
|
52
|
-
export function createProjectPicker({ el, scope, scopeError = () => null, api, where = () => ({ search: "" }),
|
|
46
|
+
// +scopeError+ returns the page's current folder error, if any.
|
|
47
|
+
export function createProjectPicker({ el, scope, scopeError = () => null, api,
|
|
53
48
|
doc = globalThis.document, win = globalThis.window }) {
|
|
54
49
|
let rows = [];
|
|
55
50
|
let loading = false;
|
|
56
51
|
let error = "";
|
|
57
|
-
|
|
52
|
+
// Only the newest read is drawn; a close drops the one out (latest.js).
|
|
53
|
+
const latest = createLatest();
|
|
58
54
|
|
|
59
55
|
const currentDir = () => scopeError() ? null : scope.dir || null;
|
|
60
56
|
const paint = (focus = false) => {
|
|
61
57
|
const failedScope = !!scopeError();
|
|
62
58
|
render(pickerTemplate({
|
|
63
|
-
rows, loading, error,
|
|
59
|
+
rows, loading, error, currentDir: currentDir(),
|
|
64
60
|
allCurrent: !scope.dir && !failedScope,
|
|
65
61
|
}), shell.pop);
|
|
66
62
|
if (focus) {
|
|
@@ -71,7 +67,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
|
|
|
71
67
|
};
|
|
72
68
|
|
|
73
69
|
async function load(focus = false) {
|
|
74
|
-
const
|
|
70
|
+
const fresh = latest.begin();
|
|
75
71
|
loading = true;
|
|
76
72
|
error = "";
|
|
77
73
|
if (shell.isOpen()) {
|
|
@@ -83,14 +79,14 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
|
|
|
83
79
|
const dir = currentDir();
|
|
84
80
|
const answer = await api(`/api/projects${dir ? `?dir=${encodeURIComponent(dir)}` : ""}`);
|
|
85
81
|
if (!Array.isArray(answer)) throw new Error("the server returned an invalid project list");
|
|
86
|
-
if (
|
|
82
|
+
if (!fresh()) return;
|
|
87
83
|
rows = answer;
|
|
88
84
|
} catch (e) {
|
|
89
|
-
if (
|
|
85
|
+
if (!fresh()) return;
|
|
90
86
|
error = `Can't read projects: ${e?.message || e}`;
|
|
91
87
|
rows = [];
|
|
92
88
|
}
|
|
93
|
-
if (
|
|
89
|
+
if (!fresh()) return;
|
|
94
90
|
loading = false;
|
|
95
91
|
if (shell.isOpen()) {
|
|
96
92
|
paint(focus);
|
|
@@ -108,7 +104,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
|
|
|
108
104
|
paint();
|
|
109
105
|
void load(true);
|
|
110
106
|
},
|
|
111
|
-
onClose: () => {
|
|
107
|
+
onClose: () => { latest.drop(); },
|
|
112
108
|
});
|
|
113
109
|
|
|
114
110
|
return { pop: shell.pop, isOpen: shell.isOpen, load };
|
|
@@ -91,7 +91,7 @@ export function createQuestionCards({
|
|
|
91
91
|
summary.textContent = summaryText(pq);
|
|
92
92
|
card.appendChild(summary);
|
|
93
93
|
// A hand toggle: the resolve's auto-collapse then leaves it as the user
|
|
94
|
-
// left it (rememberToggle-style, as
|
|
94
|
+
// left it (rememberToggle-style, as turn_block.js does).
|
|
95
95
|
summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
|
|
96
96
|
|
|
97
97
|
// Relayed to the parent: say where else it waits (QuestionDesk#annotate).
|
|
@@ -632,7 +632,7 @@ export function createQuestionCards({
|
|
|
632
632
|
pendingQuestion: () => pending,
|
|
633
633
|
renderContinue: renderContinueCard,
|
|
634
634
|
resolveContinue: resolveContinueCard,
|
|
635
|
-
// The turn
|
|
635
|
+
// The running turn's DOM was reset: a later offer draws a new card.
|
|
636
636
|
forgetContinue() { continueEl = null; },
|
|
637
637
|
};
|
|
638
638
|
}
|
|
@@ -21,6 +21,8 @@ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
|
|
|
21
21
|
own,
|
|
22
22
|
label: own ? null : clientLabel(origin.client_id),
|
|
23
23
|
...(back && event.verify ? { verify: event.verify } : {}),
|
|
24
|
+
// A Revise turn's draft ({turn_id}): its composer chip comes back.
|
|
25
|
+
...(back && event.revises?.turn_id ? { revises: { turn_id: event.revises.turn_id } } : {}),
|
|
24
26
|
}, back ? event.images : null);
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Revise stacks (web): a Revise turn rewrites an earlier turn's answer, and
|
|
2
|
+
// the server stamps its prompt with `revises: {turn_id}` once it completed
|
|
3
|
+
// with an answer (Engine#stamp_opening; never derived here). The page draws
|
|
4
|
+
// each stack v1 ← v2 ← … ← top in place:
|
|
5
|
+
//
|
|
6
|
+
// - every superseded turn (its opening, its work, its answer: turn_range.js's
|
|
7
|
+
// rule) is hidden by a class and replaced, where it is, by one stub row,
|
|
8
|
+
// "v1 · ‹first line› · 84 lines — superseded by v3"; a click unfolds the
|
|
9
|
+
// turn in place (per viewer: answer_fold.js's store, the key "stub:<id>").
|
|
10
|
+
// Not hidden: the cards a turn's end keeps out of its block (warn cards),
|
|
11
|
+
// verdict cards, the recap. A time-gap divider between two folded stubs
|
|
12
|
+
// folds into the later stub (it shows the divider's words);
|
|
13
|
+
// - the top answer gets a version chip, "v3 · 2 earlier ▾", listing the
|
|
14
|
+
// versions by their prompts; picking one reveals its stub;
|
|
15
|
+
// - the final answer of an ended turn (verify_target.js's rule, a delegate
|
|
16
|
+
// report's reply excluded), unstacked or a stack's top, and not a Verify
|
|
17
|
+
// turn's, gets a Revise button: it arms the composer (app.js).
|
|
18
|
+
//
|
|
19
|
+
// The stamps live on the history's prompt bubbles (data-revises: a reload's
|
|
20
|
+
// render, a live turn_completed's revises), so the stacks are read from the
|
|
21
|
+
// DOM again on every change (stacks(), pure). One observer on #history, as
|
|
22
|
+
// answer_fold.js has; history_reveal.js unfolds a stub a jump lands in.
|
|
23
|
+
//
|
|
24
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
25
|
+
// export on an old module would blank the page.
|
|
26
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
27
|
+
import { bubbleCopyText } from "./copy.js";
|
|
28
|
+
import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
|
|
29
|
+
import { clip } from "./format.js";
|
|
30
|
+
import { NOTE_BOUNDARY } from "./turn_range.js";
|
|
31
|
+
|
|
32
|
+
// The classes and attributes this module puts on the history.
|
|
33
|
+
export const HIDDEN_CLASS = "stack-hidden";
|
|
34
|
+
export const STUB_CLASS = "stack-stub";
|
|
35
|
+
export const ROW_CLASS = "revise-row";
|
|
36
|
+
const GAP_HIDDEN_CLASS = "stack-gap";
|
|
37
|
+
// What a superseded turn keeps on view (card.js leavesBlock's cards, a
|
|
38
|
+
// verdict, the recap).
|
|
39
|
+
export const KEPT = ".plugin-card.warn, .verify-verdict, .bubble.recap";
|
|
40
|
+
// A turn's final answer (verify_target.js OWNER_FINAL), and not done yet.
|
|
41
|
+
const ANSWERS = ".bubble.output:not(.report-reply)";
|
|
42
|
+
const UNFINISHED = ".streaming";
|
|
43
|
+
const OPENING = "[data-turn-id]";
|
|
44
|
+
const STOPPED = ".bubble.cancel";
|
|
45
|
+
const LABEL_CHARS = 120;
|
|
46
|
+
// The stored key of a stub unfolded by hand (answer_fold.js's list).
|
|
47
|
+
export const stubKey = (turnId) => `stub:${turnId}`;
|
|
48
|
+
|
|
49
|
+
// The chains of revisions among +messages+ (the history's openings, in
|
|
50
|
+
// order: {turn_id, revises?: {turn_id}}), each [v1, v2, …, top] by turn id;
|
|
51
|
+
// only chains of two or more. A revises whose target isn't an earlier
|
|
52
|
+
// opening is ignored (a stale page, a rolled-back turn); one whose target
|
|
53
|
+
// was revised already goes on that stack's top (as the server re-targets).
|
|
54
|
+
export function stacks(messages) {
|
|
55
|
+
const order = new Map();
|
|
56
|
+
const revisesOf = new Map();
|
|
57
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
58
|
+
const id = m?.turn_id;
|
|
59
|
+
if (typeof id !== "string" || !id || order.has(id) || m.merged) continue;
|
|
60
|
+
order.set(id, order.size);
|
|
61
|
+
const target = m.revises?.turn_id;
|
|
62
|
+
if (typeof target === "string" && target) revisesOf.set(id, target);
|
|
63
|
+
}
|
|
64
|
+
const next = new Map();
|
|
65
|
+
const prev = new Map();
|
|
66
|
+
for (const [id, at] of order) {
|
|
67
|
+
let target = revisesOf.get(id);
|
|
68
|
+
if (!target || !order.has(target) || order.get(target) >= at) continue;
|
|
69
|
+
while (next.has(target)) target = next.get(target);
|
|
70
|
+
next.set(target, id);
|
|
71
|
+
prev.set(id, target);
|
|
72
|
+
}
|
|
73
|
+
const chains = [];
|
|
74
|
+
for (const id of order.keys()) {
|
|
75
|
+
if (prev.has(id) || !next.has(id)) continue;
|
|
76
|
+
const chain = [id];
|
|
77
|
+
while (next.has(chain.at(-1))) chain.push(next.get(chain.at(-1)));
|
|
78
|
+
chains.push(chain);
|
|
79
|
+
}
|
|
80
|
+
return chains;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A stub's words: "v1 · "‹label›" · 84 lines — superseded by v3".
|
|
84
|
+
export function stubText({ version, label = "", lines = 0, top }) {
|
|
85
|
+
const said = label ? ` · “${label}”` : "";
|
|
86
|
+
const size = lines ? ` · ${lines} line${lines === 1 ? "" : "s"}` : "";
|
|
87
|
+
return `v${version}${said}${size} — superseded by v${top}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The version chip's words: "v3 · 2 earlier".
|
|
91
|
+
export function versionChipText(count) {
|
|
92
|
+
return `v${count} · ${count - 1} earlier`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
|
|
96
|
+
// the worker predates Revise turns).
|
|
97
|
+
export const REVISE_UNAVAILABLE = "restart this session to use Revise";
|
|
98
|
+
|
|
99
|
+
// The feature gate (app.js featureGates): a live worker without the
|
|
100
|
+
// "revise" feature predates Revise turns (chi web would answer 409
|
|
101
|
+
// revise_unavailable). A worker that isn't up isn't asked: the send wakes a
|
|
102
|
+
// new one, which has it (as Verify's gate, verify_preset.js).
|
|
103
|
+
export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
|
|
104
|
+
return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("revise");
|
|
105
|
+
}
|
|
106
|
+
export function reviseChipText(label, { unavailable = false } = {}) {
|
|
107
|
+
if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
|
|
108
|
+
return label ? `Revising: ${label}` : "Revising the answer";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The composer's chip (app.js renders it into its own element): the armed
|
|
112
|
+
// Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
|
|
113
|
+
export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
|
|
114
|
+
if (!anchor) return nothing;
|
|
115
|
+
return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ? REVISE_UNAVAILABLE : `This message rewrites that answer: ${anchor.label || ""}`}><span class="revise-chip-text">${reviseChipText(anchor.label, { unavailable })}</span><button type="button" class="revise-chip-clear" aria-label="Don't revise" title="Don't revise (send as a plain message)" @click=${onClear}>✕</button></span>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// An answer's label (the stub's, the chip's): its first line.
|
|
119
|
+
export function answerLabel(bubble) {
|
|
120
|
+
if (!bubble) return "";
|
|
121
|
+
return foldLabel(bubbleCopyText(bubble), { display: bubble.hasAttribute?.("data-answer-display") });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A prompt bubble's first text line (a version's label in the chip's list).
|
|
125
|
+
function promptLabel(opening) {
|
|
126
|
+
if (!opening) return "";
|
|
127
|
+
const text = opening.matches?.(".bubble.user") ? bubbleCopyText(opening) : opening.querySelector?.(".note-text")?.textContent || "";
|
|
128
|
+
const line = String(text).split("\n").map((l) => l.trim()).find(Boolean) || "";
|
|
129
|
+
return clip(line, LABEL_CHARS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The history's turns, in order (one pass over #history's children):
|
|
133
|
+
// {id, opening, els (after the opening, up to the next opening or note, a
|
|
134
|
+
// trailing gap divider left to the next), answer (its final answer), wake
|
|
135
|
+
// (a note opened it), stopped (it was canceled or failed)}. Our own rows
|
|
136
|
+
// aren't part of any.
|
|
137
|
+
export function historyTurns(historyEl) {
|
|
138
|
+
const turns = [];
|
|
139
|
+
let turn = null;
|
|
140
|
+
const close = () => {
|
|
141
|
+
if (!turn) return;
|
|
142
|
+
while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
|
|
143
|
+
};
|
|
144
|
+
for (const el of historyEl.children) {
|
|
145
|
+
if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
|
|
146
|
+
const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
|
|
147
|
+
if (opening || el.matches(NOTE_BOUNDARY)) {
|
|
148
|
+
close();
|
|
149
|
+
turn = { id: opening?.dataset.turnId || null, opening: el, prompt: opening || null, els: [], answer: null, wake: !opening };
|
|
150
|
+
turns.push(turn);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!turn) continue;
|
|
154
|
+
turn.els.push(el);
|
|
155
|
+
if (el.matches(ANSWERS)) turn.answer = el;
|
|
156
|
+
// A canceled or failed turn's end line (timing.js STOPPED_LINE_CLASS,
|
|
157
|
+
// a live end's too): its partial answer isn't a completed one.
|
|
158
|
+
if (el.matches(STOPPED)) turn.stopped = true;
|
|
159
|
+
}
|
|
160
|
+
close();
|
|
161
|
+
// A wake turn's id is its answer's (timing.js turnGroups keys it).
|
|
162
|
+
for (const t of turns) if (t.wake && t.answer) t.id = t.answer.getAttribute("data-answer-turn") || null;
|
|
163
|
+
return turns;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// @param historyEl #history (also its scroller)
|
|
167
|
+
// @param sessionId () => the open session's id
|
|
168
|
+
// @param projectRoot () => its project root (the fold store's sweep), or null
|
|
169
|
+
// @param running () => null when no turn runs, else {turnId} (null id: the
|
|
170
|
+
// newest turn counts as the running one)
|
|
171
|
+
// @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
|
|
172
|
+
// its prompt's data-verify: a verdict's check_turn_id)
|
|
173
|
+
// @param unavailable () => the worker predates Revise (the feature gate)
|
|
174
|
+
// @param armed () => the turn id the composer's Revise is armed with, or null
|
|
175
|
+
// @param onRevise ({turn_id, label}) the Revise button
|
|
176
|
+
// @return {decorate()} (the observer runs it; app.js after a change it reads)
|
|
177
|
+
export function installReviseStacks({
|
|
178
|
+
historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
|
|
179
|
+
unavailable = () => false, armed = () => null, onRevise = () => {},
|
|
180
|
+
}) {
|
|
181
|
+
const stubs = new Map();
|
|
182
|
+
const rows = new Map();
|
|
183
|
+
// Each row's answer bubble (its label at a click).
|
|
184
|
+
const rowAnswer = new WeakMap();
|
|
185
|
+
let observer = null;
|
|
186
|
+
|
|
187
|
+
function stubFor(id, opening, stored) {
|
|
188
|
+
let stub = stubs.get(id);
|
|
189
|
+
if (!stub) {
|
|
190
|
+
stub = historyEl.ownerDocument.createElement("button");
|
|
191
|
+
stub.type = "button";
|
|
192
|
+
stub.className = STUB_CLASS;
|
|
193
|
+
stub.dataset.stackStub = id;
|
|
194
|
+
// The stored choice, once: after that the stub's class is the state
|
|
195
|
+
// (a jump's reveal isn't saved).
|
|
196
|
+
if (stored().includes(stubKey(id))) stub.classList.add("open");
|
|
197
|
+
stubs.set(id, stub);
|
|
198
|
+
}
|
|
199
|
+
if (stub.parentElement !== historyEl || stub.nextElementSibling !== opening) historyEl.insertBefore(stub, opening);
|
|
200
|
+
return stub;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function drawStub(stub, { version, top, answer, opening, gap }) {
|
|
204
|
+
const open = stub.classList.contains("open");
|
|
205
|
+
stub.setAttribute("aria-expanded", open ? "true" : "false");
|
|
206
|
+
stub.title = `${open ? "Fold" : "Show"} this earlier version${opening.title ? ` (${opening.title})` : ""}`;
|
|
207
|
+
const text = stubText({ version, label: answerLabel(answer), lines: answer ? lineCount(bubbleCopyText(answer)) : 0, top });
|
|
208
|
+
render(html`<span class="stack-stub-text">${text}</span>${gap ? html`<span class="stack-stub-gap">${gap}</span>` : nothing}`, stub);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rowFor(id, answer) {
|
|
212
|
+
let row = rows.get(id);
|
|
213
|
+
if (!row) {
|
|
214
|
+
row = historyEl.ownerDocument.createElement("div");
|
|
215
|
+
row.className = ROW_CLASS;
|
|
216
|
+
row.dataset.reviseFor = id;
|
|
217
|
+
rows.set(id, row);
|
|
218
|
+
}
|
|
219
|
+
if (answer.nextElementSibling !== row) answer.after(row);
|
|
220
|
+
return row;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function drawRow(row, { id, answer, chain, openings }) {
|
|
224
|
+
const off = unavailable();
|
|
225
|
+
const isArmed = armed() === id;
|
|
226
|
+
const versions = chain
|
|
227
|
+
? html`<details class="stack-versions"><summary>${versionChipText(chain.length)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
|
|
228
|
+
: nothing;
|
|
229
|
+
const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
|
|
230
|
+
row.classList.toggle("stacked", !!chain);
|
|
231
|
+
render(html`${versions}${button}`, row);
|
|
232
|
+
rowAnswer.set(row, answer);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function decorate() {
|
|
236
|
+
let folds = null;
|
|
237
|
+
const stored = () => (folds ??= readFolded(sessionId()));
|
|
238
|
+
const turns = historyTurns(historyEl);
|
|
239
|
+
const openings = new Map(turns.filter((t) => t.id).map((t) => [t.id, t.prompt || t.opening]));
|
|
240
|
+
const entries = turns.filter((t) => t.id).map((t) => {
|
|
241
|
+
const revises = t.prompt?.getAttribute("data-revises");
|
|
242
|
+
return revises ? { turn_id: t.id, revises: { turn_id: revises } } : { turn_id: t.id };
|
|
243
|
+
});
|
|
244
|
+
const superseded = new Map();
|
|
245
|
+
const tops = new Map();
|
|
246
|
+
for (const chain of stacks(entries)) {
|
|
247
|
+
chain.slice(0, -1).forEach((id, k) => superseded.set(id, { version: k + 1, top: chain.length }));
|
|
248
|
+
tops.set(chain.at(-1), chain);
|
|
249
|
+
}
|
|
250
|
+
const run = running();
|
|
251
|
+
const lastOpening = turns.filter((t) => !t.wake).at(-1);
|
|
252
|
+
const keepStubs = new Set();
|
|
253
|
+
const keepRows = new Set();
|
|
254
|
+
for (const turn of turns) {
|
|
255
|
+
const range = [turn.opening, ...turn.els];
|
|
256
|
+
const place = turn.id && superseded.get(turn.id);
|
|
257
|
+
if (place) {
|
|
258
|
+
const stub = stubFor(turn.id, turn.opening, stored);
|
|
259
|
+
keepStubs.add(turn.id);
|
|
260
|
+
const open = stub.classList.contains("open");
|
|
261
|
+
for (const el of range) {
|
|
262
|
+
const hide = !open && !el.matches(KEPT);
|
|
263
|
+
el.classList.toggle(HIDDEN_CLASS, hide);
|
|
264
|
+
if (el.dataset.stackOf !== turn.id) el.dataset.stackOf = turn.id;
|
|
265
|
+
}
|
|
266
|
+
turn.stub = stub;
|
|
267
|
+
turn.place = place;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
for (const el of range) {
|
|
271
|
+
if (el.classList.contains(HIDDEN_CLASS)) el.classList.remove(HIDDEN_CLASS);
|
|
272
|
+
if (el.dataset.stackOf) delete el.dataset.stackOf;
|
|
273
|
+
}
|
|
274
|
+
// The Revise row: an ended turn's final answer, not a Verify turn's,
|
|
275
|
+
// not the running turn's.
|
|
276
|
+
const answer = turn.answer;
|
|
277
|
+
if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
|
|
278
|
+
if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
|
|
279
|
+
if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
|
|
280
|
+
const row = rowFor(turn.id, answer);
|
|
281
|
+
keepRows.add(turn.id);
|
|
282
|
+
drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
|
|
283
|
+
}
|
|
284
|
+
// A divider between two folded stubs folds into the later one.
|
|
285
|
+
for (const turn of turns) {
|
|
286
|
+
if (!turn.stub) continue;
|
|
287
|
+
const gap = turn.stub.previousElementSibling;
|
|
288
|
+
let gapText = "";
|
|
289
|
+
if (gap?.classList.contains("time-gap")) {
|
|
290
|
+
let before = gap.previousElementSibling;
|
|
291
|
+
while (before?.classList.contains(HIDDEN_CLASS)) before = before.previousElementSibling;
|
|
292
|
+
const fold = !turn.stub.classList.contains("open") && !!before?.matches(`.${STUB_CLASS}:not(.open)`);
|
|
293
|
+
gap.classList.toggle(GAP_HIDDEN_CLASS, fold);
|
|
294
|
+
if (fold) gapText = gap.textContent.replace(/[─\s]+$/u, "").replace(/^[─\s]+/u, "");
|
|
295
|
+
}
|
|
296
|
+
drawStub(turn.stub, { ...turn.place, answer: turn.answer, opening: turn.prompt || turn.opening, gap: gapText });
|
|
297
|
+
}
|
|
298
|
+
for (const gap of historyEl.querySelectorAll(`:scope > .${GAP_HIDDEN_CLASS}`)) {
|
|
299
|
+
if (!gap.nextElementSibling?.matches(`.${STUB_CLASS}:not(.open)`)) gap.classList.remove(GAP_HIDDEN_CLASS);
|
|
300
|
+
}
|
|
301
|
+
for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
|
|
302
|
+
for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
|
|
303
|
+
// A stale row left by a re-render (its map entry gone with the nodes).
|
|
304
|
+
for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
|
|
305
|
+
if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
|
|
306
|
+
}
|
|
307
|
+
// What we changed isn't news.
|
|
308
|
+
observer?.takeRecords();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function setOpen(stub, open, { save }) {
|
|
312
|
+
keepRowInView(historyEl, stub, () => {
|
|
313
|
+
stub.classList.toggle("open", open);
|
|
314
|
+
decorate();
|
|
315
|
+
});
|
|
316
|
+
if (save) writeFold(sessionId(), stubKey(stub.dataset.stackStub), open, { root: projectRoot() });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Changes from elsewhere: a direct child added or removed, a stamp, an
|
|
320
|
+
// answer's key or its end (its class).
|
|
321
|
+
observer = new MutationObserver((records) => {
|
|
322
|
+
const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
|
|
323
|
+
&& [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
|
|
324
|
+
: r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
|
|
325
|
+
if (news) decorate();
|
|
326
|
+
});
|
|
327
|
+
observer.observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-revises", "data-answer-turn", "data-verify"] });
|
|
328
|
+
decorate();
|
|
329
|
+
|
|
330
|
+
historyEl.addEventListener("click", (e) => {
|
|
331
|
+
const stub = e.target.closest?.(`.${STUB_CLASS}`);
|
|
332
|
+
if (stub && stub.parentElement === historyEl) {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
setOpen(stub, !stub.classList.contains("open"), { save: true });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const pick = e.target.closest?.("[data-stack-pick]");
|
|
338
|
+
if (pick) {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
pick.closest("details")?.removeAttribute("open");
|
|
341
|
+
const target = stubs.get(pick.dataset.stackPick);
|
|
342
|
+
if (!target) return;
|
|
343
|
+
if (!target.classList.contains("open")) setOpen(target, true, { save: false });
|
|
344
|
+
target.scrollIntoView?.({ block: "start", behavior: "smooth" });
|
|
345
|
+
target.focus?.({ preventScroll: true });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const button = e.target.closest?.("[data-revise]");
|
|
349
|
+
const row = button?.closest(`.${ROW_CLASS}`);
|
|
350
|
+
if (!button || !row || button.disabled) return;
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
onRevise({ turn_id: button.dataset.revise, label: answerLabel(rowAnswer.get(row)) });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return { decorate };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The stub that hides +el+ (a superseded turn's element, or inside one),
|
|
359
|
+
// opened (not saved); +el+ or null. history_reveal.js runs it on a jump.
|
|
360
|
+
export function openStubOver(el) {
|
|
361
|
+
const top = el?.closest?.(`.${HIDDEN_CLASS}`);
|
|
362
|
+
const id = top?.dataset.stackOf;
|
|
363
|
+
const parent = top?.parentElement;
|
|
364
|
+
if (!id || !parent) return el ?? null;
|
|
365
|
+
const stub = [...parent.children].find((c) => c.classList.contains(STUB_CLASS) && c.dataset.stackStub === id);
|
|
366
|
+
stub?.classList.add("open");
|
|
367
|
+
stub?.setAttribute("aria-expanded", "true");
|
|
368
|
+
for (const hidden of parent.querySelectorAll(`:scope > .${HIDDEN_CLASS}`)) {
|
|
369
|
+
if (hidden.dataset.stackOf === id) hidden.classList.remove(HIDDEN_CLASS);
|
|
370
|
+
}
|
|
371
|
+
return el;
|
|
372
|
+
}
|
|
@@ -15,23 +15,15 @@ export function scopeDir(search) {
|
|
|
15
15
|
// The same place in the all view: ?scope=all without ?dir (a bare page
|
|
16
16
|
// may be the start folder's project), keeping the hash. +fromDir+ (the
|
|
17
17
|
// project view's folder) rides along as ?from=, so the all view can link
|
|
18
|
-
// back to it.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const params = ["scope=all", fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
|
|
18
|
+
// back to it.
|
|
19
|
+
export function allScopeHref(hash, fromDir = null) {
|
|
20
|
+
const params = ["scope=all", fromDir ? `from=${dirParam(fromDir)}` : ""].filter(Boolean);
|
|
22
21
|
return `/?${params.join("&")}${hash || ""}`;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
// A folder's project view, keeping the hash
|
|
26
|
-
export function projectScopeHref(dir, hash
|
|
27
|
-
|
|
28
|
-
return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// "view=<v>" from a location.search string, or "" without one.
|
|
32
|
-
function viewParam(search) {
|
|
33
|
-
const view = new URLSearchParams(search || "").get("view");
|
|
34
|
-
return view ? `view=${encodeURIComponent(view)}` : "";
|
|
24
|
+
// A folder's project view, keeping the hash.
|
|
25
|
+
export function projectScopeHref(dir, hash) {
|
|
26
|
+
return `/?dir=${dirParam(dir)}${hash || ""}`;
|
|
35
27
|
}
|
|
36
28
|
|
|
37
29
|
// A folder for the query string, its slashes left readable (as chi web
|
|
@@ -52,18 +44,18 @@ export function cardFolder(workingDirectory, projectName) {
|
|
|
52
44
|
// The header's project chip opens the picker. The "all" link remains the
|
|
53
45
|
// direct way to every session from a project view. +scope+: { dir,
|
|
54
46
|
// projectName, projectDir, serverDir, backDir, backName }; +error+: why
|
|
55
|
-
// the ?dir= folder can't be shown; +where+: () => { hash
|
|
47
|
+
// the ?dir= folder can't be shown; +where+: () => { hash } (the
|
|
56
48
|
// page's location, read again at a click).
|
|
57
|
-
export function scopeTemplate(scope, { error = null, where = () => ({ hash: ""
|
|
49
|
+
export function scopeTemplate(scope, { error = null, where = () => ({ hash: "" }) } = {}) {
|
|
58
50
|
const at = where();
|
|
59
|
-
const allHref = () => allScopeHref(where().hash, scope.dir
|
|
60
|
-
const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir
|
|
51
|
+
const allHref = () => allScopeHref(where().hash, scope.dir);
|
|
52
|
+
const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
|
|
61
53
|
if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
|
|
62
54
|
if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
|
|
63
55
|
const dir = scope.dir || scope.serverDir;
|
|
64
|
-
const backHref = () => projectScopeHref(scope.backDir, where().hash
|
|
56
|
+
const backHref = () => projectScopeHref(scope.backDir, where().hash);
|
|
65
57
|
const back = scope.backName
|
|
66
|
-
? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash
|
|
58
|
+
? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
|
|
67
59
|
: nothing;
|
|
68
60
|
const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
|
|
69
61
|
return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
|