samagotchi 0.136.1 → 0.137.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +12 -4
  5. data/docs/guardrails.md +12 -5
  6. data/docs/internals/engine.md +4 -4
  7. data/docs/internals/web-frontend.md +19 -9
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge.rb +4 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/desktop/macos.rb +3 -2
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  16. data/lib/samagotchi/guardrails/outside.rb +5 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  19. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  20. data/lib/samagotchi/guardrails/review_paths.rb +32 -2
  21. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  22. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/message_display.rb +6 -3
  25. data/lib/samagotchi/web/participant_review.rb +2 -1
  26. data/lib/samagotchi/web/public/answer_fold.js +24 -6
  27. data/lib/samagotchi/web/public/app.js +7 -1
  28. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  29. data/lib/samagotchi/web/public/context_chips.js +16 -6
  30. data/lib/samagotchi/web/public/copy.js +8 -5
  31. data/lib/samagotchi/web/public/families_view.js +15 -6
  32. data/lib/samagotchi/web/public/index.html +55 -35
  33. data/lib/samagotchi/web/public/info_bar.js +1 -1
  34. data/lib/samagotchi/web/public/lists_view.js +1 -0
  35. data/lib/samagotchi/web/public/participants_panel.js +1 -1
  36. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  37. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  38. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  39. data/lib/samagotchi/web/public/scope.js +3 -3
  40. data/lib/samagotchi/web/public/send_turn.js +4 -2
  41. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  42. data/lib/samagotchi/web/public/stale_params.js +26 -0
  43. data/lib/samagotchi/web/public/start_settings.js +1 -1
  44. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  45. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  46. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  47. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  48. metadata +4 -1
@@ -71,10 +71,6 @@ module Samagotchi
71
71
  end
72
72
  end
73
73
 
74
- # Whether +path+ is +root+ or in it, cased alike on a disk that folds
75
- # case (PathResolve.under?).
76
- def within?(path, root) = PathResolve.under?(path, root)
77
-
78
74
  # Follows the current dir and gives, per simple command, each word
79
75
  # with its path (nil when it can't be resolved). A word is opened the
80
76
  # kernel's way, so a `..` after a symlinked dir climbs out of its
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # ParentPolicy's user_only); System 1 may, only if the user lists the
24
24
  # rule by name in guardrails.system_one.rules, and never for the
25
25
  # chi-answer/chi-broadcast ones (SystemOne::SelfProtect::NEVER_AUTO).
