samagotchi 0.78.0 → 0.79.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.
@@ -32,8 +32,10 @@ module Samagotchi
32
32
  TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
33
33
  # What Messages takes as a tool name.
34
34
  TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
35
+ # The content block types a model's thinking is replayed in.
36
+ THINKING_TYPES = %w[thinking redacted_thinking].freeze
35
37
  # Blocks that can't carry a cache_control breakpoint.
36
- UNMARKABLE = %w[thinking redacted_thinking].freeze
38
+ UNMARKABLE = THINKING_TYPES
37
39
 
38
40
  # A request's system blocks and messages.
39
41
  Translated = Data.define(:system, :messages)
@@ -105,6 +107,10 @@ module Samagotchi
105
107
  end
106
108
  end
107
109
 
110
+ # Whether +block+ is a thinking or redacted_thinking block (a Hash,
111
+ # string or symbol keys).
112
+ def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?(type_of(block))
113
+
108
114
  # +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
109
115
  # and tool_result: a valid id as it is, another with its invalid
110
116
  # characters replaced and a short hash of it appended (so "a.b" and
@@ -342,15 +342,6 @@ module Samagotchi
342
342
  nil
343
343
  end
344
344
 
345
- # The shape the engine persists: role and content (parts stay an
346
- # Array), plus a model turn's tool_calls and thinking (the host's
347
- # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
348
- # refs of a user message or a tool result, a prompt's turn_id (Engine,
349
- # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
350
- # (the live row's params line and label, never sent back), an
351
- # edit/write result's tool_diffs (never sent), an empty-answer
352
- # note's marker (TurnNote.empty, never sent), and an answer's display
353
- # and display_refs (AnswerDisplay, AnswerRefs; never sent).
354
345
  # Removes the replayed thinking blocks a response's ThinkingDrop
355
346
  # names (AnthropicWire.block_identity) from the model entries'
356
347
  # anthropic_content: the edit the provider made for that request, so
@@ -375,6 +366,15 @@ module Samagotchi
375
366
  removed
376
367
  end
377
368
 
369
+ # The shape the engine persists: role and content (parts stay an
370
+ # Array), plus a model turn's tool_calls and thinking (the host's
371
+ # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
372
+ # refs of a user message or a tool result, a prompt's turn_id (Engine,
373
+ # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
374
+ # (the live row's params line and label, never sent back), an
375
+ # edit/write result's tool_diffs (never sent), an empty-answer
376
+ # note's marker (TurnNote.empty, never sent), and an answer's display
377
+ # and display_refs (AnswerDisplay, AnswerRefs; never sent).
378
378
  def plain(conversation)
379
379
  conversation.map do |entry|
380
380
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -471,8 +471,6 @@ module Samagotchi
471
471
 
472
472
  def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
473
473
 
474
- THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
475
-
476
474
  # A model entry's saved blocks (anthropic_content) to send back
477
475
  # verbatim, for an adapter that replays them: nil when there are none,
478
476
  # for an interrupted entry (its partial blocks have no signature), and
@@ -485,7 +483,7 @@ module Samagotchi
485
483
  return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
486
484
  return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
487
485
 
488
- blocks = blocks.reject { |block| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
486
+ blocks = blocks.reject { |block| AnthropicWire.thinking_block?(block) } unless keep_thinking
489
487
  blocks.empty? ? nil : blocks
490
488
  end
491
489
 
@@ -519,6 +517,17 @@ module Samagotchi
519
517
  # The empty generations, never saved: the UIs draw the turn's
520
518
  # steps from them (ModelResult#empty_steps).
521
519
  @empty_steps = []
520
+ # Set as the run goes: the last response's refusal, the context
521
+ # window the last request was sized for, that request's text and
522
+ # image estimates, whether a refused reasoning is being asked
523
+ # again without it, and whether a preserved-thinking recovery
524
+ # settled nothing once already.
525
+ @refusal = nil
526
+ @window = nil
527
+ @request_chars = nil
528
+ @request_image_tokens = nil
529
+ @without_reasoning = false
530
+ @recovery_repeated = false
522
531
  @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
523
532
  loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
524
533
  emit: method(:emit))
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../log"
4
+
3
5
  module Samagotchi
4
6
  module LLM
5
7
  # A model a host lists. Unknown fields are nil.
@@ -51,7 +53,10 @@ module Samagotchi
51
53
 
52
54
  def init_model_listing(models_ttl)
53
55
  @models_ttl = models_ttl
56
+ @models_failure_ttl = [models_ttl, 60].min
54
57
  @models_mutex = Mutex.new
58
+ @models_failure_at = nil
59
+ @models_failure_logged_at = nil
55
60
  end
56
61
 
57
62
  # +model+'s entry in the cached list: its exact id, else any case.
@@ -93,16 +98,39 @@ module Samagotchi
93
98
  end
94
99
 
95
100
  def cached_models
