samagotchi 0.119.0 → 0.121.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 +100 -1
- data/bin/chi +6 -1
- data/docs/architecture.md +35 -5
- data/docs/cli.md +33 -8
- data/docs/configuration.md +74 -20
- data/docs/internals/participants.md +111 -12
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +6 -1
- data/docs/internals/web-push.md +4 -2
- data/docs/plugins.md +24 -8
- data/docs/sessions.md +4 -3
- data/docs/testing.md +13 -2
- data/lib/samagotchi/answer_command.rb +2 -0
- data/lib/samagotchi/bridge.rb +56 -13
- data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
- data/lib/samagotchi/bridge_client.rb +58 -19
- data/lib/samagotchi/bridge_secret.rb +35 -0
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/participant_messages.rb +5 -0
- data/lib/samagotchi/participants.rb +16 -6
- data/lib/samagotchi/plugin/api.rb +6 -3
- data/lib/samagotchi/review_command.rb +3 -1
- data/lib/samagotchi/run_options.rb +3 -1
- data/lib/samagotchi/session_access/contract.rb +4 -1
- data/lib/samagotchi/session_access/local.rb +15 -4
- data/lib/samagotchi/session_access/remote.rb +43 -7
- data/lib/samagotchi/session_access.rb +11 -1
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/sessions_command.rb +1 -2
- data/lib/samagotchi/share_command.rb +29 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
- data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +50 -26
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -43
- data/lib/samagotchi/web/grants.rb +127 -16
- data/lib/samagotchi/web/link_codes.rb +24 -15
- data/lib/samagotchi/web/participant_access.rb +69 -13
- data/lib/samagotchi/web/participant_exposure.rb +44 -0
- data/lib/samagotchi/web/participant_review.rb +71 -9
- data/lib/samagotchi/web/participants_admin.rb +12 -6
- data/lib/samagotchi/web/proxy_gate.rb +18 -3
- data/lib/samagotchi/web/public/annotate_bar.css +18 -0
- data/lib/samagotchi/web/public/annotate_bar.js +10 -3
- data/lib/samagotchi/web/public/app.js +66 -155
- data/lib/samagotchi/web/public/composer.css +73 -0
- data/lib/samagotchi/web/public/composer_grip.js +21 -15
- data/lib/samagotchi/web/public/composer_mount.js +196 -0
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +14 -88
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +58 -8
- data/lib/samagotchi/web/public/participant.html +46 -10
- data/lib/samagotchi/web/public/participant.js +101 -18
- data/lib/samagotchi/web/public/participant_annotate.js +75 -0
- data/lib/samagotchi/web/public/participant_model.js +53 -1
- data/lib/samagotchi/web/public/participants_panel.js +37 -13
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +60 -0
- data/lib/samagotchi/web/public/scope.js +12 -8
- data/lib/samagotchi/web/public/stage_frame.js +9 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -4
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/notifier.rb +17 -3
- data/lib/samagotchi/web/running.rb +7 -4
- data/lib/samagotchi/web/server.rb +34 -11
- data/lib/samagotchi/web/session_create.rb +7 -7
- data/lib/samagotchi/web/start_dir.rb +26 -0
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker_sidecar.rb +22 -11
- metadata +10 -1
|
@@ -39,7 +39,7 @@ import { renderInfoBar } from "./info_bar.js";
|
|
|
39
39
|
import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
|
|
40
40
|
import { createShownTexts } from "./shown_texts.js";
|
|
41
41
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
42
|
-
import {
|
|
42
|
+
import { thumbsTemplate } from "./images.js";
|
|
43
43
|
import { normalize, failedTurnText, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
44
44
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
45
45
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
@@ -62,11 +62,10 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
|
|
|
62
62
|
import { copyText, installCopy } from "./copy.js";
|
|
63
63
|
import { createQuestionCards } from "./question_cards.js";
|
|
64
64
|
import { splitSnapshotCards } from "./card.js";
|
|
65
|
-
import { PROMPT_LINE_PX, PROMPT_MIN_PX
|
|
66
|
-
import {
|
|
65
|
+
import { PROMPT_LINE_PX, PROMPT_MIN_PX } from "./composer_size.js";
|
|
66
|
+
import { mountComposer } from "./composer_mount.js";
|
|
67
67
|
import { readStored, writeStored } from "./storage.js";
|
|
68
68
|
import { createCommandList } from "./command_list.js";
|
|
69
|
-
import { createPromptHistory } from "./prompt_history.js";
|
|
70
69
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
71
70
|
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
72
71
|
import { render } from "./vendor/lit-html.js";
|
|
@@ -283,7 +282,7 @@ const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
|
|
|
283
282
|
document.body.classList.toggle("working-set", !!workingSet);
|
|
284
283
|
const turnDom = view === "stage"
|
|
285
284
|
? createStageView({
|
|
286
|
-
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"),
|
|
285
|
+
...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"),
|
|
287
286
|
renderMarkdown, linkRefs: linkRefsIn, workingSet,
|
|
288
287
|
})
|
|
289
288
|
: createTurnView(viewArgs);
|
|
@@ -345,7 +344,7 @@ const deliveryControl = createDeliveryControl({
|
|
|
345
344
|
participantTurn: !!liveTurn.participantTurn,
|
|
346
345
|
};
|
|
347
346
|
},
|
|
348
|
-
composerEmpty: () => !
|
|
347
|
+
composerEmpty: () => !composer.hasDraft(),
|
|
349
348
|
send: (delivery) => handleSendTurn({ delivery }),
|
|
350
349
|
session: () => selected,
|
|
351
350
|
});
|
|
@@ -407,9 +406,10 @@ const sentIds = new Set();
|
|
|
407
406
|
const earlyRestores = new Map();
|
|
408
407
|
|
|
409
408
|
// The page's scope (scope.js): ?dir= is the folder a project view came
|
|
410
|
-
// from (new sessions start there)
|
|
409
|
+
// from (new sessions start there), else on a bare page chi web's start
|
|
410
|
+
// folder (data-scope-dir); the server names its project, if any.
|
|
411
411
|
const scope = {
|
|
412
|
-
dir: scopeDir(location.search),
|
|
412
|
+
dir: scopeDir(location.search) || document.body.dataset.scopeDir || null,
|
|
413
413
|
projectName: document.body.dataset.projectName || null,
|
|
414
414
|
projectDir: document.body.dataset.projectDir || "",
|
|
415
415
|
serverDir: document.body.dataset.serverDir || "",
|
|
@@ -838,7 +838,7 @@ function clearSelection() {
|
|
|
838
838
|
setMarkdownWarning(null);
|
|
839
839
|
markdownTip.show(null);
|
|
840
840
|
setCapabilities(null, null);
|
|
841
|
-
|
|
841
|
+
composer.newVisit();
|
|
842
842
|
updateComposerMode();
|
|
843
843
|
updateInfoBar();
|
|
844
844
|
focusPrompt();
|
|
@@ -873,7 +873,7 @@ async function select(id) {
|
|
|
873
873
|
resyncAttempts = 0; // this session has its own re-read budget
|
|
874
874
|
resetSessionState();
|
|
875
875
|
resetTurnView();
|
|
876
|
-
|
|
876
|
+
composer.newVisit();
|
|
877
877
|
updateInfoBar();
|
|
878
878
|
contextChips.load(id);
|
|
879
879
|
participantsControl.load(id);
|
|
@@ -941,7 +941,7 @@ async function resync({ endText = null } = {}) {
|
|
|
941
941
|
if (id !== selected) return;
|
|
942
942
|
// An archived session opens folded: decided once per visit, from its
|
|
943
943
|
// first answer (the list may not hold it yet, or ever under ?dir).
|
|
944
|
-
|
|
944
|
+
composer.decideVisit(data.session?.archived);
|
|
945
945
|
const kept = keptCommandBubbles(historyEl, id);
|
|
946
946
|
resetTurnView();
|
|
947
947
|
renderSessionView(data, { kept });
|
|
@@ -1452,7 +1452,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1452
1452
|
},
|
|
1453
1453
|
},
|
|
1454
1454
|
composer: {
|
|
1455
|
-
empty: () => !
|
|
1455
|
+
empty: () => !composer.hasDraft(),
|
|
1456
1456
|
refill: (text) => {
|
|
1457
1457
|
// A failed turn's line back: seen, so the composer opens (this visit).
|
|
1458
1458
|
openForVisit();
|
|
@@ -1548,7 +1548,7 @@ const sendTurnControl = createSendTurn({
|
|
|
1548
1548
|
composer: {
|
|
1549
1549
|
el: promptEl,
|
|
1550
1550
|
button: actionBtn,
|
|
1551
|
-
chips: () => chips,
|
|
1551
|
+
chips: () => composer.chips(),
|
|
1552
1552
|
setChips,
|
|
1553
1553
|
fit: fitPrompt,
|
|
1554
1554
|
clear: clearComposer,
|
|
@@ -1622,96 +1622,59 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
1622
1622
|
startPage.loadModels({ asked: true });
|
|
1623
1623
|
});
|
|
1624
1624
|
|
|
1625
|
-
actionBtn.addEventListener("click", () => {
|
|
1626
|
-
if (!queueRefused(false)) handleSendTurn();
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
|
-
// ── Images: paste or drop into the composer, chips until sent ─────────────
|
|
1630
|
-
const chipsEl = $("#chips");
|
|
1631
1625
|
const lightboxEl = $("#lightbox");
|
|
1632
|
-
let chips = [];
|
|
1633
|
-
|
|
1634
|
-
function renderChips() {
|
|
1635
|
-
render(chipsTemplate(chips), chipsEl);
|
|
1636
|
-
chipsEl.hidden = !chips.length;
|
|
1637
|
-
drawFoldControls();
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
// A chip that goes frees its preview; one kept in +list+ keeps it.
|
|
1641
|
-
function setChips(list) {
|
|
1642
|
-
for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
|
|
1643
|
-
chips = list;
|
|
1644
|
-
renderChips();
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
// A paste's, a drop's or the image button's files: the images the message
|
|
1648
|
-
// may still take become chips (images.js admitImages); a toast says why the
|
|
1649
|
-
// rest stayed out. Whether any went in.
|
|
1650
|
-
function addImageFiles(files) {
|
|
1651
|
-
const { take, refused } = admitImages(files, chips.length);
|
|
1652
|
-
if (refused) showToast(refused);
|
|
1653
|
-
if (!take.length) return false;
|
|
1654
|
-
for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
|
|
1655
|
-
renderChips();
|
|
1656
|
-
focusPrompt();
|
|
1657
|
-
return true;
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
chipsEl.addEventListener("click", (e) => {
|
|
1661
|
-
const btn = e.target.closest(".chip-remove");
|
|
1662
|
-
if (!btn) return;
|
|
1663
|
-
const [chip] = chips.splice(Number(btn.dataset.index), 1);
|
|
1664
|
-
if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
|
|
1665
|
-
renderChips();
|
|
1666
|
-
});
|
|
1667
|
-
// The image button: the file picker's images join the chips. Its value is
|
|
1668
|
-
// cleared after each pick, so the same file can be picked again.
|
|
1669
|
-
attachBtn.addEventListener("click", () => attachInput.click());
|
|
1670
|
-
attachInput.addEventListener("change", () => {
|
|
1671
|
-
addImageFiles([...attachInput.files]);
|
|
1672
|
-
attachInput.value = "";
|
|
1673
|
-
});
|
|
1674
|
-
// ── Composer height: grows with the text; the top-edge grip sets a floor ───
|
|
1675
|
-
// The floor is saved as it was set; fitPrompt holds it to half the window it is
|
|
1676
|
-
// drawn in (composer_size.js), so a floor saved in a bigger window fits this one
|
|
1677
|
-
// and comes back when the window is big again.
|
|
1678
|
-
let promptFloor = Number(readStored(GRIP_KEY)) || null;
|
|
1679
1626
|
|
|
1680
1627
|
// The empty box: one line in a session, three on the start page.
|
|
1681
1628
|
function promptMinPx() {
|
|
1682
1629
|
return document.body.classList.contains("has-session") ? PROMPT_LINE_PX : PROMPT_MIN_PX;
|
|
1683
1630
|
}
|
|
1684
1631
|
|
|
1632
|
+
// The composer (composer_mount.js): the box, its height, image chips, grip
|
|
1633
|
+
// and fold, ↑/↓ history and keys. The owner's parts come in as hooks: the
|
|
1634
|
+
// draft's origin (noteDraft), the / list's keys, the delivery line's Esc and
|
|
1635
|
+
// queue, the info bar's Stop turn and drop while folded, typing anywhere.
|
|
1636
|
+
const composer = mountComposer({
|
|
1637
|
+
els: {
|
|
1638
|
+
composer: composerEl, prompt: promptEl, button: actionBtn, chips: $("#chips"), attachBtn, attachInput,
|
|
1639
|
+
grip: $("#composeGrip"),
|
|
1640
|
+
},
|
|
1641
|
+
minPx: promptMinPx,
|
|
1642
|
+
toast: (text) => showToast(text),
|
|
1643
|
+
onWrite: () => noteDraft(),
|
|
1644
|
+
// ↑/↓: the prompt history shared with the TUI.
|
|
1645
|
+
loadHistory: fetchHistory,
|
|
1646
|
+
// Alt/Option+Enter: queued for after the running turn (D6), only to a
|
|
1647
|
+
// worker that queues (else it would steer): the text stays, a note says why.
|
|
1648
|
+
send: ({ queue }) => {
|
|
1649
|
+
if (!selected) return handleSendTurn();
|
|
1650
|
+
if (!queueRefused(queue)) handleSendTurn(queue ? { delivery: "queue" } : {});
|
|
1651
|
+
},
|
|
1652
|
+
keyFirst: (e) => commandList.handleKey(e),
|
|
1653
|
+
// Esc lets go of a choice armed on the delivery line.
|
|
1654
|
+
escape: () => deliveryControl.disarm(),
|
|
1655
|
+
fold: {
|
|
1656
|
+
infoCancelBtn,
|
|
1657
|
+
infoBarEl: $("#infoBar"),
|
|
1658
|
+
session: () => selected,
|
|
1659
|
+
waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
|
|
1660
|
+
turnRunning: () => liveTurn.running,
|
|
1661
|
+
allViewOpen,
|
|
1662
|
+
lightboxOpen: () => !lightboxEl.hidden,
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
// Bound here, not hoisted: no top-level code above this line may call them.
|
|
1666
|
+
const { openForVisit, focusPrompt, drawFoldControls } = composer;
|
|
1667
|
+
// Hoisted, for the hooks above that name them (called once the page runs).
|
|
1685
1668
|
function fitPrompt() {
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1669
|
+
composer.fit();
|
|
1670
|
+
}
|
|
1671
|
+
function setChips(list) {
|
|
1672
|
+
composer.setChips(list);
|
|
1673
|
+
}
|
|
1674
|
+
function refreshHistory() {
|
|
1675
|
+
composer.refreshHistory();
|
|
1691
1676
|
}
|
|
1692
1677
|
|
|
1693
|
-
promptEl.addEventListener("input", fitPrompt);
|
|
1694
|
-
window.addEventListener("resize", fitPrompt);
|
|
1695
|
-
fitPrompt();
|
|
1696
|
-
|
|
1697
|
-
promptEl.addEventListener("paste", (e) => {
|
|
1698
|
-
const files = imageFiles(e.clipboardData);
|
|
1699
|
-
// Text pasted along with an image still lands in the textarea.
|
|
1700
|
-
if (addImageFiles(files) && !e.clipboardData.getData("text/plain")) e.preventDefault();
|
|
1701
|
-
});
|
|
1702
|
-
composerEl.addEventListener("dragover", (e) => {
|
|
1703
|
-
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
1704
|
-
e.preventDefault();
|
|
1705
|
-
composerEl.classList.add("dropping");
|
|
1706
|
-
});
|
|
1707
|
-
composerEl.addEventListener("dragleave", () => composerEl.classList.remove("dropping"));
|
|
1708
|
-
composerEl.addEventListener("drop", (e) => {
|
|
1709
|
-
composerEl.classList.remove("dropping");
|
|
1710
|
-
const files = imageFiles(e.dataTransfer);
|
|
1711
|
-
if (!files.length) return;
|
|
1712
|
-
e.preventDefault();
|
|
1713
|
-
addImageFiles(files);
|
|
1714
|
-
});
|
|
1715
1678
|
// The × on a message queued for after the turn takes it back (unqueue.js).
|
|
1716
1679
|
const unqueue = createUnqueue({
|
|
1717
1680
|
sessionId: () => selected,
|
|
@@ -1720,7 +1683,7 @@ const unqueue = createUnqueue({
|
|
|
1720
1683
|
copy: (text) => copyText(text),
|
|
1721
1684
|
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1722
1685
|
putBack: (text) => {
|
|
1723
|
-
if (
|
|
1686
|
+
if (composer.hasDraft()) return false;
|
|
1724
1687
|
openForVisit();
|
|
1725
1688
|
promptEl.value = text;
|
|
1726
1689
|
noteDraft();
|
|
@@ -1743,27 +1706,6 @@ document.addEventListener("keydown", (e) => {
|
|
|
1743
1706
|
cancelBtn.addEventListener("click", () => handleCancel());
|
|
1744
1707
|
infoCancelBtn.addEventListener("click", () => handleCancel());
|
|
1745
1708
|
|
|
1746
|
-
// The composer's grip and fold (composer_grip.js).
|
|
1747
|
-
const composerGrip = createComposerGrip({
|
|
1748
|
-
composeGrip: $("#composeGrip"),
|
|
1749
|
-
promptEl,
|
|
1750
|
-
infoCancelBtn,
|
|
1751
|
-
infoBarEl: $("#infoBar"),
|
|
1752
|
-
session: () => selected,
|
|
1753
|
-
fitPrompt,
|
|
1754
|
-
promptMinPx,
|
|
1755
|
-
floor: () => promptFloor,
|
|
1756
|
-
setFloor: (px) => { promptFloor = px; },
|
|
1757
|
-
draft: () => !!(promptEl.value.trim() || chips.length),
|
|
1758
|
-
waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
|
|
1759
|
-
turnRunning: () => liveTurn.running,
|
|
1760
|
-
allViewOpen,
|
|
1761
|
-
lightboxOpen: () => !lightboxEl.hidden,
|
|
1762
|
-
imageFiles,
|
|
1763
|
-
addImageFiles,
|
|
1764
|
-
});
|
|
1765
|
-
// Bound here, not hoisted: no top-level code above this line may call them.
|
|
1766
|
-
const { openForVisit, focusPrompt, drawFoldControls } = composerGrip;
|
|
1767
1709
|
// The / autocomplete (command_list.js): the session's commands while the
|
|
1768
1710
|
// composer holds one "/word".
|
|
1769
1711
|
const commandList = createCommandList({
|
|
@@ -1776,43 +1718,6 @@ const commandList = createCommandList({
|
|
|
1776
1718
|
},
|
|
1777
1719
|
});
|
|
1778
1720
|
|
|
1779
|
-
// ↑/↓: the prompt history shared with the TUI (prompt_history.js), from a
|
|
1780
|
-
// copy fetched on focus and after each send (keydown can't wait for one).
|
|
1781
|
-
const promptHistory = createPromptHistory();
|
|
1782
|
-
function refreshHistory() {
|
|
1783
|
-
fetchHistory().then((entries) => promptHistory.setEntries(entries), () => {});
|
|
1784
|
-
}
|
|
1785
|
-
promptEl.addEventListener("focus", refreshHistory);
|
|
1786
|
-
function historyKey(e) {
|
|
1787
|
-
if (e.isComposing || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return false;
|
|
1788
|
-
const step = e.key === "ArrowUp" ? promptHistory.up : e.key === "ArrowDown" ? promptHistory.down : null;
|
|
1789
|
-
const recalled = step?.({ value: promptEl.value, start: promptEl.selectionStart, end: promptEl.selectionEnd });
|
|
1790
|
-
if (!recalled) return false;
|
|
1791
|
-
e.preventDefault();
|
|
1792
|
-
promptEl.value = recalled.value;
|
|
1793
|
-
noteDraft();
|
|
1794
|
-
promptEl.setSelectionRange(recalled.caret, recalled.caret);
|
|
1795
|
-
fitPrompt();
|
|
1796
|
-
return true;
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
promptEl.addEventListener("keydown", (e) => {
|
|
1800
|
-
if (commandList.handleKey(e)) return;
|
|
1801
|
-
if (historyKey(e)) return;
|
|
1802
|
-
// Esc lets go of a choice armed on the delivery line.
|
|
1803
|
-
if (e.key === "Escape" && deliveryControl.disarm()) {
|
|
1804
|
-
e.preventDefault();
|
|
1805
|
-
return;
|
|
1806
|
-
}
|
|
1807
|
-
// An IME's Enter picks the composition, it doesn't send.
|
|
1808
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
1809
|
-
e.preventDefault();
|
|
1810
|
-
if (!selected) handleSendTurn();
|
|
1811
|
-
// Alt/Option+Enter: queued for after the running turn (D6), only to a
|
|
1812
|
-
// worker that queues (else it would steer): the text stays, a note says why.
|
|
1813
|
-
else if (!queueRefused(e.altKey)) handleSendTurn(e.altKey ? { delivery: "queue" } : {});
|
|
1814
|
-
}
|
|
1815
|
-
});
|
|
1816
1721
|
$("#infoStopBtn").addEventListener("click", () => sessionActions.stopSelected());
|
|
1817
1722
|
$("#infoDeleteBtn").addEventListener("click", () => {
|
|
1818
1723
|
if (selected) confirmDelete(selected);
|
|
@@ -1829,7 +1734,7 @@ $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiv
|
|
|
1829
1734
|
// @return the displaced draft's text, or null
|
|
1830
1735
|
function fillOpener(created, continuedId) {
|
|
1831
1736
|
const { value, displaced } = openerFill({
|
|
1832
|
-
text: promptEl.value, hasChips: chips.length > 0, draftFrom, continuedId, linkId: created.id, created,
|
|
1737
|
+
text: promptEl.value, hasChips: composer.chips().length > 0, draftFrom, continuedId, linkId: created.id, created,
|
|
1833
1738
|
});
|
|
1834
1739
|
if (value === null) return null;
|
|
1835
1740
|
openForVisit();
|
|
@@ -1892,8 +1797,14 @@ turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
|
|
|
1892
1797
|
turnRoots().forEach((root) => installCopy(root));
|
|
1893
1798
|
|
|
1894
1799
|
// Notifications (notifications.js): an OS notification and a title count
|
|
1895
|
-
// when a session needs the user while this tab is not in front.
|
|
1896
|
-
|
|
1800
|
+
// when a session needs the user while this tab is not in front. On a
|
|
1801
|
+
// project's page the Home Screen app's device tidy-up still covers every
|
|
1802
|
+
// project: it gets a stream of every session of its own (allEvents).
|
|
1803
|
+
const notifications = createNotifications({
|
|
1804
|
+
button: $("#notifyBtn"),
|
|
1805
|
+
scoped: !!scope.dir,
|
|
1806
|
+
allEvents: (handlers, opts) => openEvents(handlers, opts),
|
|
1807
|
+
});
|
|
1897
1808
|
|
|
1898
1809
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
1899
1810
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
|
|
2
|
+
(the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
|
|
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 (#dock), the delivery line, Stop turn, the start page's picker and chips,
|
|
5
|
+
the / list, and the 16px text fields for iOS. */
|
|
6
|
+
/* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
|
|
7
|
+
bar and all view's cards use it too. */
|
|
8
|
+
:root{--icon-image: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='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
|
|
9
|
+
#composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
|
|
10
|
+
#composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
11
|
+
#composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
|
|
12
|
+
/* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
|
|
13
|
+
body.has-session #composer{flex-wrap:wrap;row-gap:6px}
|
|
14
|
+
#chips{display:flex;flex-wrap:wrap;gap:6px}
|
|
15
|
+
#chips[hidden]{display:none}
|
|
16
|
+
.chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
|
|
17
|
+
.chip img{width:32px;height:32px;object-fit:cover;border-radius:5px}
|
|
18
|
+
.chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
19
|
+
.chip .chip-remove{background:transparent;color:var(--text-3);padding:0 6px;min-height:0;font-size:14px}
|
|
20
|
+
#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}
|
|
21
|
+
/* An edge grip: the thin handle on an edge between two panes (the composer's,
|
|
22
|
+
the latest-sessions strip's). The pill is the handle's look, a 36×4 px bar
|
|
23
|
+
centred on the edge, drawn on hover, while dragging or focused; the id
|
|
24
|
+
rules hold each grip's own box: height and margins (they depend on the flex
|
|
25
|
+
gap around it: none for the composer's, main's 16 px for the strip's) and
|
|
26
|
+
its own states. */
|
|
27
|
+
.edge-grip{position:relative;z-index:2;cursor:ns-resize;touch-action:none}
|
|
28
|
+
.edge-grip::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}
|
|
29
|
+
.edge-grip:hover::after,.edge-grip.dragging::after{opacity:1}
|
|
30
|
+
.edge-grip:hover::after,.edge-grip.dragging::after{background:var(--accent)}
|
|
31
|
+
.edge-grip:focus-visible{outline:none}
|
|
32
|
+
.edge-grip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
|
|
33
|
+
#composeGrip{height:10px;margin:-5px 0}
|
|
34
|
+
#composeGrip:has(+ #composer:hover)::after{opacity:1}
|
|
35
|
+
/* Folded (composer_grip.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;
|
|
36
|
+
draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
|
|
37
|
+
body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
|
|
38
|
+
#composeGrip.draft::after{background:var(--accent)}
|
|
39
|
+
#composeGrip.waiting::after{background:var(--warn)}
|
|
40
|
+
#composeGrip.will-fold::after{opacity:1;background:var(--accent)}
|
|
41
|
+
/* 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
|
|
42
|
+
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
|
|
43
|
+
border and padding, so it never covers a button of the bar. */
|
|
44
|
+
@media(max-width:600px){
|
|
45
|
+
#composeGrip{width:96px;height:26px;margin:-19px auto -7px}
|
|
46
|
+
#composeGrip::after{top:17px;opacity:1}
|
|
47
|
+
}
|
|
48
|
+
#composer textarea::placeholder{color:var(--text-faint)}
|
|
49
|
+
/* Key hint: its line is always reserved, so focusing never shifts the layout. */
|
|
50
|
+
#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}
|
|
51
|
+
#composer:focus-within #composeHint:not(:empty){opacity:1}
|
|
52
|
+
#composer textarea:focus{outline:none}
|
|
53
|
+
/* The toolbar row: model picker · hint · Cancel · Send (the JS display toggles decide what shows). */
|
|
54
|
+
#composer .actions-col{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
|
|
55
|
+
#composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
|
|
56
|
+
#composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
|
|
57
|
+
#composer #actionBtn{min-width:64px;font-weight:600}
|
|
58
|
+
/* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
|
|
59
|
+
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. */
|
|
60
|
+
#composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
|
|
61
|
+
#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}
|
|
62
|
+
#composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
|
|
63
|
+
#composer .actions-col .attach-btn[hidden]{display:none}
|
|
64
|
+
/* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
|
|
65
|
+
and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
|
|
66
|
+
body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
67
|
+
body.has-session #composer textarea{min-height:0}
|
|
68
|
+
body.has-session #composeHint{display:none}
|
|
69
|
+
body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
70
|
+
/* The composer folded away (composer_grip.js: the grip dragged down, Enter/Space on it): the info bar is the page's bottom edge. */
|
|
71
|
+
body.has-session.composer-folded #composer{display:none}
|
|
72
|
+
/* Phones: the key hint is cut off at this width, and phones have no Shift+Enter. */
|
|
73
|
+
@media(max-width:600px){#composeHint{display:none}}
|
|
@@ -2,20 +2,24 @@ import { GRIP_CLICK_FOLD_MS, GRIP_OPEN_PX, gripFloor, gripFolds, gripGesture } f
|
|
|
2
2
|
import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
|
|
3
3
|
import { readStored, removeStored, writeStored } from "./storage.js";
|
|
4
4
|
|
|
5
|
-
// The composer's grip and fold (
|
|
6
|
-
// the fold's state per visit and its page-wide listeners.
|
|
7
|
-
// height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
|
|
5
|
+
// The composer's grip and fold (made by composer_mount.js): the grip's drag, click
|
|
6
|
+
// and keys, the fold's state per visit and its page-wide listeners. The mount keeps
|
|
7
|
+
// the box's height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
|
|
8
8
|
// fitPrompt reads; the floor is saved under GRIP_KEY.
|
|
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
12
|
// +waiting+, +turnRunning+, +allViewOpen+, +lightboxOpen+: what the page
|
|
13
13
|
// shows now. +imageFiles+ / +addImageFiles+: a drop's images and the chips.
|
|
14
|
+
// The owner page's own parts (composer_mount.js passes them through):
|
|
15
|
+
// +infoCancelBtn+ (Stop turn in the info bar while folded) and +infoBarEl+
|
|
16
|
+
// (a file dropped on it while folded), either may be null; +typeAnywhere+:
|
|
17
|
+
// typing on the page opens a folded composer.
|
|
14
18
|
export function createComposerGrip({
|
|
15
|
-
composeGrip, promptEl, infoCancelBtn, infoBarEl,
|
|
19
|
+
composeGrip, promptEl, infoCancelBtn = null, infoBarEl = null,
|
|
16
20
|
session, fitPrompt, promptMinPx, floor, setFloor,
|
|
17
|
-
draft, waiting, turnRunning, allViewOpen, lightboxOpen,
|
|
18
|
-
imageFiles, addImageFiles,
|
|
21
|
+
draft, waiting = () => false, turnRunning = () => false, allViewOpen = () => false, lightboxOpen = () => false,
|
|
22
|
+
imageFiles, addImageFiles, typeAnywhere = true,
|
|
19
23
|
doc = globalThis.document, win = globalThis.window,
|
|
20
24
|
}) {
|
|
21
25
|
// The composer's fold (composer_fold.js): the grip, which is the handle
|
|
@@ -214,7 +218,7 @@ export function createComposerGrip({
|
|
|
214
218
|
// while folded. The start page's grip only resizes: no role, no fold state.
|
|
215
219
|
function drawFoldControls() {
|
|
216
220
|
const folded = isComposerFolded();
|
|
217
|
-
infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
|
|
221
|
+
if (infoCancelBtn) infoCancelBtn.style.display = folded && turnRunning() ? "" : "none";
|
|
218
222
|
if (!session()) {
|
|
219
223
|
composeGrip.removeAttribute("role");
|
|
220
224
|
composeGrip.removeAttribute("aria-expanded");
|
|
@@ -233,18 +237,20 @@ export function createComposerGrip({
|
|
|
233
237
|
promptEl.addEventListener("input", () => drawFoldControls());
|
|
234
238
|
// Typing on the page opens a folded composer: the key lands in the box
|
|
235
239
|
// (the class goes before the focus, so the browser types it there).
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
if (typeAnywhere) {
|
|
241
|
+
doc.addEventListener("keydown", (e) => {
|
|
242
|
+
if (!isComposerFolded()) return;
|
|
243
|
+
if (!opensOnKey(e, { active: doc.activeElement, allView: allViewOpen(), lightbox: lightboxOpen() })) return;
|
|
244
|
+
setComposerFolded(false);
|
|
245
|
+
promptEl.focus();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
242
248
|
// A file dropped on the folded info bar: open (for the visit), then add it.
|
|
243
|
-
infoBarEl
|
|
249
|
+
infoBarEl?.addEventListener("dragover", (e) => {
|
|
244
250
|
if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
245
251
|
e.preventDefault();
|
|
246
252
|
});
|
|
247
|
-
infoBarEl
|
|
253
|
+
infoBarEl?.addEventListener("drop", (e) => {
|
|
248
254
|
if (!isComposerFolded()) return;
|
|
249
255
|
const files = imageFiles(e.dataTransfer);
|
|
250
256
|
if (!files.length) return;
|