samagotchi 0.136.1 → 0.138.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -7
  5. data/docs/context.md +7 -9
  6. data/docs/guardrails.md +12 -5
  7. data/docs/internals/engine.md +4 -4
  8. data/docs/internals/participants.md +2 -1
  9. data/docs/internals/web-frontend.md +19 -9
  10. data/docs/plugins.md +2 -2
  11. data/docs/sessions.md +8 -8
  12. data/docs/testing.md +2 -1
  13. data/lib/samagotchi/bridge.rb +4 -0
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/desktop/macos.rb +3 -2
  16. data/lib/samagotchi/engine.rb +6 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  19. data/lib/samagotchi/guardrails/mise_paths.rb +36 -0
  20. data/lib/samagotchi/guardrails/outside.rb +5 -5
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  22. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  23. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  24. data/lib/samagotchi/guardrails/review_paths.rb +3 -2
  25. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  26. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/message_display.rb +6 -3
  29. data/lib/samagotchi/web/participant_review.rb +2 -1
  30. data/lib/samagotchi/web/public/answer_fold.js +24 -6
  31. data/lib/samagotchi/web/public/app.js +24 -19
  32. data/lib/samagotchi/web/public/chains.js +6 -5
  33. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  34. data/lib/samagotchi/web/public/composer.css +15 -11
  35. data/lib/samagotchi/web/public/composer_grip.js +11 -13
  36. data/lib/samagotchi/web/public/composer_mount.js +2 -2
  37. data/lib/samagotchi/web/public/context_chips.js +29 -98
  38. data/lib/samagotchi/web/public/copy.js +8 -5
  39. data/lib/samagotchi/web/public/data.js +2 -0
  40. data/lib/samagotchi/web/public/families_view.js +30 -38
  41. data/lib/samagotchi/web/public/index.html +176 -132
  42. data/lib/samagotchi/web/public/info_bar.js +19 -8
  43. data/lib/samagotchi/web/public/info_bar_model.js +3 -1
  44. data/lib/samagotchi/web/public/lists_view.js +69 -42
  45. data/lib/samagotchi/web/public/participants_panel.js +4 -4
  46. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  47. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  48. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  49. data/lib/samagotchi/web/public/scope.js +3 -3
  50. data/lib/samagotchi/web/public/send_turn.js +4 -2
  51. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  52. data/lib/samagotchi/web/public/stage_view.js +51 -2
  53. data/lib/samagotchi/web/public/stale_params.js +26 -0
  54. data/lib/samagotchi/web/public/start_settings.js +1 -1
  55. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  56. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  57. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  58. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  59. data/lib/samagotchi/web/session_summary.rb +1 -1
  60. metadata +5 -1
