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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +11 -6
- data/docs/internals/anthropic.md +12 -5
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +15 -5
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +8 -4
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +30 -22
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +79 -245
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +73 -40
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +5 -6
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/toasts.js +5 -4
- metadata +24 -1
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1705
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
2279
|
-
|
|
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
|
|
2287
|
-
//
|
|
2288
|
-
// user's own hide
|
|
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
|
-
|
|
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) => {
|