samagotchi 0.136.1 → 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 +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +12 -4
- data/docs/guardrails.md +12 -5
- data/docs/internals/engine.md +4 -4
- data/docs/internals/web-frontend.md +19 -9
- 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 +24 -6
- data/lib/samagotchi/web/public/app.js +7 -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/families_view.js +15 -6
- data/lib/samagotchi/web/public/index.html +55 -35
- 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 +4 -1
|
@@ -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") ||
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// lines". Its key: a merged one's data-line-key, a wake turn's opening
|
|
17
17
|
// "<turn id>:prompt". A live merged report has no key until a reload.
|
|
18
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
|
+
//
|
|
19
25
|
// Which answers are folded is this browser's choice (localStorage through
|
|
20
26
|
// storage.js): one key per session holding the folded answers' keys and
|
|
21
27
|
// the unfolded reports' keys, the last FOLD_KEEP of each. An answer's key is its turn's id
|
|
@@ -46,9 +52,11 @@ export const FOLD_PREFIX = "chi_answer_fold:";
|
|
|
46
52
|
const LABEL_CHARS = 160;
|
|
47
53
|
|
|
48
54
|
const ROW_CLASS = "answer-fold";
|
|
49
|
-
// 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).
|
|
50
57
|
const REPORTS = '.bubble.user[data-source="delegate_report"]';
|
|
51
|
-
const
|
|
58
|
+
const PROMPTS = ".bubble.user:not([data-source])";
|
|
59
|
+
const ANSWERS = `.bubble.output, ${REPORTS}, ${PROMPTS}`;
|
|
52
60
|
// Not done yet: still streaming.
|
|
53
61
|
const UNFINISHED = ".streaming";
|
|
54
62
|
|
|
@@ -175,16 +183,21 @@ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
|
|
|
175
183
|
// ---- The rows ----
|
|
176
184
|
|
|
177
185
|
const isReport = (bubble) => !!bubble?.matches?.(REPORTS);
|
|
186
|
+
const isPrompt = (bubble) => !!bubble?.matches?.(PROMPTS);
|
|
178
187
|
|
|
179
188
|
// The key an answer bubble is folded by, or null. A delegate report's: a
|
|
180
189
|
// merged one's line key (data-line-key, "<turn id>:input:<place>"), one
|
|
181
190
|
// that opened a wake turn its turn's id and ":prompt" (never the answer's
|
|
182
|
-
// key, the bare id).
|
|
191
|
+
// key, the bare id). A prompt's: its turn's id and ":prompt".
|
|
183
192
|
export function answerKey(bubble) {
|
|
184
193
|
if (isReport(bubble)) {
|
|
185
194
|
const turn = bubble.getAttribute("data-turn-id");
|
|
186
195
|
return bubble.getAttribute("data-line-key") || (turn ? `${turn}:prompt` : null);
|
|
187
196
|
}
|
|
197
|
+
if (isPrompt(bubble)) {
|
|
198
|
+
const turn = bubble.getAttribute("data-turn-id");
|
|
199
|
+
return turn ? `${turn}:prompt` : null;
|
|
200
|
+
}
|
|
188
201
|
return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
|
|
189
202
|
}
|
|
190
203
|
|
|
@@ -208,13 +221,17 @@ function drawRow(row, bubble) {
|
|
|
208
221
|
const folded = bubble.classList.contains("folded");
|
|
209
222
|
const text = answerText(bubble);
|
|
210
223
|
const report = isReport(bubble);
|
|
224
|
+
const prompt = isPrompt(bubble);
|
|
211
225
|
const own = report ? { label: reportLabel(text, bubble.querySelector(":scope > .origin-label")?.textContent || "") } : {};
|
|
212
|
-
|
|
213
|
-
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";
|
|
214
230
|
row.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
215
231
|
row.title = folded ? `Show the ${what}` : `Fold the ${what}`;
|
|
216
232
|
row.classList.toggle("folded", folded);
|
|
217
233
|
row.classList.toggle("report", report);
|
|
234
|
+
row.classList.toggle("prompt", prompt);
|
|
218
235
|
row.classList.toggle("steered", report && bubble.classList.contains("steered"));
|
|
219
236
|
const [labelEl, sizeEl] = row.children;
|
|
220
237
|
if (labelEl.textContent !== label) labelEl.textContent = label;
|
|
@@ -295,6 +312,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
295
312
|
|
|
296
313
|
// Synchronous (not a frame later, as copy buttons are): the row lands in
|
|
297
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.
|
|
298
316
|
new MutationObserver((records) => {
|
|
299
317
|
const roots = new Set();
|
|
300
318
|
let settle = false;
|
|
@@ -314,7 +332,7 @@ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => nu
|
|
|
314
332
|
}
|
|
315
333
|
if (settle) dropOrphans();
|
|
316
334
|
for (const el of roots) if (el.isConnected) decorate(el);
|
|
317
|
-
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
335
|
+
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-turn-id"] });
|
|
318
336
|
decorate(historyEl);
|
|
319
337
|
|
|
320
338
|
historyEl.addEventListener("click", (e) => {
|
|
@@ -122,6 +122,7 @@ import { createUpdateSheet } from "./update_sheet.js";
|
|
|
122
122
|
import { createRename } from "./session_rename.js";
|
|
123
123
|
import { createSendTurn } from "./send_turn.js";
|
|
124
124
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
125
|
+
import { withoutStaleParams } from "./stale_params.js";
|
|
125
126
|
|
|
126
127
|
// A link's #code= (behind an auth proxy): traded for the cookie before
|
|
127
128
|
// anything else runs; the page reloads without it.
|
|
@@ -129,6 +130,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
|
|
|
129
130
|
// someone sent to swap this browser's session for theirs), so ask first.
|
|
130
131
|
if (fragmentCode(globalThis.location?.hash)
|
|
131
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);
|
|
132
137
|
// Signed out (the proxy's session or chi's token): a banner, or one reload.
|
|
133
138
|
const authLost = createAuthLost();
|
|
134
139
|
onAuthLost(authLost);
|
|
@@ -941,9 +946,10 @@ function setTurnRunning(running) {
|
|
|
941
946
|
liveTurn.running = !!running;
|
|
942
947
|
// A fresh link's picker goes now, not at the timing re-read after the turn.
|
|
943
948
|
if (running && store.selected) store.update({ current: { turnStarted: true } });
|
|
944
|
-
// 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.
|
|
945
950
|
store.touch("composer");
|
|
946
951
|
store.touch("infoBar");
|
|
952
|
+
store.touch("settingsPanel");
|
|
947
953
|
});
|
|
948
954
|
}
|
|
949
955
|
|
|
@@ -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.
|
|
@@ -8,6 +8,7 @@ import { ownerBadge, previewForCard, relativeTime } from "./format.js";
|
|
|
8
8
|
import { familySummary, stoppedBadge, waitingBadge } from "./sessions_list.js";
|
|
9
9
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
10
10
|
import { keepFocus } from "./keep_focus.js";
|
|
11
|
+
import { focusBack, modalDialog } from "./popover_modal.js";
|
|
11
12
|
|
|
12
13
|
// The chip in a parent's meta row: "▸ 3 delegates · ● 1 live · 1 waiting"
|
|
13
14
|
// (a waiting one in the attention colour), its tooltip the delegates by
|
|
@@ -101,7 +102,11 @@ export function syncPulses(root) {
|
|
|
101
102
|
// @param onPick (id) a row's click
|
|
102
103
|
// @param onClose () after it closed (the strip's held order catches up)
|
|
103
104
|
// @param id the element's id (a chain's popover, chains.js, is another one)
|
|
104
|
-
|
|
105
|
+
// @param label its accessible name
|
|
106
|
+
// It behaves as every popover does (popover_modal.js): a modal dialog (Tab wraps in
|
|
107
|
+
// its rows; a Tab from the chip goes in), the chip's aria-expanded, Esc
|
|
108
|
+
// closes it and focus goes back to the chip.
|
|
109
|
+
export function createFamilyPop({ id = "familyPop", label = "Delegates", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
|
|
105
110
|
let el = null;
|
|
106
111
|
let headId = null;
|
|
107
112
|
|
|
@@ -111,6 +116,7 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
|
|
|
111
116
|
el.id = id;
|
|
112
117
|
el.className = "family-pop";
|
|
113
118
|
el.hidden = true;
|
|
119
|
+
modalDialog(el, { label, isOpen: () => headId != null, doc });
|
|
114
120
|
el.addEventListener("click", (e) => {
|
|
115
121
|
const row = e.target?.closest?.(".family-row[data-id]");
|
|
116
122
|
if (row) onPick(row.dataset.id);
|
|
@@ -155,13 +161,18 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
|
|
|
155
161
|
draw();
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
// Focus that was in it (or +refocus+: Esc) goes back to the chip as
|
|
165
|
+
// drawn after onClose (a held strip catches up).
|
|
166
|
+
function close({ refocus = false } = {}) {
|
|
159
167
|
if (!headId) return;
|
|
160
|
-
|
|
168
|
+
const back = focusBack(el, { refocus, doc });
|
|
169
|
+
const id = headId;
|
|
170
|
+
anchorFor(id)?.setAttribute("aria-expanded", "false");
|
|
161
171
|
headId = null;
|
|
162
172
|
el.hidden = true;
|
|
163
173
|
render(nothing, el);
|
|
164
174
|
onClose();
|
|
175
|
+
back(anchorFor(id));
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
return {
|
|
@@ -181,12 +192,10 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
|
|
|
181
192
|
// capture phase): true when it did.
|
|
182
193
|
onKeydown(e) {
|
|
183
194
|
if (!headId || e.key !== "Escape") return false;
|
|
184
|
-
const id = headId;
|
|
185
195
|
e.preventDefault();
|
|
186
196
|
e.stopImmediatePropagation();
|
|
187
197
|
e.stopPropagation();
|
|
188
|
-
close();
|
|
189
|
-
anchorFor(id)?.focus(); // the chip as drawn after close() (a held strip catches up)
|
|
198
|
+
close({ refocus: true });
|
|
190
199
|
return true;
|
|
191
200
|
},
|
|
192
201
|
// A pointer down outside it and its chip closes it.
|