@oharness/server 0.7.0 → 0.8.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.
- package/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things that stop a turn to ask: the permission dialog, and the
|
|
3
|
+
* question cards the ask_user tool puts in the transcript.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export const DIALOGS = String.raw `
|
|
8
|
+
// --- permission dialog ----------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How many lines of the payload are shown before the body is folded.
|
|
12
|
+
*
|
|
13
|
+
* Enough to recognise what is being written without the buttons falling below
|
|
14
|
+
* the fold, which is what decides whether the dialog gets read or dismissed.
|
|
15
|
+
*/
|
|
16
|
+
const ASK_FOLD = 14;
|
|
17
|
+
|
|
18
|
+
const askBody = document.getElementById("ask-body");
|
|
19
|
+
const askMore = document.getElementById("ask-more");
|
|
20
|
+
const askExpand = document.getElementById("ask-expand");
|
|
21
|
+
const askRaw = document.getElementById("ask-raw");
|
|
22
|
+
const askScope = document.getElementById("ask-scope");
|
|
23
|
+
let askState = null;
|
|
24
|
+
/**
|
|
25
|
+
* Requests that arrived while another was on screen, oldest first. A second
|
|
26
|
+
* request used to overwrite the first one's id, which left that turn waiting
|
|
27
|
+
* on an answer nobody could give any more.
|
|
28
|
+
*/
|
|
29
|
+
const askQueue = [];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The rule a "remember" answer would write.
|
|
33
|
+
*
|
|
34
|
+
* Mirrors candidates() on the server so the dialog can name the exact
|
|
35
|
+
* pattern being persisted. Naming it is the point: "remember for this project"
|
|
36
|
+
* reads like a blanket grant, and users decline it rather than find out.
|
|
37
|
+
*/
|
|
38
|
+
function askRuleKey(request) {
|
|
39
|
+
const name = request.toolName || "";
|
|
40
|
+
// The spelling candidates() writes a rule in: capitalized, unless the name is
|
|
41
|
+
// already snake_case and would only be disfigured by it.
|
|
42
|
+
const capitalized = name.includes("_") ? name : name.charAt(0).toUpperCase() + name.slice(1);
|
|
43
|
+
const input = request.input;
|
|
44
|
+
if (!input || typeof input !== "object") return capitalized;
|
|
45
|
+
// A platform app call is named by its app, and by the app's own tool when it
|
|
46
|
+
// is executing one — see principalArgument() on the server.
|
|
47
|
+
if (typeof input.app === "string") {
|
|
48
|
+
const app = typeof input.tool === "string" ? input.app + ":" + input.tool : input.app;
|
|
49
|
+
return capitalized + "(" + app + ")";
|
|
50
|
+
}
|
|
51
|
+
const field =
|
|
52
|
+
name === "bash" ? "command"
|
|
53
|
+
: "path" in input ? "path"
|
|
54
|
+
: "pattern" in input ? "pattern"
|
|
55
|
+
: "url" in input ? "url"
|
|
56
|
+
: null;
|
|
57
|
+
const value = field ? input[field] : undefined;
|
|
58
|
+
if (typeof value !== "string") return capitalized;
|
|
59
|
+
return capitalized + "(" + value + ")";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bytes, at the precision a person actually reads them at. */
|
|
63
|
+
function askSize(text) {
|
|
64
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
65
|
+
if (bytes < 1024) return bytes + " B";
|
|
66
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
67
|
+
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lines of a payload. A file ending in a newline has no empty last line to show. */
|
|
71
|
+
const askLines = (text, kind) => {
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
74
|
+
return lines.map((s) => ({ s, kind }));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The request, rearranged for a reader.
|
|
79
|
+
*
|
|
80
|
+
* The payload of a write is the file content, not the JSON object carrying it;
|
|
81
|
+
* the payload of an edit is the substitution, which only means anything as a
|
|
82
|
+
* diff. A command leads with what it is for when the model said, and shows
|
|
83
|
+
* the command whole underneath. Other tools get their parameters as a table,
|
|
84
|
+
* with the JSON behind the toggle — and a call with no parameters gets no body.
|
|
85
|
+
*/
|
|
86
|
+
function askView(request) {
|
|
87
|
+
const name = (request.toolName || "").toLowerCase();
|
|
88
|
+
const input = request.input && typeof request.input === "object" ? request.input : {};
|
|
89
|
+
const view = {
|
|
90
|
+
tag: t("ask.tagCall"),
|
|
91
|
+
target: toolTitle(request.toolName, input, request.title),
|
|
92
|
+
path: "",
|
|
93
|
+
mono: false,
|
|
94
|
+
logo: undefined,
|
|
95
|
+
meta: [],
|
|
96
|
+
lines: [],
|
|
97
|
+
code: "",
|
|
98
|
+
params: null,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (name === "write" && typeof input.content === "string") {
|
|
102
|
+
view.tag = t("ask.tagWrite");
|
|
103
|
+
view.path = askPath(input.path);
|
|
104
|
+
view.lines = input.content === "" ? [] : askLines(input.content, "");
|
|
105
|
+
view.meta.push(
|
|
106
|
+
view.lines.length === 1 ? t("ask.oneLine") : t("ask.nLines", { n: view.lines.length }),
|
|
107
|
+
askSize(input.content),
|
|
108
|
+
);
|
|
109
|
+
return view;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (name === "edit" && typeof input.newString === "string") {
|
|
113
|
+
const removed = input.oldString ? askLines(String(input.oldString), "del") : [];
|
|
114
|
+
const added = input.newString ? askLines(input.newString, "add") : [];
|
|
115
|
+
view.tag = input.replaceAll ? t("ask.tagEditAll") : t("ask.tagEdit");
|
|
116
|
+
view.path = askPath(input.path);
|
|
117
|
+
view.meta.push(t("ask.diffStat", { added: added.length, removed: removed.length }));
|
|
118
|
+
view.lines = removed.concat(added);
|
|
119
|
+
return view;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (name === "bash" && typeof input.command === "string") {
|
|
123
|
+
view.tag = t("ask.tagBash");
|
|
124
|
+
if (typeof input.description === "string" && input.description.trim()) {
|
|
125
|
+
view.target = input.description.trim();
|
|
126
|
+
view.code = input.command;
|
|
127
|
+
} else {
|
|
128
|
+
view.target = input.command;
|
|
129
|
+
view.mono = true;
|
|
130
|
+
}
|
|
131
|
+
return view;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (name === "cloud_app_execute" || name === "cloud_app_tools") {
|
|
135
|
+
view.tag = t("ask.tagApp");
|
|
136
|
+
if (typeof input.app === "string") view.logo = cloudAppLogo(input.app);
|
|
137
|
+
if (name === "cloud_app_execute" && !isEmptyInput(input.arguments)) view.params = input.arguments;
|
|
138
|
+
return view;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof input.url === "string") {
|
|
142
|
+
view.tag = t("ask.tagFetch");
|
|
143
|
+
view.target = input.url;
|
|
144
|
+
view.mono = true;
|
|
145
|
+
const rest = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "url"));
|
|
146
|
+
if (!isEmptyInput(rest)) view.params = rest;
|
|
147
|
+
return view;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof input.path === "string") view.meta.push(askPath(input.path));
|
|
151
|
+
else if (typeof input.pattern === "string") view.meta.push(input.pattern);
|
|
152
|
+
if (!isEmptyInput(input)) view.params = input;
|
|
153
|
+
return view;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function paintAskHead(view, request) {
|
|
157
|
+
document.getElementById("ask-tag").textContent = view.tag;
|
|
158
|
+
const target = document.getElementById("ask-target");
|
|
159
|
+
target.replaceChildren();
|
|
160
|
+
target.classList.toggle("mono", view.mono || Boolean(view.path));
|
|
161
|
+
if (view.path) {
|
|
162
|
+
// The directory is context; the file name is the thing being approved.
|
|
163
|
+
const cut = view.path.lastIndexOf("/");
|
|
164
|
+
if (cut >= 0) target.appendChild(el("span", "dir", view.path.slice(0, cut + 1)));
|
|
165
|
+
target.appendChild(el("span", "base", view.path.slice(cut + 1)));
|
|
166
|
+
} else {
|
|
167
|
+
target.textContent = view.target || "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const logo = document.getElementById("ask-logo");
|
|
171
|
+
logo.hidden = !view.logo;
|
|
172
|
+
if (view.logo) {
|
|
173
|
+
logo.onerror = () => { logo.hidden = true; };
|
|
174
|
+
logo.src = view.logo;
|
|
175
|
+
} else {
|
|
176
|
+
logo.removeAttribute("src");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const meta = document.getElementById("ask-meta");
|
|
180
|
+
// Which rule made the harness ask belongs with the other metadata, and in
|
|
181
|
+
// the page's own language rather than the engine's English sentence.
|
|
182
|
+
const rule = request.rule ? t("ask.rule", { rule: request.rule }) : request.reason;
|
|
183
|
+
meta.textContent = view.meta.concat(rule ? [rule] : []).filter(Boolean).join(" · ");
|
|
184
|
+
meta.hidden = meta.textContent === "";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A path shown relative to the working directory when it sits inside it. */
|
|
188
|
+
function askPath(value) {
|
|
189
|
+
if (typeof value !== "string") return "";
|
|
190
|
+
if (currentCwd && value.startsWith(currentCwd + "/")) return value.slice(currentCwd.length + 1);
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderAskBody() {
|
|
195
|
+
if (!askState) return;
|
|
196
|
+
const { view, request } = askState;
|
|
197
|
+
askBody.replaceChildren();
|
|
198
|
+
askBody.classList.remove("diff", "clamped");
|
|
199
|
+
let foldable = false;
|
|
200
|
+
|
|
201
|
+
if (askState.raw) {
|
|
202
|
+
const pre = el("pre", "ask-code");
|
|
203
|
+
pre.textContent = JSON.stringify(request.input, null, 2);
|
|
204
|
+
askBody.appendChild(pre);
|
|
205
|
+
} else if (view.lines.length) {
|
|
206
|
+
const shown = askState.expanded ? view.lines : view.lines.slice(0, ASK_FOLD);
|
|
207
|
+
foldable = view.lines.length > ASK_FOLD;
|
|
208
|
+
askBody.classList.toggle("diff", view.lines.some((line) => line.kind));
|
|
209
|
+
for (const [index, line] of shown.entries()) {
|
|
210
|
+
const row = el("div", "ask-line" + (line.kind ? " " + line.kind : ""));
|
|
211
|
+
// A diff has no line numbers to give: the replacement has not happened, so
|
|
212
|
+
// counting the rows would name positions that do not exist in the file.
|
|
213
|
+
const gutter = line.kind === "del" ? "−" : line.kind === "add" ? "+" : String(index + 1);
|
|
214
|
+
row.append(el("span", "n", gutter), el("span", "s", line.s || " "));
|
|
215
|
+
askBody.appendChild(row);
|
|
216
|
+
}
|
|
217
|
+
askBody.classList.toggle("clamped", !askState.expanded);
|
|
218
|
+
} else if (view.code) {
|
|
219
|
+
const pre = el("pre", "ask-code");
|
|
220
|
+
pre.textContent = view.code;
|
|
221
|
+
askBody.appendChild(pre);
|
|
222
|
+
} else if (view.params) {
|
|
223
|
+
askBody.appendChild(paramTable(view.params));
|
|
224
|
+
}
|
|
225
|
+
askBody.hidden = askBody.childElementCount === 0;
|
|
226
|
+
|
|
227
|
+
askExpand.hidden = askState.raw || !foldable;
|
|
228
|
+
askExpand.textContent = askState.expanded ? t("ask.collapse") : t("ask.expand", { n: view.lines.length });
|
|
229
|
+
// Raw parameters only where the body is a rearrangement of them. A file or
|
|
230
|
+
// a command is already shown whole, and an empty call has nothing to reveal.
|
|
231
|
+
// This button used to be shown always, which on a command left one orphaned
|
|
232
|
+
// row between two rules.
|
|
233
|
+
askRaw.hidden = !view.params;
|
|
234
|
+
askRaw.textContent = askState.raw ? t("ask.rawHide") : t("ask.raw");
|
|
235
|
+
askMore.hidden = askExpand.hidden && askRaw.hidden;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function showAsk(event) {
|
|
239
|
+
if (pendingId === event.id || askQueue.some((queued) => queued.id === event.id)) return;
|
|
240
|
+
if (pendingId) return void askQueue.push(event);
|
|
241
|
+
pendingId = event.id;
|
|
242
|
+
const request = event.request;
|
|
243
|
+
const view = askView(request);
|
|
244
|
+
askState = { request, view, expanded: false, raw: false };
|
|
245
|
+
|
|
246
|
+
paintAskHead(view, request);
|
|
247
|
+
// Every description is laid out up front, one line each and stacked in one
|
|
248
|
+
// cell, so hovering swaps the text without resizing the dialog. The rule key
|
|
249
|
+
// closes each line: a long path is what gets cut, and the tooltip has it all.
|
|
250
|
+
const ruleKey = askRuleKey(request);
|
|
251
|
+
for (const line of askScope.children) {
|
|
252
|
+
const scope = line.dataset.scope;
|
|
253
|
+
line.textContent = t(scope === "once" ? "ask.scopeOnce" : scope === "session" ? "ask.scopeSession" : "ask.scopeProject", { key: ruleKey });
|
|
254
|
+
line.title = line.textContent;
|
|
255
|
+
}
|
|
256
|
+
askScope.dataset.show = "once";
|
|
257
|
+
renderAskBody();
|
|
258
|
+
// The palette is a convenience; this is a turn waiting on an answer. Two
|
|
259
|
+
// stacked modals would leave the deeper one unreachable behind the newer.
|
|
260
|
+
if (paletteDialog.open) paletteDialog.close();
|
|
261
|
+
if (!ask.open) ask.showModal();
|
|
262
|
+
ask.querySelector('[data-answer="once"]').focus();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function nextAsk() {
|
|
266
|
+
const next = askQueue.shift();
|
|
267
|
+
if (next) showAsk(next);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Taken down without an answer: another window answered it, or its turn stopped. */
|
|
271
|
+
function withdrawAsk(id) {
|
|
272
|
+
const queued = askQueue.findIndex((entry) => entry.id === id);
|
|
273
|
+
if (queued >= 0) askQueue.splice(queued, 1);
|
|
274
|
+
if (pendingId !== id) return;
|
|
275
|
+
pendingId = null;
|
|
276
|
+
ask.close();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
askExpand.addEventListener("click", () => {
|
|
280
|
+
askState.expanded = !askState.expanded;
|
|
281
|
+
renderAskBody();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
askRaw.addEventListener("click", () => {
|
|
285
|
+
askState.raw = !askState.raw;
|
|
286
|
+
renderAskBody();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
ask.addEventListener("close", () => {
|
|
290
|
+
// Escape, the backdrop, anything other than a button: a dismissal is a
|
|
291
|
+
// refusal. Approval has to be an explicit act.
|
|
292
|
+
if (pendingId) answer(false, null);
|
|
293
|
+
askState = null;
|
|
294
|
+
// After the close has finished: showModal on a dialog still closing throws.
|
|
295
|
+
setTimeout(nextAsk, 0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Enter approves once, which is the answer the dialog is focused on anyway.
|
|
299
|
+
// Escape already denies, through the dialog's own close.
|
|
300
|
+
ask.addEventListener("keydown", (event) => {
|
|
301
|
+
if (event.key !== "Enter" || event.shiftKey || event.metaKey || event.ctrlKey) return;
|
|
302
|
+
if (event.target.tagName === "BUTTON" && event.target.dataset.answer !== "once") return;
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
ask.querySelector('[data-answer="once"]').click();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
for (const button of ask.querySelectorAll("[data-answer]")) {
|
|
308
|
+
button.addEventListener("click", () => {
|
|
309
|
+
const choice = button.dataset.answer;
|
|
310
|
+
answer(choice !== "no", choice === "session" || choice === "project" ? choice : null);
|
|
311
|
+
ask.close();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// What a scope actually costs, spelled out before it is chosen rather than
|
|
315
|
+
// discovered later in the settings file.
|
|
316
|
+
const scope = button.dataset.scope;
|
|
317
|
+
if (!scope) continue;
|
|
318
|
+
const describe = () => {
|
|
319
|
+
askScope.dataset.show = scope;
|
|
320
|
+
};
|
|
321
|
+
const hide = () => {
|
|
322
|
+
askScope.dataset.show = "once";
|
|
323
|
+
};
|
|
324
|
+
button.addEventListener("mouseenter", describe);
|
|
325
|
+
button.addEventListener("focus", describe);
|
|
326
|
+
button.addEventListener("mouseleave", hide);
|
|
327
|
+
button.addEventListener("blur", hide);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function answer(allow, remember) {
|
|
331
|
+
const id = pendingId;
|
|
332
|
+
pendingId = null;
|
|
333
|
+
if (!id) return;
|
|
334
|
+
// Which way the answer went and how far it was remembered — never what was
|
|
335
|
+
// being asked about, which is a command line or a path.
|
|
336
|
+
track("permission_answered", { allow: allow, remember: remember || "once" });
|
|
337
|
+
void api("/permission", { id, allow, remember: remember || undefined });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
// --- questions -------------------------------------------------------------
|
|
342
|
+
// The ask_user tool. The card lives in the transcript of its own session, is
|
|
343
|
+
// drawn again after a reload (the server replays what is still waiting), and
|
|
344
|
+
// ends as one line saying how it was answered.
|
|
345
|
+
|
|
346
|
+
/** Every question this page has seen, by the server's interaction id. */
|
|
347
|
+
const questionCards = new Map();
|
|
348
|
+
|
|
349
|
+
function questionCard(event) {
|
|
350
|
+
// Only a server interaction can be answered; anything without its id is not one.
|
|
351
|
+
if (typeof event.id !== "string") return;
|
|
352
|
+
let entry = questionCards.get(event.id);
|
|
353
|
+
if (!entry) {
|
|
354
|
+
const questions = (event.request && event.request.questions) || [];
|
|
355
|
+
entry = {
|
|
356
|
+
id: event.id,
|
|
357
|
+
session: event.session,
|
|
358
|
+
request: event.request,
|
|
359
|
+
step: 0,
|
|
360
|
+
preview: 0,
|
|
361
|
+
status: "open",
|
|
362
|
+
error: "",
|
|
363
|
+
result: null,
|
|
364
|
+
picks: Object.fromEntries(questions.map((q) => [q.id, { selected: [], other: "" }])),
|
|
365
|
+
};
|
|
366
|
+
questionCards.set(event.id, entry);
|
|
367
|
+
}
|
|
368
|
+
paintQuestion(entry);
|
|
369
|
+
const box = questionBox(entry);
|
|
370
|
+
if (pane(entry.session) === panes.get(activeId)) {
|
|
371
|
+
follow();
|
|
372
|
+
// Focus moves to the card only when nothing is being typed: a question
|
|
373
|
+
// arriving mid-sentence must not swallow the next keystrokes.
|
|
374
|
+
if (document.activeElement !== input || !input.value) {
|
|
375
|
+
const first = box.querySelector(".q-opt");
|
|
376
|
+
if (first) first.focus({ preventScroll: true });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function questionBox(entry) {
|
|
382
|
+
const target = pane(entry.session);
|
|
383
|
+
let box = target.node.querySelector('[data-question-id="' + entry.id + '"]');
|
|
384
|
+
if (!box) {
|
|
385
|
+
box = el("section", "question-card column");
|
|
386
|
+
box.dataset.questionId = entry.id;
|
|
387
|
+
box.tabIndex = -1;
|
|
388
|
+
box.setAttribute("aria-live", "polite");
|
|
389
|
+
box.addEventListener("keydown", (e) => questionKey(entry, e));
|
|
390
|
+
target.node.appendChild(box);
|
|
391
|
+
}
|
|
392
|
+
return box;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function openQuestion(session) {
|
|
396
|
+
for (const entry of questionCards.values()) {
|
|
397
|
+
if (entry.session === session && entry.status === "open") return entry;
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const questionAnswered = (entry, q) => {
|
|
403
|
+
const pick = entry.picks[q.id];
|
|
404
|
+
return Boolean(pick && (pick.selected.length || pick.other.trim()));
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
function paintQuestion(entry) {
|
|
408
|
+
const box = questionBox(entry);
|
|
409
|
+
const focused = document.activeElement && box.contains(document.activeElement)
|
|
410
|
+
? document.activeElement.dataset.index || (document.activeElement.classList.contains("q-other-input") ? "other" : "")
|
|
411
|
+
: "";
|
|
412
|
+
box.replaceChildren();
|
|
413
|
+
const questions = entry.request.questions || [];
|
|
414
|
+
const live = entry.status === "open" || entry.status === "sending";
|
|
415
|
+
box.classList.toggle("settled", !live);
|
|
416
|
+
if (!live) {
|
|
417
|
+
for (const node of questionSettled(entry)) box.appendChild(node);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const q = questions[entry.step] || questions[0];
|
|
422
|
+
const pick = entry.picks[q.id];
|
|
423
|
+
|
|
424
|
+
const head = el("div", "q-head");
|
|
425
|
+
if (questions.length > 1) {
|
|
426
|
+
questions.forEach((item, index) => {
|
|
427
|
+
const tab = el("button", "q-tab" + (index === entry.step ? " on" : "") + (questionAnswered(entry, item) ? " done" : ""), item.header);
|
|
428
|
+
tab.type = "button";
|
|
429
|
+
tab.onclick = () => { entry.step = index; entry.preview = 0; paintQuestion(entry); };
|
|
430
|
+
head.appendChild(tab);
|
|
431
|
+
});
|
|
432
|
+
} else {
|
|
433
|
+
head.appendChild(el("span", "q-tag", q.header));
|
|
434
|
+
}
|
|
435
|
+
if (q.multiSelect) head.appendChild(el("span", "q-hint", t("question.multi")));
|
|
436
|
+
box.appendChild(head);
|
|
437
|
+
box.appendChild(el("div", "q-text", q.question));
|
|
438
|
+
|
|
439
|
+
const layout = el("div", "q-layout");
|
|
440
|
+
const list = el("div", "q-options");
|
|
441
|
+
list.setAttribute("role", "group");
|
|
442
|
+
q.options.forEach((option, index) => {
|
|
443
|
+
const on = pick.selected.includes(option.label);
|
|
444
|
+
const row = el("button", "q-opt" + (on ? " on" : ""));
|
|
445
|
+
row.type = "button";
|
|
446
|
+
row.dataset.index = String(index);
|
|
447
|
+
row.setAttribute("aria-pressed", String(on));
|
|
448
|
+
row.appendChild(el("span", "q-key", String(index + 1)));
|
|
449
|
+
const words = el("span", "q-words");
|
|
450
|
+
words.appendChild(el("span", "q-label", option.label));
|
|
451
|
+
if (option.description) words.appendChild(el("span", "q-desc", option.description));
|
|
452
|
+
row.appendChild(words);
|
|
453
|
+
row.appendChild(el("span", "q-check", on ? "✓" : ""));
|
|
454
|
+
row.onclick = () => pickOption(entry, q, index);
|
|
455
|
+
const show = () => {
|
|
456
|
+
if (entry.preview === index) return;
|
|
457
|
+
entry.preview = index;
|
|
458
|
+
const panel = box.querySelector(".q-preview");
|
|
459
|
+
if (!panel) return;
|
|
460
|
+
panel.textContent = option.preview || "";
|
|
461
|
+
panel.hidden = !option.preview;
|
|
462
|
+
};
|
|
463
|
+
row.addEventListener("mouseenter", show);
|
|
464
|
+
row.addEventListener("focus", show);
|
|
465
|
+
list.appendChild(row);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const other = el("label", "q-opt q-other" + (pick.other.trim() ? " on" : ""));
|
|
469
|
+
other.appendChild(el("span", "q-key", String(q.options.length + 1)));
|
|
470
|
+
const field = el("input", "q-other-input");
|
|
471
|
+
field.type = "text";
|
|
472
|
+
field.maxLength = 2000;
|
|
473
|
+
field.placeholder = t("question.other");
|
|
474
|
+
field.value = pick.other;
|
|
475
|
+
field.addEventListener("input", () => {
|
|
476
|
+
pick.other = field.value;
|
|
477
|
+
// One answer to a single-choice question: typing replaces the pick.
|
|
478
|
+
if (!q.multiSelect && field.value.trim() && pick.selected.length) {
|
|
479
|
+
pick.selected = [];
|
|
480
|
+
for (const row of list.querySelectorAll(".q-opt[data-index]")) {
|
|
481
|
+
row.classList.remove("on");
|
|
482
|
+
row.setAttribute("aria-pressed", "false");
|
|
483
|
+
row.querySelector(".q-check").textContent = "";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
other.classList.toggle("on", Boolean(field.value.trim()));
|
|
487
|
+
paintQuestionActions(entry, box);
|
|
488
|
+
});
|
|
489
|
+
field.addEventListener("keydown", (e) => {
|
|
490
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
advanceQuestion(entry);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
other.appendChild(field);
|
|
496
|
+
list.appendChild(other);
|
|
497
|
+
layout.appendChild(list);
|
|
498
|
+
|
|
499
|
+
// Under the options, not beside them: side by side halved both inside a chat
|
|
500
|
+
// column, and most options carry no preview at all. It follows the row under
|
|
501
|
+
// the pointer or the keyboard, and folds away for a row that has none.
|
|
502
|
+
if (q.options.some((option) => option.preview)) {
|
|
503
|
+
const preview = el("pre", "q-preview mono");
|
|
504
|
+
const shown = (q.options[entry.preview] || q.options[0]).preview || "";
|
|
505
|
+
preview.textContent = shown;
|
|
506
|
+
preview.hidden = !shown;
|
|
507
|
+
layout.appendChild(preview);
|
|
508
|
+
}
|
|
509
|
+
box.appendChild(layout);
|
|
510
|
+
|
|
511
|
+
const actions = el("div", "q-actions");
|
|
512
|
+
box.appendChild(actions);
|
|
513
|
+
paintQuestionActions(entry, box);
|
|
514
|
+
if (entry.error) box.appendChild(el("div", "q-error", entry.error));
|
|
515
|
+
|
|
516
|
+
// Keep the keyboard where it was across the redraw a pick causes.
|
|
517
|
+
if (focused === "other") field.focus();
|
|
518
|
+
else if (focused) {
|
|
519
|
+
const again = list.querySelector('.q-opt[data-index="' + focused + '"]');
|
|
520
|
+
if (again) again.focus();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function paintQuestionActions(entry, box) {
|
|
525
|
+
const actions = box.querySelector(".q-actions");
|
|
526
|
+
if (!actions) return;
|
|
527
|
+
actions.replaceChildren();
|
|
528
|
+
const questions = entry.request.questions || [];
|
|
529
|
+
const last = entry.step >= questions.length - 1;
|
|
530
|
+
const sending = entry.status === "sending";
|
|
531
|
+
|
|
532
|
+
// The hint holds the left end; every button sits together at the right,
|
|
533
|
+
// Skip nearest the reader's way in and Submit last.
|
|
534
|
+
actions.appendChild(el("span", "q-keys", t("question.keys")));
|
|
535
|
+
const skip = el("button", "quiet", t("question.skip"));
|
|
536
|
+
skip.type = "button";
|
|
537
|
+
skip.disabled = sending;
|
|
538
|
+
skip.onclick = () => void sendQuestion(entry, { outcome: "dismissed" });
|
|
539
|
+
actions.appendChild(skip);
|
|
540
|
+
|
|
541
|
+
if (entry.step > 0) {
|
|
542
|
+
const back = el("button", "", t("question.back"));
|
|
543
|
+
back.type = "button";
|
|
544
|
+
back.disabled = sending;
|
|
545
|
+
back.onclick = () => { entry.step--; entry.preview = 0; paintQuestion(entry); };
|
|
546
|
+
actions.appendChild(back);
|
|
547
|
+
}
|
|
548
|
+
const primary = el("button", "approve", last ? t("question.submit") : t("question.next"));
|
|
549
|
+
primary.type = "button";
|
|
550
|
+
primary.disabled = sending || (last
|
|
551
|
+
? !questions.every((q) => questionAnswered(entry, q))
|
|
552
|
+
: !questionAnswered(entry, questions[entry.step]));
|
|
553
|
+
primary.onclick = () => advanceQuestion(entry);
|
|
554
|
+
actions.appendChild(primary);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function pickOption(entry, q, index) {
|
|
558
|
+
if (entry.status !== "open") return;
|
|
559
|
+
const pick = entry.picks[q.id];
|
|
560
|
+
const label = q.options[index].label;
|
|
561
|
+
if (q.multiSelect) {
|
|
562
|
+
pick.selected = pick.selected.includes(label)
|
|
563
|
+
? pick.selected.filter((chosen) => chosen !== label)
|
|
564
|
+
: pick.selected.concat(label);
|
|
565
|
+
} else {
|
|
566
|
+
pick.selected = pick.selected[0] === label ? [] : [label];
|
|
567
|
+
if (pick.selected.length) pick.other = "";
|
|
568
|
+
}
|
|
569
|
+
entry.preview = index;
|
|
570
|
+
paintQuestion(entry);
|
|
571
|
+
// A single choice with more questions after it moves on by itself; the last
|
|
572
|
+
// one still waits for Submit, so a slip of the finger is not sent.
|
|
573
|
+
const questions = entry.request.questions;
|
|
574
|
+
if (!q.multiSelect && pick.selected.length && entry.step < questions.length - 1) {
|
|
575
|
+
entry.step++;
|
|
576
|
+
entry.preview = 0;
|
|
577
|
+
paintQuestion(entry);
|
|
578
|
+
const first = questionBox(entry).querySelector(".q-opt");
|
|
579
|
+
if (first) first.focus();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function advanceQuestion(entry) {
|
|
584
|
+
if (entry.status !== "open") return;
|
|
585
|
+
const questions = entry.request.questions;
|
|
586
|
+
if (!questionAnswered(entry, questions[entry.step])) return;
|
|
587
|
+
if (entry.step < questions.length - 1) {
|
|
588
|
+
entry.step++;
|
|
589
|
+
entry.preview = 0;
|
|
590
|
+
paintQuestion(entry);
|
|
591
|
+
const first = questionBox(entry).querySelector(".q-opt");
|
|
592
|
+
if (first) first.focus();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const unanswered = questions.findIndex((q) => !questionAnswered(entry, q));
|
|
596
|
+
if (unanswered >= 0) {
|
|
597
|
+
entry.step = unanswered;
|
|
598
|
+
return void paintQuestion(entry);
|
|
599
|
+
}
|
|
600
|
+
const answers = {};
|
|
601
|
+
for (const q of questions) {
|
|
602
|
+
const pick = entry.picks[q.id];
|
|
603
|
+
const other = pick.other.trim();
|
|
604
|
+
answers[q.id] = other ? { selected: pick.selected, other } : { selected: pick.selected };
|
|
605
|
+
}
|
|
606
|
+
void sendQuestion(entry, { outcome: "answered", answers });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function questionKey(entry, e) {
|
|
610
|
+
if (entry.status !== "open" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
611
|
+
if (e.target && e.target.tagName === "INPUT") return;
|
|
612
|
+
const questions = entry.request.questions;
|
|
613
|
+
const q = questions[entry.step];
|
|
614
|
+
if (!q) return;
|
|
615
|
+
if (/^[1-9]$/.test(e.key)) {
|
|
616
|
+
const n = Number(e.key);
|
|
617
|
+
if (n <= q.options.length) {
|
|
618
|
+
e.preventDefault();
|
|
619
|
+
return void pickOption(entry, q, n - 1);
|
|
620
|
+
}
|
|
621
|
+
if (n === q.options.length + 1) {
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
const field = questionBox(entry).querySelector(".q-other-input");
|
|
624
|
+
if (field) field.focus();
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Enter moves on rather than toggling the focused row; Space still toggles.
|
|
629
|
+
if (e.key === "Enter") {
|
|
630
|
+
e.preventDefault();
|
|
631
|
+
return void advanceQuestion(entry);
|
|
632
|
+
}
|
|
633
|
+
if ((e.key === "ArrowRight" && entry.step < questions.length - 1) || (e.key === "ArrowLeft" && entry.step > 0)) {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
entry.step += e.key === "ArrowRight" ? 1 : -1;
|
|
636
|
+
entry.preview = 0;
|
|
637
|
+
paintQuestion(entry);
|
|
638
|
+
const first = questionBox(entry).querySelector(".q-opt");
|
|
639
|
+
if (first) first.focus();
|
|
640
|
+
}
|
|
641
|
+
if (e.key === "Escape") {
|
|
642
|
+
e.preventDefault();
|
|
643
|
+
void sendQuestion(entry, { outcome: "dismissed" });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function sendQuestion(entry, answer) {
|
|
648
|
+
if (entry.status !== "open") return;
|
|
649
|
+
entry.status = "sending";
|
|
650
|
+
entry.error = "";
|
|
651
|
+
paintQuestion(entry);
|
|
652
|
+
const reply = await api("/question", { id: entry.id, answer });
|
|
653
|
+
if (reply && reply.error) {
|
|
654
|
+
// Gone already means it was settled elsewhere; the result event says how.
|
|
655
|
+
// A 404 is that case with the event lost on the way (a stream that
|
|
656
|
+
// reconnected): the question is over, so the card says so instead of
|
|
657
|
+
// offering an answer nobody is waiting for.
|
|
658
|
+
if (entry.status === "sending" && reply.error === "no such pending question") {
|
|
659
|
+
entry.status = "closed";
|
|
660
|
+
} else if (entry.status === "sending") {
|
|
661
|
+
entry.status = "open";
|
|
662
|
+
entry.error = reply.error;
|
|
663
|
+
}
|
|
664
|
+
return void paintQuestion(entry);
|
|
665
|
+
}
|
|
666
|
+
// Counts and outcome only, never the questions or what was chosen.
|
|
667
|
+
track("question_answered", {
|
|
668
|
+
questions: entry.request.questions.length,
|
|
669
|
+
outcome: answer.outcome,
|
|
670
|
+
other: Boolean(answer.answers && Object.values(answer.answers).some((pick) => pick.other)),
|
|
671
|
+
});
|
|
672
|
+
if (entry.status === "sending" || entry.status === "closed") {
|
|
673
|
+
entry.status = answer.outcome;
|
|
674
|
+
entry.result = answer;
|
|
675
|
+
}
|
|
676
|
+
paintQuestion(entry);
|
|
677
|
+
if (pane(entry.session) === panes.get(activeId)) input.focus();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** The engine's own account of how the question ended — authoritative over the page's. */
|
|
681
|
+
function settleQuestion(event) {
|
|
682
|
+
for (const entry of questionCards.values()) {
|
|
683
|
+
if (entry.session !== event.session || !entry.request || entry.request.id !== event.id) continue;
|
|
684
|
+
if (!event.result) continue;
|
|
685
|
+
entry.status = event.result.outcome;
|
|
686
|
+
entry.result = event.result;
|
|
687
|
+
paintQuestion(entry);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function closeQuestion(id) {
|
|
692
|
+
const entry = questionCards.get(id);
|
|
693
|
+
if (!entry || (entry.status !== "open" && entry.status !== "sending")) return;
|
|
694
|
+
entry.status = "closed";
|
|
695
|
+
paintQuestion(entry);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function questionSettled(entry) {
|
|
699
|
+
const result = entry.result || {};
|
|
700
|
+
const titles = {
|
|
701
|
+
answered: "question.answered",
|
|
702
|
+
dismissed: "question.dismissed",
|
|
703
|
+
aborted: "question.aborted",
|
|
704
|
+
unavailable: "question.unavailable",
|
|
705
|
+
};
|
|
706
|
+
const nodes = [el("div", "q-settled-title", t(titles[entry.status] || "question.closed"))];
|
|
707
|
+
if (entry.status === "answered" && result.answers) {
|
|
708
|
+
for (const q of entry.request.questions) {
|
|
709
|
+
const pick = result.answers[q.id] || {};
|
|
710
|
+
const parts = (pick.selected || []).slice();
|
|
711
|
+
if (pick.other) parts.push(pick.other);
|
|
712
|
+
const line = el("div", "q-settled-line");
|
|
713
|
+
line.append(el("span", "q-settled-head", q.header), el("span", "", parts.join(", ")));
|
|
714
|
+
nodes.push(line);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (entry.status === "dismissed" && result.note) {
|
|
718
|
+
nodes.push(el("div", "q-settled-line", t("question.note", { note: result.note })));
|
|
719
|
+
}
|
|
720
|
+
return nodes;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
`;
|
|
724
|
+
//# sourceMappingURL=dialogs.js.map
|