samagotchi 0.136.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -6
  5. data/docs/guardrails.md +12 -5
  6. data/docs/internals/engine.md +4 -4
  7. data/docs/internals/web-frontend.md +20 -10
  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 +44 -15
  27. data/lib/samagotchi/web/public/app.js +12 -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/details_motion.js +74 -0
  32. data/lib/samagotchi/web/public/families_view.js +15 -6
  33. data/lib/samagotchi/web/public/index.html +67 -49
  34. data/lib/samagotchi/web/public/info_bar.js +1 -1
  35. data/lib/samagotchi/web/public/lists_view.js +1 -0
  36. data/lib/samagotchi/web/public/participants_panel.js +1 -1
  37. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  38. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  39. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  40. data/lib/samagotchi/web/public/scope.js +3 -3
  41. data/lib/samagotchi/web/public/send_turn.js +4 -2
  42. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  43. data/lib/samagotchi/web/public/stale_params.js +26 -0
  44. data/lib/samagotchi/web/public/start_settings.js +1 -1
  45. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  46. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  47. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  48. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  49. metadata +5 -1
@@ -7,6 +7,36 @@ require_relative "../paths"
7
7
 
8
8
  module Samagotchi
9
9
  module Guardrails
10
+ # mise's file names, in one place for the boundaries that judge them:
11
+ # its local files hold secrets (ReviewPaths' SECRET_FILE / SECRET_PATH,
12
+ # PairBoundary's SECRET_TEXT) and its config runs at the owner's next
13
+ # worker start (PairCommand's mise config writes, ProjectEnv). Kept in
14
+ # this file: the testing kit loads it already (its file budget).
15
+ module MisePaths
16
+ # A local file's name: mise.local.toml, .mise.dev.local.toml (a
17
+ # Regexp source, to be embedded under the embedding pattern's flags).
18
+ LOCAL_FILE = '\.?mise(?:\.[\w-]+)?\.local\.toml'
19
+ # A local file in mise's config dirs: .config/mise/config.local.toml,
20
+ # config.<env>.local.toml; mise/ and .mise/ too (a Regexp source).
21
+ LOCAL_DIR_FILE = '(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml'
22
+
23
+ # mise's config files, by a path's end: mise.toml, .mise.toml,
24
+ # mise.<env>.toml, .config/mise.toml, anything under .config/mise/,
25
+ # mise/ or .mise/ config*.toml, and the .tool-versions it reads too.
26
+ CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
27
+ \.?mise/config[^/]*\.toml|\.tool-versions)\z}x
28
+ # The same files named in a command's or script's text.
29
+ CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml|
30
+ \.tool-versions(?![\w.-]))}x
31
+
32
+ # Whether +path+ is one of mise's config files (CONFIG_PATH), as
33
+ # given or as it resolves.
34
+ def self.config?(path)
35
+ path.to_s.match?(CONFIG_PATH) ||
36
+ (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(CONFIG_PATH) })
37
+ end
38
+ end
39
+
10
40
  # What a participant may read (the shared-sessions plan's §2): a file
11
41
  # under the session's project root that isn't on the read denylist
12
42
  # (chi's state and config dirs, home secret dirs, project secret files),
@@ -35,11 +65,11 @@ module Samagotchi
35
65
  # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
36
66
  # secrets file; and a git config (.git/config, a submodule's
37
67
  # .git/modules/x/config), checked apart.
