samagotchi 0.143.0 → 0.145.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/docs/bundle-authoring.md +1 -1
  4. data/docs/cli.md +21 -4
  5. data/docs/configuration.md +1 -1
  6. data/docs/guardrails.md +6 -3
  7. data/docs/hooks.md +5 -1
  8. data/docs/internals/bridge.md +2 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/internals/web-frontend.md +59 -2
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +3 -2
  14. data/docs/testing.md +1 -1
  15. data/lib/samagotchi/bridge/sse_writer.rb +11 -1
  16. data/lib/samagotchi/bundle_command/check.rb +13 -2
  17. data/lib/samagotchi/bundle_settings_check.rb +34 -1
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
  20. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
  25. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
  26. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
  30. data/lib/samagotchi/context_status.rb +1 -1
  31. data/lib/samagotchi/engine.rb +17 -7
  32. data/lib/samagotchi/kernel_loop.rb +7 -0
  33. data/lib/samagotchi/live_versions.rb +28 -2
  34. data/lib/samagotchi/llm/chat_loop.rb +5 -0
  35. data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
  36. data/lib/samagotchi/plugin/api.rb +2 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/peer.rb +1 -1
  39. data/lib/samagotchi/plugin_session_state.rb +6 -3
  40. data/lib/samagotchi/self_report.rb +15 -13
  41. data/lib/samagotchi/session_access/local.rb +7 -10
  42. data/lib/samagotchi/session_commands.rb +2 -1
  43. data/lib/samagotchi/system_one/answer.rb +8 -5
  44. data/lib/samagotchi/system_one/client.rb +17 -0
  45. data/lib/samagotchi/system_one/http_backend.rb +23 -10
  46. data/lib/samagotchi/testing/boundary.rb +76 -0
  47. data/lib/samagotchi/testing/env_not_shown.rb +17 -0
  48. data/lib/samagotchi/testing.rb +1 -0
  49. data/lib/samagotchi/update_command.rb +8 -7
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/annotate_bar.js +1 -1
  52. data/lib/samagotchi/web/public/app.js +15 -3
  53. data/lib/samagotchi/web/public/chip_popover.js +14 -4
  54. data/lib/samagotchi/web/public/composer_fold.js +5 -2
  55. data/lib/samagotchi/web/public/composer_grip.js +45 -2
  56. data/lib/samagotchi/web/public/copy.js +1 -1
  57. data/lib/samagotchi/web/public/index.html +79 -40
  58. data/lib/samagotchi/web/public/info_bar_model.js +6 -4
  59. data/lib/samagotchi/web/public/more_sheet.js +212 -0
  60. data/lib/samagotchi/web/public/question_cards.js +26 -3
  61. data/lib/samagotchi/web/public/ref_scan.js +4 -2
  62. data/lib/samagotchi/web/public/stage_frame.js +58 -13
  63. data/lib/samagotchi/web/public/stage_model.js +2 -1
  64. data/lib/samagotchi/web/public/stage_view.js +52 -20
  65. data/lib/samagotchi/web/public/turn_block.js +43 -9
  66. data/lib/samagotchi/worker.rb +21 -4
  67. metadata +3 -1
@@ -171,6 +171,82 @@ module Samagotchi
171
171
  name == entry.name || name.start_with?("#{entry.name}.", "#{entry.name}::")
172
172
  end
173
173
  end
