samagotchi 0.134.0 → 0.136.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 +78 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +43 -38
- data/docs/configuration.md +11 -9
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/web-frontend.md +263 -0
- data/docs/internals/working-set.md +2 -2
- data/docs/sessions.md +5 -3
- data/docs/testing.md +36 -6
- 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 +6 -18
- data/lib/samagotchi/web/message_display.rb +33 -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 +100 -31
- data/lib/samagotchi/web/public/app.js +451 -367
- data/lib/samagotchi/web/public/bubbles.js +30 -10
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/chip_popover.js +2 -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 +10 -6
- data/lib/samagotchi/web/public/index.html +105 -53
- data/lib/samagotchi/web/public/info_bar.js +11 -12
- data/lib/samagotchi/web/public/info_bar_model.js +8 -10
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/level_chip.js +33 -95
- 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/llm_context_chip.js +13 -144
- 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/reasoning_history_chip.js +8 -34
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +378 -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 +9 -5
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/settings_chip.js +212 -0
- data/lib/samagotchi/web/public/settings_panel.js +121 -0
- data/lib/samagotchi/web/public/shell_command.js +26 -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/start_settings.js +25 -88
- 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/thinking_chip.js +7 -45
- data/lib/samagotchi/web/public/time_gaps.js +5 -2
- data/lib/samagotchi/web/public/timing.js +15 -5
- data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
- data/lib/samagotchi/web/public/turn_events.js +3 -3
- data/lib/samagotchi/web/public/turn_html.js +20 -16
- 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/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +6 -3
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +15 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -0,0 +1,378 @@
|
|
|
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
|
+
// A fold row (answer_fold.js) just before an opening: a delegate report's
|
|
45
|
+
// that opens its turn, the opening's header (in its range, the stub above it).
|
|
46
|
+
const FOLD_ROW = "answer-fold";
|
|
47
|
+
const headOf = (opening) => (opening.previousElementSibling?.classList.contains(FOLD_ROW) ? opening.previousElementSibling : null);
|
|
48
|
+
const STOPPED = ".bubble.cancel";
|
|
49
|
+
const LABEL_CHARS = 120;
|
|
50
|
+
// The stored key of a stub unfolded by hand (answer_fold.js's list).
|
|
51
|
+
export const stubKey = (turnId) => `stub:${turnId}`;
|
|
52
|
+
|
|
53
|
+
// The chains of revisions among +messages+ (the history's openings, in
|
|
54
|
+
// order: {turn_id, revises?: {turn_id}}), each [v1, v2, …, top] by turn id;
|
|
55
|
+
// only chains of two or more. A revises whose target isn't an earlier
|
|
56
|
+
// opening is ignored (a stale page, a rolled-back turn); one whose target
|
|
57
|
+
// was revised already goes on that stack's top (as the server re-targets).
|
|
58
|
+
export function stacks(messages) {
|
|
59
|
+
const order = new Map();
|
|
60
|
+
const revisesOf = new Map();
|
|
61
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
62
|
+
const id = m?.turn_id;
|
|
63
|
+
if (typeof id !== "string" || !id || order.has(id) || m.merged) continue;
|
|
64
|
+
order.set(id, order.size);
|
|
65
|
+
const target = m.revises?.turn_id;
|
|
66
|
+
if (typeof target === "string" && target) revisesOf.set(id, target);
|
|
67
|
+
}
|
|
68
|
+
const next = new Map();
|
|
69
|
+
const prev = new Map();
|
|
70
|
+
for (const [id, at] of order) {
|
|
71
|
+
let target = revisesOf.get(id);
|
|
72
|
+
if (!target || !order.has(target) || order.get(target) >= at) continue;
|
|
73
|
+
while (next.has(target)) target = next.get(target);
|
|
74
|
+
next.set(target, id);
|
|
75
|
+
prev.set(id, target);
|
|
76
|
+
}
|
|
77
|
+
const chains = [];
|
|
78
|
+
for (const id of order.keys()) {
|
|
79
|
+
if (prev.has(id) || !next.has(id)) continue;
|
|
80
|
+
const chain = [id];
|
|
81
|
+
while (next.has(chain.at(-1))) chain.push(next.get(chain.at(-1)));
|
|
82
|
+
chains.push(chain);
|
|
83
|
+
}
|
|
84
|
+
return chains;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A stub's words: "v1 · "‹label›" · 84 lines — superseded by v3".
|
|
88
|
+
export function stubText({ version, label = "", lines = 0, top }) {
|
|
89
|
+
const said = label ? ` · “${label}”` : "";
|
|
90
|
+
const size = lines ? ` · ${lines} line${lines === 1 ? "" : "s"}` : "";
|
|
91
|
+
return `v${version}${said}${size} — superseded by v${top}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The version chip's words: "v3 · 2 earlier".
|
|
95
|
+
export function versionChipText(count) {
|
|
96
|
+
return `v${count} · ${count - 1} earlier`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
|
|
100
|
+
// the worker predates Revise turns).
|
|
101
|
+
export const REVISE_UNAVAILABLE = "restart this session to use Revise";
|
|
102
|
+
|
|
103
|
+
// The feature gate (app.js featureGates): a live worker without the
|
|
104
|
+
// "revise" feature predates Revise turns (chi web would answer 409
|
|
105
|
+
// revise_unavailable). A worker that isn't up isn't asked: the send wakes a
|
|
106
|
+
// new one, which has it (as Verify's gate, verify_preset.js).
|
|
107
|
+
export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
|
|
108
|
+
return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("revise");
|
|
109
|
+
}
|
|
110
|
+
export function reviseChipText(label, { unavailable = false } = {}) {
|
|
111
|
+
if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
|
|
112
|
+
return label ? `Revising: ${label}` : "Revising the answer";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The composer's chip (app.js renders it into its own element): the armed
|
|
116
|
+
// Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
|
|
117
|
+
export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
|
|
118
|
+
if (!anchor) return nothing;
|
|
119
|
+
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>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// An answer's label (the stub's, the chip's): its first line.
|
|
123
|
+
export function answerLabel(bubble) {
|
|
124
|
+
if (!bubble) return "";
|
|
125
|
+
return foldLabel(bubbleCopyText(bubble), { display: bubble.hasAttribute?.("data-answer-display") });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A prompt bubble's first text line (a version's label in the chip's list).
|
|
129
|
+
function promptLabel(opening) {
|
|
130
|
+
if (!opening) return "";
|
|
131
|
+
const text = opening.matches?.(".bubble.user") ? bubbleCopyText(opening) : opening.querySelector?.(".note-text")?.textContent || "";
|
|
132
|
+
const line = String(text).split("\n").map((l) => l.trim()).find(Boolean) || "";
|
|
133
|
+
return clip(line, LABEL_CHARS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The history's turns, in order (one pass over #history's children):
|
|
137
|
+
// {id, opening, els (after the opening, up to the next opening or note, a
|
|
138
|
+
// trailing gap divider left to the next), answer (its final answer), wake
|
|
139
|
+
// (a note opened it), stopped (it was canceled or failed)}. Our own rows
|
|
140
|
+
// aren't part of any.
|
|
141
|
+
export function historyTurns(historyEl) {
|
|
142
|
+
const turns = [];
|
|
143
|
+
let turn = null;
|
|
144
|
+
const close = () => {
|
|
145
|
+
if (!turn) return;
|
|
146
|
+
while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
|
|
147
|
+
};
|
|
148
|
+
for (const el of historyEl.children) {
|
|
149
|
+
if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
|
|
150
|
+
if (el.classList.contains(FOLD_ROW) && el.nextElementSibling?.matches(OPENING)) continue;
|
|
151
|
+
const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
|
|
152
|
+
if (opening || el.matches(NOTE_BOUNDARY)) {
|
|
153
|
+
close();
|
|
154
|
+
turn = { id: opening?.dataset.turnId || null, opening: el, head: headOf(el), prompt: opening || null, els: [], answer: null, wake: !opening };
|
|
155
|
+
turns.push(turn);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!turn) continue;
|
|
159
|
+
turn.els.push(el);
|
|
160
|
+
if (el.matches(ANSWERS)) turn.answer = el;
|
|
161
|
+
// A canceled or failed turn's end line (timing.js STOPPED_LINE_CLASS,
|
|
162
|
+
// a live end's too): its partial answer isn't a completed one.
|
|
163
|
+
if (el.matches(STOPPED)) turn.stopped = true;
|
|
164
|
+
}
|
|
165
|
+
close();
|
|
166
|
+
// A wake turn's id is its answer's (timing.js turnGroups keys it).
|
|
167
|
+
for (const t of turns) if (t.wake && t.answer) t.id = t.answer.getAttribute("data-answer-turn") || null;
|
|
168
|
+
return turns;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// @param historyEl #history (also its scroller)
|
|
172
|
+
// @param sessionId () => the open session's id
|
|
173
|
+
// @param projectRoot () => its project root (the fold store's sweep), or null
|
|
174
|
+
// @param running () => null when no turn runs, else {turnId} (null id: the
|
|
175
|
+
// newest turn counts as the running one)
|
|
176
|
+
// @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
|
|
177
|
+
// its prompt's data-verify: a verdict's check_turn_id)
|
|
178
|
+
// @param unavailable () => the worker predates Revise (the feature gate)
|
|
179
|
+
// @param armed () => the turn id the composer's Revise is armed with, or null
|
|
180
|
+
// @param onRevise ({turn_id, label}) the Revise button
|
|
181
|
+
// @return {decorate()} (the observer runs it; app.js after a change it reads)
|
|
182
|
+
export function installReviseStacks({
|
|
183
|
+
historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
|
|
184
|
+
unavailable = () => false, armed = () => null, onRevise = () => {},
|
|
185
|
+
}) {
|
|
186
|
+
const stubs = new Map();
|
|
187
|
+
const rows = new Map();
|
|
188
|
+
// Each row's answer bubble (its label at a click).
|
|
189
|
+
const rowAnswer = new WeakMap();
|
|
190
|
+
let observer = null;
|
|
191
|
+
|
|
192
|
+
function stubFor(id, opening, stored) {
|
|
193
|
+
let stub = stubs.get(id);
|
|
194
|
+
if (!stub) {
|
|
195
|
+
stub = historyEl.ownerDocument.createElement("button");
|
|
196
|
+
stub.type = "button";
|
|
197
|
+
stub.className = STUB_CLASS;
|
|
198
|
+
stub.dataset.stackStub = id;
|
|
199
|
+
// The stored choice, once: after that the stub's class is the state
|
|
200
|
+
// (a jump's reveal isn't saved).
|
|
201
|
+
if (stored().includes(stubKey(id))) stub.classList.add("open");
|
|
202
|
+
stubs.set(id, stub);
|
|
203
|
+
}
|
|
204
|
+
const top = headOf(opening) || opening;
|
|
205
|
+
if (stub.parentElement !== historyEl || stub.nextElementSibling !== top) historyEl.insertBefore(stub, top);
|
|
206
|
+
return stub;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function drawStub(stub, { version, top, answer, opening, gap }) {
|
|
210
|
+
const open = stub.classList.contains("open");
|
|
211
|
+
stub.setAttribute("aria-expanded", open ? "true" : "false");
|
|
212
|
+
stub.title = `${open ? "Fold" : "Show"} this earlier version${opening.title ? ` (${opening.title})` : ""}`;
|
|
213
|
+
const text = stubText({ version, label: answerLabel(answer), lines: answer ? lineCount(bubbleCopyText(answer)) : 0, top });
|
|
214
|
+
render(html`<span class="stack-stub-text">${text}</span>${gap ? html`<span class="stack-stub-gap">${gap}</span>` : nothing}`, stub);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function rowFor(id, answer) {
|
|
218
|
+
let row = rows.get(id);
|
|
219
|
+
if (!row) {
|
|
220
|
+
row = historyEl.ownerDocument.createElement("div");
|
|
221
|
+
row.className = ROW_CLASS;
|
|
222
|
+
row.dataset.reviseFor = id;
|
|
223
|
+
rows.set(id, row);
|
|
224
|
+
}
|
|
225
|
+
if (answer.nextElementSibling !== row) answer.after(row);
|
|
226
|
+
return row;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function drawRow(row, { id, answer, chain, openings }) {
|
|
230
|
+
const off = unavailable();
|
|
231
|
+
const isArmed = armed() === id;
|
|
232
|
+
const versions = chain
|
|
233
|
+
? 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>`
|
|
234
|
+
: nothing;
|
|
235
|
+
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>`;
|
|
236
|
+
row.classList.toggle("stacked", !!chain);
|
|
237
|
+
render(html`${versions}${button}`, row);
|
|
238
|
+
rowAnswer.set(row, answer);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function decorate() {
|
|
242
|
+
let folds = null;
|
|
243
|
+
const stored = () => (folds ??= readFolded(sessionId()));
|
|
244
|
+
const turns = historyTurns(historyEl);
|
|
245
|
+
const openings = new Map(turns.filter((t) => t.id).map((t) => [t.id, t.prompt || t.opening]));
|
|
246
|
+
const entries = turns.filter((t) => t.id).map((t) => {
|
|
247
|
+
const revises = t.prompt?.getAttribute("data-revises");
|
|
248
|
+
return revises ? { turn_id: t.id, revises: { turn_id: revises } } : { turn_id: t.id };
|
|
249
|
+
});
|
|
250
|
+
const superseded = new Map();
|
|
251
|
+
const tops = new Map();
|
|
252
|
+
for (const chain of stacks(entries)) {
|
|
253
|
+
chain.slice(0, -1).forEach((id, k) => superseded.set(id, { version: k + 1, top: chain.length }));
|
|
254
|
+
tops.set(chain.at(-1), chain);
|
|
255
|
+
}
|
|
256
|
+
const run = running();
|
|
257
|
+
const lastOpening = turns.filter((t) => !t.wake).at(-1);
|
|
258
|
+
const keepStubs = new Set();
|
|
259
|
+
const keepRows = new Set();
|
|
260
|
+
for (const turn of turns) {
|
|
261
|
+
const range = [turn.head, turn.opening, ...turn.els].filter(Boolean);
|
|
262
|
+
const place = turn.id && superseded.get(turn.id);
|
|
263
|
+
if (place) {
|
|
264
|
+
const stub = stubFor(turn.id, turn.opening, stored);
|
|
265
|
+
keepStubs.add(turn.id);
|
|
266
|
+
const open = stub.classList.contains("open");
|
|
267
|
+
for (const el of range) {
|
|
268
|
+
const hide = !open && !el.matches(KEPT);
|
|
269
|
+
el.classList.toggle(HIDDEN_CLASS, hide);
|
|
270
|
+
if (el.dataset.stackOf !== turn.id) el.dataset.stackOf = turn.id;
|
|
271
|
+
}
|
|
272
|
+
turn.stub = stub;
|
|
273
|
+
turn.place = place;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
for (const el of range) {
|
|
277
|
+
if (el.classList.contains(HIDDEN_CLASS)) el.classList.remove(HIDDEN_CLASS);
|
|
278
|
+
if (el.dataset.stackOf) delete el.dataset.stackOf;
|
|
279
|
+
}
|
|
280
|
+
// The Revise row: an ended turn's final answer, not a Verify turn's,
|
|
281
|
+
// not the running turn's.
|
|
282
|
+
const answer = turn.answer;
|
|
283
|
+
if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
|
|
284
|
+
if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
|
|
285
|
+
if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
|
|
286
|
+
const row = rowFor(turn.id, answer);
|
|
287
|
+
keepRows.add(turn.id);
|
|
288
|
+
drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
|
|
289
|
+
}
|
|
290
|
+
// A divider between two folded stubs folds into the later one.
|
|
291
|
+
for (const turn of turns) {
|
|
292
|
+
if (!turn.stub) continue;
|
|
293
|
+
const gap = turn.stub.previousElementSibling;
|
|
294
|
+
let gapText = "";
|
|
295
|
+
if (gap?.classList.contains("time-gap")) {
|
|
296
|
+
let before = gap.previousElementSibling;
|
|
297
|
+
while (before?.classList.contains(HIDDEN_CLASS)) before = before.previousElementSibling;
|
|
298
|
+
const fold = !turn.stub.classList.contains("open") && !!before?.matches(`.${STUB_CLASS}:not(.open)`);
|
|
299
|
+
gap.classList.toggle(GAP_HIDDEN_CLASS, fold);
|
|
300
|
+
if (fold) gapText = gap.textContent.replace(/[─\s]+$/u, "").replace(/^[─\s]+/u, "");
|
|
301
|
+
}
|
|
302
|
+
drawStub(turn.stub, { ...turn.place, answer: turn.answer, opening: turn.prompt || turn.opening, gap: gapText });
|
|
303
|
+
}
|
|
304
|
+
for (const gap of historyEl.querySelectorAll(`:scope > .${GAP_HIDDEN_CLASS}`)) {
|
|
305
|
+
if (!gap.nextElementSibling?.matches(`.${STUB_CLASS}:not(.open)`)) gap.classList.remove(GAP_HIDDEN_CLASS);
|
|
306
|
+
}
|
|
307
|
+
for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
|
|
308
|
+
for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
|
|
309
|
+
// A stale row left by a re-render (its map entry gone with the nodes).
|
|
310
|
+
for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
|
|
311
|
+
if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
|
|
312
|
+
}
|
|
313
|
+
// What we changed isn't news.
|
|
314
|
+
observer?.takeRecords();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setOpen(stub, open, { save }) {
|
|
318
|
+
keepRowInView(historyEl, stub, () => {
|
|
319
|
+
stub.classList.toggle("open", open);
|
|
320
|
+
decorate();
|
|
321
|
+
});
|
|
322
|
+
if (save) writeFold(sessionId(), stubKey(stub.dataset.stackStub), open, { root: projectRoot() });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Changes from elsewhere: a direct child added or removed, a stamp, an
|
|
326
|
+
// answer's key or its end (its class).
|
|
327
|
+
observer = new MutationObserver((records) => {
|
|
328
|
+
const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
|
|
329
|
+
&& [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
|
|
330
|
+
: r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
|
|
331
|
+
if (news) decorate();
|
|
332
|
+
});
|
|
333
|
+
observer.observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-revises", "data-answer-turn", "data-verify"] });
|
|
334
|
+
decorate();
|
|
335
|
+
|
|
336
|
+
historyEl.addEventListener("click", (e) => {
|
|
337
|
+
const stub = e.target.closest?.(`.${STUB_CLASS}`);
|
|
338
|
+
if (stub && stub.parentElement === historyEl) {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
setOpen(stub, !stub.classList.contains("open"), { save: true });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const pick = e.target.closest?.("[data-stack-pick]");
|
|
344
|
+
if (pick) {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
pick.closest("details")?.removeAttribute("open");
|
|
347
|
+
const target = stubs.get(pick.dataset.stackPick);
|
|
348
|
+
if (!target) return;
|
|
349
|
+
if (!target.classList.contains("open")) setOpen(target, true, { save: false });
|
|
350
|
+
target.scrollIntoView?.({ block: "start", behavior: "smooth" });
|
|
351
|
+
target.focus?.({ preventScroll: true });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const button = e.target.closest?.("[data-revise]");
|
|
355
|
+
const row = button?.closest(`.${ROW_CLASS}`);
|
|
356
|
+
if (!button || !row || button.disabled) return;
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
onRevise({ turn_id: button.dataset.revise, label: answerLabel(rowAnswer.get(row)) });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return { decorate };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The stub that hides +el+ (a superseded turn's element, or inside one),
|
|
365
|
+
// opened (not saved); +el+ or null. history_reveal.js runs it on a jump.
|
|
366
|
+
export function openStubOver(el) {
|
|
367
|
+
const top = el?.closest?.(`.${HIDDEN_CLASS}`);
|
|
368
|
+
const id = top?.dataset.stackOf;
|
|
369
|
+
const parent = top?.parentElement;
|
|
370
|
+
if (!id || !parent) return el ?? null;
|
|
371
|
+
const stub = [...parent.children].find((c) => c.classList.contains(STUB_CLASS) && c.dataset.stackStub === id);
|
|
372
|
+
stub?.classList.add("open");
|
|
373
|
+
stub?.setAttribute("aria-expanded", "true");
|
|
374
|
+
for (const hidden of parent.querySelectorAll(`:scope > .${HIDDEN_CLASS}`)) {
|
|
375
|
+
if (hidden.dataset.stackOf === id) hidden.classList.remove(HIDDEN_CLASS);
|
|
376
|
+
}
|
|
377
|
+
return el;
|
|
378
|
+
}
|
|
@@ -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}`;
|
|
@@ -11,8 +11,9 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
|
|
|
11
11
|
// /turn and its ack, a fast failure's restore (restore.js) and the error's
|
|
12
12
|
// words.
|
|
13
13
|
//
|
|
14
|
-
// Hooks: +session()+ the open session's id or null; +
|
|
15
|
-
// (
|
|
14
|
+
// Hooks: +session()+ the open session's id or null; +store+ the page's
|
|
15
|
+
// session store (session_store.js; store.current: owner, commands,
|
|
16
|
+
// firstPreview, written through store.update); +running()+ a turn
|
|
16
17
|
// runs; +clientId+; +sentIds+ / +earlyRestores+ (restore.js' bookkeeping);
|
|
17
18
|
// +api+: {createIdleSession, uploadImage, sendTurn} (data.js); +dir+: the
|
|
18
19
|
// page's scope folder (new sessions start there); +startPage+
|
|
@@ -23,15 +24,16 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
|
|
|
23
24
|
// text was written in session id)}; +historyEl+,
|
|
24
25
|
// +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
|
|
25
26
|
// +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
|
|
26
|
-
// +showNotice(text)+,
|
|
27
|
+
// +showNotice(text)+,
|
|
27
28
|
// +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
|
|
28
29
|
// +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
|
|
29
30
|
// +alert+: the browser's.
|
|
30
31
|
export function createSendTurn({
|
|
31
|
-
session,
|
|
32
|
+
session, store, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
|
|
32
33
|
verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
|
|
34
|
+
revise = () => null, clearRevise = () => {}, restoreRevise = () => {},
|
|
33
35
|
historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
|
|
34
|
-
addCreatedSession, select, showNotice,
|
|
36
|
+
addCreatedSession, select, showNotice, followWokenWorker, refreshHistory,
|
|
35
37
|
leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
|
|
36
38
|
}) {
|
|
37
39
|
const promptEl = composer.el;
|
|
@@ -63,7 +65,7 @@ export function createSendTurn({
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
async function send({ delivery: chosen = null } = {}) {
|
|
66
|
-
if (
|
|
68
|
+
if (store.current.owner === "tui") return;
|
|
67
69
|
// A fresh link's pick first: its /model is queued in the worker once
|
|
68
70
|
// acked, and the worker runs queued commands before a turn's input.
|
|
69
71
|
const pick = startPage.pendingPick();
|
|
@@ -81,7 +83,7 @@ export function createSendTurn({
|
|
|
81
83
|
// Where the line goes (composer.js): the page's own reply, a session to
|
|
82
84
|
// make first (the start page), a command, or a turn.
|
|
83
85
|
let route = routeComposerLine(prompt, {
|
|
84
|
-
session: !!session(), running: running(), commands:
|
|
86
|
+
session: !!session(), running: running(), commands: store.current.commands, images: composer.chips().length,
|
|
85
87
|
});
|
|
86
88
|
if (route.create) {
|
|
87
89
|
actionBtn.disabled = true;
|
|
@@ -99,7 +101,7 @@ export function createSendTurn({
|
|
|
99
101
|
actionBtn.disabled = false;
|
|
100
102
|
}
|
|
101
103
|
// In the new session, by its command list (select read it).
|
|
102
|
-
route = routeSessionLine(route.send, { commands:
|
|
104
|
+
route = routeSessionLine(route.send, { commands: store.current.commands, images: composer.chips().length, delivery: route.delivery });
|
|
103
105
|
}
|
|
104
106
|
if (route.reply) {
|
|
105
107
|
// The start page's reply is a notice: no session is made for it (2.29).
|
|
@@ -116,16 +118,15 @@ export function createSendTurn({
|
|
|
116
118
|
if (hint) hint.remove();
|
|
117
119
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
118
120
|
// would land among its rows); into a running one it is a queued row there.
|
|
119
|
-
turnDom.handOff
|
|
121
|
+
turnDom.handOff({ immediate: true });
|
|
120
122
|
const sending = composer.chips().slice();
|
|
121
123
|
// Into a running turn the echo says what it waits for (its delivery); a
|
|
122
124
|
// line with images always waits for the turn's end (the worker queues it).
|
|
123
125
|
const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
124
126
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
125
|
-
if (!turnDom.holds
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
updateInfoBar();
|
|
127
|
+
if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
128
|
+
if (!store.current.firstPreview || store.current.firstPreview === "—") {
|
|
129
|
+
store.update({ current: { firstPreview: firstPreview(prompt) } });
|
|
129
130
|
}
|
|
130
131
|
actionBtn.disabled = true;
|
|
131
132
|
// The session the line goes to: the page may switch to another while an
|
|
@@ -134,8 +135,14 @@ export function createSendTurn({
|
|
|
134
135
|
try {
|
|
135
136
|
await uploadChips(sending, sentTo, { upload: api.uploadImage });
|
|
136
137
|
const verifyAnchor = verify();
|
|
138
|
+
// The armed Revise ({turn_id, label}; null while its gate refuses).
|
|
139
|
+
const reviseAnchor = revise();
|
|
140
|
+
// A Verify turn's prompt: no Revise on its answer (revise_stack.js),
|
|
141
|
+
// as a reload's data-verify says.
|
|
142
|
+
if (verifyAnchor) echo.dataset.verify = "1";
|
|
137
143
|
const ack = await api.sendTurn(sentTo, prompt, {
|
|
138
144
|
clientId, images: turnRefs(sending), history: typed, delivery, ...(verifyAnchor ? { verify: verifyAnchor } : {}),
|
|
145
|
+
...(reviseAnchor ? { revises: { turn_id: reviseAnchor.turn_id } } : {}),
|
|
139
146
|
});
|
|
140
147
|
if (ack?.command_id) {
|
|
141
148
|
// The worker knew it as a command (its list was newer than ours):
|
|
@@ -146,6 +153,7 @@ export function createSendTurn({
|
|
|
146
153
|
return;
|
|
147
154
|
}
|
|
148
155
|
if (verifyAnchor) clearVerify(verifyAnchor);
|
|
156
|
+
if (reviseAnchor) clearRevise(reviseAnchor);
|
|
149
157
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
150
158
|
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
151
159
|
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
@@ -161,6 +169,8 @@ export function createSendTurn({
|
|
|
161
169
|
if (restored?.refill && sentTo === session()) composer.openForVisit();
|
|
162
170
|
// Its Verify claim too, so the resend still records a verdict.
|
|
163
171
|
if (restored?.refill && restored.verify && sentTo === session()) restoreVerify(restored.verify);
|
|
172
|
+
// And its Revise draft, so the resend still stacks.
|
|
173
|
+
if (restored?.refill && restored.revises && sentTo === session()) restoreRevise(restored.revises);
|
|
164
174
|
const before = promptEl.value;
|
|
165
175
|
promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
|
|
166
176
|
// A line back from sentTo is a draft of that session, wherever the page is now.
|
|
@@ -189,8 +199,8 @@ export function createSendTurn({
|
|
|
189
199
|
// image refused (bad_image, too_large, bad_images) says why below.
|
|
190
200
|
if (e.code === "owned_by_tui") {
|
|
191
201
|
if (sentTo !== session()) return toast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
|
|
192
|
-
|
|
193
|
-
|
|
202
|
+
// The composer and the bar (its continue and stop, the title's ✎) follow.
|
|
203
|
+
store.update({ current: { owner: "tui" } });
|
|
194
204
|
return;
|
|
195
205
|
}
|
|
196
206
|
// A stale tab sent into a session deleted meanwhile: the start page
|
|
@@ -198,9 +208,12 @@ export function createSendTurn({
|
|
|
198
208
|
if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
|
|
199
209
|
// A /queue to a worker that can't queue (it would steer): its words, the text kept.
|
|
200
210
|
if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
211
|
+
// A Revise to a worker from before Revise turns (the page's gate saw
|
|
212
|
+
// none yet): its words, the text and the chip kept.
|
|
213
|
+
if (e.code === "revise_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
201
214
|
toast(cantText("send", e), null, { sticky: true });
|
|
202
215
|
} finally {
|
|
203
|
-
actionBtn.disabled =
|
|
216
|
+
actionBtn.disabled = store.current.owner === "tui";
|
|
204
217
|
composer.focus();
|
|
205
218
|
}
|
|
206
219
|
}
|