38
- SECRET_FILE = /\A(?:\.env.*|\.?mise(?:\.[\w-]+)?\.local\.toml|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
68
+ SECRET_FILE = /\A(?:\.env.*|#{MisePaths::LOCAL_FILE}|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
39
69
  \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
40
70
  # mise's local config in its config dirs (.config/mise/config.local.toml,
41
71
  # config.<env>.local.toml; mise/ and .mise/ too): by the path's end.
42
- SECRET_PATH = %r{(?:\A|/)(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml\z}i
72
+ SECRET_PATH = %r{(?:\A|/)#{MisePaths::LOCAL_DIR_FILE}\z}i
43
73
  # The .docker/config.json kind: a dot dir's config holding tokens.
44
74
  SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
45
75
  GIT_DIR = ".git"
@@ -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.0"
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") ||
@@ -3,9 +3,11 @@
3
3
  // gets a fold row: a sibling just before the bubble, never inside it (the
4
4
  // bubble's copy text, its claims and a live end's innerHTML stay the
5
5
  // bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
6
- // no inline style); the row reads its first line and its size, "▸
7
- // ‹first line› · N lines", folded or not: unfolded it is the bubble's header,
8
- // flush on top of it, its bar the bubble's.
6
+ // no inline style) and the row is the answer's box closed: "▸ ‹first
7
+ // line› · N lines" in the bubble's shape; unfolded, the row is that box's
8
+ // top edge, "▾ N lines", the bubble its rest (CSS joins the two). The box
9
+ // never changes shape, a click changes its height, animated
10
+ // (details_motion.js animateHeight) unless the viewer prefers no motion.
9
11
  //
10
12
  // A delegate report (a user line chi brought in, data-source
11
13
  // "delegate_report") gets the same row, folded by default (it is written
@@ -14,6 +16,12 @@
14
16
  // lines". Its key: a merged one's data-line-key, a wake turn's opening
15
17
  // "<turn id>:prompt". A live merged report has no key until a reload.
16
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
+ //
17
25
  // Which answers are folded is this browser's choice (localStorage through
18
26
  // storage.js): one key per session holding the folded answers' keys and
19
27
  // the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
@@ -30,6 +38,7 @@
30
38
  // A module of its own: static JS is cached at unversioned URLs, and a new
31
39
  // export on an old module would blank the page.
32
40
  import { bubbleCopyText } from "./copy.js";
41
+ import { animateHeight, canAnimate } from "./details_motion.js";
33
42
  import { clip } from "./format.js";
34
43
  import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
35
44
 
@@ -43,9 +52,11 @@ export const FOLD_PREFIX = "chi_answer_fold:";
43
52
  const LABEL_CHARS = 160;
44
53
 
45
54
  const ROW_CLASS = "answer-fold";
46
- // 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).
47
57
  const REPORTS = '.bubble.user[data-source="delegate_report"]';
48
- const ANSWERS = `.bubble.output, ${REPORTS}`;
58
+ const PROMPTS = ".bubble.user:not([data-source])";
59
+ const ANSWERS = `.bubble.output, ${REPORTS}, ${PROMPTS}`;
49
60
  // Not done yet: still streaming.
50
61
  const UNFINISHED = ".streaming";
51
62
 
@@ -80,12 +91,13 @@ export function foldLabel(text, { display = false } = {}) {
80
91
  return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
81
92
  }
82
93
 
83
- // The row's words, folded or not: the label and the size (an open row is
84
- // the answer's header, as an open steps block keeps its summary).
85
- // +label+: the row's own (a report's, reportLabel), else the answer's.
86
- export function foldRowText(text, { display = false, label = foldLabel(text, { display }) } = {}) {
94
+ // The row's words: folded, the label and the size; unfolded, the size
95
+ // alone (the content is right under it). +label+: the row's own (a
96
+ // report's, reportLabel), else the answer's.
97
+ export function foldRowText(text, { display = false, folded = true, label = foldLabel(text, { display }) } = {}) {
87
98
  const n = lineCount(text);
88
99
  const size = `${n} line${n === 1 ? "" : "s"}`;
100
+ if (!folded) return { label: "", size };
89
101
  return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
90
102
  }
91
103
 
@@ -171,16 +183,21 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
171
183
  // ---- The rows ----
172
184
 
173
185
  const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
186
+ const isPrompt = (bubble) => !!bubble?.matches?.(PROMPTS);
174
187
 
175
188
  // The key an answer bubble is folded by, or null. A delegate report's: a
176
189
  // merged one's line key (data-line-key, "<turn id>:input:<place>"), one
177
190
  // that opened a wake turn its turn's id and ":prompt" (never the answer's
178
- // key, the bare id).
191
+ // key, the bare id). A prompt's: its turn's id and ":prompt".
179
192
  export function answerKey(bubble) {
180
193
  if (isReport(bubble)) {
181
194
  const turn = bubble.getAttribute("data-turn-id");
182
195
  return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
183
196
  }
197
+ if (isPrompt(bubble)) {
198
+ const turn = bubble.getAttribute("data-turn-id");
199
+ return turn ? `${turn}:prompt` : null;
200
+ }
184
201
  return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
185
202
  }
186
203
 
@@ -204,13 +221,17 @@ function drawRow(row, bubble) {
204
221
  const folded = bubble.classList.contains("folded");
205
222
  const text = answerText(bubble);
206
223
  const report = isReport(bubble);
224
+ const prompt = isPrompt(bubble);
207
225
  const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
208
- const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), ...own });
209
- 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";
210
230
  row.setAttribute("aria-expanded", folded ? "false" : "true");
211
231
  row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
212
232
  row.classList.toggle("folded", folded);
213
233
  row.classList.toggle("report", report);
234
+ row.classList.toggle("prompt", prompt);
214
235
  row.classList.toggle("steered", report && bubble.classList.contains("steered"));
215
236
  const [labelEl, sizeEl] = row.children;
216
237
  if (labelEl.textContent !== label) labelEl.textContent = label;
@@ -245,8 +266,9 @@ export function keepRowInView(scroller, row, mutate) {
245
266
  // @param historyEl the history (#history), also its scroller
246
267
  // @param sessionId () => the open session's id
247
268
  // @param projectRoot () => the open session's project root, or null
269
+ // @param motion (bubble) => whether a fold or unfold animates (canAnimate)
248
270
  // @return {decorate(root)} (the observer runs it; specs call it)
249
- export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
271
+ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null, motion = canAnimate }) {
250
272
  // Each bubble's row, and each row's bubble.
251
273
  const rows = new WeakMap();
252
274
  const bubbleOf = new WeakMap();
@@ -290,6 +312,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
290
312
 
291
313
  // Synchronous (not a frame later, as copy buttons are): the row lands in
292
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.
293
316
  new MutationObserver((records) => {
294
317
  const roots = new Set();
295
318
  let settle = false;
@@ -309,7 +332,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
309
332
  }
310
333
  if (settle) dropOrphans();
311
334
  for (const el of roots) if (el.isConnected) decorate(el);
312
- }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
335
+ }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-turn-id"] });
313
336
  decorate(historyEl);
314
337
 
315
338
  historyEl.addEventListener("click", (e) => {
@@ -318,10 +341,16 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
318
341
  if (!bubble) return;
319
342
  e.preventDefault();
320
343
  const folded = !bubble.classList.contains("folded");
321
- keepRowInView(historyEl, row, () => {
344
+ const settle = () => keepRowInView(historyEl, row, () => {
322
345
  bubble.classList.toggle("folded", folded);
323
346
  drawRow(row, bubble);
324
347
  });
348
+ if (!motion(bubble)) settle();
349
+ else if (folded) animateHeight(bubble, bubble.offsetHeight, 0, { fade: true }).then(settle);
350
+ else {
351
+ settle();
352
+ animateHeight(bubble, 0, bubble.offsetHeight, { fade: true });
353
+ }
325
354
  // An answer's choice is its fold, a report's its unfold (D2: the default differs).
326
355
  if (isReport(bubble)) writeOpened(sessionId(), answerKey(bubble), !folded, { root: projectRoot() });
327
356
  else writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
@@ -72,6 +72,7 @@ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
72
72
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
73
73
  import { copyText, installCopy } from "./copy.js";
74
74
  import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
75
+ import { installDetailsMotion } from "./details_motion.js";
75
76
  import { answerLabel, installReviseStacks, reviseChipTemplate, reviseWorkerUnavailable } from "./revise_stack.js";
76
77
  import { createQuestionCards } from "./question_cards.js";
77
78
  import { splitSnapshotCards } from "./card.js";
@@ -121,6 +122,7 @@ import { createUpdateSheet } from "./update_sheet.js";
121
122
  import { createRename } from "./session_rename.js";
122
123
  import { createSendTurn } from "./send_turn.js";
123
124
  import { createMarkdownTip } from "./markdown_tip.js";
125
+ import { withoutStaleParams } from "./stale_params.js";
124
126
 
125
127
  // A link's #code= (behind an auth proxy): traded for the cookie before
126
128
  // anything else runs; the page reloads without it.
@@ -128,6 +130,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
128
130
  // someone sent to swap this browser's session for theirs), so ask first.
129
131
  if (fragmentCode(globalThis.location?.hash)
130
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);
131
137
  // Signed out (the proxy's session or chi's token): a banner, or one reload.
132
138
  const authLost = createAuthLost();
133
139
  onAuthLost(authLost);
@@ -940,9 +946,10 @@ function setTurnRunning(running) {
940
946
  liveTurn.running = !!running;
941
947
  // A fresh link's picker goes now, not at the timing re-read after the turn.
942
948
  if (running && store.selected) store.update({ current: { turnStarted: true } });
943
- // 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.
944
950
  store.touch("composer");
945
951
  store.touch("infoBar");
952
+ store.touch("settingsPanel");
946
953
  });
947
954
  }
948
955
 
@@ -2085,6 +2092,10 @@ historyEl.addEventListener("click", (e) => {
2085
2092
  });
2086
2093
  turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
2087
2094
  turnRoots().forEach((root) => installCopy(root));
2095
+ // A steps block, a thinking block and the recap open and close by hand
2096
+ // with their height animated (details_motion.js), in the history and on
2097
+ // the stage.
2098
+ installDetailsMotion({ root: document.body, selector: ".turn-work, .bubble.thinking, .bubble.recap" });
2088
2099
  // Long answers fold by hand (answer_fold.js), per viewer and session.
2089
2100
  installAnswerFold({
2090
2101
  historyEl,
@@ -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.