26
- PROTECTED_RULES = %w[chi-config chi-home chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
26
+ PROTECTED_RULES = %w[chi-config chi-home chi-shim chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
27
27
  model-overlay-write chi-answer-piped chi-answer-http].freeze
28
28
  SCOPES = %w[once session repo rule].freeze
29
29
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.136.1"
4
+ VERSION = "0.137.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -87,7 +87,10 @@ module Samagotchi
87
87
  # show but that (a text-less step) is kept with empty content
88
88
  # @param cwd [String, nil] the session's working directory (the parts'
89
89
  # tool titles are relative to it)
90
- def messages_for_display(msgs, parts: false, cwd: nil)
90
+ # @param markdown [Boolean] false: no html for a delegate report or an
91
+ # answer (the participant review drops both and renders its replies
92
+ # from their redacted text, ParticipantReview#with_reply_html)
93
+ def messages_for_display(msgs, parts: false, cwd: nil, markdown: true)
91
94
  filtered = []
92
95
  list = Array(msgs)
93
96
  # The outputs' ✂ marks (LLM context edits), by id, for the parts.
@@ -145,7 +148,7 @@ module Samagotchi
145
148
  # A delegate report is the child's markdown: rendered as an
146
149
  # answer is (its copy source stays the text). Another user line
147
150
  # stays plain.
148
- if source == Samagotchi::Steer::DELEGATE_REPORT && @markdown_renderer.available?
151
+ if markdown && source == Samagotchi::Steer::DELEGATE_REPORT && @markdown_renderer.available?
149
152
  message[:html] = @markdown_renderer.render(report_markdown(stripped))
150
153
  end
151
154
  end
@@ -177,7 +180,7 @@ module Samagotchi
177
180
  shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
178
181
  message[:display] = shown unless shown.strip.empty?
179
182
  rendered = message[:display] || stripped
180
- message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
183
+ message[:html] = @markdown_renderer.render(rendered) if markdown && !rendered.empty? && @markdown_renderer.available?
181
184
  # The refs after_turn hooks attached (AnswerRefs), checked again:
182
185
  # a stored file is never trusted. Never raises.
183
186
  refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
@@ -65,7 +65,8 @@ module Samagotchi
65
65
  turn_snapshot = live && live["snapshot"]
66
66
  state = live && live["session_state_snapshot"]
67
67
  raw = live_or_saved(turn_snapshot, "messages") { session.messages }
68
- shown = messages_for_display(visible_replies(raw), parts: true, cwd: session.working_directory)
68
+ # No html here: ParticipantView drops a report and the owner's html; with_reply_html renders the redacted replies.
69
+ shown = messages_for_display(visible_replies(raw), parts: true, cwd: session.working_directory, markdown: false)
69
70
  current = turn_snapshot && ParticipantView.current_turn(turn_snapshot["current_turn"], viewer)
70
71
  automatic = automatic_turn_ids(id, session, shown, current, state)
71
72
  waiting = !!(current&.dig("waiting_for_owner") || turn_snapshot&.dig("continue_offer") ||
@@ -16,6 +16,12 @@
16
16
  // lines". Its key: a merged one's data-line-key, a wake turn's opening
17
17
  // "<turn id>:prompt". A live merged report has no key until a reload.
18
18
  //
19
+ // A long prompt (a user bubble with no data-source: typed, not brought in)
20
+ // gets the same row over the same threshold, open by default as an answer
21
+ // is, "▾ N lines" over it and "▸ ‹first line› · N lines" folded; its key is
22
+ // its turn's "<turn id>:prompt" (an opening only: a queued prompt, a steer
23
+ // has no turn id and no row), kept with the answers folded by hand.
24
+ //
19
25
  // Which answers are folded is this browser's choice (localStorage through
20
26
  // storage.js): one key per session holding the folded answers' keys and
21
27
  // the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
@@ -46,9 +52,11 @@ export const FOLD_PREFIX = "chi_answer_fold:";
46
52
  const LABEL_CHARS = 160;
47
53
 
48
54
  const ROW_CLASS = "answer-fold";
49
- // What gets a row: answers and delegate reports (user lines chi brought in).
55
+ // What gets a row: answers, delegate reports (user lines chi brought in)
56
+ // and the user's own prompts (no source).
50
57
  const REPORTS = '.bubble.user[data-source="delegate_report"]';
51
- const ANSWERS = `.bubble.output, ${REPORTS}`;
58
+ const PROMPTS = ".bubble.user:not([data-source])";
59
+ const ANSWERS = `.bubble.output, ${REPORTS}, ${PROMPTS}`;
52
60
  // Not done yet: still streaming.
53
61
  const UNFINISHED = ".streaming";
54
62
 
@@ -175,16 +183,21 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
175
183
  // ---- The rows ----
176
184
 
177
185
  const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
186
+ const isPrompt = (bubble) => !!bubble?.matches?.(PROMPTS);
178
187
 
179
188
  // The key an answer bubble is folded by, or null. A delegate report's: a
180
189
  // merged one's line key (data-line-key, "<turn id>:input:<place>"), one
181
190
  // that opened a wake turn its turn's id and ":prompt" (never the answer's
182
- // key, the bare id).
191
+ // key, the bare id). A prompt's: its turn's id and ":prompt".
183
192
  export function answerKey(bubble) {
184
193
  if (isReport(bubble)) {
185
194
  const turn = bubble.getAttribute("data-turn-id");
186
195
  return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
187
196
  }
197
+ if (isPrompt(bubble)) {
198
+ const turn = bubble.getAttribute("data-turn-id");
199
+ return turn ? `${turn}:prompt` : null;
200
+ }
188
201
  return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
189
202
  }
190
203
 
@@ -208,13 +221,17 @@ function drawRow(row, bubble) {
208
221
  const folded = bubble.classList.contains("folded");
209
222
  const text = answerText(bubble);
210
223
  const report = isReport(bubble);
224
+ const prompt = isPrompt(bubble);
211
225
  const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
212
- const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded, ...own });
213
- const what = report ? "report" : "answer";
226
+ // A prompt's label is its first line as typed (no heading picked).
227
+ const display = prompt || bubble.hasAttribute("data-answer-display");
228
+ const { label, size } = foldRowText(text, { display, folded, ...own });
229
+ const what = report ? "report" : prompt ? "message" : "answer";
214
230
  row.setAttribute("aria-expanded", folded ? "false" : "true");
215
231
  row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
216
232
  row.classList.toggle("folded", folded);
217
233
  row.classList.toggle("report", report);
234
+ row.classList.toggle("prompt", prompt);
218
235
  row.classList.toggle("steered", report && bubble.classList.contains("steered"));
219
236
  const [labelEl, sizeEl] = row.children;
220
237
  if (labelEl.textContent !== label) labelEl.textContent = label;
@@ -295,6 +312,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
295
312
 
296
313
  // Synchronous (not a frame later, as copy buttons are): the row lands in
297
314
  // the same frame as its bubble, so nothing below it shifts after a paint.
315
+ // data-turn-id: a live prompt's key comes at its turn's start.
298
316
  new MutationObserver((records) => {
299
317
  const roots = new Set();
300
318
  let settle = false;
@@ -314,7 +332,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
314
332
  }
315
333
  if (settle) dropOrphans();
316
334
  for (const el of roots) if (el.isConnected) decorate(el);
317
- }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
335
+ }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-turn-id"] });
318
336
  decorate(historyEl);
