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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- data/docs/configuration.md +4 -2
- data/docs/internals/anthropic.md +9 -4
- data/docs/internals/engine.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +37 -177
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/question_desk.rb +10 -3
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +32 -222
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/index.html +2 -2
- data/lib/samagotchi/web/public/model_picker.js +4 -5
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/toasts.js +5 -4
- metadata +4 -1
|
@@ -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 =
|
|
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|
|
|
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 && (
|
|
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
|
|
10
|
-
# write takes the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
129
|
+
Array(entry[:anthropic_content]).any? { |block| LLM::AnthropicWire.thinking_block?(block) }
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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);
|
|
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
|
-
|
|
2279
|
-
|
|
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
|
-
|
|
2109
|
+
writeStored("chi_strip_hidden", hidden ? "1" : "0");
|
|
2300
2110
|
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
2301
2111
|
applyStripHidden();
|
|
2302
2112
|
}
|