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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -1
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +21 -4
- data/docs/configuration.md +1 -1
- data/docs/guardrails.md +6 -3
- data/docs/hooks.md +5 -1
- data/docs/internals/bridge.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/web-frontend.md +59 -2
- data/docs/plugins.md +4 -2
- data/docs/releasing.md +3 -2
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bridge/sse_writer.rb +11 -1
- data/lib/samagotchi/bundle_command/check.rb +13 -2
- data/lib/samagotchi/bundle_settings_check.rb +34 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
- data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/engine.rb +17 -7
- data/lib/samagotchi/kernel_loop.rb +7 -0
- data/lib/samagotchi/live_versions.rb +28 -2
- data/lib/samagotchi/llm/chat_loop.rb +5 -0
- data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/context.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +1 -1
- data/lib/samagotchi/plugin_session_state.rb +6 -3
- data/lib/samagotchi/self_report.rb +15 -13
- data/lib/samagotchi/session_access/local.rb +7 -10
- data/lib/samagotchi/session_commands.rb +2 -1
- data/lib/samagotchi/system_one/answer.rb +8 -5
- data/lib/samagotchi/system_one/client.rb +17 -0
- data/lib/samagotchi/system_one/http_backend.rb +23 -10
- data/lib/samagotchi/testing/boundary.rb +76 -0
- data/lib/samagotchi/testing/env_not_shown.rb +17 -0
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/update_command.rb +8 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/annotate_bar.js +1 -1
- data/lib/samagotchi/web/public/app.js +15 -3
- data/lib/samagotchi/web/public/chip_popover.js +14 -4
- data/lib/samagotchi/web/public/composer_fold.js +5 -2
- data/lib/samagotchi/web/public/composer_grip.js +45 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/index.html +79 -40
- data/lib/samagotchi/web/public/info_bar_model.js +6 -4
- data/lib/samagotchi/web/public/more_sheet.js +212 -0
- data/lib/samagotchi/web/public/question_cards.js +26 -3
- data/lib/samagotchi/web/public/ref_scan.js +4 -2
- data/lib/samagotchi/web/public/stage_frame.js +58 -13
- data/lib/samagotchi/web/public/stage_model.js +2 -1
- data/lib/samagotchi/web/public/stage_view.js +52 -20
- data/lib/samagotchi/web/public/turn_block.js +43 -9
- data/lib/samagotchi/worker.rb +21 -4
- 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)
|
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -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")),
|
|
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,
|
|
373
|
-
|
|
374
|
-
web = info
|
|
375
|
-
if
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
|
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", () =>
|
|
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:
|
|
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
|
-
|
|
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
|
|
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];
|