319
337
 
320
338
  historyEl.addEventListener("click", (e) => {
@@ -122,6 +122,7 @@ import { createUpdateSheet } from "./update_sheet.js";
122
122
  import { createRename } from "./session_rename.js";
123
123
  import { createSendTurn } from "./send_turn.js";
124
124
  import { createMarkdownTip } from "./markdown_tip.js";
125
+ import { withoutStaleParams } from "./stale_params.js";
125
126
 
126
127
  // A link's #code= (behind an auth proxy): traded for the cookie before
127
128
  // anything else runs; the page reloads without it.
@@ -129,6 +130,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
129
130
  // someone sent to swap this browser's session for theirs), so ask first.
130
131
  if (fragmentCode(globalThis.location?.hash)
131
132
  && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
133
+ // A query parameter the page no longer reads (?view=, the removed turn
134
+ // view's) leaves the address bar, so the next bookmark is clean.
135
+ const staleFree = withoutStaleParams(location.search);
136
+ if (staleFree !== null) history.replaceState(null, "", location.pathname + staleFree + location.hash);
132
137
  // Signed out (the proxy's session or chi's token): a banner, or one reload.
133
138
  const authLost = createAuthLost();
134
139
  onAuthLost(authLost);
@@ -941,9 +946,10 @@ function setTurnRunning(running) {
941
946
  liveTurn.running = !!running;
942
947
  // A fresh link's picker goes now, not at the timing re-read after the turn.
943
948
  if (running && store.selected) store.update({ current: { turnStarted: true } });
944
- // liveTurn.running isn't the store's: the composer and the bar read it.
949
+ // liveTurn.running isn't the store's: the composer, the bar and the open ⚙ panel's note read it.
945
950
  store.touch("composer");
946
951
  store.touch("infoBar");
952
+ store.touch("settingsPanel");
947
953
  });
948
954
  }
949
955
 
@@ -8,8 +8,10 @@
8
8
  // +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet. An
9
9
  // open one is placed again when the window (or a phone's visual viewport:
10
10
  // its keyboard) resizes.
11
+ // It is modal as every popover is (popover_modal.js).
11
12
 
12
13
  import { nothing, render } from "./vendor/lit-html.js";
14
+ import { focusBack, modalDialog } from "./popover_modal.js";
13
15
 
14
16
  const EDGE = 16;
15
17
  const GAP = 8;
@@ -48,13 +50,6 @@ export function sheetPlace({ innerWidth, innerHeight }, { share = SHEET_SHARE, v
48
50
  maxHeight: Math.round(height * share) };
49
51
  }
50
52
 
51
- const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
52
-
53
- // The elements Tab reaches in +root+, in order (none hidden or disabled).
54
- export function tabStops(root) {
55
- return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
56
- }
57
-
58
53
  // id, className: the popover's; width: its widest (px); el: where the chip
59
54
  // is drawn; anchor: the chip's selector; openers: the selector of every
60
55
  // element that opens it (a click there is not a click outside; default the
@@ -66,23 +61,19 @@ export function tabStops(root) {
66
61
  // sheet: a bottom sheet in a narrow window (sheetPlace). label: the
67
62
  // dialog's accessible name (aria-label), none by default. onOpen(): after
68
63
  // an open has shown and placed it (where focus can go into it: show runs
69
- // while it is still hidden). expands: every opener carries aria-expanded
70
- // ("true" while open, "false" once closed; set "false" in the markup too),
71
- // for openers that are fixed buttons (a lit-drawn chip owns its own).
72
- // modal: a dialog that holds the focus while open (aria-modal; Tab and
73
- // Shift+Tab wrap inside it, a Tab from outside goes in). sheetShare: the
74
- // share of a phone's window the sheet takes at most (SHEET_SHARE).
64
+ // while it is still hidden). sheetShare: the share of a phone's window the
65
+ // sheet takes at most (SHEET_SHARE). Always a modal dialog (modalDialog);
66
+ // the opener clicked (and the anchor it is placed at) carries aria-expanded
67
+ // "true" while it is open, "false" once closed: a lit-drawn chip writes
68
+ // aria-expanded="false" as a static attribute (lit leaves it to the shell).
75
69
  // @return {pop, open, close, refresh, isOpen, opener}
76
70
  export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
77
71
  session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {}, onOpen = () => {},
78
- sheet = false, label = null, expands = false, modal = false, sheetShare = SHEET_SHARE,
72
+ sheet = false, label = null, sheetShare = SHEET_SHARE,
79
73
  doc = document, win = window }) {
80
74
  const pop = doc.createElement("div");
81
75
  pop.id = id;
82
76
  pop.className = className;
83
- pop.setAttribute("role", "dialog");
84
- if (label) pop.setAttribute("aria-label", label);
85
- if (modal) pop.setAttribute("aria-modal", "true");
86
77
  pop.hidden = true;
87
78
  doc.body.appendChild(pop);
88
79
  // The session the open form is for, the element it is placed at, and the
@@ -90,9 +81,13 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
90
81
  let openFor = null;
91
82
  let opener = null;
92
83
  let returnTo = null;
84
+ // The openers marked aria-expanded="true" (the anchor and the one clicked).
85
+ let marked = [];
93
86
 
94
- const expanded = (value) => {
95
- if (expands) doc.querySelectorAll(openers).forEach((e) => e.setAttribute("aria-expanded", value));
87
+ const expanded = (els) => {
88
+ marked.forEach((e) => e.setAttribute("aria-expanded", "false"));
89
+ marked = [...new Set(els)].filter(Boolean);
90
+ marked.forEach((e) => e.setAttribute("aria-expanded", "true"));
96
91
  };
97
92
 
98
93
  function place(anchor) {
@@ -129,7 +124,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
129
124
  openFor = session();
130
125
  opener = anchor;
131
126
  returnTo = from;
132
- expanded("true");
127
+ expanded([anchor, from]);
133
128
  show(data, true);
134
129
  pop.hidden = false;
135
130
  place(anchor);
@@ -140,16 +135,16 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
140
135
  // edits are gone). Focus that was in it (or +refocus+: Esc) goes back to
141
136
  // its opener.
142
137
  function close({ refocus = false } = {}) {
143
- const had = refocus || pop.contains(doc.activeElement);
144
- const back = returnTo?.isConnected && !returnTo.hidden ? returnTo : opener;
145
- expanded("false");
138
+ const back = focusBack(pop, { refocus, doc });
139
+ const to = returnTo?.isConnected && !returnTo.hidden ? returnTo : opener;
140
+ expanded([]);
146
141
  pop.hidden = true;
147
142
  openFor = null;
148
143
  opener = null;
149
144
  returnTo = null;
150
145
  onClose();
151
146
  render(nothing, pop);
152
- if (had && back?.isConnected) back.focus?.();
147
+ back(to);
153
148
  }
154
149
 
155
150
  function isOpen() { return !pop.hidden; }
@@ -196,29 +191,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
196
191
  });
197
192
  doc.addEventListener("keydown", (e) => {
198
193
  if (e.key === "Escape" && isOpen()) close({ refocus: true });
199
- else if (e.key === "Tab" && modal && isOpen()) holdFocus(e);
200
194
  });
195
+ modalDialog(pop, { label, isOpen, doc });
201
196
  // A resize (a rotation, a phone's keyboard) places an open one again: a
202
197
  // sheet's height follows the window, a popover its chip.
203
198
  win.addEventListener?.("resize", replace);
204
199
  win.visualViewport?.addEventListener?.("resize", replace);
205
200
 
206
- // Tab past the last stop goes to the first, Shift+Tab before the first to
207
- // the last; a Tab from outside (focus lost to a redraw) goes in.
208
- function holdFocus(e) {
209
- const stops = tabStops(pop);
210
- if (!stops.length) return;
211
- const first = stops[0];
212
- const last = stops[stops.length - 1];
213
- const at = doc.activeElement;
214
- let to = null;
215
- if (!pop.contains(at)) to = e.shiftKey ? last : first;
216
- else if (e.shiftKey && at === first) to = last;
217
- else if (!e.shiftKey && at === last) to = first;
218
- if (!to) return;
219
- e.preventDefault();
220
- to.focus();
221
- }
222
-
223
201
  return { pop, open, close, refresh, isOpen, replace, opener: () => opener };
224
202
  }
