samagotchi 0.122.0 → 0.124.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 +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- metadata +7 -1
|
@@ -88,6 +88,23 @@ export function listModels(opts = {}) {
|
|
|
88
88
|
return api("/api/models", opts);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Update chi to this exact version; progress and component rows arrive as
|
|
92
|
+
// `chi_update` events on the page's events stream.
|
|
93
|
+
export function updateChi(version, opts = {}) {
|
|
94
|
+
return api("/api/update", { method: "POST", body: JSON.stringify({ version }), ...opts });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Restart every worker running an older chi; results arrive as
|
|
98
|
+
// `chi_update` events on the page's events stream.
|
|
99
|
+
export function restartWorkers(opts = {}) {
|
|
100
|
+
return api("/api/update/restart_workers", { method: "POST", body: JSON.stringify({}), ...opts });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Ask this chi web to shut down and exec the installed version.
|
|
104
|
+
export function restartWeb(opts = {}) {
|
|
105
|
+
return api("/api/update/restart_web", { method: "POST", body: JSON.stringify({}), ...opts });
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
// The prompt history the composer's ↑/↓ walk (the TUI's too), oldest first.
|
|
92
109
|
export function fetchHistory(opts = {}) {
|
|
93
110
|
return api("/api/history", opts).then((j) => (Array.isArray(j.entries) ? j.entries : []));
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
// read-only review turn): while the composer holds it (hold(), till it is
|
|
21
21
|
// sent, emptied or Esc'd) Enter queues it, and the line says so.
|
|
22
22
|
//
|
|
23
|
-
// While a participant's review turn
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// While a participant's turn runs (a review turn, or a working session's
|
|
24
|
+
// pair turn), the worker queues the owner's input behind it whatever it
|
|
25
|
+
// asks (never a steer or a cut into their turn): Enter queues, no cut is
|
|
26
|
+
// offered, and the line says so.
|
|
26
27
|
//
|
|
27
28
|
// deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
|
|
28
29
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
@@ -41,9 +42,10 @@ export const APPLY_QUEUES = "your Apply turn queues for after this turn · Esc t
|
|
|
41
42
|
export const APPLY_QUEUES_SHORT = "Apply: queued after the turn";
|
|
42
43
|
// The same to a worker without the delivery feature: Enter refuses it.
|
|
43
44
|
export const APPLY_CANT_QUEUE = "your Apply turn can't queue on this worker (restart it) · Esc to steer it in instead";
|
|
44
|
-
// The line while a participant's
|
|
45
|
-
|
|
46
|
-
export const
|
|
45
|
+
// The line while a participant's turn runs (the worker queues), whichever
|
|
46
|
+
// mode: review or pair.
|
|
47
|
+
export const PARTICIPANT_TURN_QUEUES = "goes in as your own turn after this participant's turn";
|
|
48
|
+
export const PARTICIPANT_TURN_QUEUES_SHORT = "after their turn";
|
|
47
49
|
// The idle row: the keys (the placeholder's hint before the row existed).
|
|
48
50
|
export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
|
|
49
51
|
|
|
@@ -1182,6 +1182,23 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1182
1182
|
.ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
|
|
1183
1183
|
.ctx-pop-head{display:flex;align-items:baseline;gap:8px}
|
|
1184
1184
|
.ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
|
|
1185
|
+
#updateSheet{width:calc(100vw - 32px);max-width:560px;max-height:calc(100dvh - 32px);overflow:auto;margin:auto;padding:14px 16px;border:1px solid var(--border-strong);border-radius:14px;background:var(--bg-panel);color:var(--text);box-shadow:var(--toast-shadow)}
|
|
1186
|
+
#updateSheet::backdrop{background:var(--backdrop);backdrop-filter:blur(3px)}
|
|
1187
|
+
.us-head{display:flex;align-items:center;gap:10px}
|
|
1188
|
+
.us-head h2{flex:1;min-width:0;font-size:16px;font-weight:650;color:var(--text-bright);overflow-wrap:anywhere}
|
|
1189
|
+
.us-close{flex:none;padding:0 6px;background:transparent;color:var(--text-muted);font-size:19px;line-height:1.2}
|
|
1190
|
+
.us-status{margin:8px 0;color:var(--text-2);font-size:13px;overflow-wrap:anywhere}
|
|
1191
|
+
.us-rows{display:flex;flex-direction:column;gap:7px;list-style:none;margin:10px 0;padding:0}
|
|
1192
|
+
.us-row{min-width:0;padding:9px 10px;border-radius:9px;background:var(--bg-inset);overflow-wrap:anywhere}
|
|
1193
|
+
.us-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
|
|
1194
|
+
.us-row-head strong{flex:none;max-width:100%;color:var(--text-bright);overflow-wrap:anywhere}
|
|
1195
|
+
.us-row-head span{flex:1 1 0;min-width:0;text-align:right;color:var(--text-2);font-size:12px;overflow-wrap:anywhere}
|
|
1196
|
+
.us-detail{margin-top:4px;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}
|
|
1197
|
+
.us-kept{margin-top:6px;color:var(--warn-text);font-size:12px}
|
|
1198
|
+
.us-kept ul{margin:3px 0 0;padding-left:18px;color:var(--text-2)}
|
|
1199
|
+
.us-kept li{overflow-wrap:anywhere}
|
|
1200
|
+
.us-foot{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
|
|
1201
|
+
@media(max-width:600px){#updateSheet{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(80dvh,calc(100dvh - 16px));margin:0}}
|
|
1185
1202
|
.ctx-pop-kind{font-size:11px;color:var(--text-muted)}
|
|
1186
1203
|
.ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
|
|
1187
1204
|
.ctx-pop-close:hover{color:var(--text)}
|
|
@@ -1448,8 +1465,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
1448
1465
|
/* bottom: above the session's composer card when one shows (--toast-bottom, dock_reserve.js placeToast). */
|
|
1449
1466
|
#toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
|
|
1450
1467
|
#toast[hidden]{display:none}
|
|
1451
|
-
#toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
|
|
1452
|
-
#toast .toast-
|
|
1468
|
+
#toast .toast-item{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
|
|
1469
|
+
#toast .toast-message{flex:1 1 180px;min-width:0;overflow-wrap:anywhere}
|
|
1470
|
+
#toast .toast-action{flex:0 0 auto;white-space:nowrap;background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
1453
1471
|
#toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
|
|
1454
1472
|
#toast .toast-close:hover{color:var(--text)}
|
|
1455
1473
|
/* Each row is as tall as its tallest card. An `auto` row in a grid that overflows its
|
|
@@ -1608,6 +1626,7 @@ a{color:var(--accent)}
|
|
|
1608
1626
|
<div id="allList"></div>
|
|
1609
1627
|
</section>
|
|
1610
1628
|
</main>
|
|
1629
|
+
<dialog id="updateSheet" aria-labelledby="updateSheetTitle"></dialog>
|
|
1611
1630
|
<div id="toast" role="status" aria-live="polite" hidden></div>
|
|
1612
1631
|
<script type="module" src="/assets/app.js"></script>
|
|
1613
1632
|
</body>
|
|
@@ -200,7 +200,7 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
200
200
|
</div>
|
|
201
201
|
<div class="p-foot">
|
|
202
202
|
<div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
|
|
203
|
-
<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 class="p-hint" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
|
|
204
204
|
</div>
|
|
205
205
|
</div>
|
|
206
206
|
</footer>
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
// (spend vs cap, queue, link expiry, model). The owner's participant view
|
|
13
13
|
// mounts none.
|
|
14
14
|
import {
|
|
15
|
-
authorColor, participantAuthor, authorLabel, toolLine,
|
|
15
|
+
authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
16
16
|
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
|
|
17
|
+
modeCopy,
|
|
17
18
|
} from "./participant_model.js";
|
|
19
|
+
import { toolsOverview } from "./tools_overview.js";
|
|
18
20
|
import { fullTime } from "./format.js";
|
|
19
21
|
import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
20
22
|
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
@@ -40,7 +42,6 @@ const $ = (id) => document.getElementById(id);
|
|
|
40
42
|
const body = document.body.dataset;
|
|
41
43
|
const ownerView = body.viewer === "owner";
|
|
42
44
|
const sessionId = body.session || new URLSearchParams(location.hash.slice(1)).get("s") || "";
|
|
43
|
-
const phone = window.matchMedia("(max-width: 640px)");
|
|
44
45
|
|
|
45
46
|
let data = null;
|
|
46
47
|
let live = null;
|
|
@@ -209,7 +210,9 @@ function render() {
|
|
|
209
210
|
return chip;
|
|
210
211
|
}));
|
|
211
212
|
$("viewBanner").hidden = !ownerView;
|
|
212
|
-
|
|
213
|
+
const copy = modeCopy(viewer.mode);
|
|
214
|
+
$("note").textContent = ownerView ? "" : copy.note;
|
|
215
|
+
$("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
|
|
213
216
|
$("head").hidden = false;
|
|
214
217
|
$("main").hidden = false;
|
|
215
218
|
renderPlan();
|
|
@@ -284,7 +287,7 @@ function messageNode(message) {
|
|
|
284
287
|
const who = el("div", "p-who");
|
|
285
288
|
who.append(el("b", "", authorLabel(message)));
|
|
286
289
|
if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
|
|
287
|
-
if (message.author) who.append(el("span", "p-tag",
|
|
290
|
+
if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
|
|
288
291
|
node.append(who);
|
|
289
292
|
const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
|
|
290
293
|
node.append(el("div", "p-bubble", text));
|
|
@@ -305,16 +308,11 @@ function messageNode(message) {
|
|
|
305
308
|
return node;
|
|
306
309
|
}
|
|
307
310
|
|
|
308
|
-
// +open+: the
|
|
309
|
-
// a wide screen.
|
|
311
|
+
// +open+: the overview's state as the reader left it.
|
|
310
312
|
function toolsNode(rows, open = null) {
|
|
311
|
-
if (rows.length === 1) {
|
|
312
|
-
const row = rows[0];
|
|
313
|
-
return el("div", `p-tools p-tool-one${row.status === "error" ? " error" : row.status === "running" ? " running" : ""}`, `↳ ${toolLine(row)}`);
|
|
314
|
-
}
|
|
315
313
|
const details = el("details", "p-tools");
|
|
316
|
-
details.open = open ??
|
|
317
|
-
details.append(el("summary", "",
|
|
314
|
+
details.open = open ?? false;
|
|
315
|
+
details.append(el("summary", "", toolsOverview(rows)));
|
|
318
316
|
const list = el("ul");
|
|
319
317
|
for (const row of rows) list.append(el("li", row.status === "error" ? "error" : row.status === "running" ? "running" : "", toolLine(row)));
|
|
320
318
|
details.append(list);
|
|
@@ -38,14 +38,6 @@ export function toolLine(row) {
|
|
|
38
38
|
return row?.path ? `${row.tool} ${row.path}` : String(row?.tool || "tool");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// A tool rows' summary: "read docs/plan.md", "read docs/plan.md + 2 more".
|
|
42
|
-
export function toolsSummary(rows) {
|
|
43
|
-
const list = Array.isArray(rows) ? rows : [];
|
|
44
|
-
if (list.length === 0) return "";
|
|
45
|
-
const first = toolLine(list[0]);
|
|
46
|
-
return list.length === 1 ? first : `${first} + ${list.length - 1} more`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
41
|
// The conversation with each run of text-less tool steps (a reply that
|
|
50
42
|
// only called tools) folded into the next reply's rows, or one row group
|
|
51
43
|
// when no reply follows: one "3 tools: …" line instead of three.
|
|
@@ -271,6 +263,26 @@ export function leftText(iso, now = Date.now()) {
|
|
|
271
263
|
return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
|
|
272
264
|
}
|
|
273
265
|
|
|
266
|
+
// What the page says a participant's turn does, by the session's mode
|
|
267
|
+
// (viewer.mode: ParticipantMode.live on the server): a review turn only
|
|
268
|
+
// reads; a pair turn (a working session's) acts with the session's tools
|
|
269
|
+
// under the owner's guardrails. +tag+ is a message's: its own stamp
|
|
270
|
+
// (participant_mode), a message without one a review's.
|
|
271
|
+
export const MODE_COPY = {
|
|
272
|
+
review: {
|
|
273
|
+
note: "You can comment and ask; the model answers in a turn that can only read. The owner sees everything.",
|
|
274
|
+
hint: "The model answers in a read-only turn; the owner sees everything.",
|
|
275
|
+
tag: "review",
|
|
276
|
+
},
|
|
277
|
+
pair: {
|
|
278
|
+
note: "You can ask the model to work in this session: your turns use its tools, writes included, under the owner's "
|
|
279
|
+
+ "guardrails, and what needs approval goes to the owner. The owner sees everything.",
|
|
280
|
+
hint: "The model works in this session; the owner approves what needs it and sees everything.",
|
|
281
|
+
tag: "pair",
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
export const modeCopy = (mode) => MODE_COPY[mode] || MODE_COPY.review;
|
|
285
|
+
|
|
274
286
|
export function usageItems(usage, now = Date.now()) {
|
|
275
287
|
if (!usage || typeof usage !== "object") return [];
|
|
276
288
|
const items = [];
|
|
@@ -25,15 +25,35 @@ export function participantName(author) {
|
|
|
25
25
|
|
|
26
26
|
// A participant's badge: {author, name, color}, and unnamed: true when
|
|
27
27
|
// neither the prompt nor the list named them yet; null for the owner's.
|
|
28
|
-
|
|
28
|
+
// +mode+: the turn's participant_mode stamp ("review", "pair"; the
|
|
29
|
+
// message's, or turn_started's), kept as mode: when known.
|
|
30
|
+
export function authorBadge(author, name = null, mode = null) {
|
|
29
31
|
if (!author || author === "owner") return null;
|
|
30
32
|
const known = name || participantName(author);
|
|
31
|
-
return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }) };
|
|
33
|
+
return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
// A badge's text and tooltip (turn_html.js userBubbleTemplate)
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
// A badge's text and tooltip (turn_html.js userBubbleTemplate), by the
|
|
37
|
+
// turn's mode: a pair turn (a working session's) acted with the session's
|
|
38
|
+
// tools; a review turn, or one whose mode isn't known (a queued prompt, a
|
|
39
|
+
// message from before the stamp), could only read.
|
|
40
|
+
export const PAIR = "pair";
|
|
41
|
+
export const authorBadgeText = (name, mode = null) => (mode === PAIR ? `${name} · pair` : `${name} · review`);
|
|
42
|
+
export const authorBadgeTitle = (name, mode = null) => (mode === PAIR
|
|
43
|
+
? `A turn from ${name}, a participant: it acted with this session's tools, its asks came to you`
|
|
44
|
+
: `A review comment from ${name}, a participant: its turn could only read`);
|
|
45
|
+
|
|
46
|
+
// The badge on +bubble+ (a prompt drawn before its turn started) takes the
|
|
47
|
+
// turn's +mode+ once turn_started names it.
|
|
48
|
+
export function setBadgeMode(bubble, mode) {
|
|
49
|
+
const badge = bubble?.querySelector?.(".author-badge");
|
|
50
|
+
if (!badge || !mode) return;
|
|
51
|
+
bubble.dataset.participantMode = mode;
|
|
52
|
+
if (badge.dataset.unnamed) return;
|
|
53
|
+
const name = participantName(bubble.dataset.author) || badge.textContent.replace(/ · (review|pair)$/, "");
|
|
54
|
+
badge.textContent = authorBadgeText(name, mode);
|
|
55
|
+
badge.title = authorBadgeTitle(name, mode);
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
// The badges under +root+ drawn before the list named their participant
|
|
39
59
|
// (data-unnamed) take the name now known. Bubbles are drawn once
|
|
@@ -42,10 +62,11 @@ export const authorBadgeTitle = (name) => `A review comment from ${name}, a part
|
|
|
42
62
|
export function nameUnnamedBadges(root) {
|
|
43
63
|
let named = 0;
|
|
44
64
|
for (const badge of root?.querySelectorAll?.(".author-badge[data-unnamed]") || []) {
|
|
45
|
-
const
|
|
65
|
+
const bubble = badge.closest("[data-author]");
|
|
66
|
+
const name = participantName(bubble?.dataset.author);
|
|
46
67
|
if (!name) continue;
|
|
47
|
-
badge.textContent = authorBadgeText(name);
|
|
48
|
-
badge.title = authorBadgeTitle(name);
|
|
68
|
+
badge.textContent = authorBadgeText(name, bubble?.dataset.participantMode);
|
|
69
|
+
badge.title = authorBadgeTitle(name, bubble?.dataset.participantMode);
|
|
49
70
|
badge.removeAttribute("data-unnamed");
|
|
50
71
|
named += 1;
|
|
51
72
|
}
|
|
@@ -181,22 +181,30 @@ function inviteTemplate(invite, { canShare }) {
|
|
|
181
181
|
${invite.qr_svg ? html`<div class="pp-qr" .innerHTML=${invite.qr_svg}></div>` : nothing}</div>`;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// The empty list's line, by the session's mode.
|
|
185
|
+
export const EMPTY_REVIEW = "Nobody yet. Invite a person (or an agent) to review: their comments become read-only review turns; you see and approve everything.";
|
|
186
|
+
export const EMPTY_PAIR = "Nobody yet. Invite a person (or an agent) to work with you here: their turns act with this session's tools under your guardrails, and their asks come to you.";
|
|
187
|
+
|
|
184
188
|
// The popover's content: the participants, the Invite form (or the link it
|
|
185
189
|
// gave), the participant view.
|
|
186
190
|
// +working+: the server's warning for a session that isn't a review session
|
|
187
191
|
// (shown before the Invite, with Start a review of it), null for one.
|
|
192
|
+
// +pair+: a participant invited now gets pair turns (the list's mode: a
|
|
193
|
+
// working session on a worker with pair turns): the empty list says their
|
|
194
|
+
// turns act; otherwise (a review session, an older worker) that they read.
|
|
188
195
|
// +identityHeader+: the header chi web's auth proxy names the signed-in
|
|
189
196
|
// user in (the list's identity_header), null without one: then the Invite
|
|
190
197
|
// has no email field (a bound link could never open).
|
|
191
198
|
export function panelTemplate(grants, { now, sessionId, invite = null, error = null, capFor = null, canShare = false,
|
|
192
|
-
busy = false, working = null, reviewing = false, identityHeader = null
|
|
199
|
+
busy = false, working = null, reviewing = false, identityHeader = null,
|
|
200
|
+
pair = false } = {}) {
|
|
193
201
|
const rows = (grants || []).map((g) => rowModel(g, now));
|
|
194
202
|
const active = rows.filter((row) => row.active);
|
|
195
203
|
const ended = rows.filter((row) => !row.active);
|
|
196
204
|
return html`<div class="ctx-pop-head"><strong>Participants</strong><span class="ctx-pop-kind">this session</span
|
|
197
205
|
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
198
206
|
${active.length ? html`<ul class="pp-list">${active.map((row) => rowTemplate(row, { capFor }))}</ul>`
|
|
199
|
-
: html`<div class="ctx-pop-meta"
|
|
207
|
+
: html`<div class="ctx-pop-meta">${pair ? EMPTY_PAIR : EMPTY_REVIEW}</div>`}
|
|
200
208
|
${ended.length ? html`<details class="pp-ended"><summary>Ended (${ended.length})</summary><ul class="pp-list">${
|
|
201
209
|
ended.map((row) => rowTemplate(row, { capFor }))}</ul></details>` : nothing}
|
|
202
210
|
<div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div>
|
|
@@ -233,6 +241,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
233
241
|
// The open session's share warning (GET …/participants), null for a
|
|
234
242
|
// review session.
|
|
235
243
|
let working = null;
|
|
244
|
+
// What a participant invited now gets (GET …/participants mode: "pair" or
|
|
245
|
+
// "review", the server's ReviewStart.share_terms), never guessed from the warning.
|
|
246
|
+
let inviteMode = null;
|
|
236
247
|
// The header chi web's proxy names the user in (GET …/participants), null
|
|
237
248
|
// without one: the Invite's email field.
|
|
238
249
|
let identityHeader = null;
|
|
@@ -268,7 +279,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
268
279
|
|
|
269
280
|
function draw() {
|
|
270
281
|
render(panelTemplate(grants, { now: clock(), sessionId: session(), invite, error, capFor, busy,
|
|
271
|
-
working: startReview ? working : null, reviewing: reviewing(), identityHeader,
|
|
282
|
+
working: startReview ? working : null, pair: inviteMode === "pair", reviewing: reviewing(), identityHeader,
|
|
272
283
|
canShare: typeof win.navigator?.share === "function" }), shell.pop);
|
|
273
284
|
shell.replace();
|
|
274
285
|
}
|
|
@@ -277,6 +288,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
277
288
|
if (!id) {
|
|
278
289
|
grants = null;
|
|
279
290
|
working = null;
|
|
291
|
+
inviteMode = null;
|
|
280
292
|
loadedFor = null;
|
|
281
293
|
setParticipantNames([]);
|
|
282
294
|
onChange();
|
|
@@ -287,6 +299,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
287
299
|
if (id !== session()) return;
|
|
288
300
|
grants = Array.isArray(body.participants) ? body.participants : [];
|
|
289
301
|
working = typeof body.warning === "string" ? body.warning : null;
|
|
302
|
+
inviteMode = typeof body.mode === "string" ? body.mode : null;
|
|
290
303
|
identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
|
|
291
304
|
loadedFor = id;
|
|
292
305
|
if (setParticipantNames(grants)) onNames();
|
|
@@ -296,6 +309,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
296
309
|
if (loadedFor !== id) {
|
|
297
310
|
grants = [];
|
|
298
311
|
working = null;
|
|
312
|
+
inviteMode = null;
|
|
299
313
|
setParticipantNames([]);
|
|
300
314
|
}
|
|
301
315
|
}
|
|
@@ -63,10 +63,19 @@ export function relayView(pq) {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Who asks when a participant's turn asks (approval.author, Approval.payload):
|
|
67
|
+
// their name, else their id; null for the owner's own turns.
|
|
68
|
+
export function approvalAsker(pq) {
|
|
69
|
+
const a = (pq && pq.approval) || {};
|
|
70
|
+
if (!a.author) return null;
|
|
71
|
+
return a.author_name ? String(a.author_name) : String(a.author).replace(/^p:/, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
|
|
67
75
|
// paths are what +what+ joins when it shows paths (the card draws a ref
|
|
68
76
|
// for each); preview is an edit/write's dry-run diff (diff_view.js draws
|
|
69
|
-
// it); delegate is relayView's, for a delegate's approval
|
|
77
|
+
// it); delegate is relayView's, for a delegate's approval; asker is
|
|
78
|
+
// approvalAsker's, for a participant's turn's ask
|
|
70
79
|
export function approvalView(pq) {
|
|
71
80
|
if (!isApproval(pq)) return null;
|
|
72
81
|
const a = pq.approval || {};
|
|
@@ -94,6 +103,7 @@ export function approvalView(pq) {
|
|
|
94
103
|
why: `${reason} (${who})`,
|
|
95
104
|
...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
|
|
96
105
|
...(relayView(pq) ? { delegate: relayView(pq) } : {}),
|
|
106
|
+
...(approvalAsker(pq) ? { asker: approvalAsker(pq), askedBy: String(a.asked_by || "") } : {}),
|
|
97
107
|
};
|
|
98
108
|
}
|
|
99
109
|
|
|
@@ -320,6 +320,13 @@ export function createQuestionCards({
|
|
|
320
320
|
box.className = "approval-details";
|
|
321
321
|
// A delegate's approval: who asks, and its task.
|
|
322
322
|
if (view.delegate) box.appendChild(renderDelegateLine(view.delegate));
|
|
323
|
+
// A participant's turn asks: the server's line (Approval::ASKED_BY), else their name.
|
|
324
|
+
if (view.asker) {
|
|
325
|
+
const who = doc.createElement("div");
|
|
326
|
+
who.className = "approval-delegate approval-asker";
|
|
327
|
+
who.textContent = view.askedBy || view.asker;
|
|
328
|
+
box.appendChild(who);
|
|
329
|
+
}
|
|
323
330
|
const tool = doc.createElement("div");
|
|
324
331
|
tool.className = "approval-tool";
|
|
325
332
|
tool.textContent = view.tool;
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from "./turn_events.js";
|
|
54
54
|
import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
|
|
55
55
|
import { keepEarlyRestore, restoreAction } from "./restore.js";
|
|
56
|
+
import { PAIR, setBadgeMode } from "./participant_names.js";
|
|
56
57
|
|
|
57
58
|
export function createStreamHandlers(deps) {
|
|
58
59
|
const {
|
|
@@ -159,9 +160,12 @@ export function createStreamHandlers(deps) {
|
|
|
159
160
|
liveTurn.cutAfter = data.cut_after ?? null;
|
|
160
161
|
liveTurn.serverGen = null;
|
|
161
162
|
// A participant's review turn: its id and author, for Apply at its end
|
|
162
|
-
// (turn_completed names neither).
|
|
163
|
+
// (turn_completed names neither). A pair turn acted itself: no Apply.
|
|
163
164
|
const author = data.author || data.origin?.author;
|
|
164
|
-
|
|
165
|
+
const pair = data.participant_mode === PAIR;
|
|
166
|
+
liveTurn.review = author && author !== "owner" && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
|
|
167
|
+
// Its prompt drawn while queued takes the turn's mode now.
|
|
168
|
+
if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
|
|
165
169
|
// The owner's input queues behind it (the composer's delivery line).
|
|
166
170
|
liveTurn.participantTurn = !!(author && author !== "owner");
|
|
167
171
|
setTurnRunning(true);
|
|
@@ -30,9 +30,11 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function draw(entry, text, action, sticky) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
|
34
|
+
render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${actions.map((item) =>
|
|
35
|
+
html`<button type="button" class="toast-action" @click=${() => { remove(entry); item.run(); }}>${item.label}</button>`)}${sticky
|
|
36
|
+
? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>`
|
|
37
|
+
: nothing}`,
|
|
36
38
|
entry.el);
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -61,7 +63,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
61
63
|
el.appendChild(duplicate.el);
|
|
62
64
|
if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
|
|
63
65
|
sync();
|
|
64
|
-
return;
|
|
66
|
+
return duplicate;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
if (entries.length === MAX_TOASTS) {
|
|
@@ -72,7 +74,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
72
74
|
el.appendChild(entry.el);
|
|
73
75
|
if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
|
|
74
76
|
sync();
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Takes down a toast show returned (gone already: nothing).
|
|
81
|
+
function dismiss(entry) {
|
|
82
|
+
if (entry) remove(entry);
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
return { show };
|
|
85
|
+
return { show, dismiss };
|
|
78
86
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A turn's tool rows as one line: "read 12 files · ran 3 commands · edited
|
|
2
|
+
// 2 files (engine.rb, worker.rb)". Pure, names only (rows carry a tool name
|
|
3
|
+
// and a project path, never args or output); the participant page folds a
|
|
4
|
+
// turn's rows into it, and the owner's page may later.
|
|
5
|
+
const READERS = new Set(["read"]);
|
|
6
|
+
const COMMANDS = new Set(["execute", "bash"]);
|
|
7
|
+
const EDITORS = new Set(["write", "edit"]);
|
|
8
|
+
const FILE_NAMES = 3;
|
|
9
|
+
|
|
10
|
+
const rowsOf = (rows) => (Array.isArray(rows) ? rows : []);
|
|
11
|
+
const namedPath = (row) => typeof row?.path === "string" && row.path ? row.path : null;
|
|
12
|
+
const basename = (path) => path.split(/[\\/]/).at(-1);
|
|
13
|
+
const noun = (count, singular, plural) => `${count} ${count === 1 ? singular : plural}`;
|
|
14
|
+
|
|
15
|
+
function fileCount(rows) {
|
|
16
|
+
const paths = new Set(rows.map(namedPath).filter(Boolean));
|
|
17
|
+
return paths.size + rows.filter((row) => !namedPath(row)).length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fileNames(rows) {
|
|
21
|
+
const paths = [...new Set(rows.map(namedPath).filter(Boolean))];
|
|
22
|
+
const names = paths.slice(0, FILE_NAMES).map(basename);
|
|
23
|
+
if (paths.length > FILE_NAMES) names.push("…");
|
|
24
|
+
return names.length ? ` (${names.join(", ")})` : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toolsOverview(rows) {
|
|
28
|
+
const list = rowsOf(rows);
|
|
29
|
+
if (!list.length) return "";
|
|
30
|
+
|
|
31
|
+
const readers = list.filter((row) => READERS.has(row?.tool));
|
|
32
|
+
const commands = list.filter((row) => COMMANDS.has(row?.tool));
|
|
33
|
+
const editors = list.filter((row) => EDITORS.has(row?.tool));
|
|
34
|
+
const otherCount = list.length - readers.length - commands.length - editors.length;
|
|
35
|
+
const parts = [];
|
|
36
|
+
|
|
37
|
+
if (readers.length) parts.push(`read ${noun(fileCount(readers), "file", "files")}`);
|
|
38
|
+
if (commands.length) parts.push(`ran ${noun(commands.length, "command", "commands")}`);
|
|
39
|
+
if (editors.length) parts.push(`edited ${noun(fileCount(editors), "file", "files")}${fileNames(editors)}`);
|
|
40
|
+
if (otherCount) parts.push(`used ${noun(otherCount, "other tool", "other tools")}`);
|
|
41
|
+
return parts.join(" · ");
|
|
42
|
+
}
|
|
@@ -289,8 +289,8 @@ export function initWaitLine({ tasks } = {}) {
|
|
|
289
289
|
// no "steered" badge, as a reload shows it).
|
|
290
290
|
// A participant's prompt's badge, as an op's field ({author}); {} for the
|
|
291
291
|
// owner's, so their ops stay as they were.
|
|
292
|
-
function authorOf(author, name = null) {
|
|
293
|
-
const badge = authorBadge(author, name);
|
|
292
|
+
function authorOf(author, name = null, mode = null) {
|
|
293
|
+
const badge = authorBadge(author, name, mode);
|
|
294
294
|
return badge ? { author: badge } : {};
|
|
295
295
|
}
|
|
296
296
|
|
|
@@ -314,7 +314,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
|
|
|
314
314
|
// draws the bubble from this.
|
|
315
315
|
return [withImages({ op: "start", enqueuedId: id, prompt: event.prompt }, event.images)];
|
|
316
316
|
}
|
|
317
|
-
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id), ...authorOf(event.author || origin.author, event.author_name) }, event.images)];
|
|
317
|
+
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id), ...authorOf(event.author || origin.author, event.author_name, event.participant_mode) }, event.images)];
|
|
318
318
|
}
|
|
319
319
|
case "turn_dequeued":
|
|
320
320
|
return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
|
|
@@ -362,7 +362,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
362
362
|
const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
|
|
363
363
|
const turnId = turn.turn_id ? { turn_id: turn.turn_id } : {};
|
|
364
364
|
// A participant's turn names them (TurnAccumulator keeps author, author_name).
|
|
365
|
-
const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {})
|
|
365
|
+
const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}),
|
|
366
|
+
...(turn.participant_mode ? { participant_mode: turn.participant_mode } : {}) } : {};
|
|
366
367
|
// Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
|
|
367
368
|
const at = typeof turn.at === "string" ? { at: turn.at } : {};
|
|
368
369
|
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...turnId, ...author }, turn.images));
|
|
@@ -311,9 +311,9 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
|
|
|
311
311
|
export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null, author = null } = {}) {
|
|
312
312
|
const named = BADGES[state] ? state : null;
|
|
313
313
|
const from = author
|
|
314
|
-
? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
|
|
314
|
+
? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name, author.mode)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name, author.mode)}</span>`
|
|
315
315
|
: label ? html`<span class="origin-label">${label}</span>` : nothing;
|
|
316
|
-
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
316
|
+
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
// The notice of a turn that ended with no answer (the server's
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// A block that holds one gen with no thinking and no tool rows hides its
|
|
21
21
|
// chrome (CSS :has), so a plain answer streams like today's bubble, and is
|
|
22
22
|
// removed at the end: the answer bubble takes its place.
|
|
23
|
-
import { authorBadge } from "./participant_names.js";
|
|
23
|
+
import { authorBadge, PAIR } from "./participant_names.js";
|
|
24
24
|
import { applyRowTemplate } from "./review_apply.js";
|
|
25
25
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
26
26
|
import { answerRefsOf, setAnswerRefs } from "./answer_refs.js";
|
|
@@ -870,7 +870,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
870
870
|
const userTemplate = (m, opts = {}) => {
|
|
871
871
|
const shell = shellCommandMessage(m?.content);
|
|
872
872
|
if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
|
|
873
|
-
return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
|
|
873
|
+
return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name, m?.participant_mode), ...opts });
|
|
874
874
|
};
|
|
875
875
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
876
876
|
// the turn's answer (shown anyway).
|
|
@@ -946,8 +946,10 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
946
946
|
else if (group.answer === null) parts.push(timingTemplate(group));
|
|
947
947
|
else parts.push(answerTemplate(group.answer, timingTemplate(group)));
|
|
948
948
|
// A participant's review turn with a reply: Apply under it (the owner's
|
|
949
|
-
// page only: the participant page has its own view).
|
|
950
|
-
|
|
949
|
+
// page only: the participant page has its own view). Not under a pair
|
|
950
|
+
// turn: it acted itself.
|
|
951
|
+
const reviewer = group.answer !== null && prompt.turn_id && prompt.participant_mode !== PAIR
|
|
952
|
+
? authorBadge(prompt.author, prompt.author_name) : null;
|
|
951
953
|
if (reviewer) parts.push(applyRowTemplate(prompt.turn_id, reviewer));
|
|
952
954
|
// A canceled turn ends with its cancel line, a failed one whose steps
|
|
953
955
|
// stayed with its failure line, as the live view left them.
|