101
+ now = monotonic
96
102
  @models_mutex.synchronize do
97
- fresh = @models_at && (monotonic - @models_at) < @models_ttl
103
+ fresh = @models_at && (now - @models_at) < @models_ttl
98
104
  return @models if fresh
105
+
106
+ failed_recently = @models_failure_at && (now - @models_failure_at) < @models_failure_ttl
107
+ return [] if failed_recently
99
108
  end
100
109
  models = list_models
101
110
  @models_mutex.synchronize do
102
111
  @models = models
103
112
  @models_at = monotonic
113
+ @models_failure_at = nil
104
114
  end
105
115
  models
116
+ rescue StandardError => e
117
+ @models_mutex.synchronize { @models_failure_at = monotonic }
118
+ log_model_listing_failure(e)
119
+ raise
120
+ end
121
+
122
+ def log_model_listing_failure(error)
123
+ now = monotonic
124
+ should_log = @models_mutex.synchronize do
125
+ next false if @models_failure_logged_at && (now - @models_failure_logged_at) < @models_ttl
126
+
127
+ @models_failure_logged_at = now
128
+ true
129
+ end
130
+ return unless should_log
131
+
132
+ host = @host_name.to_s.empty? ? @base_url : @host_name
133
+ Log.warn(:model, "list_failed", host: host, error: error.class.name, msg: error.message.to_s[0, 500])
106
134
  end
107
135
 
108
136
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -6,8 +6,9 @@ module Samagotchi
6
6
  # host's, HostRegistry keeps one per worker; ChatLoop: the worker's): a
7
7
  # +kind+ (a Symbol) for one model, or for the whole host when the model
8
8
  # is nil, with a value (true, or what the refusal named: an output cap).
9
- # The turn, side-ask and recap threads share a holder, so every read and
10
- # write takes the lock.
9
+ # The turn thread and the model-list reads share HostRegistry's adapter
10
+ # (an IdleClient's adapter is its own), so every read and write takes the
11
+ # lock.
11
12
  class RefusalMemo
12
13
  def initialize
13
14
  @mutex = Mutex.new
@@ -349,7 +349,7 @@ module Samagotchi
349
349
  @pending = pending = pending.with(relayed_to: relayed_to)
350
350
  if session
351
351
  session.pending_question = pending
352
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
352
+ save_session(session)
353
353
  end
354
354
  pending.id
355
355
  end
@@ -439,7 +439,7 @@ module Samagotchi
439
439
  end
440
440
  if session
441
441
  session.pending_question = pending
442
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
442
+ save_session(session)
443
443
  end
444
444
  event = { type: :question_requested, pending_question: pending.to_h }
445
445
  event[:standing] = true if standing
@@ -559,12 +559,19 @@ module Samagotchi
559
559
  def cancel_controller = @cancel_controller_lookup.call
560
560
  def emit(event) = @emit.call(event)
561
561
 
562
+ def save_session(session)
563
+ session.save(state_dir: state_dir)
564
+ rescue StandardError => e
565
+ Log.warn(:turn, "question_save_failed", sid: session.id, error: e.class.name, msg: e.message.to_s[0, 200])
566
+ nil
567
+ end
568
+
562
569
  # The session file no longer holds a pending question.
563
570
  def clear_saved_question
564
571
  return unless session
565
572
 
566
573
  session.pending_question = nil
567
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
574
+ save_session(session)
568
575
  end
569
576
  end
570
577
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "setting_explained"
5
+ require_relative "llm/anthropic_wire"
5
6
 
6
7
  module Samagotchi
7
8
  # Which of its own reasoning a chat host's model gets back
@@ -125,7 +126,7 @@ module Samagotchi
125
126
  next false unless entry[:role].to_s == "model"
126
127
 
