samagotchi 0.30.0 → 0.32.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 +87 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +7 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -1
- data/lib/samagotchi/child_reports.rb +212 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +12 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +30 -7
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +37 -5
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +48 -7
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
- data/lib/samagotchi/terminal_ui.rb +9 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +28 -7
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -3
- data/lib/samagotchi/web/public/app.js +52 -14
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +34 -10
- data/lib/samagotchi/web/public/turn_view.js +26 -4
- data/lib/samagotchi/worker.rb +343 -8
- metadata +16 -1
|
@@ -6,18 +6,18 @@ require_relative "../parent_report"
|
|
|
6
6
|
require_relative "output_guardrails"
|
|
7
7
|
require_relative "peers"
|
|
8
8
|
require_relative "delegate_relay"
|
|
9
|
+
require_relative "delegate_cursor"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Tools
|
|
12
13
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
13
14
|
# delegate_result: ReplyWait in the tools' words, with the status words
|
|
14
|
-
# `chi send --wait` and `chi answer` use (ParentReport.status), with a
|
|
15
|
-
# child
|
|
16
|
-
# as it was
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# reply once or waits for a later turn.
|
|
15
|
+
# `chi send --wait` and `chi answer` use (ParentReport.status), with a
|
|
16
|
+
# DelegateCursor per child: the newest reply this parent was already
|
|
17
|
+
# given, and the child as it was when the parent last heard from it or
|
|
18
|
+
# sent it a message (ReplyWait's baseline), so a turn that ends before
|
|
19
|
+
# the wait's first look (a fast failure) still ends it. The cursors are
|
|
20
|
+
# on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
|
|
21
21
|
module DelegateWait
|
|
22
22
|
TIMEOUT_DEFAULT = 600
|
|
23
23
|
POLL_INTERVAL = 0.5
|
|
@@ -32,16 +32,6 @@ module Samagotchi
|
|
|
32
32
|
|
|
33
33
|
module_function
|
|
34
34
|
|
|
35
|
-
# @return [Hash{Array(String, String) => String}] [parent id, child id] → newest reply filename given
|
|
36
|
-
def seen
|
|
37
|
-
@seen ||= {}
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
|
|
41
|
-
def baselines
|
|
42
|
-
@baselines ||= {}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
35
|
# @return [Hash{Array(String, String) => Array<String>}] [parent id,
|
|
46
36
|
# child id] → outcome lines of that child's approvals relayed while
|
|
47
37
|
# the parent waited on another child, for its next result
|
|
@@ -69,18 +59,20 @@ module Samagotchi
|
|
|
69
59
|
cancelled = -> { peers.cancelled? }
|
|
70
60
|
relay = peers.respond_to?(:relay) ? peers.relay : nil
|
|
71
61
|
others = relay && others_for(peers.session_id, child_id, relay, sd)
|
|
72
|
-
|
|
62
|
+
cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
|
|
63
|
+
baseline = cursor.baseline
|
|
73
64
|
left = timeout.to_i
|
|
74
65
|
outcomes = relayed_outcomes.delete(key) || []
|
|
75
66
|
loop do
|
|
76
67
|
started = monotonic
|
|
77
|
-
wait = ReplyWait.call(child_id, state_dir: sd, cursor:
|
|
68
|
+
wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
|
|
78
69
|
poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
|
|
79
70
|
owner_grace: owner_grace, interject: others && -> { others.poll })
|
|
80
71
|
left -= monotonic - started
|
|
81
72
|
unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
|
|
82
73
|
outcomes.concat(relayed_outcomes.delete(key) || [])
|
|
83
|
-
|
|
74
|
+
advance(peers.session_id, child_id, wait, state_dir: sd)
|
|
75
|
+
return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
|
|
84
76
|
end
|
|
85
77
|
|
|
86
78
|
outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
|
|
@@ -118,24 +110,43 @@ module Samagotchi
|
|
|
118
110
|
|
|
119
111
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
120
112
|
|
|
113
|
+
REPORT_NEXT_REPLY = "chi brings the child's next reply here by itself; don't wait for it with delegate_result."
|
|
114
|
+
|
|
115
|
+
# Outcomes that tell the parent something about the child's state.
|
|
116
|
+
REPORTED = %i[done no_reply error stopped waiting_for_answer worker_gone].freeze
|
|
117
|
+
|
|
118
|
+
# Move the parent's cursor past what +wait+ reported: the reply it
|
|
119
|
+
# gave, and the child as that same look loaded it (a fresh load could
|
|
120
|
+
# swallow a turn that ended in between), so a later wait looks for a
|
|
121
|
+
# later turn and a question already reported isn't reported again.
|
|
122
|
+
# Nothing moves on a timeout or a cancel.
|
|
123
|
+
# @param wait [ReplyWait::Result]
|
|
124
|
+
# @return [DelegateCursor, nil] the new cursor
|
|
125
|
+
def advance(parent_id, child_id, wait, state_dir:)
|
|
126
|
+
return nil unless REPORTED.include?(wait.status) && wait.session
|
|
127
|
+
|
|
128
|
+
DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
|
|
129
|
+
cursor = cursor.with(reply_file: wait.file) if wait.status == :done
|
|
130
|
+
cursor.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
121
134
|
# The tool result for how the wait ended.
|
|
122
135
|
# @param wait [ReplyWait::Result]
|
|
123
|
-
# @param
|
|
136
|
+
# @param report [Boolean] a delegate report's text (ChildReports), not
|
|
137
|
+
# the tool's: a question's last line says chi brings the next reply
|
|
124
138
|
# @return [String]
|
|
125
|
-
def finish(wait, child_id,
|
|
126
|
-
# The turn sent to is handed over: a later wait looks for a later one.
|
|
127
|
-
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
139
|
+
def finish(wait, child_id, timeout:, report: false)
|
|
128
140
|
status = ParentReport.status(wait)
|
|
129
141
|
case wait.status
|
|
130
142
|
when :done
|
|
131
|
-
seen[key] = wait.file
|
|
132
143
|
reply_result(child_id, status, wait.text)
|
|
133
144
|
when :error
|
|
134
145
|
result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
135
146
|
when :stopped
|
|
136
147
|
result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
137
148
|
when :waiting_for_answer
|
|
138
|
-
result(child_id, status, waiting_text(child_id, wait.question))
|
|
149
|
+
result(child_id, status, waiting_text(child_id, wait.question, report: report))
|
|
139
150
|
when :canceled
|
|
140
151
|
canceled_result(child_id)
|
|
141
152
|
when :no_reply
|
|
@@ -153,14 +164,18 @@ module Samagotchi
|
|
|
153
164
|
# child's baseline before the follow-up goes in.
|
|
154
165
|
# @param child [Session] the child as loaded before sending
|
|
155
166
|
def mark_seen(parent_id, child, state_dir:)
|
|
156
|
-
|
|
157
|
-
|
|
167
|
+
newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
|
|
168
|
+
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
169
|
+
cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
|
|
170
|
+
end
|
|
158
171
|
end
|
|
159
172
|
|
|
160
173
|
# A new child's baseline: as spawned, before its first turn ran.
|
|
161
174
|
# @param child [Session] the session spawn_session returned
|
|
162
|
-
def mark_started(parent_id, child)
|
|
163
|
-
|
|
175
|
+
def mark_started(parent_id, child, state_dir:)
|
|
176
|
+
DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
|
|
177
|
+
cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
|
|
178
|
+
end
|
|
164
179
|
end
|
|
165
180
|
|
|
166
181
|
def reply_result(child_id, status, text)
|
|
@@ -188,13 +203,17 @@ module Samagotchi
|
|
|
188
203
|
# decide (it isn't relayed: Continue grants no permission): continue
|
|
189
204
|
# it with chi answer through execute, send a follow-up instead, or
|
|
190
205
|
# report back.
|
|
191
|
-
|
|
206
|
+
# A report's last line differs (+report+): "waits again" made models
|
|
207
|
+
# call delegate_result right after answering Continue.
|
|
208
|
+
def waiting_text(child_id, pending, report: false)
|
|
192
209
|
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
193
210
|
if ParentReport.continue?(pending)
|
|
194
211
|
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
195
212
|
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
196
213
|
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
197
214
|
end
|
|
215
|
+
return "#{text}#{REPORT_NEXT_REPLY}" if report
|
|
216
|
+
|
|
198
217
|
"#{text}delegate_result #{child_id} waits again once it is answered."
|
|
199
218
|
end
|
|
200
219
|
|
|
@@ -154,7 +154,7 @@ module Samagotchi
|
|
|
154
154
|
|
|
155
155
|
def get_record(task_id)
|
|
156
156
|
record = load_record(task_id)
|
|
157
|
-
return [nil,
|
|
157
|
+
return [nil, not_found(task_id)] unless record
|
|
158
158
|
|
|
159
159
|
[refresh_record(record), nil]
|
|
160
160
|
rescue StandardError => e
|
|
@@ -180,7 +180,7 @@ module Samagotchi
|
|
|
180
180
|
# @param by [String] "model" (task_stop) or "user" (the web's button)
|
|
181
181
|
def stop_task(task_id, by:)
|
|
182
182
|
record = load_record(task_id)
|
|
183
|
-
return [nil,
|
|
183
|
+
return [nil, not_found(task_id)] unless record
|
|
184
184
|
|
|
185
185
|
refreshed = refresh_record(record)
|
|
186
186
|
return [refreshed, nil] unless refreshed["status"] == "running"
|
|
@@ -223,7 +223,26 @@ module Samagotchi
|
|
|
223
223
|
record
|
|
224
224
|
end
|
|
225
225
|
|
|
226
|
+
# A session id (a uuid) is never a task's: task ids are a timestamp
|
|
227
|
+
# and hex (20261005093000-1a2b3c4d).
|
|
228
|
+
SESSION_ID_SHAPE = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
229
|
+
|
|
230
|
+
# A model mixing up ids (Qwen passes session ids to task_get and
|
|
231
|
+
# task_stop) is told which kind it passed and where task ids come from.
|
|
232
|
+
def not_found(task_id)
|
|
233
|
+
error = "Error: task not found: #{task_id}"
|
|
234
|
+
return error unless SESSION_ID_SHAPE.match?(task_id.to_s)
|
|
235
|
+
|
|
236
|
+
"#{error} (that is a session id; task ids come from task_create, and task_list lists them)"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Exactly the shape generate_task_id makes. Anything else (../x, an
|
|
240
|
+
# absolute path) is no task, so a model's id never leaves tmp/tasks.
|
|
241
|
+
TASK_ID_SHAPE = /\A\d{14}-\h{8}\z/
|
|
242
|
+
|
|
226
243
|
def load_record(task_id)
|
|
244
|
+
return nil unless TASK_ID_SHAPE.match?(task_id.to_s)
|
|
245
|
+
|
|
227
246
|
path = metadata_path(task_id)
|
|
228
247
|
return nil unless File.exist?(path)
|
|
229
248
|
|
|
@@ -34,7 +34,7 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
loop do
|
|
36
36
|
record, error = TaskRuntime.get_record(task_id)
|
|
37
|
-
return
|
|
37
|
+
return error if error
|
|
38
38
|
|
|
39
39
|
status = record.fetch("status")
|
|
40
40
|
return finished_response(record, tail_lines) unless status == "running"
|
|
@@ -64,7 +64,7 @@ module Samagotchi
|
|
|
64
64
|
# normal finished result. Only a task still running says so.
|
|
65
65
|
def self.cancelled_response(task_id, tail_lines, started)
|
|
66
66
|
record, error = TaskRuntime.get_record(task_id)
|
|
67
|
-
return
|
|
67
|
+
return error if error
|
|
68
68
|
return finished_response(record, tail_lines) unless record.fetch("status") == "running"
|
|
69
69
|
|
|
70
70
|
note = [
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -29,6 +29,8 @@ require_relative "../config"
|
|
|
29
29
|
require_relative "../output_formatter"
|
|
30
30
|
require_relative "../image_store"
|
|
31
31
|
require_relative "../context_note"
|
|
32
|
+
require_relative "../context_sources"
|
|
33
|
+
require_relative "../context_providers"
|
|
32
34
|
require_relative "../recap_store"
|
|
33
35
|
require_relative "markdown_renderer"
|
|
34
36
|
require_relative "message_parts"
|
|
@@ -150,6 +152,10 @@ module Samagotchi
|
|
|
150
152
|
["POST", %r{\A/api/sessions/([^/]+)/command\z}, :handle_command],
|
|
151
153
|
["POST", %r{\A/api/sessions/([^/]+)/images\z}, :handle_image_upload],
|
|
152
154
|
["GET", %r{\A/api/sessions/([^/]+)/images/([^/]+)\z}, :handle_image],
|
|
155
|
+
["GET", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_list],
|
|
156
|
+
["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show],
|
|
157
|
+
["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add],
|
|
158
|
+
["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete],
|
|
153
159
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show],
|
|
154
160
|
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete]
|
|
155
161
|
].freeze
|
|
@@ -1117,6 +1123,101 @@ module Samagotchi
|
|
|
1117
1123
|
error_response(404, "not_found", e.message)
|
|
1118
1124
|
end
|
|
1119
1125
|
|
|
1126
|
+
# ── Attached context (ContextSources): the session bar's chips ───────
|
|
1127
|
+
# A source's command isn't shown (the page may be on a phone over the
|
|
1128
|
+
# LAN), and the web adds a URL only, through an installed bundle's
|
|
1129
|
+
# provider (ContextProviders): never a command.
|
|
1130
|
+
|
|
1131
|
+
# GET /api/sessions/:id/context: the sources this session sees, muted
|
|
1132
|
+
# ones marked, with what the chips show.
|
|
1133
|
+
def handle_context_list(_req, id)
|
|
1134
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1135
|
+
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1136
|
+
subs = own.subscriptions
|
|
1137
|
+
rows = attached_context(session).map { |attached| context_row(attached, own, subs) }
|
|
1138
|
+
json_response(200, { session_id: id, sources: rows, can_add_url: ContextProviders.any? })
|
|
1139
|
+
rescue ArgumentError => e
|
|
1140
|
+
error_response(404, "not_found", e.message)
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
# GET /api/sessions/:id/context/:name: one source with its text.
|
|
1144
|
+
def handle_context_show(_req, id, name)
|
|
1145
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1146
|
+
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1147
|
+
own = ContextSources.session_location(id, state_dir: default_state_dir)
|
|
1148
|
+
json_response(200, context_row(attached, own, own.subscriptions).merge(text: attached.snapshot.text))
|
|
1149
|
+
rescue ArgumentError => e
|
|
1150
|
+
error_response(404, "not_found", e.message)
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# POST /api/sessions/:id/context {url, why}: the source an installed
|
|
1154
|
+
# bundle's provider makes of +url+, attached to this session.
|
|
1155
|
+
def handle_context_add(req, id)
|
|
1156
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1157
|
+
body = parse_json(request_body(req))
|
|
1158
|
+
return error_response(400, "invalid_json", "give {url, why}") unless body.is_a?(Hash) && body["url"].is_a?(String)
|
|
1159
|
+
|
|
1160
|
+
url = body["url"].strip
|
|
1161
|
+
return error_response(422, "not_a_url", "give an http(s) URL") unless url.match?(%r{\Ahttps?://\S+\z})
|
|
1162
|
+
|
|
1163
|
+
resolved = ContextProviders.resolve(url)
|
|
1164
|
+
return error_response(422, "no_provider", "no installed bundle resolves #{url[0, 200]}") unless resolved
|
|
1165
|
+
|
|
1166
|
+
own = ContextSources.session_location(session.id, state_dir: default_state_dir)
|
|
1167
|
+
own.add(web_source(resolved, body["why"], own.scope))
|
|
1168
|
+
json_response(201, { status: "attached", name: resolved.name })
|
|
1169
|
+
rescue ContextProviders::Invalid => e
|
|
1170
|
+
error_response(422, "no_provider", e.message)
|
|
1171
|
+
rescue ContextSources::Invalid => e
|
|
1172
|
+
error_response(409, "exists", e.message)
|
|
1173
|
+
rescue ArgumentError => e
|
|
1174
|
+
error_response(404, "not_found", e.message)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
def web_source(resolved, why, scope)
|
|
1178
|
+
why = ContextSources.one_line(why.is_a?(String) ? why : nil, ContextSources::LINE_MAX_CHARS) || resolved.why
|
|
1179
|
+
ContextSources::Source.new(name: resolved.name, cmd: resolved.cmd, every_seconds: resolved.every_seconds, why: why,
|
|
1180
|
+
hint: resolved.hint, scope: scope, added_by: "web", created_at: Time.now.utc.iso8601,
|
|
1181
|
+
provider: resolved.bundle)
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
# DELETE /api/sessions/:id/context/:name: the session's own source is
|
|
1185
|
+
# detached; a project's is muted for this session only.
|
|
1186
|
+
def handle_context_delete(_req, id, name)
|
|
1187
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1188
|
+
attached = find_context(session, name) or return error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
1189
|
+
if attached.location.session?
|
|
1190
|
+
attached.location.remove(attached.name)
|
|
1191
|
+
json_response(200, { status: "detached", name: attached.name })
|
|
1192
|
+
else
|
|
1193
|
+
ContextSources.session_location(id, state_dir: default_state_dir).mute(attached.name)
|
|
1194
|
+
json_response(200, { status: "muted", name: attached.name })
|
|
1195
|
+
end
|
|
1196
|
+
rescue ArgumentError => e
|
|
1197
|
+
error_response(404, "not_found", e.message)
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
def attached_context(session)
|
|
1201
|
+
ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir)
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
# @return [ContextSources::Attached, nil]
|
|
1205
|
+
def find_context(session, name)
|
|
1206
|
+
ContextSources.check_name!(name)
|
|
1207
|
+
attached_context(session).find { |attached| attached.name == name }
|
|
1208
|
+
rescue ContextSources::Invalid
|
|
1209
|
+
nil
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
def context_row(attached, own, subs)
|
|
1213
|
+
source = attached.source
|
|
1214
|
+
snapshot = attached.snapshot
|
|
1215
|
+
{ name: source.name, scope: source.scope, kind: source.push? ? "push" : "cmd", every_seconds: source.every_seconds,
|
|
1216
|
+
why: source.why, hint: snapshot.hint || source.hint, summary: snapshot.summary, error: snapshot.error,
|
|
1217
|
+
fetched_at: snapshot.fetched_at, has_text: snapshot.text?, muted: own.muted?(source.name),
|
|
1218
|
+
unread: snapshot.text? && subs[source.name]&.read != snapshot.revision }
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1120
1221
|
def handle_stream(req, id)
|
|
1121
1222
|
# Validate session exists
|
|
1122
1223
|
begin
|
|
@@ -1491,7 +1592,14 @@ module Samagotchi
|
|
|
1491
1592
|
role = (m[:role] || m["role"]).to_s
|
|
1492
1593
|
content = (m[:content] || m["content"]).to_s
|
|
1493
1594
|
if Samagotchi::ContextNote.note?(m)
|
|
1494
|
-
|
|
1595
|
+
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
|
|
1596
|
+
# A context wake turn's note starts that turn (Steer.turn_prompt?):
|
|
1597
|
+
# the page opens the turn with it and pairs it with its record.
|
|
1598
|
+
if Samagotchi::Steer.turn_prompt?(m)
|
|
1599
|
+
note[:turn_start] = true
|
|
1600
|
+
note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
|
|
1601
|
+
end
|
|
1602
|
+
filtered << note
|
|
1495
1603
|
next
|
|
1496
1604
|
end
|
|
1497
1605
|
# A plugin's steer: a row of the step that answered it, which the
|
|
@@ -1522,8 +1630,14 @@ module Samagotchi
|
|
|
1522
1630
|
|
|
1523
1631
|
message = { role: norm_role, content: stripped }
|
|
1524
1632
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1525
|
-
# read by step +step
|
|
1526
|
-
|
|
1633
|
+
# read by step +step+; a wake turn's report starts its turn
|
|
1634
|
+
# (Steer.turn_prompt?). Its sender (a delegate report, chi send):
|
|
1635
|
+
# the page's label.
|
|
1636
|
+
if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1637
|
+
message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
|
|
1638
|
+
source = (m[:source] || m["source"]).to_s
|
|
1639
|
+
message[:source] = source unless source.empty?
|
|
1640
|
+
end
|
|
1527
1641
|
# The turn it started (Engine): the page pairs it with that turn's
|
|
1528
1642
|
# record by this, not by its place.
|
|
1529
1643
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
uploadImage,
|
|
16
16
|
listModels,
|
|
17
17
|
fetchHistory,
|
|
18
|
+
listContext,
|
|
19
|
+
getContext,
|
|
20
|
+
detachContext,
|
|
21
|
+
addContext,
|
|
18
22
|
} from "./data.js";
|
|
23
|
+
import { createContextChips } from "./context_chips.js";
|
|
19
24
|
import { MODEL_KEY } from "./model_pick.js";
|
|
20
25
|
import { emptySessionState } from "./session_state.js";
|
|
21
26
|
import { createModelPicker } from "./model_picker.js";
|
|
@@ -32,7 +37,7 @@ import { routeChunk } from "./chunk_router.js";
|
|
|
32
37
|
import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
33
38
|
import { createQuestionCards } from "./question_cards.js";
|
|
34
39
|
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
35
|
-
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
40
|
+
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
36
41
|
import { createCommandList } from "./command_list.js";
|
|
37
42
|
import { createPromptHistory } from "./prompt_history.js";
|
|
38
43
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -46,7 +51,7 @@ import {
|
|
|
46
51
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
|
|
47
52
|
} from "./timing.js";
|
|
48
53
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
49
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
54
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
50
55
|
|
|
51
56
|
const $ = (s) => document.querySelector(s);
|
|
52
57
|
const topStripEl = $("#topStrip");
|
|
@@ -57,6 +62,14 @@ const composerEl = $("#composer");
|
|
|
57
62
|
const infoBarEl = $("#infoBar");
|
|
58
63
|
const firstPeekEl = $("#firstPeek");
|
|
59
64
|
const infoTextEl = $("#infoText");
|
|
65
|
+
// The open session's attached context, in its own row of the session bar
|
|
66
|
+
// (updateInfoBar rewrites #infoText every tick; the chips keep their popover).
|
|
67
|
+
const contextChips = createContextChips({
|
|
68
|
+
el: $("#contextChips"),
|
|
69
|
+
api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
|
|
70
|
+
escapeHtml,
|
|
71
|
+
toast: (text) => showToast(text),
|
|
72
|
+
});
|
|
60
73
|
const markdownWarningEl = $("#markdownWarning");
|
|
61
74
|
const promptEl = $("#prompt");
|
|
62
75
|
const actionBtn = $("#actionBtn");
|
|
@@ -142,8 +155,11 @@ let selectSeq = null;
|
|
|
142
155
|
// new turn has started on it yet: see followWokenWorker.
|
|
143
156
|
let streamFromGuess = false;
|
|
144
157
|
// The last input_merged had an origin with no bubble: its merged text
|
|
145
|
-
// (pending_input_merged) gets one.
|
|
158
|
+
// (pending_input_merged) gets one ("report": a delegate report's).
|
|
146
159
|
let unmatchedMerge = false;
|
|
160
|
+
// The running turn is one chi ran for delegate reports and its report
|
|
161
|
+
// bubble isn't drawn yet: that bubble starts the turn.
|
|
162
|
+
let wakeStart = false;
|
|
147
163
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
148
164
|
// into the composer when their turn fails.
|
|
149
165
|
const sentIds = new Set();
|
|
@@ -549,18 +565,24 @@ function setPlaceholder(long, short) {
|
|
|
549
565
|
promptEl.placeholder = narrowScreen.matches ? short : long;
|
|
550
566
|
}
|
|
551
567
|
|
|
552
|
-
// The key hint under the composer (shown while it has focus
|
|
568
|
+
// The key hint under the composer (shown while it has focus; on the start
|
|
569
|
+
// page only: in a session the placeholder carries it, the hint row is gone).
|
|
553
570
|
const SEND_HINT = "Enter to send · Shift+Enter for a newline · ↑ history";
|
|
554
571
|
|
|
572
|
+
let hadSession = false;
|
|
555
573
|
// While a turn runs, Send still sends: the prompt merges into the turn at
|
|
556
574
|
// its next step (steering). Cancel is its own button then.
|
|
557
575
|
function updateComposerMode() {
|
|
558
|
-
|
|
576
|
+
// In a session the box is one line: its minimum follows the class.
|
|
577
|
+
if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
|
|
578
|
+
hadSession = !hadSession;
|
|
579
|
+
fitPrompt();
|
|
580
|
+
}
|
|
559
581
|
if (selected) modelPicker.close();
|
|
560
582
|
const running = turnRunning && !!selected;
|
|
561
583
|
cancelBtn.style.display = running ? "" : "none";
|
|
562
584
|
if (running) {
|
|
563
|
-
setPlaceholder("Running… a message now steers this turn
|
|
585
|
+
setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
|
|
564
586
|
composeHintEl.textContent = SEND_HINT;
|
|
565
587
|
actionBtn.textContent = "Send";
|
|
566
588
|
emptyEl.classList.add("hidden");
|
|
@@ -575,13 +597,13 @@ function updateComposerMode() {
|
|
|
575
597
|
historyEl.innerHTML = "";
|
|
576
598
|
updateInfoBar();
|
|
577
599
|
} else if (current.owner === "tui") {
|
|
578
|
-
setPlaceholder("A terminal chi owns this session
|
|
600
|
+
setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
|
|
579
601
|
composeHintEl.textContent = "";
|
|
580
602
|
actionBtn.textContent = "Send";
|
|
581
603
|
actionBtn.disabled = true;
|
|
582
604
|
emptyEl.classList.add("hidden");
|
|
583
605
|
} else {
|
|
584
|
-
setPlaceholder("Message chi…", "Message chi…");
|
|
606
|
+
setPlaceholder("Message chi… · Enter to send · Shift+Enter newline · ↑ history", "Message chi…");
|
|
585
607
|
composeHintEl.textContent = SEND_HINT;
|
|
586
608
|
actionBtn.textContent = "Send";
|
|
587
609
|
actionBtn.disabled = false;
|
|
@@ -650,6 +672,7 @@ function clearSelection() {
|
|
|
650
672
|
setSessionHash(null);
|
|
651
673
|
document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
|
|
652
674
|
resetSessionState();
|
|
675
|
+
contextChips.clear();
|
|
653
676
|
clearTimingInterval();
|
|
654
677
|
liveTurnTimingEl = null;
|
|
655
678
|
liveTurnId = null;
|
|
@@ -696,6 +719,7 @@ async function select(id) {
|
|
|
696
719
|
resetTurnView();
|
|
697
720
|
updateFirstPeek();
|
|
698
721
|
updateInfoBar();
|
|
722
|
+
contextChips.load(id);
|
|
699
723
|
updateComposerMode();
|
|
700
724
|
await resync();
|
|
701
725
|
promptEl.focus();
|
|
@@ -710,6 +734,7 @@ function resetTurnView() {
|
|
|
710
734
|
questionCards.removeQuestion();
|
|
711
735
|
questionCards.forgetContinue();
|
|
712
736
|
unmatchedMerge = false;
|
|
737
|
+
wakeStart = false;
|
|
713
738
|
setTurnRunning(false);
|
|
714
739
|
releaseDisplayWait();
|
|
715
740
|
}
|
|
@@ -1067,14 +1092,18 @@ function untaggedOwnBubble(prompt) {
|
|
|
1067
1092
|
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
1068
1093
|
// into its prompt line), else null.
|
|
1069
1094
|
function applyPromptOps(event) {
|
|
1070
|
-
|
|
1095
|
+
if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
1096
|
+
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
|
|
1071
1097
|
if (event.type === "input_merged") unmatchedMerge = false;
|
|
1072
1098
|
if (event.type === "pending_input_merged") unmatchedMerge = false;
|
|
1073
1099
|
let started = null;
|
|
1074
1100
|
for (const op of ops) {
|
|
1075
|
-
if (op.op === "
|
|
1101
|
+
if (op.op === "report") {
|
|
1102
|
+
unmatchedMerge = "report";
|
|
1103
|
+
} else if (op.op === "add") {
|
|
1076
1104
|
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1077
1105
|
if (op.state === "started") started = bubble;
|
|
1106
|
+
if (event.type === "pending_input_merged") wakeStart = false;
|
|
1078
1107
|
} else if (op.op === "tag") {
|
|
1079
1108
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1080
1109
|
const echo = untaggedOwnBubble(op.prompt);
|
|
@@ -1459,7 +1488,7 @@ function updateInfoBar() {
|
|
|
1459
1488
|
const attachCmd = `chi --attach ${selected}`;
|
|
1460
1489
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1461
1490
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1462
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml(
|
|
1491
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1463
1492
|
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1464
1493
|
infoTextEl.innerHTML = metaHtml + previewHtml;
|
|
1465
1494
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
@@ -1836,7 +1865,11 @@ const streamHandlers = {
|
|
|
1836
1865
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
1837
1866
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
1838
1867
|
// A context note joined the conversation (between turns; no turn runs).
|
|
1839
|
-
context_added: (data) =>
|
|
1868
|
+
context_added: (data) => {
|
|
1869
|
+
addNoteBubble({ label: data.label, content: data.text || "" });
|
|
1870
|
+
// An attached context source's note: its chip changed.
|
|
1871
|
+
if (data.context_source) contextChips.refresh();
|
|
1872
|
+
},
|
|
1840
1873
|
continue_offered: (data) => questionCards.renderContinue(data),
|
|
1841
1874
|
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
1842
1875
|
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
@@ -2230,10 +2263,15 @@ const GRIP_KEY = "chi_composer_floor";
|
|
|
2230
2263
|
let promptFloor = null;
|
|
2231
2264
|
try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
|
|
2232
2265
|
|
|
2266
|
+
// The empty box: one line in a session, three on the start page.
|
|
2267
|
+
function promptMinPx() {
|
|
2268
|
+
return document.body.classList.contains("has-session") ? PROMPT_LINE_PX : PROMPT_MIN_PX;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2233
2271
|
function fitPrompt() {
|
|
2234
2272
|
const borders = promptEl.offsetHeight - promptEl.clientHeight;
|
|
2235
2273
|
promptEl.style.height = "0px";
|
|
2236
|
-
const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight });
|
|
2274
|
+
const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight, minPx: promptMinPx() });
|
|
2237
2275
|
promptEl.style.height = `${height}px`;
|
|
2238
2276
|
promptEl.style.overflowY = scrolls ? "auto" : "hidden";
|
|
2239
2277
|
}
|
|
@@ -2246,7 +2284,7 @@ composeGrip.addEventListener("pointerdown", (e) => {
|
|
|
2246
2284
|
const startY = e.clientY;
|
|
2247
2285
|
const startH = promptEl.offsetHeight;
|
|
2248
2286
|
const move = (ev) => {
|
|
2249
|
-
promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight);
|
|
2287
|
+
promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight, promptMinPx());
|
|
2250
2288
|
fitPrompt();
|
|
2251
2289
|
};
|
|
2252
2290
|
const up = () => {
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// The composer's height. It grows with its text up to a cap; the grip on
|
|
2
2
|
// its top edge sets a taller floor (the cap then is at least that floor).
|
|
3
|
-
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field
|
|
3
|
+
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
|
|
4
|
+
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
4
5
|
export const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
5
6
|
export const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
6
7
|
|
|
7
8
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
8
|
-
// grip's height, or null.
|
|
9
|
-
export function promptHeight({ contentPx, floorPx = null, viewportPx }) {
|
|
10
|
-
const floor = Math.max(
|
|
9
|
+
// grip's height, or null; minPx: the empty box's height.
|
|
10
|
+
export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
|
|
11
|
+
const floor = Math.max(minPx, floorPx || 0);
|
|
11
12
|
const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
|
|
12
13
|
return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
// A grip drag: the floor it asks for, kept between the minimum and the
|
|
16
17
|
// grip's share of the window.
|
|
17
|
-
export function gripFloor(px, viewportPx) {
|
|
18
|
-
return Math.round(Math.min(Math.max(px,
|
|
18
|
+
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
19
|
+
return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
|
|
19
20
|
}
|