@@ -10,6 +10,7 @@
10
10
  import { html, nothing, render } from "./vendor/lit-html.js";
11
11
  import { keepFocus } from "./keep_focus.js";
12
12
  import { popoverPlace } from "./chip_popover.js";
13
+ import { focusBack, modalDialog } from "./popover_modal.js";
13
14
  import { cantText } from "./format.js";
14
15
  import { createLatest, stillOn } from "./latest.js";
15
16
 
@@ -52,10 +53,10 @@ const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about
52
53
  // "+ URL" form is.
53
54
  export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
54
55
  return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
55
- data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
56
+ data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"} aria-expanded=${chip.name === open ? "true" : "false"}>${
56
57
  chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
57
58
  addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
58
- aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
59
+ aria-haspopup=${"dialog"} aria-expanded=${addOpen ? "true" : "false"}>+ URL</button>` : nothing}`;
59
60
  }
60
61
 
61
62
  // The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
@@ -101,7 +102,9 @@ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
101
102
  // The chips for the session bar. el: #contextChips; api: {list(id),
102
103
  // show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
103
104
  // Chips, popover and form are lit templates: a redraw (a refresh, the
104
- // minute's tick) keeps their nodes, so focus and typed text stay.
105
+ // minute's tick) keeps their nodes, so focus and typed text stay. The
106
+ // popover behaves as every popover does (popover_modal.js): a modal dialog, its chip's
107
+ // aria-expanded, Esc closes it and focus goes back to the chip.
105
108
  export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
