samagotchi 0.78.0 → 0.80.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. metadata +24 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.78.0"
4
+ VERSION = "0.80.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,7 +24,7 @@ import {
24
24
  } from "./data.js";
25
25
  import { createContextChips } from "./context_chips.js";
26
26
  import {
27
- chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
+ chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
28
28
  withChoice,
29
29
  } from "./llm_context_chip.js";
30
30
  import {
@@ -38,7 +38,7 @@ import { createShownTexts } from "./shown_texts.js";
38
38
  import { MODEL_KEY } from "./model_pick.js";
39
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
40
  import { createModelPicker } from "./model_picker.js";
41
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
42
  import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
@@ -56,7 +56,9 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
56
56
  import { copyText, installCopy } from "./copy.js";
57
57
  import { createQuestionCards } from "./question_cards.js";
58
58
  import { splitSnapshotCards } from "./card.js";
59
- import { GRIP_OPEN_PX, PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, gripFolds, promptHeight } from "./composer_size.js";
59
+ import { PROMPT_LINE_PX, PROMPT_MIN_PX, promptHeight } from "./composer_size.js";
60
+ import { GRIP_KEY, createComposerGrip } from "./composer_grip.js";
61
+ import { readStored, writeStored } from "./storage.js";
60
62
  import { createCommandList } from "./command_list.js";
61
63
  import { createPromptHistory } from "./prompt_history.js";
62
64
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -64,7 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
64
66
  import { html, nothing, render } from "./vendor/lit-html.js";
65
67
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
66
68
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
67
- import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
69
+ import { createStripGrip } from "./strip_grip.js";
68
70
  import { waitingOn } from "./notify.js";
69
71
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
70
72
  import { createNotifications } from "./notifications.js";
@@ -120,7 +122,7 @@ const llmContextControl = createLlmContextControl({
120
122
  run: (line) => runSessionCommand(line),
121
123
  });
122
124
  // The start page's LLM context chip, beside the model picker: the picked
123
- // model's values, and a choice for the next new chat only (sent with its
125
+ // model's values, and a choice for the next new session only (sent with its
124
126
  // create, then dropped; never remembered: a reload or opening a session
125
127
  // drops it too, a failed create keeps it).
126
128
  const llmContextNewEl = $("#llmContextNew");
@@ -129,7 +131,7 @@ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPi
129
131
  const llmContextNewControl = createLlmContextControl({
130
132
  el: llmContextNewEl,
131
133
  summary: newChatLlmSummary,
132
- kind: NEW_CHAT,
134
+ kind: NEW_SESSION,
133
135
  choice: () => newChatLlmContext,
134
136
  choose: (next) => {
135
137
  newChatLlmContext = next;
@@ -158,14 +160,14 @@ const reasoningHistoryControl = createReasoningHistoryControl({
158
160
  run: (line) => runSessionCommand(line),
159
161
  });
160
162
  // The start page's thinking chip: the picked model's level, and a level
161
- // for the next new chat only (sent with its create, then dropped).
163
+ // for the next new session only (sent with its create, then dropped).
162
164
  const thinkingNewEl = $("#thinkingNew");
163
165
  let newChatThinking = null;
164
166
  const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
165
167
  const thinkingNewControl = createThinkingControl({
166
168
  el: thinkingNewEl,
167
169
  summary: newChatThinkingSummary,
168
- kind: NEW_CHAT,
170
+ kind: NEW_SESSION,
169
171
  choice: () => newChatThinking,
170
172
  choose: (next) => {
171
173
  newChatThinking = next;
@@ -176,7 +178,7 @@ const thinkingNewControl = createThinkingControl({
176
178
  anchor: "#thinkingNew",
177
179
  });
178
180
  function renderNewChatThinking() {
179
- const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
181
+ const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
180
182
  thinkingNewEl.hidden = !chip;
181
183
  if (!chip) return thinkingNewControl.close();
182
184
  thinkingNewEl.textContent = chip.text;
@@ -185,7 +187,7 @@ function renderNewChatThinking() {
185
187
  }
186
188
  // Hidden without the model's values (the list not in, an older server).
187
189
  function renderNewChatLlmContext() {
188
- const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
190
+ const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
189
191
  llmContextNewEl.hidden = !chip;
190
192
  if (!chip) return llmContextNewControl.close();
191
193
  llmContextNewEl.textContent = chip.text;
@@ -204,19 +206,9 @@ let pageEditorUrl = null;
204
206
  const promptEl = $("#prompt");
205
207
  const actionBtn = $("#actionBtn");
206
208
  const cancelBtn = $("#cancelBtn");
207
- // The composer's fold (composer_fold.js): the grip, which is the handle
208
- // while folded, and the info bar's Stop turn while folded (the composer's
209
- // #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
210
- // past the fold threshold.
211
- const composeGrip = $("#composeGrip");
212
- const gripStartTitle = composeGrip.title;
213
- let gripWillFold = false;
209
+ const attachBtn = $("#attachBtn");
210
+ const attachInput = $("#attachInput");
214
211
  const infoCancelBtn = $("#infoCancelBtn");
215
- // This visit's state (a visit is one select() call): opened by the page
216
- // (a focus, a restored line), archived (decided once, from the visit's
217
- // first getSession answer).
218
- const newFoldVisit = () => ({ open: false, archived: false, decided: false });
219
- let foldVisit = newFoldVisit();
220
212
  const modelPickEl = $("#modelPick");
221
213
  const composeHintEl = $("#composeHint");
222
214
 
@@ -368,7 +360,7 @@ const sentIds = new Set();
368
360
  const earlyRestores = new Map();
369
361
 
370
362
  // The page's scope (scope.js): ?dir= is the folder a project view came
371
- // from (new chats start there); the server names its project, if any.
363
+ // from (new sessions start there); the server names its project, if any.
372
364
  const scope = {
373
365
  dir: scopeDir(location.search),
374
366
  projectName: document.body.dataset.projectName || null,
@@ -478,7 +470,7 @@ function followSelectedSession(sess) {
478
470
 
479
471
  // The header's scope chip: "<project> · all" in a project view (all links
480
472
  // to the same place without ?dir); "all projects" otherwise, with where a
481
- // new chat starts on the start page.
473
+ // new session starts on the start page.
482
474
  function renderScope() {
483
475
  const el = $("#scope");
484
476
  render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
@@ -511,7 +503,6 @@ const lists = createListsView({
511
503
  allViewOpen: () => allViewOpen(),
512
504
  openAllView: () => openAllView(),
513
505
  leaveAllView: (hash) => leaveAllView(hash),
514
- setStripHidden: (hidden) => setStripHidden(hidden),
515
506
  });
516
507
 
517
508
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
@@ -555,7 +546,7 @@ function closeAllView() {
555
546
  leaveAllView(sessionHash(selected));
556
547
  }
557
548
  }
558
- // Leave all sessions for a place of our own (a picked session, a new chat):
549
+ // Leave all sessions for a place of our own (a picked session, a new session):
559
550
  // replace its history entry, since history.back() would come back later
560
551
  // with the session the view was opened from.
561
552
  function leaveAllView(hash) {
@@ -631,7 +622,7 @@ function updateComposerMode() {
631
622
  syncModelPick();
632
623
  if (selected) {
633
624
  llmContextNewControl.close();
634
- // The start page's choice was for a new chat: opening a session drops it.
625
+ // The start page's choice was for a new session: opening a session drops it.
635
626
  if (newChatLlmContext) {
636
627
  newChatLlmContext = null;
637
628
  renderNewChatLlmContext();
@@ -644,6 +635,9 @@ function updateComposerMode() {
644
635
  }
645
636
  const running = liveTurn.running && !!selected;
646
637
  cancelBtn.style.display = running ? "" : "none";
638
+ // No images for a session a terminal owns (it takes no message from here);
639
+ // on a phone none while a turn runs either: Stop turn needs the row's room.
640
+ attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
647
641
  syncDeliveryLine();
648
642
  if (running) {
649
643
  setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
@@ -754,8 +748,7 @@ function clearSelection() {
754
748
  closeStream();
755
749
  setMarkdownWarning(null);
756
750
  setCapabilities(null, null);
757
- foldVisit = newFoldVisit();
758
- applyComposerFolded();
751
+ composerGrip.newVisit();
759
752
  updateComposerMode();
760
753
  updateInfoBar();
761
754
  focusPrompt();
@@ -791,8 +784,7 @@ async function select(id) {
791
784
  closeStream();
792
785
  resetSessionState();
793
786
  resetTurnView();
794
- foldVisit = newFoldVisit();
795
- applyComposerFolded();
787
+ composerGrip.newVisit();
796
788
  updateInfoBar();
797
789
  contextChips.load(id);
798
790
  updateComposerMode();
@@ -837,11 +829,7 @@ async function resync({ endText = null } = {}) {
837
829
  if (id !== selected) return;
838
830
  // An archived session opens folded: decided once per visit, from its
839
831
  // first answer (the list may not hold it yet, or ever under ?dir).
840
- if (!foldVisit.decided) {
841
- foldVisit.decided = true;
842
- foldVisit.archived = !!data.session?.archived;
843
- applyComposerFolded();
844
- }
832
+ composerGrip.decideVisit(data.session?.archived);
845
833
  const kept = keptCommandBubbles(historyEl, id);
846
834
  resetTurnView();
847
835
  renderSessionView(data, { kept });
@@ -1552,7 +1540,7 @@ async function handleCancel() {
1552
1540
 
1553
1541
  // The chi logo: back to the empty start, where the composer makes a new
1554
1542
  // session (from all sessions too).
1555
- // The typed draft stays in the composer: paste in a session, then + new chat
1543
+ // The typed draft stays in the composer: paste in a session, then + new session
1556
1544
  // starts from it (with the model picker). clearSelection leaves #prompt alone.
1557
1545
  $("#newBtn").addEventListener("click", () => {
1558
1546
  if (allViewOpen()) leaveAllView("");
@@ -1570,7 +1558,7 @@ $("#newBtn").addEventListener("click", () => {
1570
1558
  // tooltip; a route that fails leaves the picker hidden and the server's
1571
1559
  // default in charge. The search list itself is model_picker.js'; a pick
1572
1560
  // sends the focus to the message box.
1573
- const START_PICK_TITLE = "The model the new chat starts on";
1561
+ const START_PICK_TITLE = "The model the new session starts on";
1574
1562
  let startPickTitle = START_PICK_TITLE;
1575
1563
  // The fresh link the picker shows (null: the start page, or a session it
1576
1564
  // is hidden in), and a link pick's /model still in flight (a send waits).
@@ -1641,14 +1629,12 @@ function syncModelPick() {
1641
1629
  modelPickEl.setAttribute("aria-label", "Model this link starts on");
1642
1630
  } else {
1643
1631
  modelPickEl.title = startPickTitle;
1644
- modelPickEl.setAttribute("aria-label", "Model for the new chat");
1632
+ modelPickEl.setAttribute("aria-label", "Model for the new session");
1645
1633
  }
1646
1634
  }
1647
1635
  // Leaving a session: the start page's remembered model again (no fetch).
1648
1636
  function restoreStartPick() {
1649
- let stored = null;
1650
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1651
- modelPicker.restore(stored);
1637
+ modelPicker.restore(readStored(MODEL_KEY));
1652
1638
  renderNewChatLlmContext();
1653
1639
  renderNewChatThinking();
1654
1640
  }
@@ -1659,8 +1645,7 @@ async function loadModels() {
1659
1645
  } catch (_) {
1660
1646
  return;
1661
1647
  }
1662
- let stored = null;
1663
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1648
+ const stored = readStored(MODEL_KEY);
1664
1649
  if (!modelPicker.setModels(payload, stored)) return;
1665
1650
  startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1666
1651
  modelPickEl.hidden = false;
@@ -1700,9 +1685,14 @@ function clearChips() {
1700
1685
  setChips([]);
1701
1686
  }
1702
1687
 
1688
+ // A paste's, a drop's or the image button's files: the images the message
1689
+ // may still take become chips (images.js admitImages); a toast says why the
1690
+ // rest stayed out. Whether any went in.
1703
1691
  function addImageFiles(files) {
1704
- if (!files.length) return false;
1705
- for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1692
+ const { take, refused } = admitImages(files, chips.length);
1693
+ if (refused) showToast(refused);
1694
+ if (!take.length) return false;
1695
+ for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1706
1696
  renderChips();
1707
1697
  focusPrompt();
1708
1698
  return true;
@@ -1715,10 +1705,15 @@ chipsEl.addEventListener("click", (e) => {
1715
1705
  if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
1716
1706
  renderChips();
1717
1707
  });
1708
+ // The image button: the file picker's images join the chips. Its value is
1709
+ // cleared after each pick, so the same file can be picked again.
1710
+ attachBtn.addEventListener("click", () => attachInput.click());
1711
+ attachInput.addEventListener("change", () => {
1712
+ addImageFiles([...attachInput.files]);
1713
+ attachInput.value = "";
1714
+ });
1718
1715
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1719
- const GRIP_KEY = "chi_composer_floor";
1720
- let promptFloor = null;
1721
- try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
1716
+ let promptFloor = Number(readStored(GRIP_KEY)) || null;
1722
1717
 
1723
1718
  // The empty box: one line in a session, three on the start page.
1724
1719
  function promptMinPx() {
@@ -1733,95 +1728,6 @@ function fitPrompt() {
1733
1728
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
1734
1729
  }
1735
1730
 
1736
- // A drag down past the box folds the composer on release (a session only: the
1737
- // start page's grip only resizes); from folded, a pull up opens it and the box
1738
- // follows the pointer in the same gesture, a tap opens it and focuses the box.
1739
- // No click listener: a click still fires after pointerdown's preventDefault
1740
- // and would toggle twice.
1741
- composeGrip.addEventListener("pointerdown", (e) => {
1742
- e.preventDefault();
1743
- composeGrip.setPointerCapture(e.pointerId);
1744
- composeGrip.classList.add("dragging");
1745
- const tapY = e.clientY;
1746
- let startY = tapY;
1747
- let startH = promptEl.offsetHeight;
1748
- let opened = false;
1749
- const move = (ev) => {
1750
- if (selected && isComposerFolded()) {
1751
- if (startY - ev.clientY < GRIP_OPEN_PX) return;
1752
- setComposerFolded(false);
1753
- opened = true;
1754
- // Re-based after the unfold (its fitPrompt sized the box: the minimum
1755
- // when empty, the text's height with a draft).
1756
- startH = promptEl.offsetHeight;
1757
- startY = ev.clientY;
1758
- return;
1759
- }
1760
- const requested = startH + (startY - ev.clientY);
1761
- promptFloor = gripFloor(requested, window.innerHeight, promptMinPx());
1762
- fitPrompt();
1763
- const willFold = !!selected && gripFolds({ requestedPx: requested, shownPx: promptEl.offsetHeight });
1764
- if (willFold !== gripWillFold) {
1765
- gripWillFold = willFold;
1766
- composeGrip.classList.toggle("will-fold", willFold);
1767
- drawFoldControls();
1768
- }
1769
- };
1770
- const end = () => {
1771
- composeGrip.classList.remove("dragging");
1772
- composeGrip.removeEventListener("pointermove", move);
1773
- composeGrip.removeEventListener("pointerup", up);
1774
- composeGrip.removeEventListener("pointercancel", cancel);
1775
- const folds = gripWillFold;
1776
- gripWillFold = false;
1777
- composeGrip.classList.remove("will-fold");
1778
- return folds;
1779
- };
1780
- const up = (ev) => {
1781
- const folds = end();
1782
- if (folds) {
1783
- // The next opening starts at the minimum (or the text's height).
1784
- promptFloor = null;
1785
- try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1786
- setComposerFolded(true);
1787
- } else if (selected && isComposerFolded()) {
1788
- if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
1789
- setComposerFolded(false);
1790
- promptEl.focus();
1791
- }
1792
- } else {
1793
- try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
1794
- // A pull that opened the box leaves the focus in it, as a tap does.
1795
- if (opened) promptEl.focus();
1796
- }
1797
- drawFoldControls();
1798
- };
1799
- const cancel = () => {
1800
- end();
1801
- drawFoldControls();
1802
- };
1803
- composeGrip.addEventListener("pointermove", move);
1804
- composeGrip.addEventListener("pointerup", up);
1805
- composeGrip.addEventListener("pointercancel", cancel);
1806
- });
1807
- composeGrip.addEventListener("dblclick", () => {
1808
- if (selected && isComposerFolded()) return;
1809
- promptFloor = null;
1810
- try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1811
- fitPrompt();
1812
- });
1813
- // Enter or Space on the focused handle folds, or opens and focuses the box
1814
- // (a session only; preventDefault: Space would scroll the page).
1815
- composeGrip.addEventListener("keydown", (e) => {
1816
- if (!selected || (e.key !== "Enter" && e.key !== " ")) return;
1817
- e.preventDefault();
1818
- if (isComposerFolded()) {
1819
- setComposerFolded(false);
1820
- promptEl.focus();
1821
- } else {
1822
- setComposerFolded(true);
1823
- }
1824
- });
1825
1731
  promptEl.addEventListener("input", fitPrompt);
1826
1732
  window.addEventListener("resize", fitPrompt);
1827
1733
  fitPrompt();
@@ -1906,102 +1812,27 @@ document.addEventListener("keydown", (e) => {
1906
1812
  cancelBtn.addEventListener("click", () => handleCancel());
1907
1813
  infoCancelBtn.addEventListener("click", () => handleCancel());
1908
1814
 
1909
- // ── Composer fold (composer_fold.js) ───────────────────────────────────────
1910
- // Dragging the composer's grip down folds the session's composer away; the
1911
- // grip, now the handle on the info bar's top edge, pulled up or clicked, or
1912
- // typing on the page, brings it back. The user's own choice is saved per
1913
- // browser; an archived session opens folded for its visit, and expanding
1914
- // there saves nothing. The page's own focus or a restored line opens it
1915
- // for the visit only. The start page never folds.
1916
- function composerFoldSaved() {
1917
- try {
1918
- return localStorage.getItem(COMPOSER_FOLD_KEY) === "1";
1919
- } catch (_) {
1920
- return false;
1921
- }
1922
- }
1923
- function isComposerFolded() {
1924
- return !!selected && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
1925
- }
1926
- function applyComposerFolded() {
1927
- const folded = isComposerFolded();
1928
- const was = document.body.classList.contains("composer-folded");
1929
- document.body.classList.toggle("composer-folded", folded);
1930
- // Its height was measured while hidden.
1931
- if (was && !folded) fitPrompt();
1932
- drawFoldControls();
1933
- }
1934
- // The user's own fold or unfold (the grip, typing): saved, unless this
1935
- // is an archived visit (both are for the visit then).
1936
- function setComposerFolded(folded) {
1937
- if (foldVisit.archived) {
1938
- foldVisit.open = !folded;
1939
- } else {
1940
- try { localStorage.setItem(COMPOSER_FOLD_KEY, folded ? "1" : "0"); } catch (_) {}
1941
- foldVisit.open = false;
1942
- }
1943
- applyComposerFolded();
1944
- }
1945
- // The page opens it for this visit: nothing saved.
1946
- function openForVisit() {
1947
- if (!isComposerFolded()) return;
1948
- foldVisit.open = true;
1949
- applyComposerFolded();
1950
- }
1951
- // Every focus of the message box: in a session it opens a folded composer
1952
- // for the visit first (+open+ false: only when it shows already).
1953
- function focusPrompt({ open = true } = {}) {
1954
- if (selected && isComposerFolded()) {
1955
- if (!open) return;
1956
- openForVisit();
1957
- }
1958
- promptEl.focus();
1959
- }
1960
- // The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
1961
- // while folded. The start page's grip only resizes: no role, no fold state.
1962
- function drawFoldControls() {
1963
- const folded = isComposerFolded();
1964
- infoCancelBtn.style.display = folded && liveTurn.running ? "" : "none";
1965
- if (!selected) {
1966
- composeGrip.removeAttribute("role");
1967
- composeGrip.removeAttribute("aria-expanded");
1968
- composeGrip.title = gripStartTitle;
1969
- for (const c of ["folded", "draft", "waiting"]) composeGrip.classList.remove(c);
1970
- return;
1971
- }
1972
- const id = selected;
1973
- const waiting = folded && !!(waitingOn(allSessions.find((s) => s.id === id)) || questionCards.pendingQuestion());
1974
- const h = foldHandle({ folded, draft: !!(promptEl.value.trim() || chips.length), waiting });
1975
- composeGrip.title = gripWillFold ? "Release to hide the message box" : h.title;
1976
- composeGrip.setAttribute("role", "button");
1977
- composeGrip.setAttribute("aria-expanded", folded ? "false" : "true");
1978
- composeGrip.classList.toggle("folded", h.folded);
1979
- composeGrip.classList.toggle("draft", h.draft);
1980
- composeGrip.classList.toggle("waiting", h.waiting);
1981
- }
1982
- promptEl.addEventListener("input", () => drawFoldControls());
1983
- // Typing on the page opens a folded composer: the key lands in the box
1984
- // (the class goes before the focus, so the browser types it there).
1985
- document.addEventListener("keydown", (e) => {
1986
- if (!isComposerFolded()) return;
1987
- if (!opensOnKey(e, { active: document.activeElement, allView: allViewOpen(), lightbox: !lightboxEl.hidden })) return;
1988
- setComposerFolded(false);
1989
- promptEl.focus();
1990
- });
1991
- // A file dropped on the folded info bar: open (for the visit), then add it.
1992
- const infoBarEl = $("#infoBar");
1993
- infoBarEl.addEventListener("dragover", (e) => {
1994
- if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
1995
- e.preventDefault();
1996
- });
1997
- infoBarEl.addEventListener("drop", (e) => {
1998
- if (!isComposerFolded()) return;
1999
- const files = imageFiles(e.dataTransfer);
2000
- if (!files.length) return;
2001
- e.preventDefault();
2002
- openForVisit();
2003
- addImageFiles(files);
1815
+ // The composer's grip and fold (composer_grip.js).
1816
+ const composerGrip = createComposerGrip({
1817
+ composeGrip: $("#composeGrip"),
1818
+ promptEl,
1819
+ infoCancelBtn,
1820
+ infoBarEl: $("#infoBar"),
1821
+ session: () => selected,
1822
+ fitPrompt,
1823
+ promptMinPx,
1824
+ floor: () => promptFloor,
1825
+ setFloor: (px) => { promptFloor = px; },
1826
+ draft: () => !!(promptEl.value.trim() || chips.length),
1827
+ waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
1828
+ turnRunning: () => liveTurn.running,
1829
+ allViewOpen,
1830
+ lightboxOpen: () => !lightboxEl.hidden,
1831
+ imageFiles,
1832
+ addImageFiles,
2004
1833
  });
1834
+ // Bound here, not hoisted: no top-level code above this line may call them.
1835
+ const { openForVisit, focusPrompt, drawFoldControls } = composerGrip;
2005
1836
  // The / autocomplete (command_list.js): the session's commands while the
2006
1837
  // composer holds one "/word".
2007
1838
  const commandList = createCommandList({
@@ -2275,28 +2106,33 @@ const notifications = createNotifications({ button: $("#notifyBtn") });
2275
2106
  // The latest-sessions strip can be hidden for more chat room; the choice
2276
2107
  // stays per browser (it replaced zen mode, so a zen user starts hidden).
2277
2108
  function stripHiddenSaved() {
2278
- try {
2279
- const saved = localStorage.getItem("chi_strip_hidden");
2280
- return saved === null ? localStorage.getItem("chi_zen") === "1" : saved === "1";
2281
- } catch (_) {
2282
- return false;
2283
- }
2109
+ const saved = readStored("chi_strip_hidden");
2110
+ return saved === null ? readStored("chi_zen") === "1" : saved === "1";
2284
2111
  }
2285
2112
  // A short window hides the strip by itself, so the stage keeps room for a
2286
- // card that waits (its question and choices); the sessions pill brings it
2287
- // back until the window is tall again. Not saved: the saved choice is the
2288
- // user's own hide ▴ / show.
2113
+ // card that waits (its question and choices); the grip brings it back
2114
+ // until the window is tall again. Not saved: the saved choice is the
2115
+ // user's own hide or show.
2289
2116
  const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2290
2117
  let stripShownWhileShort = false;
2118
+ // The strip's grip (strip_grip.js): the handle on the edge between the strip
2119
+ // and the chat. It is given the hidden state and the toggle; it speaks its own
2120
+ // title and aria-expanded, refreshed below after every change.
2121
+ const stripGripRefresh = createStripGrip({
2122
+ gripEl: $("#stripGrip"),
2123
+ isHidden: () => document.body.classList.contains("strip-hidden"),
2124
+ setHidden: (hidden) => setStripHidden(hidden),
2125
+ });
2291
2126
  function applyStripHidden() {
2292
2127
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
2293
2128
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2294
2129
  }));
2130
+ stripGripRefresh();
2295
2131
  updateSessionTitle();
2296
2132
  }
2297
2133
  function setStripHidden(hidden) {
2298
2134
  if (hidden) lists.familyPop.close();
2299
- try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2135
+ writeStored("chi_strip_hidden", hidden ? "1" : "0");
2300
2136
  stripShownWhileShort = !hidden && shortWindow.matches;
2301
2137
  applyStripHidden();
2302
2138
  }
@@ -2305,8 +2141,6 @@ shortWindow.addEventListener("change", () => {
2305
2141
  if (!shortWindow.matches) stripShownWhileShort = false;
2306
2142
  applyStripHidden();
2307
2143
  });
2308
- $("#stripShow").addEventListener("click", () => setStripHidden(false));
2309
- $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2310
2144
 
2311
2145
  // Esc leaves select mode first, then the all-sessions view.
2312
2146
  document.addEventListener("keydown", (e) => {