samagotchi 0.133.0 → 0.134.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 +68 -1
- data/docs/cli.md +11 -0
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +102 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +6 -5
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -2
- data/docs/testing.md +7 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +13 -0
- data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
- data/lib/samagotchi/guardrails/pair_command.rb +493 -60
- data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
- data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/system_one/self_protect.rb +58 -2
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +18 -1
- data/lib/samagotchi/web/public/cards_place.js +5 -2
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +3 -1
- data/lib/samagotchi/web/public/history_reveal.js +23 -0
- data/lib/samagotchi/web/public/index.html +44 -28
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/timing.js +5 -1
- data/lib/samagotchi/web/public/turn_html.js +12 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/turn_view.js +21 -11
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- metadata +5 -1
|
@@ -60,6 +60,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
60
60
|
.bubble.output.boxed{overflow:hidden;transition:max-height 360ms ease}
|
|
61
61
|
.bubble.output.revealing>:not(.copy-btn){animation:fadeIn 240ms ease}
|
|
62
62
|
@media (prefers-reduced-motion:reduce){.bubble.output.boxed{transition:none}.bubble.output.revealing>:not(.copy-btn){animation:none}}
|
|
63
|
+
/* A long answer folded by hand (answer_fold.js): the row is the bubble's sibling just before it, styled as a closed turn's steps; folded, the bubble is hidden and the row reads its first line and size; unfolded, a slim "▾ N lines" header close above it. */
|
|
64
|
+
.bubble.output.folded{display:none}
|
|
65
|
+
.answer-fold{display:flex;align-items:baseline;gap:0;align-self:flex-start;max-width:100%;min-width:0;margin:0;padding:3px 10px;font:inherit;font-size:12px;font-weight:500;line-height:1.4;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:transparent;border:1px solid transparent;border-left:3px solid var(--accent-strong);border-radius:6px;transition:background .12s ease}
|
|
66
|
+
.answer-fold:hover{background:var(--bg-panel)}
|
|
67
|
+
.answer-fold:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
|
|
68
|
+
.answer-fold::before{content:"▾\00a0";color:var(--accent);flex:none}
|
|
69
|
+
.answer-fold.folded::before{content:"▸\00a0"}
|
|
70
|
+
.answer-fold:not(.folded){margin-bottom:-8px;padding-top:1px;padding-bottom:1px;border-left-color:transparent}
|
|
71
|
+
.answer-fold-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-2);font-size:13px}
|
|
72
|
+
.answer-fold-label:empty{display:none}
|
|
73
|
+
.answer-fold-size{flex:none;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
63
74
|
/* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
|
|
64
75
|
.time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
|
|
65
76
|
.turn-timing{margin-top:8px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
@@ -1121,36 +1132,39 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1121
1132
|
.pp-working button{font:inherit;font-size:12px;padding:3px 10px;border-radius:6px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
|
|
1122
1133
|
.pp-working button:disabled{opacity:.7;cursor:progress}
|
|
1123
1134
|
.pp-link{display:flex;gap:4px}
|
|
1124
|
-
/* The start page's ⚙ panel (start_settings.js): rows of segments (a label, then pill buttons with aria-pressed), the context's Apply and budget disclosure, the review row; a bottom sheet on a phone. */
|
|
1135
|
+
/* The start page's ⚙ panel (start_settings.js): rows of segments (a label, then pill buttons with aria-pressed), the context's Apply and budget disclosure, the review row; a bottom sheet on a phone. Its classes are sset- (the Stop sheet's are ss-). */
|
|
1125
1136
|
.start-settings{gap:10px}
|
|
1126
1137
|
.start-settings .ctx-pop-head strong{font-family:inherit}
|
|
1127
|
-
.
|
|
1128
|
-
.
|
|
1129
|
-
.
|
|
1130
|
-
.
|
|
1131
|
-
.
|
|
1132
|
-
.
|
|
1133
|
-
.
|
|
1134
|
-
.
|
|
1135
|
-
.
|
|
1136
|
-
.
|
|
1137
|
-
.
|
|
1138
|
-
.
|
|
1139
|
-
.
|
|
1140
|
-
.
|
|
1141
|
-
.
|
|
1142
|
-
.
|
|
1143
|
-
.
|
|
1144
|
-
.
|
|
1138
|
+
.sset-row{display:flex;align-items:flex-start;gap:10px}
|
|
1139
|
+
.sset-label{flex:0 0 64px;font-size:12px;line-height:26px;color:var(--text-muted)}
|
|
1140
|
+
.sset-seg{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:4px}
|
|
1141
|
+
.sset-seg button{height:26px;min-height:26px;padding:0 9px;border:1px solid var(--border-strong);border-radius:7px;background:transparent;color:var(--text-2);font-size:12px;line-height:24px;white-space:nowrap}
|
|
1142
|
+
.sset-seg button:hover{filter:none}
|
|
1143
|
+
.sset-seg button[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
|
|
1144
|
+
.sset-seg button:focus-visible,.sset-link:focus-visible,.sset-back:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
|
1145
|
+
.sset-ctx-more{margin:-4px 0 0 74px}
|
|
1146
|
+
.sset-link{padding:2px 4px;border:0;border-radius:5px;background:transparent;color:var(--accent);font-size:12px}
|
|
1147
|
+
.sset-link:hover{filter:none}
|
|
1148
|
+
.sset-ctx-form{display:flex;flex-direction:column;gap:8px;margin:0 0 0 74px}
|
|
1149
|
+
.sset-anyway{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px}
|
|
1150
|
+
.sset-rule{width:100%;margin:0;border:0;border-top:1px solid var(--border)}
|
|
1151
|
+
.sset-review{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}
|
|
1152
|
+
.sset-review>.sset-link:first-child{margin-left:-4px}
|
|
1153
|
+
.sset-review-set{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-size:12.5px}
|
|
1154
|
+
.sset-back{margin:0 2px 0 -6px;padding:0 6px;border:0;border-radius:5px;background:transparent;color:var(--text-muted);font-size:18px;line-height:1}
|
|
1155
|
+
.sset-back:hover{filter:none}
|
|
1145
1156
|
.start-settings.sheet{border-radius:14px;padding:14px 16px}
|
|
1146
|
-
|
|
1147
|
-
|
|
1157
|
+
/* Tints only where a pointer hovers: on a touch screen a tapped control would keep it (the ⚙/🧰 panels' rows and links;
|
|
1158
|
+
older popovers' :hover rules aren't gated yet). */
|
|
1159
|
+
@media(hover:hover){.sset-seg button:hover{color:var(--text);border-color:var(--accent-border)}.sset-link:hover{background:var(--accent-bg)}.sset-back:hover{color:var(--text);background:var(--bg-hover)}.st-row:hover{color:var(--text);background:var(--bg-hover)}}
|
|
1160
|
+
@media(max-width:400px){.sset-row{flex-direction:column;gap:4px}.sset-label{flex:none;line-height:1.4}.sset-ctx-more,.sset-ctx-form{margin-left:0}}
|
|
1161
|
+
@media(pointer:coarse){.sset-seg button{height:36px;min-height:36px;line-height:34px;padding:0 12px;font-size:13px}.sset-link,.sset-back{min-height:36px}.sset-label{line-height:36px}}
|
|
1148
1162
|
/* The start page's 🧰 list (start_toolkit.js): a row per kit (a check, its name, a muted detail), the checked one accent; a bottom sheet on a phone. */
|
|
1149
1163
|
.start-toolkit{gap:8px}
|
|
1150
1164
|
.start-toolkit .ctx-pop-head strong{font-family:inherit}
|
|
1151
1165
|
.st-rows{display:flex;flex-direction:column;gap:2px;margin:0 -6px}
|
|
1152
1166
|
.st-row{display:flex;align-items:baseline;gap:8px;width:100%;min-height:30px;padding:5px 8px 5px 6px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-2);font-size:13px;text-align:left}
|
|
1153
|
-
.st-row:hover{filter:none
|
|
1167
|
+
.st-row:hover{filter:none}
|
|
1154
1168
|
.st-row[aria-pressed="true"]{color:var(--accent);background:var(--accent-bg);border-color:var(--accent-border)}
|
|
1155
1169
|
.st-row:focus-visible{outline:2px solid var(--accent);outline-offset:-1px}
|
|
1156
1170
|
.st-check{flex:0 0 14px;text-align:center}
|
|
@@ -1239,6 +1253,8 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1239
1253
|
.ctx-pop-kind{font-size:11px;color:var(--text-muted)}
|
|
1240
1254
|
.ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
|
|
1241
1255
|
.ctx-pop-close:hover{color:var(--text)}
|
|
1256
|
+
/* A finger's × (the other sheet controls are 36-40 px): the negative margins keep the head's height. */
|
|
1257
|
+
@media(pointer:coarse){.ctx-pop-close{min-width:36px;min-height:36px;margin:-10px -10px -10px auto;font-size:18px}}
|
|
1242
1258
|
.ctx-pop-why{color:var(--text)}
|
|
1243
1259
|
.ctx-pop-hint{font-size:12px;color:var(--accent);word-break:break-all}
|
|
1244
1260
|
.ctx-pop-summary{padding:6px 8px;border-radius:8px;background:var(--bg-inset);border-left:3px solid var(--accent-border)}
|
|
@@ -1644,13 +1660,13 @@ a{color:var(--accent)}
|
|
|
1644
1660
|
<div class="actions-col">
|
|
1645
1661
|
<button type="button" id="modelPick" class="model-pick" title="The model the new session starts on" aria-label="Model for the new session" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
|
|
1646
1662
|
<div id="composeHint">Enter to start · Shift+Enter for a newline</div>
|
|
1647
|
-
<button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
|
|
1648
|
-
<button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
|
|
1649
|
-
<button type="button" id="reviewNew" class="llmctx-new review-new" aria-haspopup="dialog" hidden></button>
|
|
1650
|
-
<button type="button" id="toolkitNew" class="llmctx-new toolkit-new" aria-haspopup="dialog" hidden></button>
|
|
1663
|
+
<button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1664
|
+
<button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1665
|
+
<button type="button" id="reviewNew" class="llmctx-new review-new" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1666
|
+
<button type="button" id="toolkitNew" class="llmctx-new toolkit-new" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1651
1667
|
<button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1652
|
-
<button type="button" id="settingsNew" class="icon-btn settings-btn" title="Session settings" aria-label="Session settings" aria-haspopup="dialog" hidden></button>
|
|
1653
|
-
<button type="button" id="toolkitBtn" class="icon-btn toolkit-btn" title="Toolkit" aria-label="Toolkit" aria-haspopup="dialog" hidden></button>
|
|
1668
|
+
<button type="button" id="settingsNew" class="icon-btn settings-btn" title="Session settings" aria-label="Session settings" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1669
|
+
<button type="button" id="toolkitBtn" class="icon-btn toolkit-btn" title="Toolkit" aria-label="Toolkit" aria-haspopup="dialog" aria-expanded="false" hidden></button>
|
|
1654
1670
|
<button type="button" id="attachBtn" class="attach-btn" title="Add images (or paste or drop them here)" aria-label="Add images" hidden></button>
|
|
1655
1671
|
<input type="file" id="attachInput" accept="image/*" multiple hidden>
|
|
1656
1672
|
<div class="split">
|
|
@@ -142,10 +142,9 @@ export function nowText(summary) {
|
|
|
142
142
|
|
|
143
143
|
// The popover's form (a lit-html template). +now+ is the summary its "Now:"
|
|
144
144
|
// line reads (refresh passes a newer one, the rest stays as the form opened,
|
|
145
|
-
// so lit leaves what the user typed alone)
|
|
146
|
-
// The selects take their value as a property (.value), set after their
|
|
145
|
+
// so lit leaves what the user typed alone). The selects take their value as a property (.value), set after their
|
|
147
146
|
// options: +unset+ draws the options alone, the first pass renderForm makes.
|
|
148
|
-
export function formTemplate(summary, { running = false, now = summary,
|
|
147
|
+
export function formTemplate(summary, { running = false, now = summary, unset = false } = {}) {
|
|
149
148
|
const own = ownValues(summary);
|
|
150
149
|
const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
|
|
151
150
|
const when = "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
|
|
@@ -160,7 +159,6 @@ export function formTemplate(summary, { running = false, now = summary, error =
|
|
|
160
159
|
option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></span></label
|
|
161
160
|
><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
|
|
162
161
|
placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
|
|
163
|
-
><div class="ctx-pop-error llmctx-error" ?hidden=${!error}>${error || ""}</div
|
|
164
162
|
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
165
163
|
><div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button
|
|
166
164
|
><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
@@ -171,7 +169,7 @@ export function formTemplate(summary, { running = false, now = summary, error =
|
|
|
171
169
|
// values: lit sets an element's own bindings before its children, so a
|
|
172
170
|
// select's .value would land before its options exist. A later draw is
|
|
173
171
|
// one pass, and lit writes only what changed since the last (the "Now:"
|
|
174
|
-
// line
|
|
172
|
+
// line), never a value the user changed.
|
|
175
173
|
export function renderForm(target, summary, opts = {}) {
|
|
176
174
|
if (!target.firstElementChild) render(formTemplate(summary, { ...opts, unset: true }), target);
|
|
177
175
|
render(formTemplate(summary, opts), target);
|
|
@@ -192,7 +190,7 @@ export function createLlmContextControl({ el, summary, session = () => null, run
|
|
|
192
190
|
let shown = null;
|
|
193
191
|
const draw = (changes = {}) => {
|
|
194
192
|
shown = { ...shown, ...changes };
|
|
195
|
-
renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now
|
|
193
|
+
renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now });
|
|
196
194
|
};
|
|
197
195
|
|
|
198
196
|
// A refresh (a newer summary): an open form keeps what the user typed;
|
|
@@ -202,7 +200,7 @@ export function createLlmContextControl({ el, summary, session = () => null, run
|
|
|
202
200
|
if (!opening) return draw({ now: data });
|
|
203
201
|
initial = ownValues(data);
|
|
204
202
|
shown = { data, opts: { running: running() } };
|
|
205
|
-
draw({ now: data
|
|
203
|
+
draw({ now: data });
|
|
206
204
|
}
|
|
207
205
|
|
|
208
206
|
function submit(form) {
|
|
@@ -223,12 +221,6 @@ export function createLlmContextControl({ el, summary, session = () => null, run
|
|
|
223
221
|
id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
|
|
224
222
|
onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
|
|
225
223
|
});
|
|
226
|
-
// An edit makes an error the last Set showed stale.
|
|
227
|
-
const clearError = () => {
|
|
228
|
-
if (shown?.error) draw({ error: null });
|
|
229
|
-
};
|
|
230
|
-
shell.pop.addEventListener("input", clearError);
|
|
231
|
-
shell.pop.addEventListener("change", clearError);
|
|
232
224
|
|
|
233
225
|
return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
|
|
234
226
|
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { escapeHtml } from "./format.js";
|
|
24
24
|
import { copyText } from "./copy.js";
|
|
25
25
|
import { createRefMenu } from "./ref_menu.js";
|
|
26
|
+
import { revealInHistory } from "./history_reveal.js";
|
|
26
27
|
|
|
27
28
|
// The data attributes a ref's element carries (its tab stop comes from
|
|
28
29
|
// syncRefTabs, for the viewer).
|
|
@@ -416,10 +417,10 @@ export function showInTurn(target, { behavior = smoothScroll() } = {}) {
|
|
|
416
417
|
return showRow(row, { line: target.line, behavior });
|
|
417
418
|
}
|
|
418
419
|
|
|
419
|
-
// Shows a tool row: what hides it opened (its step, its
|
|
420
|
-
// view that hides its rows its own way opens on the row's
|
|
421
|
-
// event), its diff (or the read showing +line+) opened, scrolled
|
|
422
|
-
// line (else the row) and both flashed. The working set's step tags jump
|
|
420
|
+
// Shows a tool row: what hides it opened (history_reveal.js: its step, its
|
|
421
|
+
// turn's block; a view that hides its rows its own way opens on the row's
|
|
422
|
+
// ref:reveal event), its diff (or the read showing +line+) opened, scrolled
|
|
423
|
+
// to the line (else the row) and both flashed. The working set's step tags jump
|
|
423
424
|
// here. The row.
|
|
424
425
|
export function showRow(row, { line: lineNo = null, behavior = smoothScroll() } = {}) {
|
|
425
426
|
const view = row.ownerDocument.defaultView || globalThis;
|
|
@@ -427,9 +428,7 @@ export function showRow(row, { line: lineNo = null, behavior = smoothScroll() }
|
|
|
427
428
|
row.dispatchEvent(reveal);
|
|
428
429
|
const line = lineNo ? lineIn(row, lineNo) : null;
|
|
429
430
|
const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
|
|
430
|
-
|
|
431
|
-
details.open = true;
|
|
432
|
-
}
|
|
431
|
+
revealInHistory(line || fold || row);
|
|
433
432
|
if (fold) fold.open = true;
|
|
434
433
|
const scroll = () => (line || row).scrollIntoView?.({ block: "center", behavior });
|
|
435
434
|
if (reveal.detail?.until) reveal.detail.until.then(scroll);
|
|
@@ -41,7 +41,7 @@ export function reviewChipModel(subjects) {
|
|
|
41
41
|
// +back+: a ‹ before the heading (data-act="back": the start page's ⚙
|
|
42
42
|
// panel, start_settings.js, whose view this is).
|
|
43
43
|
export function reviewFormTemplate(subjects, { error = null, text = null, back = false } = {}) {
|
|
44
|
-
return html`<div class="ctx-pop-head">${back ? html`<button type="button" class="
|
|
44
|
+
return html`<div class="ctx-pop-head">${back ? html`<button type="button" class="sset-back" data-act="back"
|
|
45
45
|
aria-label="Back to the settings" title="Back to the settings">‹</button>` : nothing}<strong>Review</strong><span class="ctx-pop-kind">new session</span
|
|
46
46
|
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
47
47
|
<div class="ctx-pop-meta" id="reviewNewHelp">The files participants read beside the thread: project paths, relative to this folder,
|
|
@@ -90,9 +90,9 @@ export function createStartPage({
|
|
|
90
90
|
doc,
|
|
91
91
|
win,
|
|
92
92
|
});
|
|
93
|
-
thinkingEl.addEventListener("click", () => settings.toggle("main"));
|
|
94
|
-
llmContextEl.addEventListener("click", () => settings.toggle("main"));
|
|
95
|
-
reviewEl?.addEventListener("click", () => settings.toggle("review"));
|
|
93
|
+
thinkingEl.addEventListener("click", () => settings.toggle("main", thinkingEl));
|
|
94
|
+
llmContextEl.addEventListener("click", () => settings.toggle("main", llmContextEl));
|
|
95
|
+
reviewEl?.addEventListener("click", () => settings.toggle("review", reviewEl));
|
|
96
96
|
const toolkitList = toolkitBtnEl ? createStartToolkit({
|
|
97
97
|
el: toolkitBtnEl,
|
|
98
98
|
button: toolkitBtnEl,
|
|
@@ -104,7 +104,7 @@ export function createStartPage({
|
|
|
104
104
|
doc,
|
|
105
105
|
win,
|
|
106
106
|
}) : null;
|
|
107
|
-
toolkitEl?.addEventListener("click", () => toolkitList?.toggle());
|
|
107
|
+
toolkitEl?.addEventListener("click", () => toolkitList?.toggle(toolkitEl));
|
|
108
108
|
// The override chips (only what is set) and ⚙: its dot and label say
|
|
109
109
|
// what is set; hidden with nothing to show (no model values, no review).
|
|
110
110
|
function renderOverrides() {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// high", "ctx stale", "review: README.md"); on a phone ⚙ carries a dot
|
|
11
11
|
// instead (settingsLabel()).
|
|
12
12
|
|
|
13
|
-
import { createChipPopover } from "./chip_popover.js";
|
|
13
|
+
import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
|
|
14
14
|
import { APPLIES, choiceWords, mergeChoice, ownValues, strategyValue } from "./llm_context_chip.js";
|
|
15
15
|
import { readSubjects, reviewChipModel, reviewFormTemplate } from "./review_start.js";
|
|
16
16
|
import { LEVELS } from "./thinking_chip.js";
|
|
@@ -92,8 +92,8 @@ export function settingsLabel(over) {
|
|
|
92
92
|
return { set: set.length > 0, label: set.length ? `${PANEL_LABEL}: ${set.map((o) => o.text).join(", ")}` : PANEL_LABEL };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
const segmentsTemplate = (name, label, segments, act) => html`<div class="
|
|
96
|
-
id=${`
|
|
95
|
+
const segmentsTemplate = (name, label, segments, act) => html`<div class="sset-row"><span class="sset-label"
|
|
96
|
+
id=${`sset-${name}-label`}>${label}</span><div class="sset-seg" role="group" aria-labelledby=${`sset-${name}-label`}>${
|
|
97
97
|
segments.map((s) => html`<button type="button" data-act=${`${act}:${s.value}`} aria-pressed=${s.pressed ? "true" : "false"}
|
|
98
98
|
title=${s.title || nothing}>${s.label}</button>`)}</div></div>`;
|
|
99
99
|
|
|
@@ -104,7 +104,7 @@ const option = (value, label, chosen) => html`<option value=${value} ?selected=$
|
|
|
104
104
|
function contextMoreTemplate(summary, choice, error) {
|
|
105
105
|
const own = ownValues({ own: choice });
|
|
106
106
|
const budget = summary.budget_tokens ? String(summary.budget_tokens) : "off";
|
|
107
|
-
return html`<form class="
|
|
107
|
+
return html`<form class="sset-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
|
|
108
108
|
name="apply">${option(DEFAULT, `${DEFAULT} (${summary.apply})`, own.apply)}${APPLIES.map((a) => option(a, a, own.apply))}</select
|
|
109
109
|
></span></label><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric"
|
|
110
110
|
autocomplete="off" placeholder=${`${DEFAULT} (${budget}): 64000, 64k or off`} .value=${own.budget}></label
|
|
@@ -113,8 +113,8 @@ function contextMoreTemplate(summary, choice, error) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// A choice whose row is hidden (the picked model has no values for it).
|
|
116
|
-
const sentAnywayTemplate = (text, act) => html`<div class="
|
|
117
|
-
<button type="button" class="
|
|
116
|
+
const sentAnywayTemplate = (text, act) => html`<div class="sset-anyway ctx-pop-meta">${text}
|
|
117
|
+
<button type="button" class="sset-link" data-act=${act}>Clear</button></div>`;
|
|
118
118
|
|
|
119
119
|
// The main view. +state+: {thinking: {summary, choice}, llmContext: {summary,
|
|
120
120
|
// choice}, review: subjects or null (no review row), more: the disclosure
|
|
@@ -128,15 +128,15 @@ export function panelTemplate({ thinking, llmContext, review = null, more = fals
|
|
|
128
128
|
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
129
129
|
>${think ? segmentsTemplate("think", "Thinking", think, "think") : nothing
|
|
130
130
|
}${!think && thinking.choice ? sentAnywayTemplate(`Thinking ${thinking.choice}: ${NO_THINKING}.`, "think-clear") : nothing
|
|
131
|
-
}${ctx ? html`${segmentsTemplate("ctx", "Context", ctx, "ctx")}<div class="
|
|
131
|
+
}${ctx ? html`${segmentsTemplate("ctx", "Context", ctx, "ctx")}<div class="sset-ctx-more"><button type="button" class="sset-link"
|
|
132
132
|
data-act="ctx-more" aria-expanded=${more ? "true" : "false"}>Apply and budget ${more ? "▾" : "▸"}</button></div
|
|
133
133
|
>${more ? contextMoreTemplate(llmContext.summary, llmContext.choice, error) : nothing}` : nothing
|
|
134
134
|
}${ctxAnyway ? sentAnywayTemplate(`Context ${ctxAnyway}: ${NO_LLM_CONTEXT}.`, "ctx-clear") : nothing
|
|
135
|
-
}${review ? html`${think || ctx ? html`<hr class="
|
|
136
|
-
? html`<span class="
|
|
137
|
-
><button type="button" class="
|
|
135
|
+
}${review ? html`${think || ctx ? html`<hr class="sset-rule">` : nothing}<div class="sset-review">${reviewChip
|
|
136
|
+
? html`<span class="sset-review-set" title=${reviewChip.title}>Review: ${reviewChip.text.replace(/^review: /, "")}</span
|
|
137
|
+
><button type="button" class="sset-link" data-act="review">Edit</button><button type="button" class="sset-link"
|
|
138
138
|
data-act="review-clear">Remove</button>`
|
|
139
|
-
: html`<button type="button" class="
|
|
139
|
+
: html`<button type="button" class="sset-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`
|
|
140
140
|
: nothing
|
|
141
141
|
}<div class="ctx-pop-meta">${NOTE}</div>`;
|
|
142
142
|
}
|
|
@@ -178,7 +178,7 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
|
|
|
178
178
|
function focusInside() {
|
|
179
179
|
const pop = shell.pop;
|
|
180
180
|
const at = view === "review" ? pop.querySelector("textarea")
|
|
181
|
-
: pop.querySelector(".
|
|
181
|
+
: pop.querySelector(".sset-seg [aria-pressed=true]") || pop.querySelector(".sset-link, .sset-seg button");
|
|
182
182
|
at?.focus();
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -203,10 +203,18 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
|
|
|
203
203
|
draw();
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
function clearContext() {
|
|
207
|
+
error = null;
|
|
208
|
+
llmContext.choose(null);
|
|
209
|
+
}
|
|
210
|
+
|
|
206
211
|
function act(name) {
|
|
207
212
|
const [what, value] = name.split(/:(.*)/s);
|
|
208
213
|
if (what === "think") thinking.choose(value === DEFAULT ? null : value);
|
|
209
214
|
else if (what === "think-clear") thinking.choose(null);
|
|
215
|
+
// "default" follows the model for the whole context, apply and budget
|
|
216
|
+
// too (as Thinking's does): the pressed segment and the chip agree.
|
|
217
|
+
else if (what === "ctx" && value === DEFAULT) clearContext();
|
|
210
218
|
else if (what === "ctx") return pickContext({ strategy: value });
|
|
211
219
|
else if (what === "ctx-clear") llmContext.choose(null);
|
|
212
220
|
else if (what === "ctx-more") more = !more;
|
|
@@ -225,7 +233,7 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
|
|
|
225
233
|
}
|
|
226
234
|
|
|
227
235
|
function submit(form) {
|
|
228
|
-
if (form.classList.contains("
|
|
236
|
+
if (form.classList.contains("sset-ctx-form")) {
|
|
229
237
|
return pickContext({ apply: form.elements.apply.value, budget: form.elements.budget.value });
|
|
230
238
|
}
|
|
231
239
|
const typed = form.elements.subjects?.value || "";
|
|
@@ -243,7 +251,9 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
|
|
|
243
251
|
|
|
244
252
|
const shell = createChipPopover({
|
|
245
253
|
id: "startSettingsPopover", className: "ctx-popover start-settings", width: 440, el, anchor: `#${button.id}`, openers,
|
|
246
|
-
|
|
254
|
+
// Taller than a plain sheet: the Apply and budget fields or the review
|
|
255
|
+
// form would push the note under the edge of a 60 % one.
|
|
256
|
+
summary: () => ({}), sheet: true, sheetShare: SHEET_TALL, expands: true, modal: true, label: PANEL_LABEL, doc, win,
|
|
247
257
|
show: (_data, opening) => {
|
|
248
258
|
if (opening) {
|
|
249
259
|
const choice = llmContext.choice();
|
|
@@ -263,26 +273,27 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
|
|
|
263
273
|
});
|
|
264
274
|
// An edit makes the context Set's error stale.
|
|
265
275
|
shell.pop.addEventListener("input", (e) => {
|
|
266
|
-
if (error && e.target.closest(".
|
|
276
|
+
if (error && e.target.closest(".sset-ctx-form")) {
|
|
267
277
|
error = null;
|
|
268
278
|
draw();
|
|
269
279
|
}
|
|
270
280
|
});
|
|
271
281
|
|
|
272
|
-
|
|
282
|
+
// +from+: the opener clicked (focus goes back to it), ⚙ by default.
|
|
283
|
+
function open(at = "main", from = button) {
|
|
273
284
|
view = review ? at : "main";
|
|
274
285
|
if (shell.isOpen()) {
|
|
275
286
|
draw();
|
|
276
287
|
focusInside();
|
|
277
288
|
} else {
|
|
278
|
-
shell.open(button);
|
|
289
|
+
shell.open(button, { from });
|
|
279
290
|
}
|
|
280
291
|
}
|
|
281
292
|
|
|
282
293
|
return {
|
|
283
294
|
open,
|
|
284
295
|
// An override chip's click: opens the panel (on +at+'s view), or closes it.
|
|
285
|
-
toggle: (at = "main") => (shell.isOpen() ? shell.close() : open(at)),
|
|
296
|
+
toggle: (at = "main", from = button) => (shell.isOpen() ? shell.close() : open(at, from)),
|
|
286
297
|
close: () => shell.close(),
|
|
287
298
|
isOpen: () => shell.isOpen(),
|
|
288
299
|
// The picked model changed (its default labels, which rows show).
|
|
@@ -109,7 +109,7 @@ export function createStartToolkit({ el, button, openers = `#${button.id}`, tool
|
|
|
109
109
|
|
|
110
110
|
const shell = createChipPopover({
|
|
111
111
|
id: "startToolkitPopover", className: "ctx-popover start-toolkit", width: 380, el, anchor: `#${button.id}`, openers,
|
|
112
|
-
summary: () => toolkits(), sheet: true, label: LIST_LABEL, doc, win,
|
|
112
|
+
summary: () => toolkits(), sheet: true, expands: true, modal: true, label: LIST_LABEL, doc, win,
|
|
113
113
|
show: () => draw(),
|
|
114
114
|
onOpen: focusInside,
|
|
115
115
|
onClose: () => { error = null; },
|
|
@@ -131,19 +131,20 @@ export function createStartToolkit({ el, button, openers = `#${button.id}`, tool
|
|
|
131
131
|
rows[Math.max(0, Math.min(rows.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)))].focus();
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// +from+: the opener clicked (focus goes back to it), 🧰 by default.
|
|
135
|
+
function open(from = button) {
|
|
135
136
|
if (shell.isOpen()) {
|
|
136
137
|
draw();
|
|
137
138
|
focusInside();
|
|
138
139
|
} else {
|
|
139
|
-
shell.open(button);
|
|
140
|
+
shell.open(button, { from });
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
return {
|
|
144
145
|
open,
|
|
145
146
|
// The override chip's click: opens the list, or closes it.
|
|
146
|
-
toggle: () => (shell.isOpen() ? shell.close() : open()),
|
|
147
|
+
toggle: (from = button) => (shell.isOpen() ? shell.close() : open(from)),
|
|
147
148
|
close: () => shell.close(),
|
|
148
149
|
isOpen: () => shell.isOpen(),
|
|
149
150
|
refresh: () => { if (shell.isOpen()) draw(); },
|
|
@@ -213,8 +213,12 @@ export function turnGroups(items, timing) {
|
|
|
213
213
|
// A turn that ended with no answer: its notice (the item's index) in
|
|
214
214
|
// the answer's place.
|
|
215
215
|
const ended = g.emptyAnswer == null ? group : { ...group, emptyAnswer: g.emptyAnswer };
|
|
216
|
+
// Its opening's turn id (a prompt's, or a wake turn's note's): its
|
|
217
|
+
// answer's key (data-answer-turn). A turn saved before turn ids keeps
|
|
218
|
+
// its shape.
|
|
219
|
+
const keyed = typeof g.turnId === "string" && g.turnId ? { ...ended, turnId: g.turnId } : ended;
|
|
216
220
|
// A turn with no merged lines keeps its shape.
|
|
217
|
-
return g.merged.length ? { ...
|
|
221
|
+
return g.merged.length ? { ...keyed, merged: g.merged } : keyed;
|
|
218
222
|
});
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -263,21 +263,27 @@ export function userBodyTemplate(content) {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
// An answer's bubble (+cls+: a report reply's; +at+: its message's index,
|
|
266
|
-
// data-answer-at
|
|
267
|
-
//
|
|
266
|
+
// data-answer-at; +turnId+: its turn's id, data-answer-turn, the key that
|
|
267
|
+
// stays; +replyKey+: a report reply's own key, data-reply-key; a hook's
|
|
268
|
+
// display shown in its place marks it data-answer-display): an
|
|
269
|
+
// assistant's rendered markdown (the server's html, sanitized there) set
|
|
270
|
+
// as it is, as the live turn end sets it
|
|
268
271
|
// (answer_refs.js applyFinalAnswer), else its text, set whole (no lit
|
|
269
272
|
// markers inside: the bubble's nodes are the live one's). Its markdown
|
|
270
273
|
// source is kept for the copy button when it shows rendered html.
|
|
271
|
-
export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
274
|
+
export function answerBubbleTemplate(message, { cls = "", at = null, turnId = null, replyKey = null } = {}) {
|
|
272
275
|
const rendered = message?.role === "assistant" && typeof message.html === "string";
|
|
273
276
|
const user = !rendered && message?.role === "user";
|
|
274
277
|
const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
|
|
275
278
|
const answerAt = at ?? nothing;
|
|
276
279
|
const source = rendered || user ? copySource(message) : nothing;
|
|
277
280
|
const title = timeTitle(message?.at);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
+
const turn = turnId || nothing;
|
|
282
|
+
const reply = replyKey || nothing;
|
|
283
|
+
const display = rendered && typeof message.display === "string" ? "1" : nothing;
|
|
284
|
+
if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-answer-turn=${turn} data-reply-key=${reply} data-answer-display=${display} data-copy-source=${source} .innerHTML=${message.html}></div>`;
|
|
285
|
+
if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-answer-turn=${turn} data-reply-key=${reply} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
|
|
286
|
+
return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-answer-turn=${turn} data-reply-key=${reply} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
// A context note's bubble (background the user or another session pushed
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A turn's elements in the history, as the page groups them: the siblings
|
|
2
|
+
// after its opening ([data-turn-id], the prompt bubble) up to the next
|
|
3
|
+
// opening, or a context note before it (a wake turn starts with one, and
|
|
4
|
+
// its bubble carries no turn id: what follows it isn't the turn before's).
|
|
5
|
+
// A gap divider just before that end belongs to the next group. One rule
|
|
6
|
+
// for every walker of a turn (verdict_card.js, verify_target.js).
|
|
7
|
+
//
|
|
8
|
+
// Pure DOM reads, so it runs under `node --test` with happy-dom. A module of
|
|
9
|
+
// its own: static JS is cached at unversioned URLs, and a new export on an
|
|
10
|
+
// old module would blank the page.
|
|
11
|
+
|
|
12
|
+
// The owner's page: a context note ends a turn. The participant page has
|
|
13
|
+
// none (its rule passes null).
|
|
14
|
+
export const NOTE_BOUNDARY = ".bubble.note";
|
|
15
|
+
|
|
16
|
+
// The elements of the turn +opening+ starts (the [data-turn-id] element, or
|
|
17
|
+
// one inside it), at +container+'s level (default: the opening's parent),
|
|
18
|
+
// in order; a +boundary+ element ends them too. [] when the opening isn't
|
|
19
|
+
// in +container+ or its turn has nothing after it.
|
|
20
|
+
export function turnElements(opening, { container = opening?.parentElement, boundary = NOTE_BOUNDARY } = {}) {
|
|
21
|
+
let top = opening;
|
|
22
|
+
while (top && top.parentElement !== container) top = top.parentElement;
|
|
23
|
+
if (!top) return [];
|
|
24
|
+
const elements = [];
|
|
25
|
+
for (let next = top.nextElementSibling; next; next = next.nextElementSibling) {
|
|
26
|
+
if (next.matches("[data-turn-id]") || next.querySelector("[data-turn-id]")) break;
|
|
27
|
+
if (boundary && next.matches(boundary)) break;
|
|
28
|
+
elements.push(next);
|
|
29
|
+
}
|
|
30
|
+
while (elements.at(-1)?.classList.contains("time-gap")) elements.pop();
|
|
31
|
+
return elements;
|
|
32
|
+
}
|
|
@@ -584,8 +584,9 @@ export function createTurnView({
|
|
|
584
584
|
// bubble is held at that height, scrolled to its tail, until answerReady()
|
|
585
585
|
// (the rendered markdown landed) or 1.5 s reveals it.
|
|
586
586
|
// +endNote+: the notice of a turn that ended with no answer (app.js
|
|
587
|
-
// builds it), placed where the answer bubble would go.
|
|
588
|
-
|
|
587
|
+
// builds it), placed where the answer bubble would go. +turnId+: a
|
|
588
|
+
// completed turn's id, the bubble's data-answer-turn as a reload's.
|
|
589
|
+
function promote(gen, { heldHeight = 0, endNote = null, turnId = null } = {}) {
|
|
589
590
|
const entry = genEls.get(gen);
|
|
590
591
|
// No text (an empty answer): no bubble.
|
|
591
592
|
let bubble = null;
|
|
@@ -593,6 +594,7 @@ export function createTurnView({
|
|
|
593
594
|
bubble = document.createElement("div");
|
|
594
595
|
bubble.className = "bubble output";
|
|
595
596
|
bubble.textContent = gen.text;
|
|
597
|
+
if (turnId) bubble.dataset.answerTurn = turnId;
|
|
596
598
|
}
|
|
597
599
|
// A hidden tab gets no pop: nobody sees it, and its timers are throttled.
|
|
598
600
|
const holding = useHold && heldHeight > 0 && !!bubble && !document.hidden;
|
|
@@ -638,8 +640,11 @@ export function createTurnView({
|
|
|
638
640
|
// Returns the cards that left the block (card.js leavesBlock), detached:
|
|
639
641
|
// app.js puts them after the turn's end line. +endNote+ (a completed turn
|
|
640
642
|
// with no answer): its notice, in the answer's place; left unplaced
|
|
641
|
-
// with no turn to end (app.js then shows it itself).
|
|
642
|
-
|
|
643
|
+
// with no turn to end (app.js then shows it itself). +turnId+: the
|
|
644
|
+
// turn's id (app.js: the live timing's, from turn_started), the key of a
|
|
645
|
+
// completed turn's answer; the bubble keeps it through the stage's
|
|
646
|
+
// hand-off (the same node moves).
|
|
647
|
+
function turnEnded({ kind, endNote = null, turnId = null }) {
|
|
643
648
|
// Once per turn: a second end (a re-dispatched event) would promote again.
|
|
644
649
|
if (!turn || turn.ended) return [];
|
|
645
650
|
const last = model.currentGen(turn);
|
|
@@ -656,7 +661,7 @@ export function createTurnView({
|
|
|
656
661
|
const heldHeight = box ? box.getBoundingClientRect().height : 0;
|
|
657
662
|
if (pendingSteers.length) flushSteers();
|
|
658
663
|
apply(model.turnEnded(turn));
|
|
659
|
-
if (promoting) promote(last, { heldHeight, endNote });
|
|
664
|
+
if (promoting) promote(last, { heldHeight, endNote, turnId: kind === "completed" ? turnId : null });
|
|
660
665
|
else if (endNote) placeAnswer([endNote], { plain: false, blockEl });
|
|
661
666
|
const kept = [];
|
|
662
667
|
if (blockEl) {
|
|
@@ -859,11 +864,13 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
859
864
|
: nothing;
|
|
860
865
|
};
|
|
861
866
|
// An answer's bubble; +at+, the message's index, is for its refs
|
|
862
|
-
// (data-answer-at, gone before the nodes are returned)
|
|
863
|
-
|
|
867
|
+
// (data-answer-at, gone before the nodes are returned); +turnId+, its
|
|
868
|
+
// turn's (data-answer-turn: a turn's answer only, not a report reply);
|
|
869
|
+
// +replyKey+, a report reply's (data-reply-key).
|
|
870
|
+
const answerBubble = (at, cls = "", turnId = null, replyKey = null) => answerBubbleTemplate(items[at], { cls, at, turnId, replyKey });
|
|
864
871
|
// The timing line after the bubble, not in it: where the live view
|
|
865
872
|
// leaves it at the turn's end.
|
|
866
|
-
const answerTemplate = (at, timing) => html`${answerBubble(at)}${timing}`;
|
|
873
|
+
const answerTemplate = (at, timing, turnId = null) => html`${answerBubble(at, "", turnId)}${timing}`;
|
|
867
874
|
// A user's line, labelled when chi brought it in (a delegate report). A
|
|
868
875
|
// `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
|
|
869
876
|
// as its command bubble, as live does — not as a user bubble.
|
|
@@ -873,12 +880,15 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
873
880
|
return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name, m?.participant_mode), ...opts });
|
|
874
881
|
};
|
|
875
882
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
876
|
-
// the turn's answer (shown anyway).
|
|
883
|
+
// the turn's answer (shown anyway). Its key (answer_fold.js): the turn's
|
|
884
|
+
// id and the report's place after the turn's opening (the history only
|
|
885
|
+
// grows); none in a turn saved before turn ids.
|
|
877
886
|
const reportReplyTemplate = (i, group) => {
|
|
878
887
|
if (items[i]?.source !== "delegate_report") return nothing;
|
|
879
888
|
const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
|
|
880
889
|
if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return nothing;
|
|
881
|
-
|
|
890
|
+
const replyKey = group.turnId ? `${group.turnId}:report:${i - group.user}` : null;
|
|
891
|
+
return answerBubble(j, " report-reply", null, replyKey);
|
|
882
892
|
};
|
|
883
893
|
const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
|
|
884
894
|
const parts = [];
|
|
@@ -944,7 +954,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
944
954
|
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
945
955
|
// timing after its prompt and steps, where the live view leaves it.
|
|
946
956
|
else if (group.answer === null) parts.push(timingTemplate(group));
|
|
947
|
-
else parts.push(answerTemplate(group.answer, timingTemplate(group)));
|
|
957
|
+
else parts.push(answerTemplate(group.answer, timingTemplate(group), group.turnId));
|
|
948
958
|
// A participant's review turn with a reply: Apply under it (the owner's
|
|
949
959
|
// page only: the participant page has its own view). Not under a pair
|
|
950
960
|
// turn: it acted itself.
|