samagotchi 0.136.1 → 0.138.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 +58 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +15 -7
- data/docs/context.md +7 -9
- data/docs/guardrails.md +12 -5
- data/docs/internals/engine.md +4 -4
- data/docs/internals/participants.md +2 -1
- data/docs/internals/web-frontend.md +19 -9
- data/docs/plugins.md +2 -2
- data/docs/sessions.md +8 -8
- 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/mise_paths.rb +36 -0
- 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 +3 -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 +24 -19
- data/lib/samagotchi/web/public/chains.js +6 -5
- data/lib/samagotchi/web/public/chip_popover.js +20 -42
- data/lib/samagotchi/web/public/composer.css +15 -11
- data/lib/samagotchi/web/public/composer_grip.js +11 -13
- data/lib/samagotchi/web/public/composer_mount.js +2 -2
- data/lib/samagotchi/web/public/context_chips.js +29 -98
- data/lib/samagotchi/web/public/copy.js +8 -5
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/families_view.js +30 -38
- data/lib/samagotchi/web/public/index.html +176 -132
- data/lib/samagotchi/web/public/info_bar.js +19 -8
- data/lib/samagotchi/web/public/info_bar_model.js +3 -1
- data/lib/samagotchi/web/public/lists_view.js +69 -42
- data/lib/samagotchi/web/public/participants_panel.js +4 -4
- 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/stage_view.js +51 -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
- data/lib/samagotchi/web/session_summary.rb +1 -1
- metadata +5 -1
|
@@ -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) => {
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
listParticipants, inviteParticipant, revokeParticipant, setParticipantCap,
|
|
24
24
|
getContext,
|
|
25
25
|
detachContext,
|
|
26
|
-
addContext,
|
|
27
26
|
renderText,
|
|
28
27
|
getWorkingSet,
|
|
29
28
|
updateChi,
|
|
@@ -122,6 +121,7 @@ import { createUpdateSheet } from "./update_sheet.js";
|
|
|
122
121
|
import { createRename } from "./session_rename.js";
|
|
123
122
|
import { createSendTurn } from "./send_turn.js";
|
|
124
123
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
124
|
+
import { withoutStaleParams } from "./stale_params.js";
|
|
125
125
|
|
|
126
126
|
// A link's #code= (behind an auth proxy): traded for the cookie before
|
|
127
127
|
// anything else runs; the page reloads without it.
|
|
@@ -129,6 +129,10 @@ import { createMarkdownTip } from "./markdown_tip.js";
|
|
|
129
129
|
// someone sent to swap this browser's session for theirs), so ask first.
|
|
130
130
|
if (fragmentCode(globalThis.location?.hash)
|
|
131
131
|
&& await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
|
|
132
|
+
// A query parameter the page no longer reads (?view=, the removed turn
|
|
133
|
+
// view's) leaves the address bar, so the next bookmark is clean.
|
|
134
|
+
const staleFree = withoutStaleParams(location.search);
|
|
135
|
+
if (staleFree !== null) history.replaceState(null, "", location.pathname + staleFree + location.hash);
|
|
132
136
|
// Signed out (the proxy's session or chi's token): a banner, or one reload.
|
|
133
137
|
const authLost = createAuthLost();
|
|
134
138
|
onAuthLost(authLost);
|
|
@@ -151,7 +155,7 @@ const infoTextEl = $("#infoText");
|
|
|
151
155
|
// (updateInfoBar redraws #infoText every tick; the chips keep their popover).
|
|
152
156
|
const contextChips = createContextChips({
|
|
153
157
|
el: $("#contextChips"),
|
|
154
|
-
api: { list: listContext, show: getContext, detach: detachContext
|
|
158
|
+
api: { list: listContext, show: getContext, detach: detachContext },
|
|
155
159
|
toast: (text) => showToast(text),
|
|
156
160
|
});
|
|
157
161
|
// The open session's ⚙ chip in the info bar, and its panel (thinking,
|
|
@@ -221,10 +225,8 @@ let pageCapabilities = {};
|
|
|
221
225
|
let pageEditorUrl = null;
|
|
222
226
|
const promptEl = $("#prompt");
|
|
223
227
|
const actionBtn = $("#actionBtn");
|
|
224
|
-
const cancelBtn = $("#cancelBtn");
|
|
225
228
|
const attachBtn = $("#attachBtn");
|
|
226
229
|
const attachInput = $("#attachInput");
|
|
227
|
-
const infoCancelBtn = $("#infoCancelBtn");
|
|
228
230
|
const composeHintEl = $("#composeHint");
|
|
229
231
|
|
|
230
232
|
// The open session (store.selected), its fields (store.current: session_state.js;
|
|
@@ -349,6 +351,8 @@ const turnDom = createStageView({
|
|
|
349
351
|
renderMarkdown,
|
|
350
352
|
linkRefs: linkRefsIn,
|
|
351
353
|
workingSet,
|
|
354
|
+
// Stop turn in its status row (disabled until the request settles).
|
|
355
|
+
onCancel: () => handleCancel(),
|
|
352
356
|
});
|
|
353
357
|
// The history's bottom reserve and the toast's place follow the composer
|
|
354
358
|
// card and the stage floating over the history (dock_reserve.js).
|
|
@@ -679,6 +683,7 @@ const lists = createListsView({
|
|
|
679
683
|
showToast: (text, action) => showToast(text, action),
|
|
680
684
|
allViewOpen: () => allViewOpen(),
|
|
681
685
|
openAllView: () => openAllView(),
|
|
686
|
+
closeAllView: () => closeAllView(),
|
|
682
687
|
leaveAllView: (hash) => leaveAllView(hash),
|
|
683
688
|
renameShown,
|
|
684
689
|
onRename: () => rename.open(),
|
|
@@ -844,6 +849,8 @@ function applyView() {
|
|
|
844
849
|
topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
|
|
845
850
|
}
|
|
846
851
|
document.body.classList.toggle("all-view", open);
|
|
852
|
+
// The sessions pill is the view's button there: pressed while it is open.
|
|
853
|
+
$("#stripStatus").setAttribute("aria-pressed", String(open));
|
|
847
854
|
updateSessionTitle();
|
|
848
855
|
// Its chip was in the list just hidden (the strip, or all sessions).
|
|
849
856
|
lists.chainPop.close();
|
|
@@ -861,7 +868,6 @@ window.addEventListener("hashchange", () => {
|
|
|
861
868
|
applyView();
|
|
862
869
|
selectFromHash();
|
|
863
870
|
});
|
|
864
|
-
$("#allBack").addEventListener("click", closeAllView);
|
|
865
871
|
|
|
866
872
|
// A phone's composer fits a few words and has no Shift+Enter: the short
|
|
867
873
|
// placeholder there.
|
|
@@ -893,9 +899,9 @@ function updateComposerMode() {
|
|
|
893
899
|
// The start page's choice was for a new session: opening a session drops it.
|
|
894
900
|
if (store.selected) startPage.dropChoices();
|
|
895
901
|
const running = liveTurn.running && !!store.selected;
|
|
896
|
-
cancelBtn.style.display = running ? "" : "none";
|
|
897
902
|
// No images for a session a terminal owns (it takes no message from here);
|
|
898
|
-
// on a phone none while a turn runs either:
|
|
903
|
+
// on a phone none while a turn runs either: the row keeps its room for the
|
|
904
|
+
// message box (steering).
|
|
899
905
|
attachBtn.hidden = (!!store.selected && store.current.owner === "tui") || (running && narrowScreen.matches);
|
|
900
906
|
syncDeliveryLine();
|
|
901
907
|
if (running) {
|
|
@@ -937,13 +943,16 @@ function syncDeliveryLine() {
|
|
|
937
943
|
}
|
|
938
944
|
|
|
939
945
|
function setTurnRunning(running) {
|
|
946
|
+
// A running turn the stage held none of goes with it (runningWithoutTurn).
|
|
947
|
+
if (!running) turnDom.runningWithoutTurn?.(false);
|
|
940
948
|
store.batch(() => {
|
|
941
949
|
liveTurn.running = !!running;
|
|
942
950
|
// A fresh link's picker goes now, not at the timing re-read after the turn.
|
|
943
951
|
if (running && store.selected) store.update({ current: { turnStarted: true } });
|
|
944
|
-
// liveTurn.running isn't the store's: the composer and the
|
|
952
|
+
// liveTurn.running isn't the store's: the composer, the bar and the open ⚙ panel's note read it.
|
|
945
953
|
store.touch("composer");
|
|
946
954
|
store.touch("infoBar");
|
|
955
|
+
store.touch("settingsPanel");
|
|
947
956
|
});
|
|
948
957
|
}
|
|
949
958
|
|
|
@@ -1196,6 +1205,8 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
1196
1205
|
liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
|
|
1197
1206
|
if (!data.current_turn) {
|
|
1198
1207
|
setTurnRunning(store.current.status === "running");
|
|
1208
|
+
// Running with no turn to draw: the stage's status row still holds Stop turn.
|
|
1209
|
+
turnDom.runningWithoutTurn?.(liveTurn.running);
|
|
1199
1210
|
if (liveTurn.running && store.current.timing.activeTurn?.started_at) liveTurn.timing.start(store.current.timing.activeTurn.started_at, activeTurnId);
|
|
1200
1211
|
} else if (liveTurn.running) {
|
|
1201
1212
|
// The replayed turn_started carries no id: the running turn's record's.
|
|
@@ -1493,6 +1504,8 @@ function updateInfoBar() {
|
|
|
1493
1504
|
continueBtn.style.display = buttons.continue.shown ? "" : "none";
|
|
1494
1505
|
continueBtn.disabled = buttons.continue.disabled;
|
|
1495
1506
|
continueBtn.textContent = buttons.continue.text;
|
|
1507
|
+
// The accent on an archived session, where it is the thing to do.
|
|
1508
|
+
continueBtn.classList.toggle("accent", buttons.archive.archived);
|
|
1496
1509
|
}
|
|
1497
1510
|
if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
|
|
1498
1511
|
if (archiveBtn) {
|
|
@@ -1860,18 +1873,14 @@ function queueRefused(altKey) {
|
|
|
1860
1873
|
return true;
|
|
1861
1874
|
}
|
|
1862
1875
|
|
|
1876
|
+
// The stage's Stop turn (its button stays disabled until this settles).
|
|
1863
1877
|
async function handleCancel() {
|
|
1864
1878
|
if (!store.selected) return;
|
|
1865
|
-
cancelBtn.disabled = true;
|
|
1866
|
-
infoCancelBtn.disabled = true;
|
|
1867
1879
|
try {
|
|
1868
1880
|
await cancelTurn(store.selected);
|
|
1869
1881
|
} catch (e) {
|
|
1870
1882
|
// The turn ended meanwhile: nothing left to cancel.
|
|
1871
1883
|
if (e.code !== "not_running") alert(e.message);
|
|
1872
|
-
} finally {
|
|
1873
|
-
cancelBtn.disabled = false;
|
|
1874
|
-
infoCancelBtn.disabled = false;
|
|
1875
1884
|
}
|
|
1876
1885
|
}
|
|
1877
1886
|
|
|
@@ -1895,7 +1904,7 @@ function promptMinPx() {
|
|
|
1895
1904
|
// The composer (composer_mount.js): the box, its height, image chips, grip
|
|
1896
1905
|
// and fold, ↑/↓ history and keys. The owner's parts come in as hooks: the
|
|
1897
1906
|
// draft's origin (noteDraft), the / list's keys, the delivery line's Esc and
|
|
1898
|
-
// queue, the info bar's
|
|
1907
|
+
// queue, the info bar's drop while folded, typing anywhere.
|
|
1899
1908
|
const composer = mountComposer({
|
|
1900
1909
|
els: {
|
|
1901
1910
|
composer: composerEl, prompt: promptEl, button: actionBtn, chips: $("#chips"), attachBtn, attachInput,
|
|
@@ -1916,11 +1925,9 @@ const composer = mountComposer({
|
|
|
1916
1925
|
// Esc lets go of a choice armed on the delivery line.
|
|
1917
1926
|
escape: () => deliveryControl.disarm(),
|
|
1918
1927
|
fold: {
|
|
1919
|
-
infoCancelBtn,
|
|
1920
1928
|
infoBarEl: $("#infoBar"),
|
|
1921
1929
|
session: () => store.selected,
|
|
1922
1930
|
waiting: () => !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion()),
|
|
1923
|
-
turnRunning: () => liveTurn.running,
|
|
1924
1931
|
allViewOpen,
|
|
1925
1932
|
lightboxOpen: () => !lightboxEl.hidden,
|
|
1926
1933
|
},
|
|
@@ -1966,8 +1973,6 @@ lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
|
|
|
1966
1973
|
document.addEventListener("keydown", (e) => {
|
|
1967
1974
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
1968
1975
|
});
|
|
1969
|
-
cancelBtn.addEventListener("click", () => handleCancel());
|
|
1970
|
-
infoCancelBtn.addEventListener("click", () => handleCancel());
|
|
1971
1976
|
|
|
1972
1977
|
// The / autocomplete (command_list.js): the session's commands while the
|
|
1973
1978
|
// composer holds one "/word".
|
|
@@ -2189,7 +2194,7 @@ store.on("infoBar", updateInfoBar);
|
|
|
2189
2194
|
|
|
2190
2195
|
applyView();
|
|
2191
2196
|
|
|
2192
|
-
// Where sessions live: under the all-sessions
|
|
2197
|
+
// Where sessions live: a footer line under the all-sessions list (the server fills it in).
|
|
2193
2198
|
if (document.body.dataset.sessionsDir) {
|
|
2194
2199
|
$("#allPath code").textContent = document.body.dataset.sessionsDir;
|
|
2195
2200
|
$("#allPath").hidden = false;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { normalize, previewForCard, relativeTime } from "./format.js";
|
|
10
10
|
import { stoppedBadge, waitingBadge } from "./sessions_list.js";
|
|
11
11
|
import { waitingOn } from "./notify.js";
|
|
12
|
-
import { archivedBadge, rowBadgesTemplate
|
|
12
|
+
import { archivedBadge, rowBadgesTemplate } from "./families_view.js";
|
|
13
13
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
14
14
|
|
|
15
15
|
// The opener a new link's composer is filled with (never sent: the user
|
|
@@ -242,10 +242,11 @@ export function chainChipTemplate(f, { open = false } = {}) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// The chain's list (the chip's popover): one row per other link, newest
|
|
245
|
-
// first:
|
|
245
|
+
// first: "#N", its date (with its HH:MM when another link of
|
|
246
246
|
// the chain, the head too, started the same day: chainStamp), its own title (/title) when it
|
|
247
247
|
// differs from the head's (a link inherits its chain's: the card says it), else its recap's
|
|
248
|
-
// first sentence, else its preview, the waiting / stopped badge, archived
|
|
248
|
+
// first sentence, else its preview, the waiting / error / stopped badge, archived (no status dot: the status
|
|
249
|
+
// is in the row's tooltip, as a family row's). A row's click opens
|
|
249
250
|
// that link (families_view.js's popover binds .family-row[data-id]).
|
|
250
251
|
// @param activeId the open session's id (its row is .active)
|
|
251
252
|
// A chain row's words: a name of its own (not the one it shares with the
|
|
@@ -270,9 +271,9 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
|
|
|
270
271
|
const ago = relativeTime(s.updated_at || s.created_at || "", now);
|
|
271
272
|
const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
|
|
272
273
|
return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
|
|
273
|
-
title=${tip}
|
|
274
|
+
title=${tip}><span class="cr-n">#${n}</span>${
|
|
274
275
|
date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
|
|
275
|
-
rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
276
|
+
rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
|
|
276
277
|
});
|
|
277
278
|
return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
|
|
278
279
|
}
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
|
|
2
2
|
(the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
|
|
3
3
|
linked after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
|
|
4
|
-
own parts stay in index.html: the card's placing (#dock), the delivery line's links,
|
|
4
|
+
own parts stay in index.html: the card's placing (#dock), the delivery line's links, the start page's picker and chips,
|
|
5
5
|
the / list, and the 16px text fields for iOS. */
|
|
6
6
|
/* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
|
|
7
7
|
bar and all view's cards use it too. */
|
|
@@ -18,7 +18,7 @@ body.no-glass .composer-card{background:var(--bg-panel);-webkit-backdrop-filter:
|
|
|
18
18
|
.composer-card-foot{padding:8px 14px 10px;border-top:1px solid var(--glass-border)}
|
|
19
19
|
.composer-line{flex:1 0 100%;min-width:0;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
|
|
20
20
|
#composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
|
|
21
|
-
#composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
21
|
+
#composer .compose-col{position:relative;flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
22
22
|
#composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
|
|
23
23
|
/* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
|
|
24
24
|
body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
@@ -66,24 +66,28 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
66
66
|
#composeGrip::after{top:17px;opacity:1}
|
|
67
67
|
}
|
|
68
68
|
#composer textarea::placeholder{color:var(--text-faint)}
|
|
69
|
-
/* Key hint:
|
|
70
|
-
|
|
71
|
-
#
|
|
69
|
+
/* Key hint (the start page's): a second faint line under the placeholder, inside the text box (over it, out of the
|
|
70
|
+
flow: it never moves the layout), only while the box is focused and empty; typing hides it. Not over image chips. */
|
|
71
|
+
#composeHint{position:absolute;left:2px;right:2px;top:calc(2px + 1.45em * 15 / 11);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);opacity:0;transition:opacity .12s ease;pointer-events:none;user-select:none}
|
|
72
|
+
#composer:focus-within #prompt:placeholder-shown ~ #composeHint:not(:empty){opacity:1}
|
|
73
|
+
#composer #chips:not([hidden]) ~ #composeHint{display:none}
|
|
72
74
|
#composer textarea:focus{outline:none}
|
|
73
|
-
/* The toolbar row: model picker ·
|
|
75
|
+
/* The toolbar row, two clusters: the new session's setup (model picker · ⚙ · 🧰 · the override chips) on the left, what
|
|
76
|
+
is sent (🖼 · Start / Send) on the right; the image button's auto margin is the gap (Send's own when it is hidden). */
|
|
74
77
|
#composer .actions-col{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
|
|
75
78
|
#composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
|
|
76
|
-
#composer .actions-col .split{order:
|
|
79
|
+
#composer .actions-col .split{order:4;display:flex;gap:4px;margin-left:auto}
|
|
80
|
+
#composer .actions-col .attach-btn:not([hidden]) ~ .split{margin-left:0}
|
|
77
81
|
#composer #actionBtn{min-width:64px;font-weight:600}
|
|
78
82
|
/* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
|
|
79
83
|
an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
|
|
80
|
-
#composer .actions-col .attach-btn{order:
|
|
84
|
+
#composer .actions-col .attach-btn{order:3;margin-left:auto;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
|
|
81
85
|
#composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
|
|
82
86
|
#composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
|
|
83
87
|
#composer .actions-col .attach-btn[hidden]{display:none}
|
|
84
|
-
/* An icon button on the row (the start page's ⚙ and 🧰, start_settings.js and start_toolkit.js): quiet like the image button, its icon a mask in --icon;
|
|
88
|
+
/* An icon button on the row (the start page's ⚙ and 🧰, start_settings.js and start_toolkit.js): quiet like the image button, its icon a mask in --icon; right after the model picker (order 1), before the override chips they set (order 2).
|
|
85
89
|
.set: something under it is set, an accent dot on a phone (the override chips say it on a wider row). */
|
|
86
|
-
#composer .actions-col .icon-btn{order:
|
|
90
|
+
#composer .actions-col .icon-btn{order:1;position:relative;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
|
|
87
91
|
#composer .actions-col .icon-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
88
92
|
#composer .actions-col .icon-btn:hover{filter:none}
|
|
89
93
|
#composer .actions-col .icon-btn[aria-expanded="true"]{color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
|
|
@@ -94,7 +98,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
94
98
|
@media(max-width:600px){#composer .actions-col .icon-btn.set{color:var(--accent)}#composer .actions-col .icon-btn.set::after{content:"";position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--accent)}}
|
|
95
99
|
@media(pointer:coarse){#composer .actions-col .icon-btn{width:36px;height:36px;min-height:36px}}
|
|
96
100
|
/* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
|
|
97
|
-
and
|
|
101
|
+
and 🖼 · Send at its bottom right. The key hint is in the placeholder there, so the hint line goes. */
|
|
98
102
|
body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
99
103
|
body.has-session #composer textarea{min-height:0}
|
|
100
104
|
body.has-session #composeHint{display:none}
|
|
@@ -9,23 +9,22 @@ import { readStored, removeStored, writeStored } from "./storage.js";
|
|
|
9
9
|
export const GRIP_KEY = "chi_composer_floor";
|
|
10
10
|
|
|
11
11
|
// +session+: the open session's id, or null (the start page). +draft+,
|
|
12
|
-
// +waiting+, +
|
|
13
|
-
//
|
|
12
|
+
// +waiting+, +allViewOpen+, +lightboxOpen+: what the page shows now.
|
|
13
|
+
// +imageFiles+ / +addImageFiles+: a drop's images and the chips.
|
|
14
14
|
// The owner page's own parts (composer_mount.js passes them through):
|
|
15
|
-
// +
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// +infoBarEl+ (a file dropped on it while folded; may be null);
|
|
16
|
+
// +typeAnywhere+: typing on the page opens a folded composer. Stop turn
|
|
17
|
+
// isn't the composer's: it sits in the stage's status row (stage_view.js).
|
|
18
18
|
export function createComposerGrip({
|
|
19
|
-
composeGrip, promptEl,
|
|
19
|
+
composeGrip, promptEl, infoBarEl = null,
|
|
20
20
|
session, fitPrompt, promptMinPx, floor, setFloor,
|
|
21
|
-
draft, waiting = () => false,
|
|
21
|
+
draft, waiting = () => false, allViewOpen = () => false, lightboxOpen = () => false,
|
|
22
22
|
imageFiles, addImageFiles, typeAnywhere = true,
|
|
23
23
|
doc = globalThis.document, win = globalThis.window,
|
|
24
24
|
}) {
|
|
25
25
|
// The composer's fold (composer_fold.js): the grip, which is the handle
|
|
26
|
-
// while folded
|
|
27
|
-
//
|
|
28
|
-
// past the fold threshold.
|
|
26
|
+
// while folded. +gripWillFold+: the drag in progress is past the fold
|
|
27
|
+
// threshold.
|
|
29
28
|
const gripStartTitle = composeGrip.title;
|
|
30
29
|
let gripWillFold = false;
|
|
31
30
|
// A click on the expanded grip folds GRIP_CLICK_FOLD_MS after it, so a
|
|
@@ -214,11 +213,10 @@ export function createComposerGrip({
|
|
|
214
213
|
}
|
|
215
214
|
promptEl.focus();
|
|
216
215
|
}
|
|
217
|
-
// The grip as the fold's handle (its title, marks, aria-expanded)
|
|
218
|
-
//
|
|
216
|
+
// The grip as the fold's handle (its title, marks, aria-expanded). The
|
|
217
|
+
// start page's grip only resizes: no role, no fold state.
|
|
219
218
|
function drawFoldControls() {
|
|
220
219
|
const folded = isComposerFolded();
|
|
221
|
-
if (infoCancelBtn) infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
|
|
222
220
|
if (!session()) {
|
|
223
221
|
composeGrip.removeAttribute("role");
|
|
224
222
|
composeGrip.removeAttribute("aria-expanded");
|
|
@@ -23,8 +23,8 @@ import { readStored } from "./storage.js";
|
|
|
23
23
|
// +escape()+ Esc, true when it let go of something. +imagesOff+: why this
|
|
24
24
|
// page takes no images (the participant page, until its upload path): a
|
|
25
25
|
// paste, drop or pick of one toasts it and adds no chip; null takes them.
|
|
26
|
-
// +fold+: createComposerGrip's page hooks (session, waiting,
|
|
27
|
-
//
|
|
26
|
+
// +fold+: createComposerGrip's page hooks (session, waiting, allViewOpen,
|
|
27
|
+
// lightboxOpen, infoBarEl, typeAnywhere).
|
|
28
28
|
// @return {el, button, chips(), setChips(list), addImageFiles(files), fit(),
|
|
29
29
|
// hasDraft(), refreshHistory(), and the grip's isComposerFolded,
|
|
30
30
|
// openForVisit, focusPrompt, drawFoldControls, newVisit, decideVisit}
|