samagotchi 0.136.0 → 0.137.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 +42 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +15 -6
- data/docs/guardrails.md +12 -5
- data/docs/internals/engine.md +4 -4
- data/docs/internals/web-frontend.md +20 -10
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +3 -2
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
- data/lib/samagotchi/guardrails/outside.rb +5 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
- data/lib/samagotchi/guardrails/pair_command.rb +3 -19
- data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
- data/lib/samagotchi/guardrails/review_paths.rb +32 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/message_display.rb +6 -3
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/public/answer_fold.js +44 -15
- data/lib/samagotchi/web/public/app.js +12 -1
- data/lib/samagotchi/web/public/chip_popover.js +20 -42
- data/lib/samagotchi/web/public/context_chips.js +16 -6
- data/lib/samagotchi/web/public/copy.js +8 -5
- data/lib/samagotchi/web/public/details_motion.js +74 -0
- data/lib/samagotchi/web/public/families_view.js +15 -6
- data/lib/samagotchi/web/public/index.html +67 -49
- data/lib/samagotchi/web/public/info_bar.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -0
- data/lib/samagotchi/web/public/participants_panel.js +1 -1
- data/lib/samagotchi/web/public/popover_modal.js +57 -0
- data/lib/samagotchi/web/public/revise_stack.js +76 -6
- data/lib/samagotchi/web/public/revise_switch.js +55 -0
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/send_turn.js +4 -2
- data/lib/samagotchi/web/public/settings_chip.js +2 -2
- data/lib/samagotchi/web/public/stale_params.js +26 -0
- data/lib/samagotchi/web/public/start_settings.js +1 -1
- data/lib/samagotchi/web/public/start_toolkit.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +10 -5
- data/lib/samagotchi/web/public/working_set_rail.js +2 -0
- data/lib/samagotchi/web/public/working_set_view.js +2 -2
- metadata +5 -1
|
@@ -7,6 +7,36 @@ require_relative "../paths"
|
|
|
7
7
|
|
|
8
8
|
module Samagotchi
|
|
9
9
|
module Guardrails
|
|
10
|
+
# mise's file names, in one place for the boundaries that judge them:
|
|
11
|
+
# its local files hold secrets (ReviewPaths' SECRET_FILE / SECRET_PATH,
|
|
12
|
+
# PairBoundary's SECRET_TEXT) and its config runs at the owner's next
|
|
13
|
+
# worker start (PairCommand's mise config writes, ProjectEnv). Kept in
|
|
14
|
+
# this file: the testing kit loads it already (its file budget).
|
|
15
|
+
module MisePaths
|
|
16
|
+
# A local file's name: mise.local.toml, .mise.dev.local.toml (a
|
|
17
|
+
# Regexp source, to be embedded under the embedding pattern's flags).
|
|
18
|
+
LOCAL_FILE = '\.?mise(?:\.[\w-]+)?\.local\.toml'
|
|
19
|
+
# A local file in mise's config dirs: .config/mise/config.local.toml,
|
|
20
|
+
# config.<env>.local.toml; mise/ and .mise/ too (a Regexp source).
|
|
21
|
+
LOCAL_DIR_FILE = '(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml'
|
|
22
|
+
|
|
23
|
+
# mise's config files, by a path's end: mise.toml, .mise.toml,
|
|
24
|
+
# mise.<env>.toml, .config/mise.toml, anything under .config/mise/,
|
|
25
|
+
# mise/ or .mise/ config*.toml, and the .tool-versions it reads too.
|
|
26
|
+
CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
|
|
27
|
+
\.?mise/config[^/]*\.toml|\.tool-versions)\z}x
|
|
28
|
+
# The same files named in a command's or script's text.
|
|
29
|
+
CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml|
|
|
30
|
+
\.tool-versions(?![\w.-]))}x
|
|
31
|
+
|
|
32
|
+
# Whether +path+ is one of mise's config files (CONFIG_PATH), as
|
|
33
|
+
# given or as it resolves.
|
|
34
|
+
def self.config?(path)
|
|
35
|
+
path.to_s.match?(CONFIG_PATH) ||
|
|
36
|
+
(path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(CONFIG_PATH) })
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
10
40
|
# What a participant may read (the shared-sessions plan's §2): a file
|
|
11
41
|
# under the session's project root that isn't on the read denylist
|
|
12
42
|
# (chi's state and config dirs, home secret dirs, project secret files),
|
|
@@ -35,11 +65,11 @@ module Samagotchi
|
|
|
35
65
|
# registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
|
|
36
66
|
# secrets file; and a git config (.git/config, a submodule's
|
|
37
67
|
# .git/modules/x/config), checked apart.
|
|
38
|
-
SECRET_FILE = /\A(?:\.env
|
|
68
|
+
SECRET_FILE = /\A(?:\.env.*|#{MisePaths::LOCAL_FILE}|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
|
|
39
69
|
\.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
|
|
40
70
|
# mise's local config in its config dirs (.config/mise/config.local.toml,
|
|
41
71
|
# config.<env>.local.toml; mise/ and .mise/ too): by the path's end.
|
|
42
|
-
SECRET_PATH = %r{(?:\A|/)
|
|
72
|
+
SECRET_PATH = %r{(?:\A|/)#{MisePaths::LOCAL_DIR_FILE}\z}i
|
|
43
73
|
# The .docker/config.json kind: a dot dir's config holding tokens.
|
|
44
74
|
SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
|
|
45
75
|
GIT_DIR = ".git"
|
|
@@ -71,10 +71,6 @@ module Samagotchi
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# Whether +path+ is +root+ or in it, cased alike on a disk that folds
|
|
75
|
-
# case (PathResolve.under?).
|
|
76
|
-
def within?(path, root) = PathResolve.under?(path, root)
|
|
77
|
-
|
|
78
74
|
# Follows the current dir and gives, per simple command, each word
|
|
79
75
|
# with its path (nil when it can't be resolved). A word is opened the
|
|
80
76
|
# kernel's way, so a `..` after a symlinked dir climbs out of its
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# ParentPolicy's user_only); System 1 may, only if the user lists the
|
|
24
24
|
# rule by name in guardrails.system_one.rules, and never for the
|
|
25
25
|
# chi-answer/chi-broadcast ones (SystemOne::SelfProtect::NEVER_AUTO).
|
|
26
|
-
PROTECTED_RULES = %w[chi-config chi-home chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
26
|
+
PROTECTED_RULES = %w[chi-config chi-home chi-shim chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
27
27
|
model-overlay-write chi-answer-piped chi-answer-http].freeze
|
|
28
28
|
SCOPES = %w[once session repo rule].freeze
|
|
29
29
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -87,7 +87,10 @@ module Samagotchi
|
|
|
87
87
|
# show but that (a text-less step) is kept with empty content
|
|
88
88
|
# @param cwd [String, nil] the session's working directory (the parts'
|
|
89
89
|
# tool titles are relative to it)
|
|
90
|
-
|
|
90
|
+
# @param markdown [Boolean] false: no html for a delegate report or an
|
|
91
|
+
# answer (the participant review drops both and renders its replies
|
|
92
|
+
# from their redacted text, ParticipantReview#with_reply_html)
|
|
93
|
+
def messages_for_display(msgs, parts: false, cwd: nil, markdown: true)
|
|
91
94
|
filtered = []
|
|
92
95
|
list = Array(msgs)
|
|
93
96
|
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
@@ -145,7 +148,7 @@ module Samagotchi
|
|
|
145
148
|
# A delegate report is the child's markdown: rendered as an
|
|
146
149
|
# answer is (its copy source stays the text). Another user line
|
|
147
150
|
# stays plain.
|
|
148
|
-
if source == Samagotchi::Steer::DELEGATE_REPORT && @markdown_renderer.available?
|
|
151
|
+
if markdown && source == Samagotchi::Steer::DELEGATE_REPORT && @markdown_renderer.available?
|
|
149
152
|
message[:html] = @markdown_renderer.render(report_markdown(stripped))
|
|
150
153
|
end
|
|
151
154
|
end
|
|
@@ -177,7 +180,7 @@ module Samagotchi
|
|
|
177
180
|
shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
|
|
178
181
|
message[:display] = shown unless shown.strip.empty?
|
|
179
182
|
rendered = message[:display] || stripped
|
|
180
|
-
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
183
|
+
message[:html] = @markdown_renderer.render(rendered) if markdown && !rendered.empty? && @markdown_renderer.available?
|
|
181
184
|
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
182
185
|
# a stored file is never trusted. Never raises.
|
|
183
186
|
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
|
|
@@ -65,7 +65,8 @@ module Samagotchi
|
|
|
65
65
|
turn_snapshot = live && live["snapshot"]
|
|
66
66
|
state = live && live["session_state_snapshot"]
|
|
67
67
|
raw = live_or_saved(turn_snapshot, "messages") { session.messages }
|
|
68
|
-
|
|
68
|
+
# No html here: ParticipantView drops a report and the owner's html; with_reply_html renders the redacted replies.
|
|
69
|
+
shown = messages_for_display(visible_replies(raw), parts: true, cwd: session.working_directory, markdown: false)
|
|
69
70
|
current = turn_snapshot && ParticipantView.current_turn(turn_snapshot["current_turn"], viewer)
|
|
70
71
|
automatic = automatic_turn_ids(id, session, shown, current, state)
|
|
71
72
|
waiting = !!(current&.dig("waiting_for_owner") || turn_snapshot&.dig("continue_offer") ||
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// gets a fold row: a sibling just before the bubble, never inside it (the
|
|
4
4
|
// bubble's copy text, its claims and a live end's innerHTML stay the
|
|
5
5
|
// bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
|
|
6
|
-
// no inline style)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// no inline style) and the row is the answer's box closed: "▸ ‹first
|
|
7
|
+
// line› · N lines" in the bubble's shape; unfolded, the row is that box's
|
|
8
|
+
// top edge, "▾ N lines", the bubble its rest (CSS joins the two). The box
|
|
9
|
+
// never changes shape, a click changes its height, animated
|
|
10
|
+
// (details_motion.js animateHeight) unless the viewer prefers no motion.
|
|
9
11
|
//
|
|
10
12
|
// A delegate report (a user line chi brought in, data-source
|
|
11
13
|
// "delegate_report") gets the same row, folded by default (it is written
|
|
@@ -14,6 +16,12 @@
|
|
|
14
16
|
// lines". Its key: a merged one's data-line-key, a wake turn's opening
|
|
15
17
|
// "<turn id>:prompt". A live merged report has no key until a reload.
|
|
16
18
|
//
|
|
19
|
+
// A long prompt (a user bubble with no data-source: typed, not brought in)
|
|
20
|
+
// gets the same row over the same threshold, open by default as an answer
|
|
21
|
+
// is, "▾ N lines" over it and "▸ ‹first line› · N lines" folded; its key is
|
|
22
|
+
// its turn's "<turn id>:prompt" (an opening only: a queued prompt, a steer
|
|
23
|
+
// has no turn id and no row), kept with the answers folded by hand.
|
|
24
|
+
//
|
|
17
25
|
// Which answers are folded is this browser's choice (localStorage through
|
|
18
26
|
// storage.js): one key per session holding the folded answers' keys and
|
|
19
27
|
// the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
|
|
@@ -30,6 +38,7 @@
|
|
|
30
38
|
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
31
39
|
// export on an old module would blank the page.
|
|
32
40
|
import { bubbleCopyText } from "./copy.js";
|
|
41
|
+
import { animateHeight, canAnimate } from "./details_motion.js";
|
|
33
42
|
import { clip } from "./format.js";
|
|
34
43
|
import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
|
|
35
44
|
|
|
@@ -43,9 +52,11 @@ export const FOLD_PREFIX = "chi_answer_fold:";
|
|
|
43
52
|
const LABEL_CHARS = 160;
|
|
44
53
|
|
|
45
54
|
const ROW_CLASS = "answer-fold";
|
|
46
|
-
// What gets a row: answers
|
|
55
|
+
// What gets a row: answers, delegate reports (user lines chi brought in)
|
|
56
|
+
// and the user's own prompts (no source).
|
|
47
57
|
const REPORTS = '.bubble.user[data-source="delegate_report"]';
|
|
48
|
-
const
|
|
58
|
+
const PROMPTS = ".bubble.user:not([data-source])";
|
|
59
|
+
const ANSWERS = `.bubble.output, ${REPORTS}, ${PROMPTS}`;
|
|
49
60
|
// Not done yet: still streaming.
|
|
50
61
|
const UNFINISHED = ".streaming";
|
|
51
62
|
|
|
@@ -80,12 +91,13 @@ export function foldLabel(text, { display = false } = {}) {
|
|
|
80
91
|
return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
// The row's words
|
|
84
|
-
// the
|
|
85
|
-
//
|
|
86
|
-
export function foldRowText(text, { display = false, label = foldLabel(text, { display }) } = {}) {
|
|
94
|
+
// The row's words: folded, the label and the size; unfolded, the size
|
|
95
|
+
// alone (the content is right under it). +label+: the row's own (a
|
|
96
|
+
// report's, reportLabel), else the answer's.
|
|
97
|
+
export function foldRowText(text, { display = false, folded = true, label = foldLabel(text, { display }) } = {}) {
|
|
87
98
|
const n = lineCount(text);
|
|
88
99
|
const size = `${n} line${n === 1 ? "" : "s"}`;
|
|
100
|
+
if (!folded) return { label: "", size };
|
|
89
101
|
return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -171,16 +183,21 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
|
|
|
171
183
|
// ---- The rows ----
|
|
172
184
|
|
|
173
185
|
const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
|
|
186
|
+
const isPrompt = (bubble) => !!bubble?.matches?.(PROMPTS);
|
|
174
187
|
|
|
175
188
|
// The key an answer bubble is folded by, or null. A delegate report's: a
|
|
176
189
|
// merged one's line key (data-line-key, "<turn id>:input:<place>"), one
|
|
177
190
|
// that opened a wake turn its turn's id and ":prompt" (never the answer's
|
|
178
|
-
// key, the bare id).
|
|
191
|
+
// key, the bare id). A prompt's: its turn's id and ":prompt".
|
|
179
192
|
export function answerKey(bubble) {
|
|
180
193
|
if (isReport(bubble)) {
|
|
181
194
|
const turn = bubble.getAttribute("data-turn-id");
|
|
182
195
|
return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
|
|
183
196
|
}
|
|
197
|
+
if (isPrompt(bubble)) {
|
|
198
|
+
const turn = bubble.getAttribute("data-turn-id");
|
|
199
|
+
return turn ? `${turn}:prompt` : null;
|
|
200
|
+
}
|
|
184
201
|
return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
|
|
185
202
|
}
|
|
186
203
|
|
|
@@ -204,13 +221,17 @@ function drawRow(row, bubble) {
|
|
|
204
221
|
const folded = bubble.classList.contains("folded");
|
|
205
222
|
const text = answerText(bubble);
|
|
206
223
|
const report = isReport(bubble);
|
|
224
|
+
const prompt = isPrompt(bubble);
|
|
207
225
|
const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
|
|
208
|
-
|
|
209
|
-
const
|
|
226
|
+
// A prompt's label is its first line as typed (no heading picked).
|
|
227
|
+
const display = prompt || bubble.hasAttribute("data-answer-display");
|
|
228
|
+
const { label, size } = foldRowText(text, { display, folded, ...own });
|
|
229
|
+
const what = report ? "report" : prompt ? "message" : "answer";
|
|
210
230
|
row.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
211
231
|
row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
|
|
212
232
|
row.classList.toggle("folded", folded);
|
|
213
233
|
row.classList.toggle("report", report);
|
|
234
|
+
row.classList.toggle("prompt", prompt);
|
|
214
235
|
row.classList.toggle("steered", report && bubble.classList.contains("steered"));
|
|
215
236
|
const [labelEl, sizeEl] = row.children;
|
|
216
237
|
if (labelEl.textContent !== label) labelEl.textContent = label;
|
|
@@ -245,8 +266,9 @@ export function keepRowInView(scroller, row, mutate) {
|
|
|
245
266
|
// @param historyEl the history (#history), also its scroller
|
|
246
267
|
// @param sessionId () => the open session's id
|
|
247
268
|
// @param projectRoot () => the open session's project root, or null
|
|
269
|
+
// @param motion (bubble) => whether a fold or unfold animates (canAnimate)
|
|
248
270
|
// @return {decorate(root)} (the observer runs it; specs call it)
|
|
249
|
-
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
|
|
271
|
+
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null, motion = canAnimate }) {
|
|
250
272
|
// Each bubble's row, and each row's bubble.
|
|
251
273
|
const rows = new WeakMap();
|
|
252
274
|
const bubbleOf = new WeakMap();
|
|
@@ -290,6 +312,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
290
312
|
|
|
291
313
|
// Synchronous (not a frame later, as copy buttons are): the row lands in
|
|
292
314
|
// the same frame as its bubble, so nothing below it shifts after a paint.
|
|
315
|
+
// data-turn-id: a live prompt's key comes at its turn's start.
|
|
293
316
|
new MutationObserver((records) => {
|
|
294
317
|
const roots = new Set();
|
|
295
318
|
let settle = false;
|
|
@@ -309,7 +332,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
309
332
|
}
|
|
310
333
|
if (settle) dropOrphans();
|
|
311
334
|
for (const el of roots) if (el.isConnected) decorate(el);
|
|
312
|
-
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
335
|
+
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-turn-id"] });
|
|
313
336
|
decorate(historyEl);
|
|
314
337
|
|
|
315
338
|
historyEl.addEventListener("click", (e) => {
|
|
@@ -318,10 +341,16 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
318
341
|
if (!bubble) return;
|
|
319
342
|
e.preventDefault();
|
|
320
343
|
const folded = !bubble.classList.contains("folded");
|
|
321
|
-
keepRowInView(historyEl, row, () => {
|
|
344
|
+
const settle = () => keepRowInView(historyEl, row, () => {
|
|
322
345
|
bubble.classList.toggle("folded", folded);
|
|
323
346
|
drawRow(row, bubble);
|
|
324
347
|
});
|
|
348
|
+
if (!motion(bubble)) settle();
|
|
349
|
+
else if (folded) animateHeight(bubble, bubble.offsetHeight, 0, { fade: true }).then(settle);
|
|
350
|
+
else {
|
|
351
|
+
settle();
|
|
352
|
+
animateHeight(bubble, 0, bubble.offsetHeight, { fade: true });
|
|
353
|
+
}
|
|
325
354
|
// An answer's choice is its fold, a report's its unfold (D2: the default differs).
|
|
326
355
|
if (isReport(bubble)) writeOpened(sessionId(), answerKey(bubble), !folded, { root: projectRoot() });
|
|
327
356
|
else writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
|
|
@@ -72,6 +72,7 @@ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
|
72
72
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
73
73
|
import { copyText, installCopy } from "./copy.js";
|
|
74
74
|
import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
|
|
75
|
+
import { installDetailsMotion } from "./details_motion.js";
|
|
75
76
|
import { answerLabel, installReviseStacks, reviseChipTemplate, reviseWorkerUnavailable } from "./revise_stack.js";
|
|
76
77
|
import { createQuestionCards } from "./question_cards.js";
|
|
77
78
|
import { splitSnapshotCards } from "./card.js";
|
|
@@ -121,6 +122,7 @@ import { createUpdateSheet } from "./update_sheet.js";
|
|
|
121
122
|
import { createRename } from "./session_rename.js";
|
|
122
123
|
import { createSendTurn } from "./send_turn.js";
|
|
123
124
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
125
|
+
import { withoutStaleParams } from "./stale_params.js";
|
|
124
126
|
|
|
125
127
|
// A link's #code= (behind an auth proxy): traded for the cookie before
|
|
126
128
|
// anything else runs; the page reloads without it.
|
|
@@ -128,6 +130,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
|
|
|
128
130
|
// someone sent to swap this browser's session for theirs), so ask first.
|
|
129
131
|
if (fragmentCode(globalThis.location?.hash)
|
|
130
132
|
&& await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
|
|
133
|
+
// A query parameter the page no longer reads (?view=, the removed turn
|
|
134
|
+
// view's) leaves the address bar, so the next bookmark is clean.
|
|
135
|
+
const staleFree = withoutStaleParams(location.search);
|
|
136
|
+
if (staleFree !== null) history.replaceState(null, "", location.pathname + staleFree + location.hash);
|
|
131
137
|
// Signed out (the proxy's session or chi's token): a banner, or one reload.
|
|
132
138
|
const authLost = createAuthLost();
|
|
133
139
|
onAuthLost(authLost);
|
|
@@ -940,9 +946,10 @@ function setTurnRunning(running) {
|
|
|
940
946
|
liveTurn.running = !!running;
|
|
941
947
|
// A fresh link's picker goes now, not at the timing re-read after the turn.
|
|
942
948
|
if (running && store.selected) store.update({ current: { turnStarted: true } });
|
|
943
|
-
// liveTurn.running isn't the store's: the composer and the
|
|
949
|
+
// liveTurn.running isn't the store's: the composer, the bar and the open ⚙ panel's note read it.
|
|
944
950
|
store.touch("composer");
|
|
945
951
|
store.touch("infoBar");
|
|
952
|
+
store.touch("settingsPanel");
|
|
946
953
|
});
|
|
947
954
|
}
|
|
948
955
|
|
|
@@ -2085,6 +2092,10 @@ historyEl.addEventListener("click", (e) => {
|
|
|
2085
2092
|
});
|
|
2086
2093
|
turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
|
|
2087
2094
|
turnRoots().forEach((root) => installCopy(root));
|
|
2095
|
+
// A steps block, a thinking block and the recap open and close by hand
|
|
2096
|
+
// with their height animated (details_motion.js), in the history and on
|
|
2097
|
+
// the stage.
|
|
2098
|
+
installDetailsMotion({ root: document.body, selector: ".turn-work, .bubble.thinking, .bubble.recap" });
|
|
2088
2099
|
// Long answers fold by hand (answer_fold.js), per viewer and session.
|
|
2089
2100
|
installAnswerFold({
|
|
2090
2101
|
historyEl,
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet. An
|
|
9
9
|
// open one is placed again when the window (or a phone's visual viewport:
|
|
10
10
|
// its keyboard) resizes.
|
|
11
|
+
// It is modal as every popover is (popover_modal.js).
|
|
11
12
|
|
|
12
13
|
import { nothing, render } from "./vendor/lit-html.js";
|
|
14
|
+
import { focusBack, modalDialog } from "./popover_modal.js";
|
|
13
15
|
|
|
14
16
|
const EDGE = 16;
|
|
15
17
|
const GAP = 8;
|
|
@@ -48,13 +50,6 @@ export function sheetPlace({ innerWidth, innerHeight }, { share = SHEET_SHARE, v
|
|
|
48
50
|
maxHeight: Math.round(height * share) };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
|
52
|
-
|
|
53
|
-
// The elements Tab reaches in +root+, in order (none hidden or disabled).
|
|
54
|
-
export function tabStops(root) {
|
|
55
|
-
return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
53
|
// id, className: the popover's; width: its widest (px); el: where the chip
|
|
59
54
|
// is drawn; anchor: the chip's selector; openers: the selector of every
|
|
60
55
|
// element that opens it (a click there is not a click outside; default the
|
|
@@ -66,23 +61,19 @@ export function tabStops(root) {
|
|
|
66
61
|
// sheet: a bottom sheet in a narrow window (sheetPlace). label: the
|
|
67
62
|
// dialog's accessible name (aria-label), none by default. onOpen(): after
|
|
68
63
|
// an open has shown and placed it (where focus can go into it: show runs
|
|
69
|
-
// while it is still hidden).
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// share of a phone's window the sheet takes at most (SHEET_SHARE).
|
|
64
|
+
// while it is still hidden). sheetShare: the share of a phone's window the
|
|
65
|
+
// sheet takes at most (SHEET_SHARE). Always a modal dialog (modalDialog);
|
|
66
|
+
// the opener clicked (and the anchor it is placed at) carries aria-expanded
|
|
67
|
+
// "true" while it is open, "false" once closed: a lit-drawn chip writes
|
|
68
|
+
// aria-expanded="false" as a static attribute (lit leaves it to the shell).
|
|
75
69
|
// @return {pop, open, close, refresh, isOpen, opener}
|
|
76
70
|
export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
|
|
77
71
|
session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {}, onOpen = () => {},
|
|
78
|
-
sheet = false, label = null,
|
|
72
|
+
sheet = false, label = null, sheetShare = SHEET_SHARE,
|
|
79
73
|
doc = document, win = window }) {
|
|
80
74
|
const pop = doc.createElement("div");
|
|
81
75
|
pop.id = id;
|
|
82
76
|
pop.className = className;
|
|
83
|
-
pop.setAttribute("role", "dialog");
|
|
84
|
-
if (label) pop.setAttribute("aria-label", label);
|
|
85
|
-
if (modal) pop.setAttribute("aria-modal", "true");
|
|
86
77
|
pop.hidden = true;
|
|
87
78
|
doc.body.appendChild(pop);
|
|
88
79
|
// The session the open form is for, the element it is placed at, and the
|
|
@@ -90,9 +81,13 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
90
81
|
let openFor = null;
|
|
91
82
|
let opener = null;
|
|
92
83
|
let returnTo = null;
|
|
84
|
+
// The openers marked aria-expanded="true" (the anchor and the one clicked).
|
|
85
|
+
let marked = [];
|
|
93
86
|
|
|
94
|
-
const expanded = (
|
|
95
|
-
|
|
87
|
+
const expanded = (els) => {
|
|
88
|
+
marked.forEach((e) => e.setAttribute("aria-expanded", "false"));
|
|
89
|
+
marked = [...new Set(els)].filter(Boolean);
|
|
90
|
+
marked.forEach((e) => e.setAttribute("aria-expanded", "true"));
|
|
96
91
|
};
|
|
97
92
|
|
|
98
93
|
function place(anchor) {
|
|
@@ -129,7 +124,7 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
129
124
|
openFor = session();
|
|
130
125
|
opener = anchor;
|
|
131
126
|
returnTo = from;
|
|
132
|
-
expanded(
|
|
127
|
+
expanded([anchor, from]);
|
|
133
128
|
show(data, true);
|
|
134
129
|
pop.hidden = false;
|
|
135
130
|
place(anchor);
|
|
@@ -140,16 +135,16 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
140
135
|
// edits are gone). Focus that was in it (or +refocus+: Esc) goes back to
|
|
141
136
|
// its opener.
|
|
142
137
|
function close({ refocus = false } = {}) {
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
expanded(
|
|
138
|
+
const back = focusBack(pop, { refocus, doc });
|
|
139
|
+
const to = returnTo?.isConnected && !returnTo.hidden ? returnTo : opener;
|
|
140
|
+
expanded([]);
|
|
146
141
|
pop.hidden = true;
|
|
147
142
|
openFor = null;
|
|
148
143
|
opener = null;
|
|
149
144
|
returnTo = null;
|
|
150
145
|
onClose();
|
|
151
146
|
render(nothing, pop);
|
|
152
|
-
|
|
147
|
+
back(to);
|
|
153
148
|
}
|
|
154
149
|
|
|
155
150
|
function isOpen() { return !pop.hidden; }
|
|
@@ -196,29 +191,12 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
|
|
|
196
191
|
});
|
|
197
192
|
doc.addEventListener("keydown", (e) => {
|
|
198
193
|
if (e.key === "Escape" && isOpen()) close({ refocus: true });
|
|
199
|
-
else if (e.key === "Tab" && modal && isOpen()) holdFocus(e);
|
|
200
194
|
});
|
|
195
|
+
modalDialog(pop, { label, isOpen, doc });
|
|
201
196
|
// A resize (a rotation, a phone's keyboard) places an open one again: a
|
|
202
197
|
// sheet's height follows the window, a popover its chip.
|
|
203
198
|
win.addEventListener?.("resize", replace);
|
|
204
199
|
win.visualViewport?.addEventListener?.("resize", replace);
|
|
205
200
|
|
|
206
|
-
// Tab past the last stop goes to the first, Shift+Tab before the first to
|
|
207
|
-
// the last; a Tab from outside (focus lost to a redraw) goes in.
|
|
208
|
-
function holdFocus(e) {
|
|
209
|
-
const stops = tabStops(pop);
|
|
210
|
-
if (!stops.length) return;
|
|
211
|
-
const first = stops[0];
|
|
212
|
-
const last = stops[stops.length - 1];
|
|
213
|
-
const at = doc.activeElement;
|
|
214
|
-
let to = null;
|
|
215
|
-
if (!pop.contains(at)) to = e.shiftKey ? last : first;
|
|
216
|
-
else if (e.shiftKey && at === first) to = last;
|
|
217
|
-
else if (!e.shiftKey && at === last) to = first;
|
|
218
|
-
if (!to) return;
|
|
219
|
-
e.preventDefault();
|
|
220
|
-
to.focus();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
201
|
return { pop, open, close, refresh, isOpen, replace, opener: () => opener };
|
|
224
202
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
11
11
|
import { keepFocus } from "./keep_focus.js";
|
|
12
12
|
import { popoverPlace } from "./chip_popover.js";
|
|
13
|
+
import { focusBack, modalDialog } from "./popover_modal.js";
|
|
13
14
|
import { cantText } from "./format.js";
|
|
14
15
|
import { createLatest, stillOn } from "./latest.js";
|
|
15
16
|
|
|
@@ -52,10 +53,10 @@ const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about
|
|
|
52
53
|
// "+ URL" form is.
|
|
53
54
|
export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
|
|
54
55
|
return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
|
|
55
|
-
data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
|
|
56
|
+
data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"} aria-expanded=${chip.name === open ? "true" : "false"}>${
|
|
56
57
|
chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
|
|
57
58
|
addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
|
|
58
|
-
aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
|
|
59
|
+
aria-haspopup=${"dialog"} aria-expanded=${addOpen ? "true" : "false"}>+ URL</button>` : nothing}`;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
// The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
|
|
@@ -101,7 +102,9 @@ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
|
|
|
101
102
|
// The chips for the session bar. el: #contextChips; api: {list(id),
|
|
102
103
|
// show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
|
|
103
104
|
// Chips, popover and form are lit templates: a redraw (a refresh, the
|
|
104
|
-
// minute's tick) keeps their nodes, so focus and typed text stay.
|
|
105
|
+
// minute's tick) keeps their nodes, so focus and typed text stay. The
|
|
106
|
+
// popover behaves as every popover does (popover_modal.js): a modal dialog, its chip's
|
|
107
|
+
// aria-expanded, Esc closes it and focus goes back to the chip.
|
|
105
108
|
export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
|
|
106
109
|
let sessionId = null;
|
|
107
110
|
let rows = [];
|
|
@@ -116,9 +119,9 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
116
119
|
const pop = doc.createElement("div");
|
|
117
120
|
pop.id = "contextPopover";
|
|
118
121
|
pop.className = "ctx-popover";
|
|
119
|
-
pop.setAttribute("role", "dialog");
|
|
120
122
|
pop.hidden = true;
|
|
121
123
|
doc.body.appendChild(pop);
|
|
124
|
+
modalDialog(pop, { isOpen: () => openName !== null, doc });
|
|
122
125
|
|
|
123
126
|
// openName ADD: the "+ URL" form is open.
|
|
124
127
|
const ADD = "\u0000add";
|
|
@@ -208,6 +211,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
208
211
|
render(nothing, pop);
|
|
209
212
|
openName = name;
|
|
210
213
|
text = null;
|
|
214
|
+
pop.setAttribute("aria-label", name);
|
|
211
215
|
fill(row);
|
|
212
216
|
pop.hidden = false;
|
|
213
217
|
place(chip);
|
|
@@ -223,6 +227,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
223
227
|
render(nothing, pop);
|
|
224
228
|
openName = ADD;
|
|
225
229
|
text = null;
|
|
230
|
+
pop.setAttribute("aria-label", "Attach a URL");
|
|
226
231
|
addState = {};
|
|
227
232
|
drawAdd();
|
|
228
233
|
pop.hidden = false;
|
|
@@ -252,13 +257,18 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
252
257
|
}
|
|
253
258
|
}
|
|
254
259
|
|
|
255
|
-
|
|
260
|
+
// Focus that was in it (or +refocus+: Esc) goes back to its chip.
|
|
261
|
+
function close({ refocus = false } = {}) {
|
|
262
|
+
const back = focusBack(pop, { refocus, doc });
|
|
263
|
+
const name = openName;
|
|
256
264
|
openName = null;
|
|
257
265
|
text = null;
|
|
258
266
|
addState = {};
|
|
259
267
|
pop.hidden = true;
|
|
260
268
|
render(nothing, pop);
|
|
261
269
|
drawChips();
|
|
270
|
+
if (name !== null) back(name === ADD ? el.querySelector('.ctx-chip[data-act="add"]')
|
|
271
|
+
: [...el.querySelectorAll(".ctx-chip[data-name]")].find((chip) => chip.dataset.name === name));
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
async function viewText() {
|
|
@@ -318,7 +328,7 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
|
|
|
318
328
|
if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
|
|
319
329
|
});
|
|
320
330
|
doc.addEventListener("keydown", (e) => {
|
|
321
|
-
if (e.key === "Escape" && openName) close();
|
|
331
|
+
if (e.key === "Escape" && openName) close({ refocus: true });
|
|
322
332
|
});
|
|
323
333
|
win.addEventListener("resize", placeAtOpenChip);
|
|
324
334
|
win.addEventListener("focus", () => refresh());
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Copy buttons: one on every prompt and answer bubble, one on every code
|
|
2
|
-
// block of a rendered answer. An answer copies its
|
|
3
|
-
// saved message's `display` when a hook presented one,
|
|
4
|
-
// kept on the bubble as data-copy-source when the page
|
|
5
|
-
// html), a prompt the text as typed, a code block just its
|
|
2
|
+
// block of a rendered answer or delegate report. An answer copies its
|
|
3
|
+
// markdown source (the saved message's `display` when a hook presented one,
|
|
4
|
+
// else its `content`, kept on the bubble as data-copy-source when the page
|
|
5
|
+
// shows rendered html), a prompt the text as typed, a code block just its
|
|
6
|
+
// code.
|
|
6
7
|
//
|
|
7
8
|
// The buttons carry no text (an icon; "copied ✓" is CSS content), so a
|
|
8
9
|
// bubble's textContent, the turn output dedupe and an annotation's selected
|
|
@@ -78,7 +79,9 @@ export function showsAnswer(bubble, message, normalize) {
|
|
|
78
79
|
const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>`;
|
|
79
80
|
|
|
80
81
|
const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
|
|
81
|
-
|
|
82
|
+
// An answer's code blocks and a delegate report's (a user bubble chi
|
|
83
|
+
// brought in, rendered as markdown; answer_fold.js REPORTS).
|
|
84
|
+
const CODE = '.bubble.output pre, .bubble.user[data-source="delegate_report"] pre';
|
|
82
85
|
|
|
83
86
|
// The button as a lit template, for a templated part (a tool row's
|
|
84
87
|
// command block, command_view.js); the same click handler answers it.
|