174
+
175
+ # A reference this chi still has, read as a constant path and, when the
176
+ # source names one, the method on it. Anything else is a form we can't
177
+ # read, and #gone? answers "still there" for it.
178
+ NAME_AND_METHOD = /\A(?<name>Samagotchi(?:::[A-Z]\w*)+)(?:\.(?<method>[a-z_]\w*[?!]?))?\z/
179
+
180
+ # Every .rb of this chi's library. Shipped bundles' own code is not chi's
181
+ # internals: loading it here would run a bundle's plugin twice.
182
+ CORE_FILES = Dir.glob(File.join(LIB_DIR, "**", "*.rb"))
183
+ .reject { |file| file.start_with?(File.join(LIB_DIR, "samagotchi", "bundles") + File::SEPARATOR) }
184
+ .sort.freeze
185
+
186
+ # Whether this chi no longer has a reference #scan found: its constant
187
+ # does not resolve, or the object behind it has no method the name
188
+ # names. That is the one question `chi update` asks of a bundle it
189
+ # didn't write (Check with installed: true); a name that still resolves
190
+ # breaks nothing, so it gets no row.
191
+ #
192
+ # The trap: lib/samagotchi loads lazily — each command requires what it
193
+ # uses — so the running process may simply not have a name yet, and
194
+ # asking it directly would call that "gone". When a name does not
195
+ # resolve, #load_chi requires every file of this chi's library and asks
196
+ # again, so the answer does not depend on what happened to be loaded. It
197
+ # is lazy: a bundle reaching nothing internal, or reaching only names
198
+ # this process already has, never pays for it; and the load happens in
199
+ # the forked child, not in the update itself. A name that still fails
200
+ # after that is "gone" only if this chi's source defines it nowhere: a
201
+ # file the child could not require says nothing either way.
202
+ def gone?(reference)
203
+ match = NAME_AND_METHOD.match(reference)
204
+ return gone_require?(reference) if match.nil?
205
+ return false if resolves?(match[:name], match[:method])
206
+
207
+ load_chi
208
+ return false if resolves?(match[:name], match[:method])
209
+
210
+ !defined_in_source?(match[:name])
211
+ end
212
+
213
+ def resolves?(name, method)
214
+ object = Object.const_get(name)
215
+ method.nil? || object.respond_to?(method)
216
+ rescue NameError
217
+ false
218
+ end
219
+ private_class_method :resolves?
220
+
221
+ # A require of one of chi's files is gone only when the file is not
222
+ # there; a require_relative the scan accepted points at a file it found.
223
+ def gone_require?(reference)
224
+ match = %r{\Arequire "samagotchi(?:/([\w/]+))?"\z}.match(reference)
225
+ match ? !File.file?(File.join(LIB_DIR, "#{["samagotchi", match[1]].compact.join("/")}.rb")) : false
226
+ end
227
+ private_class_method :gone_require?
228
+
229
+ def load_chi
230
+ return if @chi_loaded
231
+
232
+ @chi_loaded = true
233
+ @unloadable = []
234
+ CORE_FILES.each do |file|
235
+ require file
236
+ rescue StandardError, ScriptError # ScriptError: a LoadError, as the kit's adapters give with no rspec installed
237
+ @unloadable << file
238
+ end
239
+ end
240
+ private_class_method :load_chi
241
+
242
+ # A name that failed to resolve is "gone" only when no file of this chi
243
+ # defines it: one the child could not require (a gem missing, say) says
244
+ # nothing about what chi has.
245
+ def defined_in_source?(path)
246
+ last = path.split("::").last
247
+ @unloadable.any? { |file| File.read(file).match?(/\b(?:module|class)\s+#{last}\b|\b#{last}\s*=/) }
248
+ end
249
+ private_class_method :defined_in_source?
174
250
  end
175
251
  end
176
252
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pp"
4
+
5
+ # A failing matcher or assertion that meets the process ENV prints this
6
+ # instead of its contents, which hold real API keys: RSpec and Minitest format
7
+ # with #inspect and diff with pp. Loaded by the bundle kit and by chi's own
8
+ # spec suite; it needs nothing else of chi's. A stub_const("ENV", ENV.to_h...)
9
+ # is a plain Hash and isn't covered.
10
+ module Samagotchi
11
+ module Testing
12
+ ENV_NOT_SHOWN = "#<the process ENV, contents not shown>"
13
+ end
14
+ end
15
+
16
+ def ENV.inspect = Samagotchi::Testing::ENV_NOT_SHOWN
17
+ def ENV.pretty_print(printer) = printer.text(Samagotchi::Testing::ENV_NOT_SHOWN)
@@ -12,6 +12,7 @@
12
12
  # require "samagotchi/testing/rspec" # RSpec helpers
13
13
  # require "samagotchi/testing/minitest" # Samagotchi::Testing::Case
14
14
  require_relative "version"
15
+ require_relative "testing/env_not_shown"
15
16
  require_relative "testing/sandbox"
16
17
  require_relative "testing/recorder"
17
18
  require_relative "testing/host"
@@ -14,6 +14,7 @@ require_relative "memory_bundle/system_bundle"
14
14
  require_relative "session"
15
15
  require_relative "version"
16
16
  require_relative "web/lan"
17
+ require_relative "web/running"
17
18
  require "json"
18
19
 
19
20
  module Samagotchi
@@ -106,7 +107,7 @@ module Samagotchi
106
107
  @platform = platform || Desktop::MacOS.method(:from_config)
107
108
  @supported = supported
108
109
  @state_dir = state_dir
109
- @web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
110
+ @web = web || [Web::Lan.local_host(Config.get("web.host")), LiveVersions.configured_web_port]
110
111
  end
111
112
 
112
113
  # @return [Integer] 0 nothing failed, 1 a part failed (or a checkout), 2 usage
@@ -369,13 +370,13 @@ module Samagotchi
369
370
  rows = []
370
371
  stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
371
372
  rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
372
- host, port = @web
373
- info = LiveVersions.web_info(host, port)
374
- web = info && info["version"].to_s
375
- if web && !web.empty? && web != VERSION
373
+ host, configured = @web
374
+ answer = LiveVersions.web_answering(host, configured, path: Web::Running.path(state_dir: @state_dir))
375
+ web = answer&.info&.fetch("version", nil).to_s
376
+ if !web.empty? && web != VERSION
376
377
  # Since newest_workers, the sessions it starts run the newest chi.
377
- spreads = !Array(info["features"]).include?("newest_workers")
378
- rows << Row.new(component: "chi web :#{port}", from: web,
378
+ spreads = !Array(answer.info["features"]).include?("newest_workers")
379
+ rows << Row.new(component: "chi web :#{answer.port}", from: web,
379
380
  status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
380
381
  end
381
382
  rows
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.143.0"
4
+ VERSION = "0.145.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -4,7 +4,7 @@
4
4
  // quote (with where it came from) to the composer, a preset the quote with
5
5
  // its text as the note. Nothing is sent. The bar lives outside #history,
6
6
  // which is re-rendered whole. The selection is read when it changes: a live
7
- // bubble rewrites its text every frame, which would collapse it by the time
7
+ // bubble rewrites its text at every flush, which would collapse it by the time
8
8
  // a button is clicked.
9
9
  //
10
10
  // A bundle's pills (chi.annotation) come after the presets: each prepares
@@ -37,6 +37,7 @@ import { createContextChips } from "./context_chips.js";
37
37
  import { createParticipantsControl } from "./participants_panel.js";
38
38
  import { createSettingsControl } from "./settings_chip.js";
39
39
  import { renderInfoBar } from "./info_bar.js";
40
+ import { createMoreSheet } from "./more_sheet.js";
40
41
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
41
42
  import { createShownTexts } from "./shown_texts.js";
42
43
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
@@ -170,6 +171,8 @@ const settingsControl = createSettingsControl({
170
171
  running: () => liveTurn.running,
171
172
  run: (line) => runSessionCommand(line),
172
173
  });
174
+ // A phone's ⋯ on the info bar, and its sheet: the chips and buttons the row has no room for.
175
+ const moreSheet = createMoreSheet({ el: $("#infoBar"), button: $("#infoMoreBtn"), session: () => store.selected, hash: sessionHash });
173
176
  // The open session's participants chip in the info bar, and its panel
174
177
  // (Invite, revoke, caps, the participant view).
175
178
  const participantsControl = createParticipantsControl({
@@ -886,7 +889,11 @@ window.addEventListener("hashchange", () => {
886
889
  const narrowScreen = window.matchMedia("(max-width: 600px)");
887
890
  // An input the store doesn't hold, with the composer as its only reader: a
888
891
  // direct call (the bar doesn't read it).
889
- narrowScreen.addEventListener("change", () => updateComposerMode());
892
+ narrowScreen.addEventListener("change", () => {
893
+ updateComposerMode();
894
+ // ⋯ is a phone's alone: a window grown past it takes its sheet along.
895
+ if (!narrowScreen.matches && moreSheet.isOpen()) moreSheet.close();
896
+ });
890
897
  function setPlaceholder(long, short) {
891
898
  promptEl.placeholder = narrowScreen.matches ? short : long;
892
899
  }
@@ -1501,11 +1508,15 @@ async function restartWorker() {
1501
1508
 
1502
1509
  // The session bar (info_bar_model.js sessionBar): its buttons, and the
1503
1510
  // info line rendered by info_bar.js.
1511
+ // The open session waits on the user: its summary says so, or its card is up.
1512
+ function sessionWaits() {
1513
+ return !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion());
1514
+ }
1504
1515
  function updateInfoBar() {
1505
1516
  annotateBar?.refresh();
1506
1517
  const { buttons, bar } = sessionBar({
1507
1518
  id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
1508
- continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1519
+ continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running, waiting: sessionWaits(),
1509
1520
  workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1510
1521
  });
1511
1522
  const stopBtn = document.getElementById("infoStopBtn");
@@ -1533,6 +1544,7 @@ function updateInfoBar() {
1533
1544
  // lit writes its text alone, the bar's other nodes (a tooltip, a button
1534
1545
  // mid-press) stay.
1535
1546
  renderInfoBar(infoTextEl, bar);
1547
+ moreSheet.update({ bar, buttons });
1536
1548
  updateSessionTitle();
1537
1549
  // A fresh link's picker follows what the bar follows: its model, its
1538
1550
  // owner, a next link continued from elsewhere.
@@ -1939,7 +1951,7 @@ const composer = mountComposer({
1939
1951
  fold: {
1940
1952
  infoBarEl: $("#infoBar"),
1941
1953
  session: () => store.selected,
1942
- waiting: () => !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion()),
1954
+ waiting: sessionWaits,
1943
1955
  allViewOpen,
1944
1956
  lightboxOpen: () => !lightboxEl.hidden,
1945
1957
  },
@@ -8,7 +8,9 @@
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
+ // It is modal as every popover is (popover_modal.js). A chip with no box (the
12
+ // phone's bar folds it behind ⋯, display:none) is placed and focused at the
13
+ // page's [data-anchor-stand-in] (that ⋯; shownAnchor).
12
14
 
13
15
  import { nothing, render } from "./vendor/lit-html.js";
14
16
  import { focusBack, modalDialog } from "./popover_modal.js";
@@ -50,6 +52,14 @@ export function sheetPlace({ innerWidth, innerHeight }, { share = SHEET_SHARE, v
50
52
  maxHeight: Math.round(height * share) };
51
53
  }
52
54
 
55
+ // Where a popover at +anchor+ goes and gives focus back: the anchor, or for
56
+ // one with no box the page's stand-in, when that has a box itself.
57
+ function shownAnchor(anchor, doc) {
58
+ if (!anchor?.getClientRects || anchor.getClientRects().length) return anchor;
59
+ const standIn = doc.querySelector("[data-anchor-stand-in]");
60
+ return standIn?.getClientRects().length ? standIn : anchor;
61
+ }
62
+
53
63
  // id, className: the popover's; width: its widest (px); el: where the chip
54
64
  // is drawn; anchor: the chip's selector; openers: the selector of every
55
65
  // element that opens it (a click there is not a click outside; default the
@@ -107,7 +117,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
107
117
  const width = Math.min(widest, win.innerWidth - 32);
108
118
  pop.style.width = `${width}px`;
109
119
  pop.style.maxHeight = "";
110
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
120
+ const at = popoverPlace(shownAnchor(anchor, doc).getBoundingClientRect(), { width, height: pop.offsetHeight,
111
121
  innerWidth: win.innerWidth, innerHeight: win.innerHeight });
112
122
  pop.style.left = `${at.left}px`;
113
123
  pop.style.top = at.top === undefined ? "" : `${at.top}px`;
@@ -124,7 +134,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
124
134
  openFor = session();
125
135
  opener = anchor;
126
136
  returnTo = from;
127
- expanded([anchor, from]);
137
+ expanded([anchor, from, shownAnchor(anchor, doc)]);
128
138
  show(data, true);
129
139
  pop.hidden = false;
130
140
  place(anchor);
@@ -144,7 +154,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
144
154
  returnTo = null;
145
155
  onClose();
146
156
  render(nothing, pop);
147
- back(to);
157
+ back(shownAnchor(to, doc));
148
158
  }
149
159
 
150
160
  function isOpen() { return !pop.hidden; }
@@ -3,13 +3,16 @@
3
3
  // unfold is saved per browser (chi_composer_collapsed); an archived
4
4
  // session opens folded for that visit; the page expanding it by itself (a
5
5
  // focus, a restored line, a quote) opens it for the visit only. A visit is
6
- // one select() call.
6
+ // one select() call. A card's text field with the focus on a narrow or touch
7
+ // screen folds it for that time, on top of all of these (+cardField+): the
8
+ // keyboard is up and the card needs the rows; nothing is saved.
7
9
 
8
10
  export const COMPOSER_FOLD_KEY = "chi_composer_collapsed";
9
11
 
10
12
  // Whether the composer is folded: the saved choice or an archived visit,
11
13
  // unless this visit opened it.
12
- export function composerFolded({ saved, archivedVisit, visitOpen }) {
14
+ export function composerFolded({ saved, archivedVisit, visitOpen, cardField = false }) {
15
+ if (cardField) return true;
13
16
  if (visitOpen) return false;
14
17
  return !!saved || !!archivedVisit;
15
18
  }
@@ -7,6 +7,13 @@ import { readStored, removeStored, writeStored } from "./storage.js";
7
7
  // the box's height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
8
8
  // fitPrompt reads; the floor is saved under GRIP_KEY.
9
9
  export const GRIP_KEY = "chi_composer_floor";
10
+ // A card's text field with the focus folds the composer on these screens.
11
+ const CARD_FIELD_SCREEN = "(max-width: 600px), (hover: none) and (pointer: coarse)";
12
+ const CARD_FIELD = '.bubble.question input[type="text"], .bubble.continue input[type="text"]';
13
+ // Whether it still has the focus is looked at this often, twice before the
14
+ // composer comes back: a field an answer disables or removes fires no blur,
15
+ // and a tap on Submit must land before the card moves under the finger.
16
+ const CARD_FIELD_CHECK_MS = 150;
10
17
 
11
18
  // +session+: the open session's id, or null (the start page). +draft+,
12
19
  // +waiting+, +allViewOpen+, +lightboxOpen+: what the page shows now.
@@ -36,6 +43,9 @@ export function createComposerGrip({
36
43
  // first getSession answer).
37
44
  const newFoldVisit = () => ({ open: false, archived: false, decided: false });
38
45
  let foldVisit = newFoldVisit();
46
+ // The card's text field folding the composer now (watchCardField), or null.
47
+ let cardField = null;
48
+ let cardFieldWatch = null;
39
49
 
40
50
  function cancelPendingFold() {
41
51
  if (!pendingFold) return;
@@ -175,7 +185,7 @@ export function createComposerGrip({
175
185
  return readStored(COMPOSER_FOLD_KEY) === "1";
176
186
  }
177
187
  function isComposerFolded() {
178
- return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
188
+ return !!session() && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open, cardField: !!cardField });
179
189
  }
180
190
  function applyComposerFolded() {
181
191
  const folded = isComposerFolded();
@@ -187,9 +197,37 @@ export function createComposerGrip({
187
197
  if (was && !folded) fitPrompt();
188
198
  drawFoldControls();
189
199
  }
200
+ // A card's text field took the focus: the composer folds until it lets go.
201
+ // On top of the fold's state, which it never writes: the saved choice and
202
+ // the visit's come back as they were, and the box keeps its draft. Only the
203
+ // reader's own focus gets here while a draft is there: a card doesn't take
204
+ // the focus then (question_cards.js focusWhenIdle).
205
+ function watchCardField(field) {
206
+ releaseCardField();
207
+ cardField = field;
208
+ let missed = 0;
209
+ cardFieldWatch = setInterval(() => {
210
+ const held = doc.activeElement === field && !field.disabled && field.isConnected;
211
+ missed = held ? 0 : missed + 1;
212
+ if (missed < 2) return;
213
+ releaseCardField();
214
+ applyComposerFolded();
215
+ }, CARD_FIELD_CHECK_MS);
216
+ applyComposerFolded();
217
+ }
218
+ function releaseCardField() {
219
+ clearInterval(cardFieldWatch);
220
+ cardFieldWatch = null;
221
+ cardField = null;
222
+ }
223
+ doc.addEventListener("focusin", (e) => {
224
+ if (!session() || !e.target?.matches?.(CARD_FIELD) || !win.matchMedia?.(CARD_FIELD_SCREEN).matches) return;
225
+ watchCardField(e.target);
226
+ });
190
227
  // The user's own fold or unfold (the grip, typing): saved, unless this
191
228
  // is an archived visit (both are for the visit then).
192
229
  function setComposerFolded(folded) {
230
+ releaseCardField();
193
231
  if (foldVisit.archived) {
194
232
  foldVisit.open = !folded;
195
233
  } else {
@@ -200,7 +238,11 @@ export function createComposerGrip({
200
238
  }
201
239
  // The page opens it for this visit: nothing saved.
202
240
  function openForVisit() {
203
- if (!isComposerFolded()) return;
241
+ releaseCardField();
242
+ if (!isComposerFolded()) {
243
+ applyComposerFolded();
244
+ return;
245
+ }
204
246
  foldVisit.open = true;
205
247
  applyComposerFolded();
206
248
  }
@@ -264,6 +306,7 @@ export function createComposerGrip({
264
306
  drawFoldControls,
265
307
  // A new visit (select(), the start page): nothing opened, not decided.
266
308
  newVisit() {
309
+ releaseCardField();
267
310
  foldVisit = newFoldVisit();
268
311
  applyComposerFolded();
269
312
  },
@@ -123,7 +123,7 @@ function decorate(root) {
123
123
  // Watch +historyEl+ and answer clicks on its buttons.
124
124
  export function installCopy(historyEl) {
125
125
  // Only what changed is looked at: a streaming bubble rewrites its text
126
- // every frame, and a long history shouldn't be walked for that.
126
+ // at every flush, and a long history shouldn't be walked for that.
127
127
  const roots = new Set();
128
128
  const run = () => {
129
129
  const batch = [...roots];