samagotchi 0.77.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 +26 -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 +1 -0
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/sessions.md +1 -0
- data/lib/samagotchi/broadcast/recipients.rb +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 +2 -2
- data/lib/samagotchi/children_status.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/note_delivery.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -3
- 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/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +2 -2
- data/lib/samagotchi/tools/delegate_result.rb +2 -2
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +217 -19
- 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/index.html +27 -3
- data/lib/samagotchi/worker.rb +1 -0
- metadata +2 -1
|
@@ -56,7 +56,7 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
|
|
|
56
56
|
import { copyText, installCopy } from "./copy.js";
|
|
57
57
|
import { createQuestionCards } from "./question_cards.js";
|
|
58
58
|
import { splitSnapshotCards } from "./card.js";
|
|
59
|
-
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";
|
|
60
60
|
import { createCommandList } from "./command_list.js";
|
|
61
61
|
import { createPromptHistory } from "./prompt_history.js";
|
|
62
62
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
@@ -64,6 +64,8 @@ import { scopeDir, scopeTemplate } from "./scope.js";
|
|
|
64
64
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
65
65
|
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
|
|
66
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";
|
|
67
69
|
import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
68
70
|
import { createNotifications } from "./notifications.js";
|
|
69
71
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
@@ -132,7 +134,7 @@ const llmContextNewControl = createLlmContextControl({
|
|
|
132
134
|
choose: (next) => {
|
|
133
135
|
newChatLlmContext = next;
|
|
134
136
|
renderNewChatLlmContext();
|
|
135
|
-
|
|
137
|
+
focusPrompt();
|
|
136
138
|
},
|
|
137
139
|
id: "llmContextNewPopover",
|
|
138
140
|
anchor: "#llmContextNew",
|
|
@@ -168,7 +170,7 @@ const thinkingNewControl = createThinkingControl({
|
|
|
168
170
|
choose: (next) => {
|
|
169
171
|
newChatThinking = next;
|
|
170
172
|
renderNewChatThinking();
|
|
171
|
-
|
|
173
|
+
focusPrompt();
|
|
172
174
|
},
|
|
173
175
|
id: "thinkingNewPopover",
|
|
174
176
|
anchor: "#thinkingNew",
|
|
@@ -202,6 +204,19 @@ let pageEditorUrl = null;
|
|
|
202
204
|
const promptEl = $("#prompt");
|
|
203
205
|
const actionBtn = $("#actionBtn");
|
|
204
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();
|
|
205
220
|
const modelPickEl = $("#modelPick");
|
|
206
221
|
const composeHintEl = $("#composeHint");
|
|
207
222
|
|
|
@@ -739,9 +754,11 @@ function clearSelection() {
|
|
|
739
754
|
closeStream();
|
|
740
755
|
setMarkdownWarning(null);
|
|
741
756
|
setCapabilities(null, null);
|
|
757
|
+
foldVisit = newFoldVisit();
|
|
758
|
+
applyComposerFolded();
|
|
742
759
|
updateComposerMode();
|
|
743
760
|
updateInfoBar();
|
|
744
|
-
|
|
761
|
+
focusPrompt();
|
|
745
762
|
}
|
|
746
763
|
|
|
747
764
|
// A short line on the start page (the open session went away); gone at the
|
|
@@ -774,11 +791,14 @@ async function select(id) {
|
|
|
774
791
|
closeStream();
|
|
775
792
|
resetSessionState();
|
|
776
793
|
resetTurnView();
|
|
794
|
+
foldVisit = newFoldVisit();
|
|
795
|
+
applyComposerFolded();
|
|
777
796
|
updateInfoBar();
|
|
778
797
|
contextChips.load(id);
|
|
779
798
|
updateComposerMode();
|
|
780
799
|
await resync();
|
|
781
|
-
|
|
800
|
+
// A folded composer stays folded: typing on the page opens it.
|
|
801
|
+
focusPrompt({ open: false });
|
|
782
802
|
}
|
|
783
803
|
|
|
784
804
|
// Clear everything that belongs to one turn's rendering.
|
|
@@ -815,6 +835,13 @@ async function resync({ endText = null } = {}) {
|
|
|
815
835
|
return;
|
|
816
836
|
}
|
|
817
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
|
+
}
|
|
818
845
|
const kept = keptCommandBubbles(historyEl, id);
|
|
819
846
|
resetTurnView();
|
|
820
847
|
renderSessionView(data, { kept });
|
|
@@ -1089,6 +1116,7 @@ function updateInfoBar() {
|
|
|
1089
1116
|
archiveBtn.title = buttons.archive.title;
|
|
1090
1117
|
}
|
|
1091
1118
|
if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
|
|
1119
|
+
drawFoldControls();
|
|
1092
1120
|
// No session: the start page hides the info bar (the storage path is on
|
|
1093
1121
|
// #/sessions). The session clock ticks every second while a turn runs:
|
|
1094
1122
|
// lit writes its text alone, the bar's other nodes (a tooltip, a button
|
|
@@ -1260,6 +1288,8 @@ const streamHandlers = createStreamHandlers({
|
|
|
1260
1288
|
composer: {
|
|
1261
1289
|
empty: () => !promptEl.value.trim() && !chips.length,
|
|
1262
1290
|
refill: (text) => {
|
|
1291
|
+
// A failed turn's line back: seen, so the composer opens (this visit).
|
|
1292
|
+
openForVisit();
|
|
1263
1293
|
promptEl.value = text;
|
|
1264
1294
|
fitPrompt();
|
|
1265
1295
|
},
|
|
@@ -1427,6 +1457,8 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1427
1457
|
// (and images) go back into the composer, without replacing text typed
|
|
1428
1458
|
// since (4.13). The composer still holds the text just sent: it goes.
|
|
1429
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();
|
|
1430
1462
|
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
1431
1463
|
fitPrompt();
|
|
1432
1464
|
clearChips();
|
|
@@ -1463,7 +1495,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1463
1495
|
showToast(cantText("send", e), null, { sticky: true });
|
|
1464
1496
|
} finally {
|
|
1465
1497
|
actionBtn.disabled = current.owner === "tui";
|
|
1466
|
-
|
|
1498
|
+
focusPrompt();
|
|
1467
1499
|
}
|
|
1468
1500
|
}
|
|
1469
1501
|
|
|
@@ -1488,7 +1520,8 @@ const composerCommands = createComposerCommands({
|
|
|
1488
1520
|
clear: () => clearComposer(),
|
|
1489
1521
|
done: () => {
|
|
1490
1522
|
actionBtn.disabled = current.owner === "tui";
|
|
1491
|
-
|
|
1523
|
+
// A chip's command (the info bar) leaves a folded composer folded.
|
|
1524
|
+
focusPrompt({ open: false });
|
|
1492
1525
|
},
|
|
1493
1526
|
},
|
|
1494
1527
|
});
|
|
@@ -1505,6 +1538,7 @@ function clearComposer() {
|
|
|
1505
1538
|
async function handleCancel() {
|
|
1506
1539
|
if (!selected) return;
|
|
1507
1540
|
cancelBtn.disabled = true;
|
|
1541
|
+
infoCancelBtn.disabled = true;
|
|
1508
1542
|
try {
|
|
1509
1543
|
await cancelTurn(selected);
|
|
1510
1544
|
} catch (e) {
|
|
@@ -1512,6 +1546,7 @@ async function handleCancel() {
|
|
|
1512
1546
|
if (e.code !== "not_running") alert(e.message);
|
|
1513
1547
|
} finally {
|
|
1514
1548
|
cancelBtn.disabled = false;
|
|
1549
|
+
infoCancelBtn.disabled = false;
|
|
1515
1550
|
}
|
|
1516
1551
|
}
|
|
1517
1552
|
|
|
@@ -1560,7 +1595,7 @@ const modelPicker = createModelPicker({
|
|
|
1560
1595
|
renderNewChatLlmContext();
|
|
1561
1596
|
renderNewChatThinking();
|
|
1562
1597
|
}
|
|
1563
|
-
|
|
1598
|
+
focusPrompt();
|
|
1564
1599
|
},
|
|
1565
1600
|
});
|
|
1566
1601
|
// A pick on a fresh link: /model in its worker (its command bubble and the
|
|
@@ -1652,6 +1687,7 @@ let chips = [];
|
|
|
1652
1687
|
function renderChips() {
|
|
1653
1688
|
render(chipsTemplate(chips), chipsEl);
|
|
1654
1689
|
chipsEl.hidden = !chips.length;
|
|
1690
|
+
drawFoldControls();
|
|
1655
1691
|
}
|
|
1656
1692
|
|
|
1657
1693
|
function setChips(list) {
|
|
@@ -1668,7 +1704,7 @@ function addImageFiles(files) {
|
|
|
1668
1704
|
if (!files.length) return false;
|
|
1669
1705
|
for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
|
|
1670
1706
|
renderChips();
|
|
1671
|
-
|
|
1707
|
+
focusPrompt();
|
|
1672
1708
|
return true;
|
|
1673
1709
|
}
|
|
1674
1710
|
|
|
@@ -1697,33 +1733,95 @@ function fitPrompt() {
|
|
|
1697
1733
|
promptEl.style.overflowY = scrolls ? "auto" : "hidden";
|
|
1698
1734
|
}
|
|
1699
1735
|
|
|
1700
|
-
|
|
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.
|
|
1701
1741
|
composeGrip.addEventListener("pointerdown", (e) => {
|
|
1702
1742
|
e.preventDefault();
|
|
1703
1743
|
composeGrip.setPointerCapture(e.pointerId);
|
|
1704
1744
|
composeGrip.classList.add("dragging");
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1745
|
+
const tapY = e.clientY;
|
|
1746
|
+
let startY = tapY;
|
|
1747
|
+
let startH = promptEl.offsetHeight;
|
|
1748
|
+
let opened = false;
|
|
1707
1749
|
const move = (ev) => {
|
|
1708
|
-
|
|
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());
|
|
1709
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
|
+
}
|
|
1710
1769
|
};
|
|
1711
|
-
const
|
|
1770
|
+
const end = () => {
|
|
1712
1771
|
composeGrip.classList.remove("dragging");
|
|
1713
1772
|
composeGrip.removeEventListener("pointermove", move);
|
|
1714
1773
|
composeGrip.removeEventListener("pointerup", up);
|
|
1715
|
-
composeGrip.removeEventListener("pointercancel",
|
|
1716
|
-
|
|
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();
|
|
1717
1802
|
};
|
|
1718
1803
|
composeGrip.addEventListener("pointermove", move);
|
|
1719
1804
|
composeGrip.addEventListener("pointerup", up);
|
|
1720
|
-
composeGrip.addEventListener("pointercancel",
|
|
1805
|
+
composeGrip.addEventListener("pointercancel", cancel);
|
|
1721
1806
|
});
|
|
1722
1807
|
composeGrip.addEventListener("dblclick", () => {
|
|
1808
|
+
if (selected && isComposerFolded()) return;
|
|
1723
1809
|
promptFloor = null;
|
|
1724
1810
|
try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
|
|
1725
1811
|
fitPrompt();
|
|
1726
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
|
+
});
|
|
1727
1825
|
promptEl.addEventListener("input", fitPrompt);
|
|
1728
1826
|
window.addEventListener("resize", fitPrompt);
|
|
1729
1827
|
fitPrompt();
|
|
@@ -1806,6 +1904,104 @@ document.addEventListener("keydown", (e) => {
|
|
|
1806
1904
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
1807
1905
|
});
|
|
1808
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
|
+
});
|
|
1809
2005
|
// The / autocomplete (command_list.js): the session's commands while the
|
|
1810
2006
|
// composer holds one "/word".
|
|
1811
2007
|
const commandList = createCommandList({
|
|
@@ -1934,9 +2130,10 @@ async function continueSelected() {
|
|
|
1934
2130
|
// continue's 201.
|
|
1935
2131
|
function fillOpener(created) {
|
|
1936
2132
|
if (promptEl.value.trim() || chips.length) return;
|
|
2133
|
+
openForVisit();
|
|
1937
2134
|
promptEl.value = openerText(created);
|
|
1938
2135
|
fitPrompt();
|
|
1939
|
-
|
|
2136
|
+
focusPrompt();
|
|
1940
2137
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
1941
2138
|
}
|
|
1942
2139
|
|
|
@@ -2039,9 +2236,10 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
|
|
|
2039
2236
|
// session's plugins' pills (chi.annotation, annotate_actions.js), which fill
|
|
2040
2237
|
// the composer with their command line and the quote, or send it at once.
|
|
2041
2238
|
function fillComposer(text) {
|
|
2239
|
+
openForVisit();
|
|
2042
2240
|
promptEl.value = text;
|
|
2043
2241
|
fitPrompt();
|
|
2044
|
-
|
|
2242
|
+
focusPrompt();
|
|
2045
2243
|
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2046
2244
|
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2047
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
|
+
}
|
|
@@ -590,10 +590,25 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
|
590
590
|
#lightbox[hidden]{display:none}
|
|
591
591
|
#lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
|
|
592
592
|
#composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
|
|
593
|
-
#composeGrip{position:
|
|
593
|
+
#composeGrip{position:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
|
|
594
594
|
#composeGrip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
|
|
595
|
-
#
|
|
595
|
+
#composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
|
|
596
596
|
#composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
|
|
597
|
+
/* Folded (app.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
|
|
598
|
+
draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
|
|
599
|
+
body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
|
|
600
|
+
#composeGrip.draft::after{background:var(--accent)}
|
|
601
|
+
#composeGrip.waiting::after{background:var(--warn)}
|
|
602
|
+
#composeGrip.will-fold::after{opacity:1;background:var(--accent)}
|
|
603
|
+
#composeGrip:focus-visible{outline:none}
|
|
604
|
+
#composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
|
|
605
|
+
/* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
|
|
606
|
+
info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
|
|
607
|
+
border and padding, so it never covers a button of the bar. */
|
|
608
|
+
@media(max-width:600px){
|
|
609
|
+
#composeGrip{width:96px;height:26px;margin:-19px auto -7px}
|
|
610
|
+
#composeGrip::after{top:17px;opacity:1}
|
|
611
|
+
}
|
|
597
612
|
#composer textarea::placeholder{color:var(--text-faint)}
|
|
598
613
|
/* Key hint: its line is always reserved, so focusing never shifts the layout. */
|
|
599
614
|
#composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
|
|
@@ -851,6 +866,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
851
866
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
852
867
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
853
868
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
869
|
+
/* The composer folded away (composer_fold.js, the fold button): the info bar is the page's bottom edge.
|
|
870
|
+
Stop turn comes into the bar while folded and a turn runs. */
|
|
871
|
+
body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
|
|
872
|
+
body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
873
|
+
#infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
|
|
874
|
+
#infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
854
875
|
/* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
|
|
855
876
|
#infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
|
|
856
877
|
#infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
@@ -932,6 +953,8 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
932
953
|
#infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
933
954
|
#infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
|
|
934
955
|
#infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
|
|
956
|
+
#infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
|
|
957
|
+
#infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
|
|
935
958
|
#infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
|
|
936
959
|
#infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
|
|
937
960
|
}
|
|
@@ -1208,13 +1231,13 @@ a{color:var(--accent)}
|
|
|
1208
1231
|
<div id="lightbox" hidden><img alt=""></div>
|
|
1209
1232
|
<div id="dock">
|
|
1210
1233
|
<div id="initRow" role="status" aria-live="polite" hidden></div>
|
|
1234
|
+
<div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1211
1235
|
<div id="composer" style="position:relative">
|
|
1212
1236
|
<div id="commandList" role="listbox" aria-label="Commands" hidden></div>
|
|
1213
1237
|
<div id="modelPanel" hidden>
|
|
1214
1238
|
<label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
|
|
1215
1239
|
<div id="modelList" role="listbox" aria-label="Models"></div>
|
|
1216
1240
|
</div>
|
|
1217
|
-
<div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1218
1241
|
<div id="deliveryLine" aria-live="off" hidden></div>
|
|
1219
1242
|
<div class="compose-col">
|
|
1220
1243
|
<div id="chips" hidden></div>
|
|
@@ -1234,6 +1257,7 @@ a{color:var(--accent)}
|
|
|
1234
1257
|
<div id="infoBar">
|
|
1235
1258
|
<div class="info" id="infoText"></div>
|
|
1236
1259
|
<div id="contextChips" aria-label="Attached context" hidden></div>
|
|
1260
|
+
<button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1237
1261
|
<button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
|
|
1238
1262
|
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
1239
1263
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
|
data/lib/samagotchi/worker.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.78.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -594,6 +594,7 @@ files:
|
|
|
594
594
|
- lib/samagotchi/web/public/command_view.js
|
|
595
595
|
- lib/samagotchi/web/public/commands.js
|
|
596
596
|
- lib/samagotchi/web/public/composer.js
|
|
597
|
+
- lib/samagotchi/web/public/composer_fold.js
|
|
597
598
|
- lib/samagotchi/web/public/composer_size.js
|
|
598
599
|
- lib/samagotchi/web/public/context_chips.js
|
|
599
600
|
- lib/samagotchi/web/public/copy.js
|