@@ -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.138.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) => {
@@ -23,7 +23,6 @@ import {
23
23
  listParticipants, inviteParticipant, revokeParticipant, setParticipantCap,
24
24
  getContext,
25
25
  detachContext,
26
- addContext,
27
26
  renderText,
28
27
  getWorkingSet,
29
28
  updateChi,
@@ -122,6 +121,7 @@ import { createUpdateSheet } from "./update_sheet.js";
122
121
  import { createRename } from "./session_rename.js";
123
122
  import { createSendTurn } from "./send_turn.js";
124
123
  import { createMarkdownTip } from "./markdown_tip.js";
124
+ import { withoutStaleParams } from "./stale_params.js";
125
125
 
126
126
  // A link's #code= (behind an auth proxy): traded for the cookie before
127
127
  // anything else runs; the page reloads without it.
@@ -129,6 +129,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
129
129
  // someone sent to swap this browser's session for theirs), so ask first.
130
130
  if (fragmentCode(globalThis.location?.hash)
131
131
  && await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
132
+ // A query parameter the page no longer reads (?view=, the removed turn
133
+ // view's) leaves the address bar, so the next bookmark is clean.
134
+ const staleFree = withoutStaleParams(location.search);
135
+ if (staleFree !== null) history.replaceState(null, "", location.pathname + staleFree + location.hash);
132
136
  // Signed out (the proxy's session or chi's token): a banner, or one reload.
133
137
  const authLost = createAuthLost();
134
138
  onAuthLost(authLost);
@@ -151,7 +155,7 @@ const infoTextEl = $("#infoText");
151
155
  // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
152
156
  const contextChips = createContextChips({
153
157
  el: $("#contextChips"),
154
- api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
158
+ api: { list: listContext, show: getContext, detach: detachContext },
155
159
  toast: (text) => showToast(text),
156
160
  });
157
161
  // The open session's ⚙ chip in the info bar, and its panel (thinking,
@@ -221,10 +225,8 @@ let pageCapabilities = {};
221
225
  let pageEditorUrl = null;
222
226
  const promptEl = $("#prompt");
223
227
  const actionBtn = $("#actionBtn");
224
- const cancelBtn = $("#cancelBtn");
225
228
  const attachBtn = $("#attachBtn");
226
229
  const attachInput = $("#attachInput");
227
- const infoCancelBtn = $("#infoCancelBtn");
228
230
  const composeHintEl = $("#composeHint");
229
231
 
230
232
  // The open session (store.selected), its fields (store.current: session_state.js;
@@ -349,6 +351,8 @@ const turnDom = createStageView({
349
351
  renderMarkdown,
350
352
  linkRefs: linkRefsIn,
351
353
  workingSet,
354
+ // Stop turn in its status row (disabled until the request settles).
355
+ onCancel: () => handleCancel(),
352
356
  });
353
357
  // The history's bottom reserve and the toast's place follow the composer
354
358
  // card and the stage floating over the history (dock_reserve.js).
@@ -679,6 +683,7 @@ const lists = createListsView({
679
683
  showToast: (text, action) => showToast(text, action),
680
684
  allViewOpen: () => allViewOpen(),
681
685
  openAllView: () => openAllView(),
686
+ closeAllView: () => closeAllView(),
682
687
  leaveAllView: (hash) => leaveAllView(hash),
683
688
  renameShown,
684
689
  onRename: () => rename.open(),
@@ -844,6 +849,8 @@ function applyView() {
844
849
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
845
850
  }
846
851
  document.body.classList.toggle("all-view", open);
852
+ // The sessions pill is the view's button there: pressed while it is open.
853
+ $("#stripStatus").setAttribute("aria-pressed", String(open));
847
854
  updateSessionTitle();
848
855
  // Its chip was in the list just hidden (the strip, or all sessions).
849
856
  lists.chainPop.close();
@@ -861,7 +868,6 @@ window.addEventListener("hashchange", () => {
861
868
  applyView();
862
869
  selectFromHash();
863
870
  });
864
- $("#allBack").addEventListener("click", closeAllView);
865
871
 
866
872
  // A phone's composer fits a few words and has no Shift+Enter: the short
867
873
  // placeholder there.
@@ -893,9 +899,9 @@ function updateComposerMode() {
893
899
  // The start page's choice was for a new session: opening a session drops it.
894
900
  if (store.selected) startPage.dropChoices();
895
901
  const running = liveTurn.running && !!store.selected;
896
- cancelBtn.style.display = running ? "" : "none";
897
902
  // No images for a session a terminal owns (it takes no message from here);
898
- // on a phone none while a turn runs either: Stop turn needs the row's room.
903
+ // on a phone none while a turn runs either: the row keeps its room for the
904
+ // message box (steering).
899
905
  attachBtn.hidden = (!!store.selected && store.current.owner === "tui") || (running && narrowScreen.matches);
900
906
  syncDeliveryLine();
901
907
  if (running) {
@@ -937,13 +943,16 @@ function syncDeliveryLine() {
937
943
  }
938
944
 
939
945
  function setTurnRunning(running) {
946
+ // A running turn the stage held none of goes with it (runningWithoutTurn).
947
+ if (!running) turnDom.runningWithoutTurn?.(false);
940
948
  store.batch(() => {
941
949
  liveTurn.running = !!running;
942
950
  // A fresh link's picker goes now, not at the timing re-read after the turn.
943
951
  if (running && store.selected) store.update({ current: { turnStarted: true } });
944
- // liveTurn.running isn't the store's: the composer and the bar read it.
952
+ // liveTurn.running isn't the store's: the composer, the bar and the open ⚙ panel's note read it.
945
953
  store.touch("composer");
946
954
  store.touch("infoBar");
955
+ store.touch("settingsPanel");
947
956
  });
948
957
  }
949
958
 
@@ -1196,6 +1205,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1196
1205
  liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
1197
1206
  if (!data.current_turn) {
1198
1207
  setTurnRunning(store.current.status === "running");
1208
+ // Running with no turn to draw: the stage's status row still holds Stop turn.
1209
+ turnDom.runningWithoutTurn?.(liveTurn.running);
1199
1210
  if (liveTurn.running && store.current.timing.activeTurn?.started_at) liveTurn.timing.start(store.current.timing.activeTurn.started_at, activeTurnId);
1200
1211
  } else if (liveTurn.running) {
1201
1212
  // The replayed turn_started carries no id: the running turn's record's.
@@ -1493,6 +1504,8 @@ function updateInfoBar() {
1493
1504
  continueBtn.style.display = buttons.continue.shown ? "" : "none";
1494
1505
  continueBtn.disabled = buttons.continue.disabled;
1495
1506
  continueBtn.textContent = buttons.continue.text;
1507
+ // The accent on an archived session, where it is the thing to do.
1508
+ continueBtn.classList.toggle("accent", buttons.archive.archived);
1496
1509
  }
1497
1510
  if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
1498
1511
  if (archiveBtn) {
@@ -1860,18 +1873,14 @@ function queueRefused(altKey) {
1860
1873
  return true;
1861
1874
  }
1862
1875
 
1876
+ // The stage's Stop turn (its button stays disabled until this settles).
1863
1877
  async function handleCancel() {
1864
1878
  if (!store.selected) return;
1865
- cancelBtn.disabled = true;
1866
- infoCancelBtn.disabled = true;
1867
1879
  try {
1868
1880
  await cancelTurn(store.selected);
1869
1881
  } catch (e) {
1870
1882
  // The turn ended meanwhile: nothing left to cancel.
1871
1883
  if (e.code !== "not_running") alert(e.message);
1872
- } finally {
1873
- cancelBtn.disabled = false;
1874
- infoCancelBtn.disabled = false;
1875
1884
  }
1876
1885
  }
1877
1886
 
@@ -1895,7 +1904,7 @@ function promptMinPx() {
1895
1904
  // The composer (composer_mount.js): the box, its height, image chips, grip
1896
1905
  // and fold, ↑/↓ history and keys. The owner's parts come in as hooks: the
1897
1906
  // draft's origin (noteDraft), the / list's keys, the delivery line's Esc and
1898
- // queue, the info bar's Stop turn and drop while folded, typing anywhere.
1907
+ // queue, the info bar's drop while folded, typing anywhere.
1899
1908
  const composer = mountComposer({
1900
1909
  els: {
1901
1910
  composer: composerEl, prompt: promptEl, button: actionBtn, chips: $("#chips"), attachBtn, attachInput,
@@ -1916,11 +1925,9 @@ const composer = mountComposer({
1916
1925
  // Esc lets go of a choice armed on the delivery line.
1917
1926
  escape: () => deliveryControl.disarm(),
1918
1927
  fold: {
1919
- infoCancelBtn,
1920
1928
  infoBarEl: $("#infoBar"),
1921
1929
  session: () => store.selected,
1922
1930
  waiting: () => !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion()),
1923
- turnRunning: () => liveTurn.running,
1924
1931
  allViewOpen,
1925
1932
  lightboxOpen: () => !lightboxEl.hidden,
1926
1933
  },
@@ -1966,8 +1973,6 @@ lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
1966
1973
  document.addEventListener("keydown", (e) => {
1967
1974
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
1968
1975
  });
1969
- cancelBtn.addEventListener("click", () => handleCancel());
1970
- infoCancelBtn.addEventListener("click", () => handleCancel());
1971
1976
 
1972
1977
  // The / autocomplete (command_list.js): the session's commands while the
1973
1978
  // composer holds one "/word".
@@ -2189,7 +2194,7 @@ store.on("infoBar", updateInfoBar);
2189
2194
 
2190
2195
  applyView();
2191
2196
 
2192
- // Where sessions live: under the all-sessions search (the server fills it in).
2197
+ // Where sessions live: a footer line under the all-sessions list (the server fills it in).
2193
2198
  if (document.body.dataset.sessionsDir) {
2194
2199
  $("#allPath code").textContent = document.body.dataset.sessionsDir;
2195
2200
  $("#allPath").hidden = false;
@@ -9,7 +9,7 @@
9
9
  import { normalize, previewForCard, relativeTime } from "./format.js";
10
10
  import { stoppedBadge, waitingBadge } from "./sessions_list.js";
11
11
  import { waitingOn } from "./notify.js";
12
- import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_view.js";
12
+ import { archivedBadge, rowBadgesTemplate } from "./families_view.js";
13
13
  import { html, nothing } from "./vendor/lit-html.js";
14
14
 
15
15
  // The opener a new link's composer is filled with (never sent: the user
@@ -242,10 +242,11 @@ export function chainChipTemplate(f, { open = false } = {}) {
242
242
  }
243
243
 
244
244
  // The chain's list (the chip's popover): one row per other link, newest
245
- // first: status dot, "#N", its date (with its HH:MM when another link of
245
+ // first: "#N", its date (with its HH:MM when another link of
246
246
  // the chain, the head too, started the same day: chainStamp), its own title (/title) when it
247
247
  // differs from the head's (a link inherits its chain's: the card says it), else its recap's
248
- // first sentence, else its preview, the waiting / stopped badge, archived. A row's click opens
248
+ // first sentence, else its preview, the waiting / error / stopped badge, archived (no status dot: the status
249
+ // is in the row's tooltip, as a family row's). A row's click opens
249
250
  // that link (families_view.js's popover binds .family-row[data-id]).
250
251
  // @param activeId the open session's id (its row is .active)
251
252
  // A chain row's words: a name of its own (not the one it shares with the
@@ -270,9 +271,9 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
270
271
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
271
272
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
272
273
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
273
- title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${n}</span>${
274
+ title=${tip}><span class="cr-n">#${n}</span>${
274
275
  date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
275
- rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
276
+ rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
276
277
  });
277
278
  return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
278
279
  }
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  /* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
2
2
  (the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
3
3
  linked after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
4
- own parts stay in index.html: the card's placing (#dock), the delivery line's links, Stop turn, the start page's picker and chips,
4
+ own parts stay in index.html: the card's placing (#dock), the delivery line's links, the start page's picker and chips,
5
5
  the / list, and the 16px text fields for iOS. */
6
6
  /* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
7
7
  bar and all view's cards use it too. */
@@ -18,7 +18,7 @@ body.no-glass .composer-card{background:var(--bg-panel);-webkit-backdrop-filter:
18
18
  .composer-card-foot{padding:8px 14px 10px;border-top:1px solid var(--glass-border)}
19
19
  .composer-line{flex:1 0 100%;min-width:0;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
20
20
  #composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
21
- #composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
21
+ #composer .compose-col{position:relative;flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
22
22
  #composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
23
23
  /* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
24
24
  body.has-session #composer{flex-wrap:wrap;row-gap:6px}
@@ -66,24 +66,28 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
66
66
  #composeGrip::after{top:17px;opacity:1}
67
67
  }
68
68
  #composer textarea::placeholder{color:var(--text-faint)}
69
- /* Key hint: its line is always reserved, so focusing never shifts the layout. */
70
- #composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
71
- #composer:focus-within #composeHint:not(:empty){opacity:1}
69
+ /* Key hint (the start page's): a second faint line under the placeholder, inside the text box (over it, out of the
70
+ flow: it never moves the layout), only while the box is focused and empty; typing hides it. Not over image chips. */
71
+ #composeHint{position:absolute;left:2px;right:2px;top:calc(2px + 1.45em * 15 / 11);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);opacity:0;transition:opacity .12s ease;pointer-events:none;user-select:none}
72
+ #composer:focus-within #prompt:placeholder-shown ~ #composeHint:not(:empty){opacity:1}
73
+ #composer #chips:not([hidden]) ~ #composeHint{display:none}
72
74
  #composer textarea:focus{outline:none}
73
- /* The toolbar row: model picker · hint · Cancel · Send (the JS display toggles decide what shows). */
75
+ /* The toolbar row, two clusters: the new session's setup (model picker · ⚙ · 🧰 · the override chips) on the left, what
76
+ is sent (🖼 · Start / Send) on the right; the image button's auto margin is the gap (Send's own when it is hidden). */
74
77
  #composer .actions-col{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
75
78
  #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
76
- #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
79
+ #composer .actions-col .split{order:4;display:flex;gap:4px;margin-left:auto}
80
+ #composer .actions-col .attach-btn:not([hidden]) ~ .split{margin-left:0}
77
81
  #composer #actionBtn{min-width:64px;font-weight:600}
78
82
  /* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
79
83
  an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
80
- #composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
84
+ #composer .actions-col .attach-btn{order:3;margin-left:auto;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
81
85
  #composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
82
86
  #composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
83
87
  #composer .actions-col .attach-btn[hidden]{display:none}
84
- /* An icon button on the row (the start page's ⚙ and 🧰, start_settings.js and start_toolkit.js): quiet like the image button, its icon a mask in --icon; before it (order 2, earlier in the markup).
88
+ /* An icon button on the row (the start page's ⚙ and 🧰, start_settings.js and start_toolkit.js): quiet like the image button, its icon a mask in --icon; right after the model picker (order 1), before the override chips they set (order 2).
85
89
  .set: something under it is set, an accent dot on a phone (the override chips say it on a wider row). */
86
- #composer .actions-col .icon-btn{order:2;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
90
+ #composer .actions-col .icon-btn{order:1;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
87
91
  #composer .actions-col .icon-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
88
92
  #composer .actions-col .icon-btn:hover{filter:none}
89
93
  #composer .actions-col .icon-btn[aria-expanded="true"]{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
@@ -94,7 +98,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
94
98
  @media(max-width:600px){#composer .actions-col .icon-btn.set{color:var(--accent)}#composer .actions-col .icon-btn.set::after{content:"";position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--accent)}}
95
99
  @media(pointer:coarse){#composer .actions-col .icon-btn{width:36px;height:36px;min-height:36px}}
96
100
  /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
97
- and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
101
+ and 🖼 · Send at its bottom right. The key hint is in the placeholder there, so the hint line goes. */
98
102
  body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
99
103
  body.has-session #composer textarea{min-height:0}
100
104
  body.has-session #composeHint{display:none}
@@ -9,23 +9,22 @@ import { readStored, removeStored, writeStored } from "./storage.js";
9
9
  export const GRIP_KEY = "chi_composer_floor";
10
10
 
11
11
  // +session+: the open session's id, or null (the start page). +draft+,
12
- // +waiting+, +turnRunning+, +allViewOpen+, +lightboxOpen+: what the page
13
- // shows now. +imageFiles+ / +addImageFiles+: a drop's images and the chips.
12
+ // +waiting+, +allViewOpen+, +lightboxOpen+: what the page shows now.
13
+ // +imageFiles+ / +addImageFiles+: a drop's images and the chips.
14
14
  // The owner page's own parts (composer_mount.js passes them through):
15
- // +infoCancelBtn+ (Stop turn in the info bar while folded) and +infoBarEl+
16
- // (a file dropped on it while folded), either may be null; +typeAnywhere+:
17
- // typing on the page opens a folded composer.
15
+ // +infoBarEl+ (a file dropped on it while folded; may be null);
16
+ // +typeAnywhere+: typing on the page opens a folded composer. Stop turn
17
+ // isn't the composer's: it sits in the stage's status row (stage_view.js).
18
18
  export function createComposerGrip({
19
- composeGrip, promptEl, infoCancelBtn = null, infoBarEl = null,
19
+ composeGrip, promptEl, infoBarEl = null,
20
20
  session, fitPrompt, promptMinPx, floor, setFloor,
21
- draft, waiting = () => false, turnRunning = () => false, allViewOpen = () => false, lightboxOpen = () => false,
21
+ draft, waiting = () => false, allViewOpen = () => false, lightboxOpen = () => false,
22
22
  imageFiles, addImageFiles, typeAnywhere = true,
23
23
  doc = globalThis.document, win = globalThis.window,
24
24
  }) {
25
25
  // The composer's fold (composer_fold.js): the grip, which is the handle
26
- // while folded, and the info bar's Stop turn while folded (the composer's
27
- // #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
28
- // past the fold threshold.
26
+ // while folded. +gripWillFold+: the drag in progress is past the fold
27
+ // threshold.
29
28
  const gripStartTitle = composeGrip.title;
30
29
  let gripWillFold = false;
31
30
  // A click on the expanded grip folds GRIP_CLICK_FOLD_MS after it, so a
@@ -214,11 +213,10 @@ export function createComposerGrip({
214
213
  }
215
214
  promptEl.focus();
216
215
  }
217
- // The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
218
- // while folded. The start page's grip only resizes: no role, no fold state.
216
+ // The grip as the fold's handle (its title, marks, aria-expanded). The
217
+ // start page's grip only resizes: no role, no fold state.
219
218
  function drawFoldControls() {
220
219
  const folded = isComposerFolded();
221
- if (infoCancelBtn) infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
222
220
  if (!session()) {
223
221
  composeGrip.removeAttribute("role");
224
222
  composeGrip.removeAttribute("aria-expanded");
@@ -23,8 +23,8 @@ import { readStored } from "./storage.js";
23
23
  // +escape()+ Esc, true when it let go of something. +imagesOff+: why this
24
24
  // page takes no images (the participant page, until its upload path): a
25
25
  // paste, drop or pick of one toasts it and adds no chip; null takes them.
26
- // +fold+: createComposerGrip's page hooks (session, waiting, turnRunning,
27
- // allViewOpen, lightboxOpen, infoCancelBtn, infoBarEl, typeAnywhere).
26
+ // +fold+: createComposerGrip's page hooks (session, waiting, allViewOpen,
27
+ // lightboxOpen, infoBarEl, typeAnywhere).
28
28
  // @return {el, button, chips(), setChips(list), addImageFiles(files), fit(),
29
29
  // hasDraft(), refreshHistory(), and the grip's isComposerFolded,
30
30
  // openForVisit, focusPrompt, drawFoldControls, newVisit, decideVisit}