106
109
  let sessionId = null;
107
110
  let rows = [];
@@ -116,9 +119,9 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
116
119
  const pop = doc.createElement("div");
117
120
  pop.id = "contextPopover";
118
121
  pop.className = "ctx-popover";
119
- pop.setAttribute("role", "dialog");
120
122
  pop.hidden = true;
121
123
  doc.body.appendChild(pop);
124
+ modalDialog(pop, { isOpen: () => openName !== null, doc });
122
125
 
123
126
  // openName ADD: the "+ URL" form is open.
124
127
  const ADD = "\u0000add";
@@ -208,6 +211,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
208
211
  render(nothing, pop);
209
212
  openName = name;
210
213
  text = null;
214
+ pop.setAttribute("aria-label", name);
211
215
  fill(row);
212
216
  pop.hidden = false;
213
217
  place(chip);
@@ -223,6 +227,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
223
227
  render(nothing, pop);
224
228
  openName = ADD;
225
229
  text = null;
230
+ pop.setAttribute("aria-label", "Attach a URL");
226
231
  addState = {};
227
232
  drawAdd();
228
233
  pop.hidden = false;
@@ -252,13 +257,18 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
252
257
  }
253
258
  }
254
259
 
255
- function close() {
260
+ // Focus that was in it (or +refocus+: Esc) goes back to its chip.
261
+ function close({ refocus = false } = {}) {
262
+ const back = focusBack(pop, { refocus, doc });
263
+ const name = openName;
256
264
  openName = null;
257
265
  text = null;
258
266
  addState = {};
259
267
  pop.hidden = true;
260
268
  render(nothing, pop);
261
269
  drawChips();
270
+ if (name !== null) back(name === ADD ? el.querySelector('.ctx-chip[data-act="add"]')
271
+ : [...el.querySelectorAll(".ctx-chip[data-name]")].find((chip) => chip.dataset.name === name));
262
272
  }
263
273
 
264
274
  async function viewText() {
@@ -318,7 +328,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
318
328
  if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
319
329
  });
320
330
  doc.addEventListener("keydown", (e) => {
321
- if (e.key === "Escape" && openName) close();
331
+ if (e.key === "Escape" && openName) close({ refocus: true });
322
332
  });
323
333
  win.addEventListener("resize", placeAtOpenChip);
324
334
  win.addEventListener("focus", () => refresh());
@@ -1,8 +1,9 @@
1
1
  // Copy buttons: one on every prompt and answer bubble, one on every code