127
128
  (entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
128
- Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
129
+ Array(entry[:anthropic_content]).any? { |block| LLM::AnthropicWire.thinking_block?(block) }
129
130
  end
130
131
  end
131
132
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.78.0"
4
+ VERSION = "0.79.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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,6 @@ 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";
68
69
  import { waitingOn } from "./notify.js";
69
70
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
70
71
  import { createNotifications } from "./notifications.js";
@@ -204,19 +205,7 @@ let pageEditorUrl = null;
204
205
  const promptEl = $("#prompt");
205
206
  const actionBtn = $("#actionBtn");
206
207
  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;
214
208
  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
209
  const modelPickEl = $("#modelPick");
221
210
  const composeHintEl = $("#composeHint");
222
211
 
@@ -754,8 +743,7 @@ function clearSelection() {
754
743
  closeStream();
755
744
  setMarkdownWarning(null);
756
745
  setCapabilities(null, null);
757
- foldVisit = newFoldVisit();
758
- applyComposerFolded();
746
+ composerGrip.newVisit();
759
747
  updateComposerMode();
760
748
  updateInfoBar();
761
749
  focusPrompt();
@@ -791,8 +779,7 @@ async function select(id) {
791
779
  closeStream();
792
780
  resetSessionState();
793
781
  resetTurnView();
794
- foldVisit = newFoldVisit();
795
- applyComposerFolded();
782
+ composerGrip.newVisit();
796
783
  updateInfoBar();
797
784
  contextChips.load(id);
798
785
  updateComposerMode();
@@ -837,11 +824,7 @@ async function resync({ endText = null } = {}) {
837
824
  if (id !== selected) return;
838
825
  // An archived session opens folded: decided once per visit, from its
839
826
  // 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
- }
827
+ composerGrip.decideVisit(data.session?.archived);
845
828
  const kept = keptCommandBubbles(historyEl, id);
846
829
  resetTurnView();
847
830
  renderSessionView(data, { kept });
@@ -1646,9 +1629,7 @@ function syncModelPick() {
1646
1629
  }
1647
1630
  // Leaving a session: the start page's remembered model again (no fetch).
1648
1631
  function restoreStartPick() {
1649
- let stored = null;
1650
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1651
- modelPicker.restore(stored);
1632
+ modelPicker.restore(readStored(MODEL_KEY));
1652
1633
  renderNewChatLlmContext();
1653
1634
  renderNewChatThinking();
1654
1635
  }
@@ -1659,8 +1640,7 @@ async function loadModels() {
1659
1640
  } catch (_) {
1660
1641
  return;
1661
1642
  }
1662
- let stored = null;
1663
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1643
+ const stored = readStored(MODEL_KEY);
1664
1644
  if (!modelPicker.setModels(payload, stored)) return;
1665
1645
  startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1666
1646
  modelPickEl.hidden = false;
@@ -1716,9 +1696,7 @@ chipsEl.addEventListener("click", (e) => {
1716
1696
  renderChips();
1717
1697
  });
1718
1698
  // ── 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 (_) {}
1699
+ let promptFloor = Number(readStored(GRIP_KEY)) || null;
1722
1700
 
1723
1701
  // The empty box: one line in a session, three on the start page.
1724
1702
  function promptMinPx() {
@@ -1733,95 +1711,6 @@ function fitPrompt() {
1733
1711
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
1734
1712
  }
1735
1713
 
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
1714
  promptEl.addEventListener("input", fitPrompt);
1826
1715
  window.addEventListener("resize", fitPrompt);
1827
1716
  fitPrompt();
@@ -1906,102 +1795,27 @@ document.addEventListener("keydown", (e) => {
1906
1795
  cancelBtn.addEventListener("click", () => handleCancel());
1907
1796
  infoCancelBtn.addEventListener("click", () => handleCancel());
1908
1797
 
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);
1798
+ // The composer's grip and fold (composer_grip.js).
1799
+ const composerGrip = createComposerGrip({
1800
+ composeGrip: $("#composeGrip"),
1801
+ promptEl,
1802
+ infoCancelBtn,
1803
+ infoBarEl: $("#infoBar"),
1804
+ session: () => selected,
1805
+ fitPrompt,
1806
+ promptMinPx,
1807
+ floor: () => promptFloor,
1808
+ setFloor: (px) => { promptFloor = px; },
1809
+ draft: () => !!(promptEl.value.trim() || chips.length),
1810
+ waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
1811
+ turnRunning: () => liveTurn.running,
1812
+ allViewOpen,
1813
+ lightboxOpen: () => !lightboxEl.hidden,
1814
+ imageFiles,
1815
+ addImageFiles,
2004
1816
  });
1817
+ // Bound here, not hoisted: no top-level code above this line may call them.
1818
+ const { openForVisit, focusPrompt, drawFoldControls } = composerGrip;
2005
1819
  // The / autocomplete (command_list.js): the session's commands while the
2006
1820
  // composer holds one "/word".
2007
1821
  const commandList = createCommandList({
@@ -2275,12 +2089,8 @@ const notifications = createNotifications({ button: $("#notifyBtn") });
2275
2089
  // The latest-sessions strip can be hidden for more chat room; the choice
2276
2090
  // stays per browser (it replaced zen mode, so a zen user starts hidden).
2277
2091
  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
- }
2092
+ const saved = readStored("chi_strip_hidden");
2093
+ return saved === null ? readStored("chi_zen") === "1" : saved === "1";
2284
2094
  }
2285
2095
  // A short window hides the strip by itself, so the stage keeps room for a
2286
2096
  // card that waits (its question and choices); the sessions pill brings it
@@ -2296,7 +2106,7 @@ function applyStripHidden() {
2296
2106
  }
2297
2107
  function setStripHidden(hidden) {
2298
2108
  if (hidden) lists.familyPop.close();
2299
- try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2109
+ writeStored("chi_strip_hidden", hidden ? "1" : "0");
2300
2110
  stripShownWhileShort = !hidden && shortWindow.matches;
2301
2111
  applyStripHidden();
2302
2112
  }