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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/bin/chi +6 -1
  4. data/docs/architecture.md +35 -5
  5. data/docs/cli.md +33 -8
  6. data/docs/configuration.md +74 -20
  7. data/docs/internals/participants.md +111 -12
  8. data/docs/internals/prompt-caching.md +7 -6
  9. data/docs/internals/remote-sessions.md +6 -1
  10. data/docs/internals/web-push.md +4 -2
  11. data/docs/plugins.md +24 -8
  12. data/docs/sessions.md +4 -3
  13. data/docs/testing.md +13 -2
  14. data/lib/samagotchi/answer_command.rb +2 -0
  15. data/lib/samagotchi/bridge.rb +56 -13
  16. data/lib/samagotchi/bridge_client/event_stream.rb +34 -3
  17. data/lib/samagotchi/bridge_client.rb +58 -19
  18. data/lib/samagotchi/bridge_secret.rb +35 -0
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/claude-peer/scripts/pair +1 -1
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +3 -0
  26. data/lib/samagotchi/engine.rb +1 -0
  27. data/lib/samagotchi/kernel_loop.rb +5 -0
  28. data/lib/samagotchi/participant_messages.rb +5 -0
  29. data/lib/samagotchi/participants.rb +16 -6
  30. data/lib/samagotchi/plugin/api.rb +6 -3
  31. data/lib/samagotchi/review_command.rb +3 -1
  32. data/lib/samagotchi/run_options.rb +3 -1
  33. data/lib/samagotchi/session_access/contract.rb +4 -1
  34. data/lib/samagotchi/session_access/local.rb +15 -4
  35. data/lib/samagotchi/session_access/remote.rb +43 -7
  36. data/lib/samagotchi/session_access.rb +11 -1
  37. data/lib/samagotchi/session_manager.rb +1 -1
  38. data/lib/samagotchi/sessions_command.rb +1 -2
  39. data/lib/samagotchi/share_command.rb +29 -8
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -0
  41. data/lib/samagotchi/terminal_ui/local_link.rb +18 -3
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  43. data/lib/samagotchi/tool_declarations.rb +1 -1
  44. data/lib/samagotchi/tool_runner.rb +37 -1
  45. data/lib/samagotchi/toolkit.rb +50 -26
  46. data/lib/samagotchi/toolkit_binding.rb +32 -0
  47. data/lib/samagotchi/toolkit_command.rb +23 -0
  48. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  49. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  50. data/lib/samagotchi/tools/registry.rb +20 -6
  51. data/lib/samagotchi/tools/tool_index.rb +103 -20
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +117 -43
  54. data/lib/samagotchi/web/grants.rb +127 -16
  55. data/lib/samagotchi/web/link_codes.rb +24 -15
  56. data/lib/samagotchi/web/participant_access.rb +69 -13
  57. data/lib/samagotchi/web/participant_exposure.rb +44 -0
  58. data/lib/samagotchi/web/participant_review.rb +71 -9
  59. data/lib/samagotchi/web/participants_admin.rb +12 -6
  60. data/lib/samagotchi/web/proxy_gate.rb +18 -3
  61. data/lib/samagotchi/web/public/annotate_bar.css +18 -0
  62. data/lib/samagotchi/web/public/annotate_bar.js +10 -3
  63. data/lib/samagotchi/web/public/app.js +66 -155
  64. data/lib/samagotchi/web/public/composer.css +73 -0
  65. data/lib/samagotchi/web/public/composer_grip.js +21 -15
  66. data/lib/samagotchi/web/public/composer_mount.js +196 -0
  67. data/lib/samagotchi/web/public/forms.css +12 -0
  68. data/lib/samagotchi/web/public/index.html +14 -88
  69. data/lib/samagotchi/web/public/level_chip.js +2 -2
  70. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  71. data/lib/samagotchi/web/public/notifications.js +58 -8
  72. data/lib/samagotchi/web/public/participant.html +46 -10
  73. data/lib/samagotchi/web/public/participant.js +101 -18
  74. data/lib/samagotchi/web/public/participant_annotate.js +75 -0
  75. data/lib/samagotchi/web/public/participant_model.js +53 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +37 -13
  77. data/lib/samagotchi/web/public/question_card.js +15 -1
  78. data/lib/samagotchi/web/public/question_cards.js +11 -2
  79. data/lib/samagotchi/web/public/review_history.js +60 -0
  80. data/lib/samagotchi/web/public/scope.js +12 -8
  81. data/lib/samagotchi/web/public/stage_frame.js +9 -4
  82. data/lib/samagotchi/web/public/stage_view.js +4 -4
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  84. data/lib/samagotchi/web/push/notifier.rb +17 -3
  85. data/lib/samagotchi/web/running.rb +7 -4
  86. data/lib/samagotchi/web/server.rb +34 -11
  87. data/lib/samagotchi/web/session_create.rb +7 -7
  88. data/lib/samagotchi/web/start_dir.rb +26 -0
  89. data/lib/samagotchi/web/tunnel.rb +10 -1
  90. data/lib/samagotchi/worker_sidecar.rb +22 -11
  91. 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 { admitImages, chipName, chipsTemplate, imageFiles, thumbsTemplate } from "./images.js";
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, promptHeight } from "./composer_size.js";
66
- import { GRIP_KEY, createComposerGrip } from "./composer_grip.js";
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"), composerEl,
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: () => !promptEl.value.trim() && !chips.length,
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); the server names its project, if any.
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
- composerGrip.newVisit();
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
- composerGrip.newVisit();
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
- composerGrip.decideVisit(data.session?.archived);
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: () => !promptEl.value.trim() && !chips.length,
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
- const borders = promptEl.offsetHeight - promptEl.clientHeight;
1687
- promptEl.style.height = "0px";
1688
- const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight, minPx: promptMinPx() });
1689
- promptEl.style.height = `${height}px`;
1690
- promptEl.style.overflowY = scrolls ? "auto" : "hidden";
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 (promptEl.value.trim() || chips.length) return false;
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
- const notifications = createNotifications({ button: $("#notifyBtn"), scoped: !!scope.dir });
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 (used by app.js): the grip's drag, click and keys,
6
- // the fold's state per visit and its page-wide listeners. app.js keeps the box's
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
- doc.addEventListener("keydown", (e) => {
237
- if (!isComposerFolded()) return;
238
- if (!opensOnKey(e, { active: doc.activeElement, allView: allViewOpen(), lightbox: lightboxOpen() })) return;
239
- setComposerFolded(false);
240
- promptEl.focus();
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.addEventListener("dragover", (e) => {
249
+ infoBarEl?.addEventListener("dragover", (e) => {
244
250
  if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
245
251
  e.preventDefault();
246
252
  });
247
- infoBarEl.addEventListener("drop", (e) => {
253
+ infoBarEl?.addEventListener("drop", (e) => {
248
254
  if (!isComposerFolded()) return;
249
255
  const files = imageFiles(e.dataTransfer);
250
256
  if (!files.length) return;