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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/cli.md +11 -0
  4. data/docs/configuration.md +8 -4
  5. data/docs/guardrails.md +102 -13
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/participants.md +6 -5
  8. data/docs/internals/tool-guardrails.md +2 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -2
  11. data/docs/testing.md +7 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/chi_home.rb +12 -2
  14. data/lib/samagotchi/desktop/macos.rb +19 -4
  15. data/lib/samagotchi/engine.rb +13 -0
  16. data/lib/samagotchi/guardrails/implicit_writers.rb +143 -0
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +6 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +493 -60
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +1 -1
  20. data/lib/samagotchi/guardrails/read_only_shell.rb +34 -2
  21. data/lib/samagotchi/guardrails/shell_git_dirs.rb +6 -1
  22. data/lib/samagotchi/guardrails/shell_lex.rb +23 -0
  23. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  24. data/lib/samagotchi/guardrails/targets.rb +8 -3
  25. data/lib/samagotchi/home_command.rb +22 -6
  26. data/lib/samagotchi/home_doctor.rb +36 -3
  27. data/lib/samagotchi/home_notice.rb +8 -1
  28. data/lib/samagotchi/hooks/loader.rb +5 -0
  29. data/lib/samagotchi/log_line.rb +12 -0
  30. data/lib/samagotchi/project_env.rb +3 -11
  31. data/lib/samagotchi/self_report.rb +3 -1
  32. data/lib/samagotchi/system_one/self_protect.rb +58 -2
  33. data/lib/samagotchi/testing/waiting.rb +13 -0
  34. data/lib/samagotchi/toolkit_binding.rb +16 -0
  35. data/lib/samagotchi/update_command.rb +7 -3
  36. data/lib/samagotchi/update_hint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  39. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  40. data/lib/samagotchi/web/public/app.js +18 -1
  41. data/lib/samagotchi/web/public/cards_place.js +5 -2
  42. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  43. data/lib/samagotchi/web/public/claim_link.js +4 -2
  44. data/lib/samagotchi/web/public/composer.css +3 -1
  45. data/lib/samagotchi/web/public/history_reveal.js +23 -0
  46. data/lib/samagotchi/web/public/index.html +44 -28
  47. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  48. data/lib/samagotchi/web/public/refs.js +6 -7
  49. data/lib/samagotchi/web/public/review_start.js +1 -1
  50. data/lib/samagotchi/web/public/start_page.js +4 -4
  51. data/lib/samagotchi/web/public/start_settings.js +29 -18
  52. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  53. data/lib/samagotchi/web/public/timing.js +5 -1
  54. data/lib/samagotchi/web/public/turn_html.js +12 -6
  55. data/lib/samagotchi/web/public/turn_range.js +32 -0
  56. data/lib/samagotchi/web/public/turn_view.js +21 -11
  57. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  58. data/lib/samagotchi/web/public/verify_target.js +7 -10
  59. 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
- .ss-row{display:flex;align-items:flex-start;gap:10px}
1128
- .ss-label{flex:0 0 64px;font-size:12px;line-height:26px;color:var(--text-muted)}
1129
- .ss-seg{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:4px}
1130
- .ss-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}
1131
- .ss-seg button:hover{filter:none;color:var(--text);border-color:var(--accent-border)}
1132
- .ss-seg button[aria-pressed="true"]{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
1133
- .ss-seg button:focus-visible,.ss-link:focus-visible,.ss-back:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
1134
- .ss-more{margin:-4px 0 0 74px}
1135
- .ss-link{padding:2px 4px;border:0;border-radius:5px;background:transparent;color:var(--accent);font-size:12px}
1136
- .ss-link:hover{filter:none;background:var(--accent-bg)}
1137
- .ss-ctx-form{display:flex;flex-direction:column;gap:8px;margin:0 0 0 74px}
1138
- .ss-anyway{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px}
1139
- .ss-rule{width:100%;margin:0;border:0;border-top:1px solid var(--border)}
1140
- .ss-review{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}
1141
- .ss-review>.ss-link:first-child{margin-left:-4px}
1142
- .ss-review-set{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-size:12.5px}
1143
- .ss-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}
1144
- .ss-back:hover{filter:none;color:var(--text);background:var(--bg-hover)}
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
- @media(max-width:400px){.ss-row{flex-direction:column;gap:4px}.ss-label{flex:none;line-height:1.4}.ss-more,.ss-ctx-form{margin-left:0}}
1147
- @media(pointer:coarse){.ss-seg button{height:36px;min-height:36px;line-height:34px;padding:0 12px;font-size:13px}.ss-link,.ss-back{min-height:36px}.ss-label{line-height:36px}}
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;color:var(--text);background:var(--bg-hover)}
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); +error+ a failed Set's message.
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, error = null, unset = false } = {}) {
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, the error), never a value the user changed.
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, error: shown.error });
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, error: null });
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 turn's block; a
420
- // view that hides its rows its own way opens on the row's ref:reveal
421
- // event), its diff (or the read showing +line+) opened, scrolled to the
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
- for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
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="ss-back" data-act="back"
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="ss-row"><span class="ss-label"
96
- id=${`ss-${name}-label`}>${label}</span><div class="ss-seg" role="group" aria-labelledby=${`ss-${name}-label`}>${
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="ss-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
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="ss-anyway ctx-pop-meta">${text}
117
- <button type="button" class="ss-link" data-act=${act}>Clear</button></div>`;
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="ss-more"><button type="button" class="ss-link"
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="ss-rule">` : nothing}<div class="ss-review">${reviewChip
136
- ? html`<span class="ss-review-set" title=${reviewChip.title}>Review: ${reviewChip.text.replace(/^review: /, "")}</span
137
- ><button type="button" class="ss-link" data-act="review">Edit</button><button type="button" class="ss-link"
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="ss-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`
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(".ss-seg [aria-pressed=true]") || pop.querySelector(".ss-link, .ss-seg button");
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("ss-ctx-form")) {
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
- summary: () => ({}), sheet: true, label: PANEL_LABEL, doc, win,
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(".ss-ctx-form")) {
276
+ if (error && e.target.closest(".sset-ctx-form")) {
267
277
  error = null;
268
278
  draw();
269
279
  }
270
280
  });
271
281
 
272
- function open(at = "main") {
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
- function open() {
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 ? { ...ended, merged: g.merged } : ended;
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): an assistant's rendered markdown (the server's html,
267
- // sanitized there) set as it is, as the live turn end sets it
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
- if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
279
- if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
280
- return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
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
- function promote(gen, { heldHeight = 0, endNote = null } = {}) {
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
- function turnEnded({ kind, endNote = null }) {
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
- const answerBubble = (at, cls = "") => answerBubbleTemplate(items[at], { cls, at });
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
- return answerBubble(j, " report-reply");
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.