samagotchi 0.119.0 → 0.121.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 +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +10 -1
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
|
7
7
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
8
8
|
<link rel="stylesheet" href="/assets/tokens.css">
|
|
9
|
+
<link rel="stylesheet" href="/assets/annotate_bar.css">
|
|
10
|
+
<link rel="stylesheet" href="/assets/composer.css">
|
|
11
|
+
<link rel="stylesheet" href="/assets/forms.css">
|
|
9
12
|
<style>
|
|
10
13
|
/* The participant page (participant.js): the thread with the composer pinned, and for a review session its subjects
|
|
11
14
|
(the Plan): beside the thread on a wide screen, a Plan | Thread toggle on a narrow one (the composer pinned in both).
|
|
12
|
-
The owner's participant view is the same page with no composer. Colours are tokens.css's. */
|
|
15
|
+
The owner's participant view is the same page with no composer (nothing mounted, the dock hidden). Colours are tokens.css's. */
|
|
13
16
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
14
17
|
/* No iOS text autosizing (index.html's twin). */
|
|
15
18
|
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
|
@@ -107,9 +110,29 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
|
107
110
|
.p-queued span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
108
111
|
.p-queue-note{font-size:12.5px;color:var(--text-muted)}
|
|
109
112
|
.p-error{font-size:13px;color:var(--danger-text);background:var(--danger-bg-dim);border-radius:8px;padding:6px 10px}
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
+
/* The comment box: the shared composer (composer.css, composer_mount.js), as the owner's session composer lays it out
|
|
114
|
+
(participant.js sets body.has-session for a participant: one row, the text field and Send at its bottom right). Its
|
|
115
|
+
grip straddles the dock's top border; folded, the dock keeps the queue and the grip as the handle. No `/` list,
|
|
116
|
+
delivery line or model picker here. */
|
|
117
|
+
#composer{background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:12px}
|
|
118
|
+
#composer:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
|
|
119
|
+
body.has-session #composer{padding:6px 6px 6px 10px}
|
|
120
|
+
#composer textarea{font:inherit;line-height:1.45;padding:2px 0}
|
|
121
|
+
#composer textarea:disabled{cursor:not-allowed}
|
|
122
|
+
#composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
|
|
123
|
+
.p-dock #composeGrip{margin:-15px 0 5px}
|
|
124
|
+
body.composer-folded .p-hint{display:none}
|
|
125
|
+
/* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line
|
|
126
|
+
beside the hint; a phone shows it alone, one line, the end cut. Folded, it goes with the hint. */
|
|
127
|
+
.p-foot{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px;min-width:0}
|
|
128
|
+
.p-foot .p-hint{flex:1 1 320px;min-width:0}
|
|
129
|
+
.p-usage{display:flex;align-items:baseline;min-width:0;max-width:100%;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
130
|
+
.p-usage span{flex:0 0 auto}
|
|
131
|
+
.p-usage span+span::before{content:"·";padding:0 6px;color:var(--text-faint)}
|
|
132
|
+
.p-usage span.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
133
|
+
.p-usage .warn{color:var(--warn-text)}
|
|
134
|
+
.p-usage .over{color:var(--danger-text);font-weight:600}
|
|
135
|
+
body.composer-folded .p-foot{display:none}
|
|
113
136
|
button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text);padding:6px 12px}
|
|
114
137
|
button:hover{background:var(--bg-hover)}
|
|
115
138
|
button.primary{background:var(--primary);border-color:var(--primary);color:var(--on-solid);height:42px;padding:0 18px}
|
|
@@ -129,7 +152,9 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
129
152
|
.p-hint,.p-note{display:none}
|
|
130
153
|
}
|
|
131
154
|
/* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
|
|
132
|
-
@media (max-width:600px),(pointer:coarse){
|
|
155
|
+
@media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
|
|
156
|
+
/* A phone's grip (composer.css: 26 px tall, the pill at 17 px): the pill on the dock's top border. */
|
|
157
|
+
@media (max-width:600px){.p-dock #composeGrip{margin:-29px auto 3px}}
|
|
133
158
|
</style>
|
|
134
159
|
</head>
|
|
135
160
|
<body>
|
|
@@ -157,14 +182,25 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
157
182
|
<div class="p-thread" id="thread"></div>
|
|
158
183
|
</main>
|
|
159
184
|
<footer class="p-dock" id="dock" hidden>
|
|
185
|
+
<div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
|
|
160
186
|
<div class="p-dock-in">
|
|
161
187
|
<div class="p-queue" id="queue"></div>
|
|
162
188
|
<div class="p-error" id="error" role="alert" hidden></div>
|
|
163
|
-
<
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
189
|
+
<div id="composer">
|
|
190
|
+
<div class="compose-col">
|
|
191
|
+
<div id="chips" hidden></div>
|
|
192
|
+
<textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="actions-col">
|
|
195
|
+
<button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
|
|
196
|
+
<input type="file" id="attachInput" accept="image/*" multiple hidden>
|
|
197
|
+
<div class="split"><button type="button" class="primary" id="send">Send</button></div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="p-foot">
|
|
201
|
+
<div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
|
|
202
|
+
<div class="p-hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
|
|
203
|
+
</div>
|
|
168
204
|
</div>
|
|
169
205
|
</footer>
|
|
170
206
|
</div>
|
|
@@ -4,14 +4,27 @@
|
|
|
4
4
|
// GET /api/sessions/:id/review (the data) and the filtered stream; the
|
|
5
5
|
// page re-reads its data at every turn boundary and draws the turn in
|
|
6
6
|
// progress from the stream in between. A participant's comment is
|
|
7
|
-
// POST …/turn (text only
|
|
7
|
+
// POST …/turn (text only, always queued: a participant never steers); their
|
|
8
|
+
// queued ones come back with Dequeue. The comment box is the owner's
|
|
9
|
+
// composer (composer_mount.js) with a subset on: its height, grip and fold,
|
|
10
|
+
// ↑/↓ over this grant's own comments (review_history.js); no `/` list, no
|
|
11
|
+
// delivery choices, no images yet. Under it, the participant's own usage
|
|
12
|
+
// (spend vs cap, queue, link expiry, model). The owner's participant view
|
|
13
|
+
// mounts none.
|
|
8
14
|
import {
|
|
9
15
|
authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
10
|
-
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps,
|
|
16
|
+
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
|
|
11
17
|
} from "./participant_model.js";
|
|
12
18
|
import { fullTime } from "./format.js";
|
|
13
19
|
import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
14
20
|
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
21
|
+
import { createAnnotateBar } from "./annotate_bar.js";
|
|
22
|
+
import { parsePresets } from "./annotate_presets.js";
|
|
23
|
+
import { appendQuote } from "./annotations.js";
|
|
24
|
+
import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
|
|
25
|
+
import { mountComposer } from "./composer_mount.js";
|
|
26
|
+
import { PROMPT_LINE_PX } from "./composer_size.js";
|
|
27
|
+
import { forgetReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory } from "./review_history.js";
|
|
15
28
|
|
|
16
29
|
// A link's #code= opened in a browser that already has a cookie: traded
|
|
17
30
|
// first, the page reloads without it.
|
|
@@ -35,6 +48,7 @@ let liveStale = false;
|
|
|
35
48
|
let source = null;
|
|
36
49
|
let reloadTimer = null;
|
|
37
50
|
let retryTimer = null;
|
|
51
|
+
let usageTimer = null;
|
|
38
52
|
// Reads of the review that failed in a row (the retry's backoff), and the
|
|
39
53
|
// latest read asked for (an older one answering after it is dropped).
|
|
40
54
|
let failures = 0;
|
|
@@ -80,6 +94,10 @@ function showEnded(json) {
|
|
|
80
94
|
if (ended) return;
|
|
81
95
|
ended = true;
|
|
82
96
|
source?.close();
|
|
97
|
+
clearTimeout(usageTimer);
|
|
98
|
+
usageTimer = null;
|
|
99
|
+
// The link is over: its comments' ↑/↓ list goes with it.
|
|
100
|
+
forgetReviewHistory(data?.viewer?.id, ownerView ? null : sessionId);
|
|
83
101
|
for (const id of ["head", "main", "dock", "plan"]) $(id).hidden = true;
|
|
84
102
|
$("endedText").textContent = endedText(json);
|
|
85
103
|
$("ended").hidden = false;
|
|
@@ -108,6 +126,7 @@ async function load() {
|
|
|
108
126
|
failures = 0;
|
|
109
127
|
$("offline").hidden = true;
|
|
110
128
|
data = json;
|
|
129
|
+
if (!ownerView) rememberReviewHistory(sessionId, data?.viewer?.id);
|
|
111
130
|
live = liveFrom(data.current_turn);
|
|
112
131
|
liveStale = false;
|
|
113
132
|
render();
|
|
@@ -218,6 +237,8 @@ function renderPlan() {
|
|
|
218
237
|
}));
|
|
219
238
|
$("planPath").textContent = subject.path;
|
|
220
239
|
const doc = $("planDoc");
|
|
240
|
+
// An annotation's label names it (participant_annotate.js).
|
|
241
|
+
doc.dataset.path = subject.path;
|
|
221
242
|
const shown = subjectBody(subject);
|
|
222
243
|
doc.className = `p-doc${shown.kind === "html" ? "" : " plain"}`;
|
|
223
244
|
// The server's rendered markdown (MarkdownRenderer: raw HTML escaped,
|
|
@@ -247,6 +268,8 @@ function messageNode(message) {
|
|
|
247
268
|
if (when) node.title = when;
|
|
248
269
|
if (user) {
|
|
249
270
|
node.style.setProperty("--author-color", authorColor(message.author));
|
|
271
|
+
// Whose it is, for an annotation's label (participant_annotate.js).
|
|
272
|
+
Object.assign(node.dataset, messageAuthorData(message));
|
|
250
273
|
const who = el("div", "p-who");
|
|
251
274
|
who.append(el("b", "", authorLabel(message)));
|
|
252
275
|
if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
|
|
@@ -403,8 +426,29 @@ function renderDock() {
|
|
|
403
426
|
const send = $("send");
|
|
404
427
|
const comment = $("comment");
|
|
405
428
|
comment.disabled = !viewer.can_comment;
|
|
406
|
-
send.disabled = !viewer.can_comment;
|
|
429
|
+
send.disabled = !viewer.can_comment || sending;
|
|
407
430
|
comment.placeholder = viewer.cap_reached ? "Your spending cap is reached: ask the owner to raise it" : "Comment or ask";
|
|
431
|
+
renderUsage(viewer.usage);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The participant's own usage, one line under the comment box (only what
|
|
435
|
+
// their grant's exposure shows; the server sends nothing else). Spent and
|
|
436
|
+
// queued change on re-reads; the link's time redraws each minute without one.
|
|
437
|
+
function renderUsage(usage) {
|
|
438
|
+
clearTimeout(usageTimer);
|
|
439
|
+
usageTimer = null;
|
|
440
|
+
const items = usageItems(usage);
|
|
441
|
+
const row = $("usage");
|
|
442
|
+
row.hidden = items.length === 0;
|
|
443
|
+
row.replaceChildren(...items.map((item) => {
|
|
444
|
+
const span = el("span", [item.key === "model" ? "model" : "", item.level].filter(Boolean).join(" "), item.text);
|
|
445
|
+
span.dataset.key = item.key;
|
|
446
|
+
span.title = item.title;
|
|
447
|
+
return span;
|
|
448
|
+
}));
|
|
449
|
+
if (!ended && Number.isFinite(Date.parse(usage?.expires_at || ""))) {
|
|
450
|
+
usageTimer = setTimeout(() => renderUsage(usage), 60_000);
|
|
451
|
+
}
|
|
408
452
|
}
|
|
409
453
|
|
|
410
454
|
function showError(text) {
|
|
@@ -417,9 +461,8 @@ function showError(text) {
|
|
|
417
461
|
// disabled Send button alone doesn't stop a second Enter.
|
|
418
462
|
let sending = false;
|
|
419
463
|
|
|
420
|
-
async function sendComment(
|
|
421
|
-
|
|
422
|
-
if (sending) return;
|
|
464
|
+
async function sendComment() {
|
|
465
|
+
if (sending || !data?.viewer?.can_comment) return;
|
|
423
466
|
const comment = $("comment");
|
|
424
467
|
const prompt = comment.value.trim();
|
|
425
468
|
if (!prompt) return;
|
|
@@ -437,8 +480,11 @@ async function sendComment(event) {
|
|
|
437
480
|
const { ok, status, body: json } = reply;
|
|
438
481
|
if (ended) return;
|
|
439
482
|
if (ok) {
|
|
483
|
+
recordReviewComment(data?.viewer?.id, prompt);
|
|
440
484
|
comment.value = "";
|
|
441
|
-
|
|
485
|
+
composer.fit();
|
|
486
|
+
composer.drawFoldControls();
|
|
487
|
+
composer.refreshHistory();
|
|
442
488
|
showError("");
|
|
443
489
|
await load();
|
|
444
490
|
} else {
|
|
@@ -460,25 +506,62 @@ async function dequeue(enqueuedId, button) {
|
|
|
460
506
|
load();
|
|
461
507
|
}
|
|
462
508
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
509
|
+
// The comment box (composer_mount.js), a participant's only: the owner's
|
|
510
|
+
// participant view mounts none (its dock stays hidden). Enter or Send posts
|
|
511
|
+
// the comment: always a queued review turn, so Alt+Enter is Enter here.
|
|
512
|
+
const composer = ownerView ? null : mountComposer({
|
|
513
|
+
els: {
|
|
514
|
+
composer: $("composer"), prompt: $("comment"), button: $("send"), chips: $("chips"),
|
|
515
|
+
attachBtn: $("attachBtn"), attachInput: $("attachInput"), grip: $("composeGrip"),
|
|
516
|
+
},
|
|
517
|
+
minPx: () => PROMPT_LINE_PX,
|
|
518
|
+
toast: showError,
|
|
519
|
+
loadHistory: () => Promise.resolve(readReviewHistory(data?.viewer?.id)),
|
|
520
|
+
send: () => sendComment(),
|
|
521
|
+
imagesOff: "Images can't go in a review comment yet: describe what it shows, or ask the owner.",
|
|
522
|
+
fold: { session: () => sessionId || null },
|
|
523
|
+
});
|
|
524
|
+
if (composer) {
|
|
525
|
+
// The session composer's layout (composer.css), and the fold saved in this browser.
|
|
526
|
+
document.body.classList.add("has-session");
|
|
527
|
+
composer.newVisit();
|
|
467
528
|
}
|
|
468
529
|
|
|
469
|
-
$("compose").addEventListener("submit", sendComment);
|
|
470
530
|
for (const button of $("viewSeg").querySelectorAll("button")) {
|
|
471
531
|
button.addEventListener("click", () => {
|
|
472
532
|
setView(button.dataset.view);
|
|
473
533
|
if (button.dataset.view === "thread") $("main").scrollTop = $("main").scrollHeight;
|
|
474
534
|
});
|
|
475
535
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
536
|
+
|
|
537
|
+
// Annotate (annotate_bar.js, participant_annotate.js): a selection in the
|
|
538
|
+
// Plan or the thread offers "Annotate" and the presets
|
|
539
|
+
// (web.annotate_presets), which append a quote to the comment box; nothing
|
|
540
|
+
// is sent. No plugin pills: a review turn refuses `/` lines. Not in the
|
|
541
|
+
// owner's participant view, nor while this participant can't comment.
|
|
542
|
+
const annotateBar = createAnnotateBar({
|
|
543
|
+
presets: parsePresets(body.annotatePresets),
|
|
544
|
+
canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
|
|
545
|
+
roots: () => [$("planDoc"), $("thread")],
|
|
546
|
+
isLive: (root) => !!root.closest?.(".p-msg.live"),
|
|
547
|
+
onQuote: quoteIntoComment,
|
|
548
|
+
source: participantSource,
|
|
549
|
+
quote: participantQuote,
|
|
550
|
+
dock: () => $("dock"),
|
|
482
551
|
});
|
|
552
|
+
$("main").addEventListener("scroll", annotateBar.schedule);
|
|
553
|
+
$("planScroll").addEventListener("scroll", annotateBar.schedule);
|
|
554
|
+
|
|
555
|
+
function quoteIntoComment(text) {
|
|
556
|
+
const comment = $("comment");
|
|
557
|
+
// A folded box opens for this visit (nothing saved).
|
|
558
|
+
composer.openForVisit();
|
|
559
|
+
comment.value = appendQuote(comment.value, text);
|
|
560
|
+
composer.fit();
|
|
561
|
+
composer.drawFoldControls();
|
|
562
|
+
comment.focus();
|
|
563
|
+
comment.setSelectionRange(comment.value.length, comment.value.length);
|
|
564
|
+
comment.scrollTop = comment.scrollHeight;
|
|
565
|
+
}
|
|
483
566
|
|
|
484
567
|
load();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Annotate on the participant page (participant.js): a selection in the
|
|
2
|
+
// Plan (the review's subject) or in the thread quotes it into the comment
|
|
3
|
+
// box, with where it came from, the way the owner's page does
|
|
4
|
+
// (annotations.js, annotate_bar.js):
|
|
5
|
+
//
|
|
6
|
+
// From docs/plan.md:
|
|
7
|
+
// > step 3
|
|
8
|
+
//
|
|
9
|
+
// Is step 3 safe?
|
|
10
|
+
//
|
|
11
|
+
// A model reply carries no label (the owner's answers carry none); a
|
|
12
|
+
// comment names its author ("From Alex's comment:", "From my earlier
|
|
13
|
+
// comment:"). Not annotatable: the reply still streaming, tool rows,
|
|
14
|
+
// status lines, time gaps, the header. The bar's source and quote options
|
|
15
|
+
// (createAnnotateBar); no DOM state beyond Element#closest, so it runs
|
|
16
|
+
// under `node --test` with stand-ins. A module of its own: static JS is
|
|
17
|
+
// cached at unversioned URLs, and a new export on an old module would
|
|
18
|
+
// blank the page.
|
|
19
|
+
|
|
20
|
+
import { quoteBlock } from "./annotations.js";
|
|
21
|
+
|
|
22
|
+
// Where a node on the participant page came from: { kind, root, … } where
|
|
23
|
+
// root is the element a selection is clipped to, or null.
|
|
24
|
+
// plan: { path } the subject shown (#planDoc's data-path)
|
|
25
|
+
// reply: a model reply's bubble
|
|
26
|
+
// comment: { by, name } a prompt's bubble; by is "me", "participant",
|
|
27
|
+
// "owner" or "automatic" (its .p-msg's data-by), name the
|
|
28
|
+
// participant's (data-name)
|
|
29
|
+
export function participantSource(node) {
|
|
30
|
+
const el = node && node.nodeType === 3 ? node.parentElement : node;
|
|
31
|
+
if (!el?.closest) return null;
|
|
32
|
+
const plan = el.closest("#planDoc");
|
|
33
|
+
if (plan) {
|
|
34
|
+
// The "only the first part is shown" note isn't the subject's text.
|
|
35
|
+
if (el.closest(".p-plan-note")) return null;
|
|
36
|
+
return { kind: "plan", path: plan.dataset?.path || "", root: plan };
|
|
37
|
+
}
|
|
38
|
+
const bubble = el.closest(".p-bubble");
|
|
39
|
+
const msg = bubble?.closest(".p-msg");
|
|
40
|
+
if (!msg || msg.classList.contains("live")) return null;
|
|
41
|
+
if (msg.classList.contains("reply")) return { kind: "reply", root: bubble };
|
|
42
|
+
if (msg.classList.contains("user")) {
|
|
43
|
+
return { kind: "comment", by: msg.dataset?.by || "owner", name: msg.dataset?.name || "", root: bubble };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The line naming +source+ above the quote, or null (a reply).
|
|
49
|
+
export function participantLabel(source) {
|
|
50
|
+
if (source?.kind === "plan") return source.path ? `From ${source.path}:` : "From the plan:";
|
|
51
|
+
if (source?.kind !== "comment") return null;
|
|
52
|
+
if (source.by === "me") return "From my earlier comment:";
|
|
53
|
+
if (source.by === "participant") return `From ${source.name || "a participant"}'s comment:`;
|
|
54
|
+
if (source.by === "automatic") return "From the automatic turn:";
|
|
55
|
+
return "From the owner's message:";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The quote block for the selected +text+ (annotations.js quoteBlock's
|
|
59
|
+
// lines) under +source+'s label; null when it holds no text.
|
|
60
|
+
export function participantQuote(text, source) {
|
|
61
|
+
const block = quoteBlock(text, { kind: "answer" });
|
|
62
|
+
if (!block) return null;
|
|
63
|
+
const label = participantLabel(source);
|
|
64
|
+
return label ? `${label}\n${block}` : block;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What a message's .p-msg carries for participantSource: data-by and, for
|
|
68
|
+
// another participant, data-name. +message+ is a review message (the
|
|
69
|
+
// participant route's: mine, author, author_name, automatic).
|
|
70
|
+
export function messageAuthorData(message) {
|
|
71
|
+
if (message?.mine) return { by: "me" };
|
|
72
|
+
if (message?.author) return { by: "participant", name: message.author_name || "" };
|
|
73
|
+
if (message?.automatic) return { by: "automatic" };
|
|
74
|
+
return { by: "owner" };
|
|
75
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// refusal in words, the stream's URL, and the live turn as filtered events
|
|
4
4
|
// build it (ParticipantView on the server decides what arrives at all).
|
|
5
5
|
|
|
6
|
-
import { gapText } from "./format.js";
|
|
6
|
+
import { fullTime, gapText } from "./format.js";
|
|
7
7
|
|
|
8
8
|
// A participant's author: "p:" and its grant id, as the server writes it
|
|
9
9
|
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
@@ -246,3 +246,55 @@ export function subjectsKey(subjects) {
|
|
|
246
246
|
export function narrowView(wanted, subjects) {
|
|
247
247
|
return wanted === "plan" && (subjects || []).length ? "plan" : "thread";
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
// A participant's usage row (the review data's viewer.usage: the fields
|
|
251
|
+
// their grant's exposure shows, ParticipantExposure): one short item per
|
|
252
|
+
// field it has, each {key, text, title, level}; level "" / "warn" (most of
|
|
253
|
+
// the cap spent, the queue full) / "over" (the cap reached). Only the
|
|
254
|
+
// participant's own figures ever arrive; a field the level doesn't show is
|
|
255
|
+
// absent and draws nothing. [] with no usage (the owner's view).
|
|
256
|
+
export const USAGE_WARN = 0.8;
|
|
257
|
+
const MINUTE_MS = 60 * 1000;
|
|
258
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
259
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
260
|
+
const dollars = (n) => `$${(Number(n) || 0).toFixed(2)}`;
|
|
261
|
+
const isNum = (n) => typeof n === "number" && Number.isFinite(n);
|
|
262
|
+
|
|
263
|
+
// "6 d left", "5 h left", "12 min left", "ended"; "" for no or bad input.
|
|
264
|
+
export function leftText(iso, now = Date.now()) {
|
|
265
|
+
const at = Date.parse(iso || "");
|
|
266
|
+
if (!Number.isFinite(at)) return "";
|
|
267
|
+
const left = at - now;
|
|
268
|
+
if (left <= 0) return "ended";
|
|
269
|
+
if (left >= 2 * DAY_MS) return `${Math.round(left / DAY_MS)} d left`;
|
|
270
|
+
if (left >= 2 * HOUR_MS) return `${Math.floor(left / HOUR_MS)} h left`;
|
|
271
|
+
return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function usageItems(usage, now = Date.now()) {
|
|
275
|
+
if (!usage || typeof usage !== "object") return [];
|
|
276
|
+
const items = [];
|
|
277
|
+
if (isNum(usage.spent)) {
|
|
278
|
+
const cap = isNum(usage.cap) ? usage.cap : null;
|
|
279
|
+
const share = cap ? usage.spent / cap : 0;
|
|
280
|
+
const level = cap === null ? "" : usage.spent >= cap ? "over" : share >= USAGE_WARN ? "warn" : "";
|
|
281
|
+
items.push({ key: "spent", text: cap === null ? `${dollars(usage.spent)} spent` : `${dollars(usage.spent)} of ${dollars(cap)} cap`,
|
|
282
|
+
title: cap === null ? "What your comments have cost the owner so far"
|
|
283
|
+
: `What your comments have cost the owner so far, of the ${dollars(cap)} cap they set`, level });
|
|
284
|
+
}
|
|
285
|
+
if (isNum(usage.queued) && isNum(usage.queue_limit)) {
|
|
286
|
+
items.push({ key: "queued", text: `${usage.queued}/${usage.queue_limit} queued`,
|
|
287
|
+
title: `Your comments waiting for their turn: up to ${usage.queue_limit} can wait behind a running one`,
|
|
288
|
+
level: usage.queued >= usage.queue_limit ? "warn" : "" });
|
|
289
|
+
}
|
|
290
|
+
const left = leftText(usage.expires_at, now);
|
|
291
|
+
if (left) {
|
|
292
|
+
const day = Date.parse(usage.expires_at) - now < DAY_MS;
|
|
293
|
+
items.push({ key: "expires_at", text: left === "ended" ? "link ended" : `link: ${left}`,
|
|
294
|
+
title: `Your link ends ${fullTime(usage.expires_at)}`, level: day ? "warn" : "" });
|
|
295
|
+
}
|
|
296
|
+
if (typeof usage.model === "string" && usage.model) {
|
|
297
|
+
items.push({ key: "model", text: usage.model, title: "The model answering in this session", level: "" });
|
|
298
|
+
}
|
|
299
|
+
return items;
|
|
300
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// bar's chip (how many, someone here now) and its popover, the panel: each
|
|
3
3
|
// participant's state, expiry, spend against their cap, turns and when they
|
|
4
4
|
// were last seen, with Revoke and a new cap; Invite (name, person or agent,
|
|
5
|
-
// expiry, cap
|
|
5
|
+
// expiry, cap, and behind an auth proxy that names the signed-in user their
|
|
6
|
+
// email, which binds the link to that account) gives the link once, to
|
|
7
|
+
// copy, share (a phone's share sheet)
|
|
6
8
|
// or scan (its QR); and the participant view, the participant page as a
|
|
7
9
|
// participant sees this session (through the server's filter), in a new
|
|
8
10
|
// tab. The panel reads GET /api/sessions/:id/participants, again after
|
|
@@ -61,6 +63,7 @@ export function rowModel(grant, now) {
|
|
|
61
63
|
id: grant.id,
|
|
62
64
|
name: grant.name,
|
|
63
65
|
kind: grant.kind,
|
|
66
|
+
identity: typeof grant.identity === "string" ? grant.identity : null,
|
|
64
67
|
active,
|
|
65
68
|
present,
|
|
66
69
|
color: authorColor(participantAuthor(grant.id)),
|
|
@@ -98,12 +101,21 @@ export function chipTemplate(chip) {
|
|
|
98
101
|
title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
//
|
|
104
|
+
// An email as chi binds a link to it (Grants.clean_identity, which has the
|
|
105
|
+
// last word): one @, no spaces, commas, quotes or angle brackets.
|
|
106
|
+
const EMAIL_RE = /^[^@\s,<>"\\]+@[^@\s,<>"\\]+$/;
|
|
107
|
+
|
|
108
|
+
// What POST …/participants gets from the Invite form's values. +identity+
|
|
109
|
+
// (behind a proxy that names the user): their email, which binds the link;
|
|
110
|
+
// the name defaults to its local part.
|
|
102
111
|
// @return {{body}|{error}}
|
|
103
|
-
export function inviteBody({ name, kind, days, cap }) {
|
|
104
|
-
const
|
|
112
|
+
export function inviteBody({ name, kind, days, cap, identity }) {
|
|
113
|
+
const email = String(identity || "").trim().toLowerCase();
|
|
114
|
+
if (email && !EMAIL_RE.test(email)) return { error: "An email is one address: name@domain." };
|
|
115
|
+
const clean = String(name || "").trim() || email.split("@")[0];
|
|
105
116
|
if (!clean) return { error: "Give them a name: it is what the model and the thread show." };
|
|
106
117
|
const body = { name: clean, kind: kind === "agent" ? "agent" : "human" };
|
|
118
|
+
if (email) body.identity = email;
|
|
107
119
|
const d = Number(days);
|
|
108
120
|
body.ttl = Math.round((EXPIRY_DAYS.includes(d) ? d : DEFAULT_EXPIRY_DAYS) * 24 * 60 * 60);
|
|
109
121
|
const capText = String(cap ?? "").trim();
|
|
@@ -138,13 +150,14 @@ function rowTemplate(row, { capFor }) {
|
|
|
138
150
|
<div class="pp-who"><span class=${`pp-dot${row.present ? " present" : ""}`} aria-hidden="true"></span><b>${row.name}</b>${
|
|
139
151
|
row.kind === "agent" ? html`<span class="pp-tag">agent</span>` : nothing}<span class="pp-state">${row.state}</span></div>
|
|
140
152
|
<div class="pp-meta"><span class=${row.capReached ? "pp-capped" : ""}>${row.spend}</span> · ${row.turns}${
|
|
141
|
-
row.expires ? html` · ${row.expires}` : nothing}</div>${actions}</li>`;
|
|
153
|
+
row.expires ? html` · ${row.expires}` : nothing}${row.identity ? html` · <span class="pp-bound">bound to ${row.identity}</span>` : nothing}</div>${actions}</li>`;
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
function inviteTemplate(invite, { canShare }) {
|
|
145
157
|
if (!invite) return nothing;
|
|
146
158
|
return html`<div class="pp-invite-done">
|
|
147
|
-
<div class="ctx-pop-meta">${invite.name}'s link: shown once (chi keeps only its digest). It opens this session only
|
|
159
|
+
<div class="ctx-pop-meta">${invite.name}'s link: shown once (chi keeps only its digest). It opens this session only${
|
|
160
|
+
invite.identity ? `, for ${invite.identity} only` : ""}.${
|
|
148
161
|
invite.lan ? nothing : " This chi web has no LAN address: the link opens on this machine only."}</div>
|
|
149
162
|
${invite.warning ? html`<div class="pp-warning" role="note">${invite.warning}</div>` : nothing}
|
|
150
163
|
<div class="pp-link"><input readonly .value=${invite.link} aria-label="The review link" @focus=${(e) => e.target.select()}><button
|
|
@@ -157,8 +170,11 @@ function inviteTemplate(invite, { canShare }) {
|
|
|
157
170
|
// gave), the participant view.
|
|
158
171
|
// +working+: the server's warning for a session that isn't a review session
|
|
159
172
|
// (shown before the Invite, with Start a review of it), null for one.
|
|
173
|
+
// +identityHeader+: the header chi web's auth proxy names the signed-in
|
|
174
|
+
// user in (the list's identity_header), null without one: then the Invite
|
|
175
|
+
// has no email field (a bound link could never open).
|
|
160
176
|
export function panelTemplate(grants, { now, sessionId, invite = null, error = null, capFor = null, canShare = false,
|
|
161
|
-
busy = false, working = null, reviewing = false } = {}) {
|
|
177
|
+
busy = false, working = null, reviewing = false, identityHeader = null } = {}) {
|
|
162
178
|
const rows = (grants || []).map((g) => rowModel(g, now));
|
|
163
179
|
const active = rows.filter((row) => row.active);
|
|
164
180
|
const ended = rows.filter((row) => !row.active);
|
|
@@ -173,9 +189,13 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
|
|
|
173
189
|
data-act="start-review" ?disabled=${reviewing}>${reviewing ? "Starting…" : "Start a review of it"}</button></div>` : nothing}
|
|
174
190
|
${invite ? inviteTemplate(invite, { canShare }) : html`<form class="pp-invite">
|
|
175
191
|
<div class="pp-invite-row"><input name="name" type="text" placeholder="Name (Alex)" autocomplete="off" aria-label="Their name" maxlength="64"
|
|
176
|
-
><select name="kind" aria-label="A person or an agent"><option value="human">person</option><option value="agent">agent</option></select></div>
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
><span class="select-wrap"><select name="kind" aria-label="A person or an agent"><option value="human">person</option><option value="agent">agent</option></select></span></div>
|
|
193
|
+
${identityHeader ? html`<div class="pp-invite-row"><input name="identity" type="email" placeholder="Their email (alex@example.com)"
|
|
194
|
+
autocomplete="off" aria-label="Their email: the link opens only for that account"></div>
|
|
195
|
+
<div class="ctx-pop-meta pp-bind-note">With their email the link opens only for them, signed in through the proxy.
|
|
196
|
+
Without one, anyone the proxy lets in who gets the link can open it.</div>` : nothing}
|
|
197
|
+
<div class="pp-invite-row"><label>Expires <span class="select-wrap"><select name="days">${EXPIRY_DAYS.map((d) => html`<option value=${d}
|
|
198
|
+
?selected=${d === DEFAULT_EXPIRY_DAYS}>${d === 1 ? "1 day" : `${d} days`}</option>`)}</select></span></label
|
|
179
199
|
><label>Cap $<input name="cap" type="text" inputmode="decimal" placeholder="2" autocomplete="off" aria-label="Spending cap, dollars"></label
|
|
180
200
|
><button type="submit" class="pp-invite-btn" ?disabled=${busy}>Invite</button></div></form>`}
|
|
181
201
|
<div class="ctx-pop-actions pp-foot">${invite ? html`<button type="button" data-act="another">Invite another</button>` : nothing}<a
|
|
@@ -198,6 +218,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
198
218
|
// The open session's share warning (GET …/participants), null for a
|
|
199
219
|
// review session.
|
|
200
220
|
let working = null;
|
|
221
|
+
// The header chi web's proxy names the user in (GET …/participants), null
|
|
222
|
+
// without one: the Invite's email field.
|
|
223
|
+
let identityHeader = null;
|
|
201
224
|
let loadedFor = null;
|
|
202
225
|
let invite = null;
|
|
203
226
|
let error = null;
|
|
@@ -230,7 +253,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
230
253
|
|
|
231
254
|
function draw() {
|
|
232
255
|
render(panelTemplate(grants, { now: clock(), sessionId: session(), invite, error, capFor, busy,
|
|
233
|
-
working: startReview ? working : null, reviewing: reviewing(),
|
|
256
|
+
working: startReview ? working : null, reviewing: reviewing(), identityHeader,
|
|
234
257
|
canShare: typeof win.navigator?.share === "function" }), shell.pop);
|
|
235
258
|
shell.replace();
|
|
236
259
|
}
|
|
@@ -249,6 +272,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
249
272
|
if (id !== session()) return;
|
|
250
273
|
grants = Array.isArray(body.participants) ? body.participants : [];
|
|
251
274
|
working = typeof body.warning === "string" ? body.warning : null;
|
|
275
|
+
identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
|
|
252
276
|
loadedFor = id;
|
|
253
277
|
if (setParticipantNames(grants)) onNames();
|
|
254
278
|
} catch {
|
|
@@ -265,7 +289,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
265
289
|
}
|
|
266
290
|
|
|
267
291
|
async function submitInvite(form) {
|
|
268
|
-
const values = Object.fromEntries(["name", "kind", "days", "cap"].map((k) => [k, form.elements[k]?.value]));
|
|
292
|
+
const values = Object.fromEntries(["name", "kind", "days", "cap", "identity"].map((k) => [k, form.elements[k]?.value]));
|
|
269
293
|
const { body, error: problem } = inviteBody(values);
|
|
270
294
|
if (problem) {
|
|
271
295
|
error = problem;
|
|
@@ -278,7 +302,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
278
302
|
const answer = await api.invite(asked.id, body);
|
|
279
303
|
if (!stillOpen(asked)) return dropped(asked);
|
|
280
304
|
invite = { name: answer.participant?.name || body.name, link: answer.link, qr_svg: answer.qr_svg, lan: answer.lan,
|
|
281
|
-
warning: answer.warning || null, copied: false };
|
|
305
|
+
warning: answer.warning || null, identity: answer.participant?.identity || null, copied: false };
|
|
282
306
|
error = null;
|
|
283
307
|
} catch (err) {
|
|
284
308
|
if (!stillOpen(asked)) return dropped(asked);
|
|
@@ -15,6 +15,20 @@ export function isContinue(pq) {
|
|
|
15
15
|
return !!pq && pq.kind === "continue";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// A model's recommended option: its label ends in "(Recommended)" (the
|
|
19
|
+
// ask_user_question description asks for it first, the mark at the end).
|
|
20
|
+
// The answer keeps the full label; only what the card shows drops the mark.
|
|
21
|
+
const RECOMMENDED = /\s*\(Recommended\)$/;
|
|
22
|
+
export function isRecommended(label) {
|
|
23
|
+
return RECOMMENDED.test(String(label ?? "").trim());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// An option's label as the card shows it: without the recommended mark.
|
|
27
|
+
export function optionText(label) {
|
|
28
|
+
const s = String(label ?? "");
|
|
29
|
+
return isRecommended(s) ? s.trim().replace(RECOMMENDED, "") || s : s;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
|
|
19
33
|
|
|
20
34
|
// Why a step-limit question closed unanswered (its question_cancelled reason).
|
|
@@ -135,7 +149,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
|
|
|
135
149
|
return `Allowed: ${sel[0]}`;
|
|
136
150
|
}
|
|
137
151
|
const parts = [];
|
|
138
|
-
if (sel.length) parts.push(sel.join(", "));
|
|
152
|
+
if (sel.length) parts.push(sel.map(optionText).join(", "));
|
|
139
153
|
if (freeform) parts.push(freeform);
|
|
140
154
|
return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
|
|
141
155
|
}
|
|
@@ -16,8 +16,8 @@ import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
|
16
16
|
import { diffTemplate } from "./diff_view.js";
|
|
17
17
|
import { render } from "./vendor/lit-html.js";
|
|
18
18
|
import {
|
|
19
|
-
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue,
|
|
20
|
-
nextCardAction, relayedToView, relayView, resultText, summaryText,
|
|
19
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, isRecommended,
|
|
20
|
+
keepsAskingText, nextCardAction, optionText, relayedToView, relayView, resultText, summaryText,
|
|
21
21
|
} from "./question_card.js";
|
|
22
22
|
import { sessionHash } from "./route.js";
|
|
23
23
|
import { clientLabel, isOwn } from "./turn_events.js";
|
|
@@ -129,6 +129,15 @@ export function createQuestionCards({
|
|
|
129
129
|
text.textContent = String(label);
|
|
130
130
|
row.appendChild(input);
|
|
131
131
|
row.appendChild(text);
|
|
132
|
+
// The model's pick: marked, its mark a tag, nothing pre-checked.
|
|
133
|
+
if (!approval && isRecommended(label)) {
|
|
134
|
+
row.classList.add("recommended");
|
|
135
|
+
text.textContent = optionText(label);
|
|
136
|
+
const tag = doc.createElement("span");
|
|
137
|
+
tag.className = "question-option-tag";
|
|
138
|
+
tag.textContent = "recommended";
|
|
139
|
+
row.appendChild(tag);
|
|
140
|
+
}
|
|
132
141
|
list.appendChild(row);
|
|
133
142
|
});
|
|
134
143
|
card.appendChild(list);
|