2
- // block of a rendered answer. An answer copies its markdown source (the
3
- // saved message's `display` when a hook presented one, else its `content`,
4
- // kept on the bubble as data-copy-source when the page shows rendered
5
- // html), a prompt the text as typed, a code block just its code.
2
+ // block of a rendered answer or delegate report. An answer copies its
3
+ // markdown source (the saved message's `display` when a hook presented one,
4
+ // else its `content`, kept on the bubble as data-copy-source when the page
5
+ // shows rendered html), a prompt the text as typed, a code block just its
6
+ // code.
6
7
  //
7
8
  // The buttons carry no text (an icon; "copied ✓" is CSS content), so a
8
9
  // bubble's textContent, the turn output dedupe and an annotation's selected
@@ -78,7 +79,9 @@ export function showsAnswer(bubble, message, normalize) {
78
79
  const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>`;
79
80
 
80
81
  const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
81
- const CODE = ".bubble.output pre";
82
+ // An answer's code blocks and a delegate report's (a user bubble chi
83
+ // brought in, rendered as markdown; answer_fold.js REPORTS).
84
+ const CODE = '.bubble.output pre, .bubble.user[data-source="delegate_report"] pre';
82
85
 
83
86
  // The button as a lit template, for a templated part (a tool row's
84
87
  // command block, command_view.js); the same click handler answers it.
@@ -8,6 +8,7 @@ import { ownerBadge, previewForCard, relativeTime } from "./format.js";
8
8
  import { familySummary, stoppedBadge, waitingBadge } from "./sessions_list.js";
9
9
  import { html, nothing, render } from "./vendor/lit-html.js";
10
10
  import { keepFocus } from "./keep_focus.js";
11
+ import { focusBack, modalDialog } from "./popover_modal.js";
11
12
 
12
13
  // The chip in a parent's meta row: "▸ 3 delegates · ● 1 live · 1 waiting"
13
14
  // (a waiting one in the attention colour), its tooltip the delegates by
@@ -101,7 +102,11 @@ export function syncPulses(root) {
101
102
  // @param onPick (id) a row's click
102
103
  // @param onClose () after it closed (the strip's held order catches up)
103
104
  // @param id the element's id (a chain's popover, chains.js, is another one)
104
- export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
105
+ // @param label its accessible name
106
+ // It behaves as every popover does (popover_modal.js): a modal dialog (Tab wraps in
107
+ // its rows; a Tab from the chip goes in), the chip's aria-expanded, Esc
108
+ // closes it and focus goes back to the chip.
109
+ export function createFamilyPop({ id = "familyPop", label = "Delegates", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
105
110
  let el = null;
106
111
  let headId = null;
107
112
 
@@ -111,6 +116,7 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
111
116
  el.id = id;
112
117
  el.className = "family-pop";
113
118
  el.hidden = true;
119
+ modalDialog(el, { label, isOpen: () => headId != null, doc });
114
120
  el.addEventListener("click", (e) => {
115
121
  const row = e.target?.closest?.(".family-row[data-id]");
116
122
  if (row) onPick(row.dataset.id);
@@ -155,13 +161,18 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
155
161
  draw();
156
162
  }
157
163
 
158
- function close() {
164
+ // Focus that was in it (or +refocus+: Esc) goes back to the chip as
165
+ // drawn after onClose (a held strip catches up).
166
+ function close({ refocus = false } = {}) {
159
167
  if (!headId) return;
160
- anchorFor(headId)?.setAttribute("aria-expanded", "false");
168
+ const back = focusBack(el, { refocus, doc });
169
+ const id = headId;
170
+ anchorFor(id)?.setAttribute("aria-expanded", "false");
161
171
  headId = null;
162
172
  el.hidden = true;
163
173
  render(nothing, el);
164
174
  onClose();
175
+ back(anchorFor(id));
165
176
  }
166
177
 
167
178
  return {
@@ -181,12 +192,10 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
181
192
  // capture phase): true when it did.
182
193
  onKeydown(e) {
183
194
  if (!headId || e.key !== "Escape") return false;
184
- const id = headId;
185
195
  e.preventDefault();
186
196
  e.stopImmediatePropagation();
187
197
  e.stopPropagation();
188
- close();
189
- anchorFor(id)?.focus(); // the chip as drawn after close() (a held strip catches up)
198
+ close({ refocus: true });
190
199
  return true;
191
200
  },
192
201
  // A pointer down outside it and its chip closes it.