samagotchi 0.76.0 → 0.78.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 +51 -1
- data/docs/cli.md +2 -1
- data/docs/configuration.md +10 -6
- data/docs/internals/anthropic.md +73 -5
- data/docs/internals/engine.md +2 -1
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/plugins.md +42 -0
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +2 -2
- data/lib/samagotchi/child_reports.rb +3 -3
- data/lib/samagotchi/children_status.rb +2 -2
- data/lib/samagotchi/engine.rb +21 -12
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
- data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
- data/lib/samagotchi/llm/chat_loop.rb +49 -2
- data/lib/samagotchi/llm/errors.rb +6 -4
- data/lib/samagotchi/llm/http.rb +2 -1
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/memory_bundle/builder.rb +2 -0
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/note_delivery.rb +2 -2
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +6 -7
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +4 -1
- data/lib/samagotchi/session_manager.rb +0 -1
- data/lib/samagotchi/session_metrics/totals.rb +4 -3
- data/lib/samagotchi/session_metrics.rb +9 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui.rb +7 -5
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -2
- data/lib/samagotchi/web/public/app.js +234 -74
- data/lib/samagotchi/web/public/composer_fold.js +58 -0
- data/lib/samagotchi/web/public/composer_size.js +10 -0
- data/lib/samagotchi/web/public/ctx.js +12 -1
- data/lib/samagotchi/web/public/index.html +29 -4
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/stream_handlers.js +7 -3
- data/lib/samagotchi/web/public/toasts.js +69 -0
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
|
@@ -45,6 +45,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
|
45
45
|
import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
|
|
46
46
|
import { createCardsPlace } from "./cards_place.js";
|
|
47
47
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
48
|
+
import { createMarkdownPlace } from "./markdown_place.js";
|
|
48
49
|
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
49
50
|
import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
|
|
50
51
|
import { createTaskStopper } from "./task_stop.js";
|
|
@@ -55,7 +56,7 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
|
|
|
55
56
|
import { copyText, installCopy } from "./copy.js";
|
|
56
57
|
import { createQuestionCards } from "./question_cards.js";
|
|
57
58
|
import { splitSnapshotCards } from "./card.js";
|
|
58
|
-
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
59
|
+
import { GRIP_OPEN_PX, PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, gripFolds, promptHeight } from "./composer_size.js";
|
|
59
60
|
import { createCommandList } from "./command_list.js";
|
|
60
61
|
import { createPromptHistory } from "./prompt_history.js";
|
|
61
62
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -63,6 +64,8 @@ import { scopeDir, scopeTemplate } from "./scope.js";
|
|
|
63
64
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
64
65
|
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
|
|
65
66
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
67
|
+
import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
|
|
68
|
+
import { waitingOn } from "./notify.js";
|
|
66
69
|
import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
67
70
|
import { createNotifications } from "./notifications.js";
|
|
68
71
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
@@ -86,6 +89,7 @@ import { liveSlots } from "./stage_model.js";
|
|
|
86
89
|
import { currentGen } from "./turn_model.js";
|
|
87
90
|
import { toolName } from "./activity.js";
|
|
88
91
|
import { toastBottom } from "./toast_place.js";
|
|
92
|
+
import { createToasts } from "./toasts.js";
|
|
89
93
|
|
|
90
94
|
const $ = (s) => document.querySelector(s);
|
|
91
95
|
const topStripEl = $("#topStrip");
|
|
@@ -130,7 +134,7 @@ const llmContextNewControl = createLlmContextControl({
|
|
|
130
134
|
choose: (next) => {
|
|
131
135
|
newChatLlmContext = next;
|
|
132
136
|
renderNewChatLlmContext();
|
|
133
|
-
|
|
137
|
+
focusPrompt();
|
|
134
138
|
},
|
|
135
139
|
id: "llmContextNewPopover",
|
|
136
140
|
anchor: "#llmContextNew",
|
|
@@ -166,7 +170,7 @@ const thinkingNewControl = createThinkingControl({
|
|
|
166
170
|
choose: (next) => {
|
|
167
171
|
newChatThinking = next;
|
|
168
172
|
renderNewChatThinking();
|
|
169
|
-
|
|
173
|
+
focusPrompt();
|
|
170
174
|
},
|
|
171
175
|
id: "thinkingNewPopover",
|
|
172
176
|
anchor: "#thinkingNew",
|
|
@@ -200,6 +204,19 @@ let pageEditorUrl = null;
|
|
|
200
204
|
const promptEl = $("#prompt");
|
|
201
205
|
const actionBtn = $("#actionBtn");
|
|
202
206
|
const cancelBtn = $("#cancelBtn");
|
|
207
|
+
// The composer's fold (composer_fold.js): the grip, which is the handle
|
|
208
|
+
// while folded, and the info bar's Stop turn while folded (the composer's
|
|
209
|
+
// #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
|
|
210
|
+
// past the fold threshold.
|
|
211
|
+
const composeGrip = $("#composeGrip");
|
|
212
|
+
const gripStartTitle = composeGrip.title;
|
|
213
|
+
let gripWillFold = false;
|
|
214
|
+
const infoCancelBtn = $("#infoCancelBtn");
|
|
215
|
+
// This visit's state (a visit is one select() call): opened by the page
|
|
216
|
+
// (a focus, a restored line), archived (decided once, from the visit's
|
|
217
|
+
// first getSession answer).
|
|
218
|
+
const newFoldVisit = () => ({ open: false, archived: false, decided: false });
|
|
219
|
+
let foldVisit = newFoldVisit();
|
|
203
220
|
const modelPickEl = $("#modelPick");
|
|
204
221
|
const composeHintEl = $("#composeHint");
|
|
205
222
|
|
|
@@ -231,26 +248,25 @@ const viewArgs = {
|
|
|
231
248
|
// the route (its sidecar features).
|
|
232
249
|
taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
|
|
233
250
|
};
|
|
251
|
+
const markdownPlace = createMarkdownPlace({
|
|
252
|
+
capabilities: () => pageCapabilities,
|
|
253
|
+
renderText,
|
|
254
|
+
roots: () => turnRoots(),
|
|
255
|
+
touchedFiles,
|
|
256
|
+
currentDir: () => current.dir,
|
|
257
|
+
linkRefs,
|
|
258
|
+
applyFinalAnswer,
|
|
259
|
+
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
260
|
+
normalize,
|
|
261
|
+
pairAnswerRefs,
|
|
262
|
+
});
|
|
263
|
+
const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
|
|
234
264
|
const turnDom = view === "stage"
|
|
235
265
|
? createStageView({
|
|
236
266
|
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
|
|
237
267
|
renderMarkdown, linkRefs: linkRefsIn,
|
|
238
268
|
})
|
|
239
269
|
: createTurnView(viewArgs);
|
|
240
|
-
// A text the page already shows as its markdown HTML (POST /api/render,
|
|
241
|
-
// sanitised there), or null: markdown off here (no request), or a failed
|
|
242
|
-
// read (the raw text stays).
|
|
243
|
-
function renderMarkdown(text) {
|
|
244
|
-
if (pageCapabilities.display !== true) return Promise.resolve(null);
|
|
245
|
-
return renderText(text)
|
|
246
|
-
.then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
|
|
247
|
-
.catch(() => null);
|
|
248
|
-
}
|
|
249
|
-
// The `path:line` refs in one rendered text (the stage's asking slot, a
|
|
250
|
-
// kept reply), as linkTextRefs links the answers'.
|
|
251
|
-
function linkRefsIn(el) {
|
|
252
|
-
linkRefs(el, touchedFiles(turnRoots()), current.dir);
|
|
253
|
-
}
|
|
254
270
|
// Where a running turn's rows can be: the history, and the stage while it
|
|
255
271
|
// holds a turn (lookups by id, copy, annotate, card actions).
|
|
256
272
|
function turnRoots() {
|
|
@@ -738,9 +754,11 @@ function clearSelection() {
|
|
|
738
754
|
closeStream();
|
|
739
755
|
setMarkdownWarning(null);
|
|
740
756
|
setCapabilities(null, null);
|
|
757
|
+
foldVisit = newFoldVisit();
|
|
758
|
+
applyComposerFolded();
|
|
741
759
|
updateComposerMode();
|
|
742
760
|
updateInfoBar();
|
|
743
|
-
|
|
761
|
+
focusPrompt();
|
|
744
762
|
}
|
|
745
763
|
|
|
746
764
|
// A short line on the start page (the open session went away); gone at the
|
|
@@ -773,11 +791,14 @@ async function select(id) {
|
|
|
773
791
|
closeStream();
|
|
774
792
|
resetSessionState();
|
|
775
793
|
resetTurnView();
|
|
794
|
+
foldVisit = newFoldVisit();
|
|
795
|
+
applyComposerFolded();
|
|
776
796
|
updateInfoBar();
|
|
777
797
|
contextChips.load(id);
|
|
778
798
|
updateComposerMode();
|
|
779
799
|
await resync();
|
|
780
|
-
|
|
800
|
+
// A folded composer stays folded: typing on the page opens it.
|
|
801
|
+
focusPrompt({ open: false });
|
|
781
802
|
}
|
|
782
803
|
|
|
783
804
|
// Clear everything that belongs to one turn's rendering.
|
|
@@ -814,6 +835,13 @@ async function resync({ endText = null } = {}) {
|
|
|
814
835
|
return;
|
|
815
836
|
}
|
|
816
837
|
if (id !== selected) return;
|
|
838
|
+
// An archived session opens folded: decided once per visit, from its
|
|
839
|
+
// first answer (the list may not hold it yet, or ever under ?dir).
|
|
840
|
+
if (!foldVisit.decided) {
|
|
841
|
+
foldVisit.decided = true;
|
|
842
|
+
foldVisit.archived = !!data.session?.archived;
|
|
843
|
+
applyComposerFolded();
|
|
844
|
+
}
|
|
817
845
|
const kept = keptCommandBubbles(historyEl, id);
|
|
818
846
|
resetTurnView();
|
|
819
847
|
renderSessionView(data, { kept });
|
|
@@ -978,32 +1006,6 @@ async function readTurnEnd() {
|
|
|
978
1006
|
} catch(_){}
|
|
979
1007
|
}
|
|
980
1008
|
|
|
981
|
-
// The turn's answer as its rendered markdown and with its plugin refs,
|
|
982
|
-
// once it is final (the turn-end read, an answer_display re-read:
|
|
983
|
-
// answer_refs.js applyFinalAnswer); then the refs.
|
|
984
|
-
function applyFinalMarkdown(messages) {
|
|
985
|
-
applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
|
|
986
|
-
linkTextRefs();
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// The refs on the answers' and closed steps' texts, on this page: first
|
|
990
|
-
// each answer's own (a plugin's, answer_refs.js), then the `path:line`
|
|
991
|
-
// refs that name a file the session's rows (and cards) touched
|
|
992
|
-
// (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
|
|
993
|
-
// turn's end and after a reload's render. A ref already made stays as it
|
|
994
|
-
// is.
|
|
995
|
-
function linkTextRefs() {
|
|
996
|
-
const roots = turnRoots();
|
|
997
|
-
const touched = touchedFiles(roots);
|
|
998
|
-
for (const root of roots) {
|
|
999
|
-
for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
|
|
1000
|
-
}
|
|
1001
|
-
if (!touched.size) return;
|
|
1002
|
-
for (const root of roots) {
|
|
1003
|
-
for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
1009
|
function seedSeenFromDom() {
|
|
1008
1010
|
shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
|
|
1009
1011
|
}
|
|
@@ -1114,6 +1116,7 @@ function updateInfoBar() {
|
|
|
1114
1116
|
archiveBtn.title = buttons.archive.title;
|
|
1115
1117
|
}
|
|
1116
1118
|
if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
|
|
1119
|
+
drawFoldControls();
|
|
1117
1120
|
// No session: the start page hides the info bar (the storage path is on
|
|
1118
1121
|
// #/sessions). The session clock ticks every second while a turn runs:
|
|
1119
1122
|
// lit writes its text alone, the bar's other nodes (a tooltip, a button
|
|
@@ -1285,6 +1288,8 @@ const streamHandlers = createStreamHandlers({
|
|
|
1285
1288
|
composer: {
|
|
1286
1289
|
empty: () => !promptEl.value.trim() && !chips.length,
|
|
1287
1290
|
refill: (text) => {
|
|
1291
|
+
// A failed turn's line back: seen, so the composer opens (this visit).
|
|
1292
|
+
openForVisit();
|
|
1288
1293
|
promptEl.value = text;
|
|
1289
1294
|
fitPrompt();
|
|
1290
1295
|
},
|
|
@@ -1452,6 +1457,8 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1452
1457
|
// (and images) go back into the composer, without replacing text typed
|
|
1453
1458
|
// since (4.13). The composer still holds the text just sent: it goes.
|
|
1454
1459
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
1460
|
+
// A restored line must be seen: the composer opens for this visit.
|
|
1461
|
+
if (restored?.refill && sentTo === selected) openForVisit();
|
|
1455
1462
|
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
1456
1463
|
fitPrompt();
|
|
1457
1464
|
clearChips();
|
|
@@ -1488,7 +1495,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1488
1495
|
showToast(cantText("send", e), null, { sticky: true });
|
|
1489
1496
|
} finally {
|
|
1490
1497
|
actionBtn.disabled = current.owner === "tui";
|
|
1491
|
-
|
|
1498
|
+
focusPrompt();
|
|
1492
1499
|
}
|
|
1493
1500
|
}
|
|
1494
1501
|
|
|
@@ -1513,7 +1520,8 @@ const composerCommands = createComposerCommands({
|
|
|
1513
1520
|
clear: () => clearComposer(),
|
|
1514
1521
|
done: () => {
|
|
1515
1522
|
actionBtn.disabled = current.owner === "tui";
|
|
1516
|
-
|
|
1523
|
+
// A chip's command (the info bar) leaves a folded composer folded.
|
|
1524
|
+
focusPrompt({ open: false });
|
|
1517
1525
|
},
|
|
1518
1526
|
},
|
|
1519
1527
|
});
|
|
@@ -1530,6 +1538,7 @@ function clearComposer() {
|
|
|
1530
1538
|
async function handleCancel() {
|
|
1531
1539
|
if (!selected) return;
|
|
1532
1540
|
cancelBtn.disabled = true;
|
|
1541
|
+
infoCancelBtn.disabled = true;
|
|
1533
1542
|
try {
|
|
1534
1543
|
await cancelTurn(selected);
|
|
1535
1544
|
} catch (e) {
|
|
@@ -1537,6 +1546,7 @@ async function handleCancel() {
|
|
|
1537
1546
|
if (e.code !== "not_running") alert(e.message);
|
|
1538
1547
|
} finally {
|
|
1539
1548
|
cancelBtn.disabled = false;
|
|
1549
|
+
infoCancelBtn.disabled = false;
|
|
1540
1550
|
}
|
|
1541
1551
|
}
|
|
1542
1552
|
|
|
@@ -1585,7 +1595,7 @@ const modelPicker = createModelPicker({
|
|
|
1585
1595
|
renderNewChatLlmContext();
|
|
1586
1596
|
renderNewChatThinking();
|
|
1587
1597
|
}
|
|
1588
|
-
|
|
1598
|
+
focusPrompt();
|
|
1589
1599
|
},
|
|
1590
1600
|
});
|
|
1591
1601
|
// A pick on a fresh link: /model in its worker (its command bubble and the
|
|
@@ -1677,6 +1687,7 @@ let chips = [];
|
|
|
1677
1687
|
function renderChips() {
|
|
1678
1688
|
render(chipsTemplate(chips), chipsEl);
|
|
1679
1689
|
chipsEl.hidden = !chips.length;
|
|
1690
|
+
drawFoldControls();
|
|
1680
1691
|
}
|
|
1681
1692
|
|
|
1682
1693
|
function setChips(list) {
|
|
@@ -1693,7 +1704,7 @@ function addImageFiles(files) {
|
|
|
1693
1704
|
if (!files.length) return false;
|
|
1694
1705
|
for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
|
|
1695
1706
|
renderChips();
|
|
1696
|
-
|
|
1707
|
+
focusPrompt();
|
|
1697
1708
|
return true;
|
|
1698
1709
|
}
|
|
1699
1710
|
|
|
@@ -1722,33 +1733,95 @@ function fitPrompt() {
|
|
|
1722
1733
|
promptEl.style.overflowY = scrolls ? "auto" : "hidden";
|
|
1723
1734
|
}
|
|
1724
1735
|
|
|
1725
|
-
|
|
1736
|
+
// A drag down past the box folds the composer on release (a session only: the
|
|
1737
|
+
// start page's grip only resizes); from folded, a pull up opens it and the box
|
|
1738
|
+
// follows the pointer in the same gesture, a tap opens it and focuses the box.
|
|
1739
|
+
// No click listener: a click still fires after pointerdown's preventDefault
|
|
1740
|
+
// and would toggle twice.
|
|
1726
1741
|
composeGrip.addEventListener("pointerdown", (e) => {
|
|
1727
1742
|
e.preventDefault();
|
|
1728
1743
|
composeGrip.setPointerCapture(e.pointerId);
|
|
1729
1744
|
composeGrip.classList.add("dragging");
|
|
1730
|
-
const
|
|
1731
|
-
|
|
1745
|
+
const tapY = e.clientY;
|
|
1746
|
+
let startY = tapY;
|
|
1747
|
+
let startH = promptEl.offsetHeight;
|
|
1748
|
+
let opened = false;
|
|
1732
1749
|
const move = (ev) => {
|
|
1733
|
-
|
|
1750
|
+
if (selected && isComposerFolded()) {
|
|
1751
|
+
if (startY - ev.clientY < GRIP_OPEN_PX) return;
|
|
1752
|
+
setComposerFolded(false);
|
|
1753
|
+
opened = true;
|
|
1754
|
+
// Re-based after the unfold (its fitPrompt sized the box: the minimum
|
|
1755
|
+
// when empty, the text's height with a draft).
|
|
1756
|
+
startH = promptEl.offsetHeight;
|
|
1757
|
+
startY = ev.clientY;
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
const requested = startH + (startY - ev.clientY);
|
|
1761
|
+
promptFloor = gripFloor(requested, window.innerHeight, promptMinPx());
|
|
1734
1762
|
fitPrompt();
|
|
1763
|
+
const willFold = !!selected && gripFolds({ requestedPx: requested, shownPx: promptEl.offsetHeight });
|
|
1764
|
+
if (willFold !== gripWillFold) {
|
|
1765
|
+
gripWillFold = willFold;
|
|
1766
|
+
composeGrip.classList.toggle("will-fold", willFold);
|
|
1767
|
+
drawFoldControls();
|
|
1768
|
+
}
|
|
1735
1769
|
};
|
|
1736
|
-
const
|
|
1770
|
+
const end = () => {
|
|
1737
1771
|
composeGrip.classList.remove("dragging");
|
|
1738
1772
|
composeGrip.removeEventListener("pointermove", move);
|
|
1739
1773
|
composeGrip.removeEventListener("pointerup", up);
|
|
1740
|
-
composeGrip.removeEventListener("pointercancel",
|
|
1741
|
-
|
|
1774
|
+
composeGrip.removeEventListener("pointercancel", cancel);
|
|
1775
|
+
const folds = gripWillFold;
|
|
1776
|
+
gripWillFold = false;
|
|
1777
|
+
composeGrip.classList.remove("will-fold");
|
|
1778
|
+
return folds;
|
|
1779
|
+
};
|
|
1780
|
+
const up = (ev) => {
|
|
1781
|
+
const folds = end();
|
|
1782
|
+
if (folds) {
|
|
1783
|
+
// The next opening starts at the minimum (or the text's height).
|
|
1784
|
+
promptFloor = null;
|
|
1785
|
+
try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
|
|
1786
|
+
setComposerFolded(true);
|
|
1787
|
+
} else if (selected && isComposerFolded()) {
|
|
1788
|
+
if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
|
|
1789
|
+
setComposerFolded(false);
|
|
1790
|
+
promptEl.focus();
|
|
1791
|
+
}
|
|
1792
|
+
} else {
|
|
1793
|
+
try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
|
|
1794
|
+
// A pull that opened the box leaves the focus in it, as a tap does.
|
|
1795
|
+
if (opened) promptEl.focus();
|
|
1796
|
+
}
|
|
1797
|
+
drawFoldControls();
|
|
1798
|
+
};
|
|
1799
|
+
const cancel = () => {
|
|
1800
|
+
end();
|
|
1801
|
+
drawFoldControls();
|
|
1742
1802
|
};
|
|
1743
1803
|
composeGrip.addEventListener("pointermove", move);
|
|
1744
1804
|
composeGrip.addEventListener("pointerup", up);
|
|
1745
|
-
composeGrip.addEventListener("pointercancel",
|
|
1805
|
+
composeGrip.addEventListener("pointercancel", cancel);
|
|
1746
1806
|
});
|
|
1747
1807
|
composeGrip.addEventListener("dblclick", () => {
|
|
1808
|
+
if (selected && isComposerFolded()) return;
|
|
1748
1809
|
promptFloor = null;
|
|
1749
1810
|
try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
|
|
1750
1811
|
fitPrompt();
|
|
1751
1812
|
});
|
|
1813
|
+
// Enter or Space on the focused handle folds, or opens and focuses the box
|
|
1814
|
+
// (a session only; preventDefault: Space would scroll the page).
|
|
1815
|
+
composeGrip.addEventListener("keydown", (e) => {
|
|
1816
|
+
if (!selected || (e.key !== "Enter" && e.key !== " ")) return;
|
|
1817
|
+
e.preventDefault();
|
|
1818
|
+
if (isComposerFolded()) {
|
|
1819
|
+
setComposerFolded(false);
|
|
1820
|
+
promptEl.focus();
|
|
1821
|
+
} else {
|
|
1822
|
+
setComposerFolded(true);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1752
1825
|
promptEl.addEventListener("input", fitPrompt);
|
|
1753
1826
|
window.addEventListener("resize", fitPrompt);
|
|
1754
1827
|
fitPrompt();
|
|
@@ -1831,6 +1904,104 @@ document.addEventListener("keydown", (e) => {
|
|
|
1831
1904
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
1832
1905
|
});
|
|
1833
1906
|
cancelBtn.addEventListener("click", () => handleCancel());
|
|
1907
|
+
infoCancelBtn.addEventListener("click", () => handleCancel());
|
|
1908
|
+
|
|
1909
|
+
// ── Composer fold (composer_fold.js) ───────────────────────────────────────
|
|
1910
|
+
// Dragging the composer's grip down folds the session's composer away; the
|
|
1911
|
+
// grip, now the handle on the info bar's top edge, pulled up or clicked, or
|
|
1912
|
+
// typing on the page, brings it back. The user's own choice is saved per
|
|
1913
|
+
// browser; an archived session opens folded for its visit, and expanding
|
|
1914
|
+
// there saves nothing. The page's own focus or a restored line opens it
|
|
1915
|
+
// for the visit only. The start page never folds.
|
|
1916
|
+
function composerFoldSaved() {
|
|
1917
|
+
try {
|
|
1918
|
+
return localStorage.getItem(COMPOSER_FOLD_KEY) === "1";
|
|
1919
|
+
} catch (_) {
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
function isComposerFolded() {
|
|
1924
|
+
return !!selected && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
|
|
1925
|
+
}
|
|
1926
|
+
function applyComposerFolded() {
|
|
1927
|
+
const folded = isComposerFolded();
|
|
1928
|
+
const was = document.body.classList.contains("composer-folded");
|
|
1929
|
+
document.body.classList.toggle("composer-folded", folded);
|
|
1930
|
+
// Its height was measured while hidden.
|
|
1931
|
+
if (was && !folded) fitPrompt();
|
|
1932
|
+
drawFoldControls();
|
|
1933
|
+
}
|
|
1934
|
+
// The user's own fold or unfold (the grip, typing): saved, unless this
|
|
1935
|
+
// is an archived visit (both are for the visit then).
|
|
1936
|
+
function setComposerFolded(folded) {
|
|
1937
|
+
if (foldVisit.archived) {
|
|
1938
|
+
foldVisit.open = !folded;
|
|
1939
|
+
} else {
|
|
1940
|
+
try { localStorage.setItem(COMPOSER_FOLD_KEY, folded ? "1" : "0"); } catch (_) {}
|
|
1941
|
+
foldVisit.open = false;
|
|
1942
|
+
}
|
|
1943
|
+
applyComposerFolded();
|
|
1944
|
+
}
|
|
1945
|
+
// The page opens it for this visit: nothing saved.
|
|
1946
|
+
function openForVisit() {
|
|
1947
|
+
if (!isComposerFolded()) return;
|
|
1948
|
+
foldVisit.open = true;
|
|
1949
|
+
applyComposerFolded();
|
|
1950
|
+
}
|
|
1951
|
+
// Every focus of the message box: in a session it opens a folded composer
|
|
1952
|
+
// for the visit first (+open+ false: only when it shows already).
|
|
1953
|
+
function focusPrompt({ open = true } = {}) {
|
|
1954
|
+
if (selected && isComposerFolded()) {
|
|
1955
|
+
if (!open) return;
|
|
1956
|
+
openForVisit();
|
|
1957
|
+
}
|
|
1958
|
+
promptEl.focus();
|
|
1959
|
+
}
|
|
1960
|
+
// The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
|
|
1961
|
+
// while folded. The start page's grip only resizes: no role, no fold state.
|
|
1962
|
+
function drawFoldControls() {
|
|
1963
|
+
const folded = isComposerFolded();
|
|
1964
|
+
infoCancelBtn.style.display = folded && liveTurn.running ? "" : "none";
|
|
1965
|
+
if (!selected) {
|
|
1966
|
+
composeGrip.removeAttribute("role");
|
|
1967
|
+
composeGrip.removeAttribute("aria-expanded");
|
|
1968
|
+
composeGrip.title = gripStartTitle;
|
|
1969
|
+
for (const c of ["folded", "draft", "waiting"]) composeGrip.classList.remove(c);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
const id = selected;
|
|
1973
|
+
const waiting = folded && !!(waitingOn(allSessions.find((s) => s.id === id)) || questionCards.pendingQuestion());
|
|
1974
|
+
const h = foldHandle({ folded, draft: !!(promptEl.value.trim() || chips.length), waiting });
|
|
1975
|
+
composeGrip.title = gripWillFold ? "Release to hide the message box" : h.title;
|
|
1976
|
+
composeGrip.setAttribute("role", "button");
|
|
1977
|
+
composeGrip.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
1978
|
+
composeGrip.classList.toggle("folded", h.folded);
|
|
1979
|
+
composeGrip.classList.toggle("draft", h.draft);
|
|
1980
|
+
composeGrip.classList.toggle("waiting", h.waiting);
|
|
1981
|
+
}
|
|
1982
|
+
promptEl.addEventListener("input", () => drawFoldControls());
|
|
1983
|
+
// Typing on the page opens a folded composer: the key lands in the box
|
|
1984
|
+
// (the class goes before the focus, so the browser types it there).
|
|
1985
|
+
document.addEventListener("keydown", (e) => {
|
|
1986
|
+
if (!isComposerFolded()) return;
|
|
1987
|
+
if (!opensOnKey(e, { active: document.activeElement, allView: allViewOpen(), lightbox: !lightboxEl.hidden })) return;
|
|
1988
|
+
setComposerFolded(false);
|
|
1989
|
+
promptEl.focus();
|
|
1990
|
+
});
|
|
1991
|
+
// A file dropped on the folded info bar: open (for the visit), then add it.
|
|
1992
|
+
const infoBarEl = $("#infoBar");
|
|
1993
|
+
infoBarEl.addEventListener("dragover", (e) => {
|
|
1994
|
+
if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
1995
|
+
e.preventDefault();
|
|
1996
|
+
});
|
|
1997
|
+
infoBarEl.addEventListener("drop", (e) => {
|
|
1998
|
+
if (!isComposerFolded()) return;
|
|
1999
|
+
const files = imageFiles(e.dataTransfer);
|
|
2000
|
+
if (!files.length) return;
|
|
2001
|
+
e.preventDefault();
|
|
2002
|
+
openForVisit();
|
|
2003
|
+
addImageFiles(files);
|
|
2004
|
+
});
|
|
1834
2005
|
// The / autocomplete (command_list.js): the session's commands while the
|
|
1835
2006
|
// composer holds one "/word".
|
|
1836
2007
|
const commandList = createCommandList({
|
|
@@ -1959,9 +2130,10 @@ async function continueSelected() {
|
|
|
1959
2130
|
// continue's 201.
|
|
1960
2131
|
function fillOpener(created) {
|
|
1961
2132
|
if (promptEl.value.trim() || chips.length) return;
|
|
2133
|
+
openForVisit();
|
|
1962
2134
|
promptEl.value = openerText(created);
|
|
1963
2135
|
fitPrompt();
|
|
1964
|
-
|
|
2136
|
+
focusPrompt();
|
|
1965
2137
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
1966
2138
|
}
|
|
1967
2139
|
|
|
@@ -2053,33 +2225,21 @@ function placeToast() {
|
|
|
2053
2225
|
else root.removeProperty("--toast-bottom");
|
|
2054
2226
|
}
|
|
2055
2227
|
}
|
|
2228
|
+
const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
|
|
2056
2229
|
// The model panel opening, closing or changing height (its list filters).
|
|
2057
2230
|
new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
|
|
2058
2231
|
|
|
2059
|
-
|
|
2060
|
-
// words, or a template (chains.js openChildrenTemplate: a link in it, a
|
|
2061
|
-
// delegate a continue waits for, opens that session and hides the toast).
|
|
2062
|
-
let toastTimer = null;
|
|
2063
|
-
function showToast(text, action = null, { sticky = false } = {}) {
|
|
2064
|
-
const el = $("#toast");
|
|
2065
|
-
const hide = () => { el.hidden = true; };
|
|
2066
|
-
render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
|
|
2067
|
-
? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
|
|
2068
|
-
: nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
|
|
2069
|
-
el.hidden = false;
|
|
2070
|
-
placeToast();
|
|
2071
|
-
clearTimeout(toastTimer);
|
|
2072
|
-
if (!sticky) toastTimer = setTimeout(hide, 6000);
|
|
2073
|
-
}
|
|
2232
|
+
function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
|
|
2074
2233
|
|
|
2075
2234
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
2076
2235
|
// "Annotate" and the presets, which append a quote to the composer, and the
|
|
2077
2236
|
// session's plugins' pills (chi.annotation, annotate_actions.js), which fill
|
|
2078
2237
|
// the composer with their command line and the quote, or send it at once.
|
|
2079
2238
|
function fillComposer(text) {
|
|
2239
|
+
openForVisit();
|
|
2080
2240
|
promptEl.value = text;
|
|
2081
2241
|
fitPrompt();
|
|
2082
|
-
|
|
2242
|
+
focusPrompt();
|
|
2083
2243
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2084
2244
|
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2085
2245
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The session's composer can fold away for room to read (the info bar under
|
|
2
|
+
// it stays and becomes the page's bottom edge). The user's own fold or
|
|
3
|
+
// unfold is saved per browser (chi_composer_collapsed); an archived
|
|
4
|
+
// session opens folded for that visit; the page expanding it by itself (a
|
|
5
|
+
// focus, a restored line, a quote) opens it for the visit only. A visit is
|
|
6
|
+
// one select() call.
|
|
7
|
+
|
|
8
|
+
export const COMPOSER_FOLD_KEY = "chi_composer_collapsed";
|
|
9
|
+
|
|
10
|
+
// Whether the composer is folded: the saved choice or an archived visit,
|
|
11
|
+
// unless this visit opened it.
|
|
12
|
+
export function composerFolded({ saved, archivedVisit, visitOpen }) {
|
|
13
|
+
if (visitOpen) return false;
|
|
14
|
+
return !!saved || !!archivedVisit;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The composer's grip while it is the fold's handle. Expanded it only says
|
|
18
|
+
// how to resize and hide; folded it is drawn as the handle on the info
|
|
19
|
+
// bar's top edge, pulled up or clicked to open, and marked: `draft` (the
|
|
20
|
+
// box holds text or images: folding never clears them: the accent),
|
|
21
|
+
// `waiting` (the attention colour: the session waits on the user, and its
|
|
22
|
+
// card may not take the focus while a draft is there). The title carries
|
|
23
|
+
// the words.
|
|
24
|
+
// @return {title, folded, draft, waiting}
|
|
25
|
+
export function foldHandle({ folded, draft, waiting }) {
|
|
26
|
+
if (!folded) {
|
|
27
|
+
return { title: "Drag to make the message box taller, down to hide it (double-click resets)", folded: false, draft: false, waiting: false };
|
|
28
|
+
}
|
|
29
|
+
const marked = !!draft;
|
|
30
|
+
let title = "Pull up or click to write";
|
|
31
|
+
if (marked) title += " · a draft is in it";
|
|
32
|
+
if (waiting) title += " · the session waits on you";
|
|
33
|
+
return { title, folded: true, draft: marked, waiting: !!waiting };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Whether a key pressed on the page (the composer folded) opens it and
|
|
37
|
+
// lands in the message box: a printable character, not Space (it scrolls,
|
|
38
|
+
// and the turn marker and the stage headline use it), no Ctrl/Meta/Alt,
|
|
39
|
+
// not taken already, not mid-composition, and not while the focus is in a
|
|
40
|
+
// field of its own (an input, textarea, select or contenteditable), the
|
|
41
|
+
// all-sessions view or the lightbox is open. An IME's first key arrives as
|
|
42
|
+
// "Process": it doesn't open it.
|
|
43
|
+
// +active+: document.activeElement.
|
|
44
|
+
export function opensOnKey(e, { active, allView, lightbox }) {
|
|
45
|
+
if (!e || e.defaultPrevented || e.isComposing) return false;
|
|
46
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return false;
|
|
47
|
+
const key = typeof e.key === "string" ? e.key : "";
|
|
48
|
+
if ([...key].length !== 1 || key === " ") return false;
|
|
49
|
+
if (allView || lightbox) return false;
|
|
50
|
+
return !editable(active);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function editable(el) {
|
|
54
|
+
if (!el) return false;
|
|
55
|
+
if (el.isContentEditable) return true;
|
|
56
|
+
const tag = (el.tagName || "").toUpperCase();
|
|
57
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
58
|
+
}
|
|
@@ -4,6 +4,8 @@ export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the st
|
|
|
4
4
|
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
5
5
|
const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
6
6
|
const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
7
|
+
export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
|
|
8
|
+
export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
|
|
7
9
|
|
|
8
10
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
9
11
|
// grip's height, or null; minPx: the empty box's height.
|
|
@@ -18,3 +20,11 @@ export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PR
|
|
|
18
20
|
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
19
21
|
return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
// Whether a grip drag asks to fold the composer: it wants the box at least
|
|
25
|
+
// GRIP_FOLD_PX less than the box actually shows (+shownPx+, after the box
|
|
26
|
+
// fitted itself: with text in it the content sets the height, so measuring
|
|
27
|
+
// against the floor would fold a tall box on a short pull).
|
|
28
|
+
export function gripFolds({ requestedPx, shownPx }) {
|
|
29
|
+
return shownPx - requestedPx >= GRIP_FOLD_PX;
|
|
30
|
+
}
|
|
@@ -208,6 +208,17 @@ export function modelSegmentPrefillText(row) {
|
|
|
208
208
|
return `switch prefill ${countK(tokens)} uncached prompt${cost ? ` ≈${cost}` : ""}`;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// A ref past +max+ characters with its middle cut to one "…":
|
|
212
|
+
// the tooltip is a native title, which grows as wide as its longest line.
|
|
213
|
+
// The host's start and the model's end stay; the model chip has the full ref.
|
|
214
|
+
const TIP_REF_MAX = 40;
|
|
215
|
+
export function shortRef(text, max = TIP_REF_MAX) {
|
|
216
|
+
const chars = [...text];
|
|
217
|
+
if (chars.length <= max) return text;
|
|
218
|
+
const head = Math.floor((max - 1) / 3);
|
|
219
|
+
return `${chars.slice(0, head).join("")}…${chars.slice(chars.length - (max - 1 - head)).join("")}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
211
222
|
// The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
|
|
212
223
|
// turns · in / out · cost", a switch's prefill under its row.
|
|
213
224
|
function modelSegmentLines(segments) {
|
|
@@ -215,7 +226,7 @@ function modelSegmentLines(segments) {
|
|
|
215
226
|
const lines = ["per model:"];
|
|
216
227
|
for (const row of segments) {
|
|
217
228
|
const turns = num(row.turns) || 0;
|
|
218
|
-
const parts = [modelSegmentLabel(row), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
|
|
229
|
+
const parts = [shortRef(modelSegmentLabel(row)), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
|
|
219
230
|
const cost = modelSegmentCostText(row);
|
|
220
231
|
if (cost) parts.push(cost);
|
|
221
232
|
lines.push(` ${parts.join(" · ")}`);
|