Slash commands + app-thread Views (opt-in chat view) (#176)
* web: fix nested scroll + phantom scrollbar in the pickers
The new-chat / add-people sheet had two nested vertical scroll containers (the sheet AND .pick-list both scrolled) plus a stray horizontal scrollbar (overflow-y:auto silently promotes overflow-x to auto, so a sub-pixel overflow rendered a bar). Make picker sheets a clipped flex column so the head + search stay pinned and only the list scrolls — one clean scroll region. Scoped via :has(.pick-list) so other modals are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: merge Apps into the picker's one grouped scroll list
Apps were a pinned footer below the scrolling people/helpers list. Move them into the same .pick-list as a third group (People / Helpers / Apps) so all three scroll together under the pinned search. Search now spans all groups (relabelled 'Search people' -> 'Search'). Apps aren't roster members: tapping one still opens its personal app-thread via resolveAppThread, not createThread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: capture the commands & mini-apps design bar in CLAUDE.md
Familiar-before-novel (Slack/Discord conventions); a command invokes a response in place (bot reply, public or private) and never navigates or mutates the UI as a side effect — moving out of context is an action chip; apps live alongside the chat, not instead of it; delight (pre-baked UI or AI expressiveness) rides on top of the familiar basics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: slash-command layer — / typeahead, private replies, invalid handling
Builds the command experience on the existing registry (defineApp commands): a leading '/' opens a typeahead of all registered commands (name + description); a command answers IN PLACE with a private 'only visible to you' reply (buildLocalNotice — a client-only feed row, never sent, gone on reload), attributed to the app (To-do) or, for an unknown '/foo', to Eleven; a failed command is answered, never broadcast. /todo and /remind now confirm with a reply + a 'View …' action chip (navigating is the user's tap, never the command's side effect — see CLAUDE.md).
Registry: commands normalize to {run,desc,args}; parseCommand returns {name,args,app|null} so unknown '/word' is caught; commandCatalog() feeds the typeahead. ui.js composerToken gains a start-anchored 'command' token. Both composers pass a feed-aware respond(); the chat app renders replies in a dedicated .chatapp-notices strip (its feed is a keyedList container). Tests updated for the new parseCommand shape + composerToken '/' cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: typing-indicator fixes + Slack-style /remind parsing
Typing: (1) the chat app showed your OWN typing in any thread where you'd not yet sent a message — it derived your name from message history (null when empty), so the name-based self-filter missed you; drop your own relay echo by uid in the ephemeral hook instead. (2) A '/command' in progress no longer emits a typing beat (it's private), in both composers.
Remind: parseReminder now accepts Slack phrasing — a leading 'me', and a front-loaded time with 'to' ("/remind me in 10 seconds to do a thing"), alongside the existing trailing forms. 'meeting' isn't mistaken for the 'me' keyword.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: app-thread Views (Chat default / Interface) + reminder-as-DM + dismissable notices
Change 3: an app-thread is now a DM with its bot. It opens in the CHAT view by default (its events render as bot bubbles via a new per-app chatMessage() hook) with a header [Chat | Interface] toggle to its interactive UI. Both views fold live from one event log. Reminders: the fire arrives as 'You asked me to remind you: X' (a message, not a toast — notify hook dropped; sidebar unread + push still cover you when elsewhere); setting one confirms in the same thread.
'Views' is the model: Chat is always the default view; apps add the Interface view; more (custom, per-thread) are planned via a view dropdown. Routing via renderAppFor(id); app events fold into BOTH the owner app (Interface) and a bot bubble (Chat) on open and live.
Local notices (command replies) gain a dismiss ✕ with a radial countdown ring, auto-dismiss after a read-scaled delay (paused on hover/focus, in step with the ring), and an sr-only 'dismisses in N seconds' announcement (never shown on screen).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: capture the Views model (Chat default / Interface / future view picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web+agent: register the bot's commands with the client so /help etc. reach the bot
The client's unknown-command catch was swallowing Eleven's bot commands (/help, /theme, /dark, /light, /profile, /themes) before they reached the bot — a regression from the command layer. Fix: one shared source of truth, web/static/bot-commands.js (names + help), imported by BOTH the sidecar (agent/commands.mjs attaches each run()) and the client (apps.js). The client now SENDS a known bot command (runCommand returns false → the composer posts it, the bot answers) and lists them in the / typeahead — gated to threads that actually have a bot (chat.js threadHasBot). Only a truly unknown /word is still caught and answered privately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: make the chat view of an app-thread functional (send just works)
An app-thread's chat view is a regular DM, but a plain message typed there never folded/rendered — the untyped-message fold was gated on chatAppFor(tid), which is null for app-threads. Include app-threads (threadApp[tid]) so notes you type in the chat view fold + render, whether or not the app's bot ever replies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: stop the command reply flashing away in an app's own chat view
The chat app cleared its notices strip on every re-render; inside an app-thread's own chat, the command's own event folds a bubble here and re-renders, wiping the just-shown reply. Notices now own their lifecycle (auto-dismiss timer + ✕), so drop the render-clear; respond() still replaces the prior reply before appending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: split-pane Views on desktop (chat + interface), single-pane toggle on mobile
An app-thread mounts BOTH views of its one folded log: the Chat pane (#app-pane) and the Interface pane (#interface-pane). Desktop (>=721px): side by side; the header toggle's Interface choice hides Chat so the interface fills the pane. Mobile (<=720px): one View at a time, toggled. A normal chat thread leaves #interface-pane empty (hidden → full width), unchanged. Both panes redraw from the same state on every fold, so they stay in sync live (a reminder firing appears in Chat and updates the Interface list at once).
'View <app>' (a command reply's chip) opens the app's thread showing its interface — the desktop split already shows it; a narrow layout switches to the Interface pane. Also: the dismiss ✕ is drawn as SVG lines (centred on the ring) instead of a text glyph that wouldn't centre.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: fix group/DM chats blanking out (classList.toggle force must be boolean)
applyAppThreadView passed 'threadApp[id] && …' as the toggle force; for a group/DM that's undefined, and classList.toggle(x, undefined) FLIPS instead of removing — so opening group chats alternately gained .iface, whose desktop CSS hides the chat pane (and there's no interface pane), blanking the thread. Coerce to a real boolean so a non-app-thread always clears .iface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: three-view toggle (Chat/Split/Interface), /me action lines, first-chat intro
Views: the header toggle is now [Chat | Split | Interface] — Split (desktop-only, the default) shows both panes; Chat and Interface each show one, on both platforms. Driven by a data-app-view attribute (chat|split|interface) instead of the old .iface class.
Action lines: in an app-thread's own chat, a command's event lands as a persistent, centered /me-style line ('added "milk"', 'set a reminder: …') — chatMessage can return { text, action:true } for that, or a string for a bot message bubble (a reminder firing). The ephemeral 'only visible to you' reply is suppressed when the command targets the thread you're in (the action line is the record); it still shows when you fire a command from another chat.
First-chat intro: an empty app-thread chat shows a per-app explainer ('This is the start of your chat history with To-do. Type /todo Something …. You can also … in the Interface view'), matching each app's real commands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: full-history action lines + persistent top-of-chat intro for app-threads
Action lines were missing for a well-used bot because the chat view only folded the recent /api/messages window; older adds (compacted into the state snapshot the Interface uses) never became action lines. Fold the FULL event log for an app-thread's chat view instead (fetchAllThreadMessages paginates older pages, bounded at 2000) so the history is complete. The Interface still seeds fast from the snapshot.
The intro ('This is the start of your chat history with …') is now a PERSISTENT keyed first row that scrolls up as the conversation grows, instead of a vanishing empty-state that disappeared the moment you chatted or an action line landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: fix action lines never appearing (per-app fold de-dupe)
foldInto de-duped folded message ids PER THREAD, shared across apps. An app-thread event folds into BOTH its owning app (state → the Interface) and the chat app (an action-line bubble), reusing the same server message id — so the owner fold added the id first and the chat fold was skipped as a 'dup'. The Interface updated but no action line ever appeared. Make the de-dupe per (thread, app) so the same message can fold into two apps. Regression test added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* web: make the app-thread chat view opt-in (takeover by default)
#160 made every personal app-thread a "companion": a Chat view plus an
Interface view, with a [Chat | Split | Interface] header toggle, mandatory
for all of them. That over-committed the architecture — an app could no
longer own its thread's full surface, and chat.js decided the presentation
for every app.
Make companion opt-in and takeover the default, keyed off a single fact the
app already declares: whether it implements chatMessage(event) (the hook that
projects its events into the Chat view). An app WITH chatMessage (To-do,
Reminders) gets the companion split, unchanged; an app WITHOUT one (Bookmarks)
takes over the whole thread — its render() is the surface, no chat pane, no
toggle (the original full-pane model, restored). The gate is a one-line
predicate, appWantsChatView(name), in apps.js; chat.js consults it instead of
hardcoding "every app-thread is companion". The takeover open path restores
main's pre-#160 personal-app fold (isAppType → foldEvent) that #160 had
narrowed to the chat app only.
Verified live: To-do opens Split with the 3-way toggle (companion, unchanged);
Bookmarks opens full-pane with no toggle and its state survives reload
(takeover history-fold works); switching between them doesn't bleed view state;
no console errors. go build / go test ./... / npm test all green (added an
appWantsChatView unit test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Paul Campbell <paul@tito.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 files changed,
+1059
−197
CLAUDE.md+47 −0agent/commands.mjs+17 −15test/apps.test.mjs+91 −19test/ui.test.mjs+15 −0web/pages/index.html+9 −3web/static/apps.js+240 −32web/static/apps/chat.js+68 −9web/static/apps/remind.js+56 −28web/static/apps/todo.js+21 −3web/static/bot-commands.js+16 −0web/static/chat.js+373 −87web/static/style.css+97 −0web/static/ui.js+9 −1
diff --git a/CLAUDE.md b/CLAUDE.md| index f637136..333c4e9 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -89,6 +89,53 @@ Frontend module layout: |
| - `push.js` / `sw.js` — push subscription + service worker. |
| - `admin.js` — admin console (invites, group name, server photo, encryption key). |
| +## Commands & mini-apps: familiar first, delightful on top |
| + |
| +We're building a slash-command + mini-app layer. Hold this bar — the failure |
| +mode is being too clever. |
| + |
| +- **Familiar before novel.** Commands should feel instantly recognisable to |
| + anyone who's used Slack or Discord: `/command args`, a `/` typeahead of what's |
| + available, a reply when something's off. Match those conventions; don't invent |
| + our own. |
| +- **A command invokes a *response*, in place.** Running a command produces a |
| + reply — in almost every case a message from a bot, public or private ("only |
| + visible to you") depending on context — **not** a UI change and **not** a jump |
| + elsewhere. The default is: stay in the current context and answer. If a command |
| + needs to move you or change the UI, offer that as an **action chip** on the |
| + response, so leaving the current context is always the user's tap, never the |
| + command's side effect. (Personal commands that navigate in the app are a |
| + possible *later* addition — never the default.) |
| +- **Apps can live *alongside* the chat — but never must.** A mini-app may present |
| + as a second view onto the same thread (a Chat conversation *and* an Interface, |
| + toggled in the header), or it may take over the thread's full surface. The |
| + companion shape is a *choice an app makes*, never something the platform imposes. |
| +- **Delight rides on top.** The payoff is a delightful layer over plain chat — |
| + beautiful pre-baked UI, or the expressive, reshape-it-on-demand power AI brings |
| + — earned only once the familiar basics feel right. |
| + |
| +### Views: takeover by default, companion by opt-in |
| + |
| +A personal app-thread is presented one of two ways, and **the app chooses** — |
| +`chat.js` no longer decides for every app: |
| + |
| +- **Takeover (default).** The app's `render` *is* the whole thread — its full |
| + interactive surface, no chat pane, no toggle. This is the original full-pane |
| + model; anything from a whiteboard to a game wants it. |
| +- **Companion (opt-in).** The thread offers a **Chat** view (a DM-style |
| + conversation with the app's bot) *and* an **Interface** view, switched from a |
| + header toggle (`Chat | Split | Interface`; Split is desktop-only). To-do and |
| + Reminders use this. Both views fold from the **same event log** — one source of |
| + truth, no separate state — so an action in one shows in the other. |
| + |
| +An app opts into companion **simply by implementing `chatMessage(event)`** — the |
| +hook that projects each of its events into the Chat view (a bot bubble, or a |
| +`/me`-style action line via `{ text, action:true }`); its state renders in |
| +Interface via `render`. No `chatMessage` → takeover. So "wants a chat view" and |
| +"knows how to project into chat" are the same declaration, and the companion split |
| +is opt-in, not mandatory. The gate is `appWantsChatView(name)` in `apps.js`; the |
| +decision lives there, not spread through `chat.js`. |
| + |
| ## Conventions |
| - **Never `innerHTML` untrusted content.** Build DOM nodes / use `textContent`. |
diff --git a/agent/commands.mjs b/agent/commands.mjs| index 59ec686..1c21ba7 100644 |
| --- a/agent/commands.mjs |
| +++ b/agent/commands.mjs |
| @@ -7,6 +7,7 @@ |
| // "/dark" (no AI) reach the identical chip. Pure + dependency-light so it's |
| // unit-testable in Node (test/commands.test.mjs). |
| import { THEMES, APPEARANCES } from "./actions.mjs"; |
| +import { BOT_COMMANDS } from "../web/static/bot-commands.js"; |
| // Human-friendly theme names for the reply text (the chip itself carries the id). |
| const THEME_LABELS = { |
| @@ -30,23 +31,24 @@ function appearanceCmd(mode) { |
| return { text: `Here you go — tap to switch to ${mode} mode.`, actions: [{ type: "set_appearance", appearance: mode }] }; |
| } |
| -// name -> { help, run(args, ctx) -> { text, actions? } }. `run` is pure: no I/O, |
| -// no model. `ctx.aiEnabled` only tweaks the /help wording (it never gates a chip). |
| -export const COMMANDS = { |
| - "/help": { help: "what I can do", run: (_args, ctx) => ({ text: helpText(ctx) }) }, |
| - "/theme": { help: "switch colour theme, e.g. /theme lagoon", run: themeCmd }, |
| - "/themes": { |
| - help: "browse the theme picker", |
| - run: () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }), |
| - }, |
| - "/dark": { help: "switch to dark mode", run: () => appearanceCmd("dark") }, |
| - "/light": { help: "switch to light mode", run: () => appearanceCmd("light") }, |
| - "/profile": { |
| - help: "edit your screen name & photo", |
| - run: () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }), |
| - }, |
| +// The deterministic run(args, ctx) -> { text, actions? } for each command. Pure: |
| +// no I/O, no model. `ctx.aiEnabled` only tweaks the /help wording (never gates a |
| +// chip). Keyed by the shared BOT_COMMANDS name list (web/static/bot-commands.js), |
| +// which the client also registers — so bot + client can't drift. |
| +const RUN = { |
| + "/help": (_args, ctx) => ({ text: helpText(ctx) }), |
| + "/theme": themeCmd, |
| + "/themes": () => ({ text: "Here you go — tap to browse themes.", actions: [{ type: "open_theme_picker" }] }), |
| + "/dark": () => appearanceCmd("dark"), |
| + "/light": () => appearanceCmd("light"), |
| + "/profile": () => ({ text: "Here you go — tap to open your profile.", actions: [{ type: "open_profile" }] }), |
| }; |
| +// name -> { help, run } — help from the shared list, run from above. |
| +export const COMMANDS = Object.fromEntries( |
| + Object.entries(BOT_COMMANDS).map(([name, help]) => [name, { help, run: RUN[name] }]), |
| +); |
| + |
| // A bullet list of the commands and what they do — shared by /help and Eleven's |
| // own reference (eleven.mjs), so the LLM side knows the commands its bot side |
| // answers and can point people at them. One source of truth, no drift. |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 33fe828..088bc4a 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; |
| import { |
| parseCommand, |
| runCommand, |
| + commandCatalog, |
| isAppType, |
| foldEvent, |
| appStateFor, |
| @@ -26,6 +27,7 @@ import { |
| updateItem, |
| removeItem, |
| keyedList, |
| + appWantsChatView, |
| } from "../web/static/apps.js"; |
| import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app |
| import { app as pollApp, pollSummary } from "../web/static/apps/poll.js"; |
| @@ -62,21 +64,33 @@ async function test(name, fn) { |
| // ---- command parsing ---------------------------------------------------- |
| await test("parseCommand splits a registered command and its args", () => { |
| - assert.deepEqual(parseCommand("/todo buy milk"), { name: "/todo", args: "buy milk" }); |
| + const c = parseCommand("/todo buy milk"); |
| + assert.equal(c.name, "/todo"); |
| + assert.equal(c.args, "buy milk"); |
| + assert.ok(c.app, "a registered command resolves its owning app"); |
| }); |
| await test("parseCommand trims surrounding arg whitespace", () => { |
| - assert.deepEqual(parseCommand("/todo buy milk "), { name: "/todo", args: "buy milk" }); |
| + const c = parseCommand("/todo buy milk "); |
| + assert.equal(c.name, "/todo"); |
| + assert.equal(c.args, "buy milk"); |
| }); |
| await test("parseCommand allows a bare command with no args", () => { |
| - assert.deepEqual(parseCommand("/todo"), { name: "/todo", args: "" }); |
| + const c = parseCommand("/todo"); |
| + assert.equal(c.name, "/todo"); |
| + assert.equal(c.args, ""); |
| + assert.ok(c.app); |
| }); |
| -await test("parseCommand ignores plain text and unknown commands", () => { |
| - assert.equal(parseCommand("hello"), null); |
| - assert.equal(parseCommand("/nope do a thing"), null); // no app owns /nope → sends as chat |
| +await test("parseCommand flags an unknown /command (app:null) but ignores plain text", () => { |
| + assert.equal(parseCommand("hello"), null); // no leading "/" → not a command attempt |
| assert.equal(parseCommand(""), null); |
| + assert.equal(parseCommand("/ hi"), null); // a lone "/" is punctuation, sent as chat |
| + const c = parseCommand("/nope do a thing"); |
| + assert.equal(c.name, "/nope"); |
| + assert.equal(c.args, "do a thing"); |
| + assert.equal(c.app, null); // unowned verb → a failed command, not chat (caller replies) |
| }); |
| // ---- command execution → emitted event ---------------------------------- |
| @@ -113,6 +127,24 @@ await test("an empty /todo emits nothing", async () => { |
| assert.equal(sent.length, 0); |
| }); |
| +await test("bot commands pass through (sent to the bot); only unknown /words are caught", () => { |
| + // /help, /theme are Eleven's (agent/commands.mjs) — the client SENDS them so the |
| + // bot answers: runCommand returns false (composer posts it), no private reply. |
| + const replies = []; |
| + const respond = (r) => replies.push(r); |
| + assert.equal(runCommand("/help", "chatX", respond), false); |
| + assert.equal(runCommand("/theme lagoon", "chatX", respond), false); |
| + assert.equal(replies.length, 0, "bot commands aren't caught/answered locally"); |
| + // A truly unknown "/word" is handled (not broadcast) and answered privately. |
| + assert.equal(runCommand("/blahblah", "chatX", respond), true); |
| + assert.equal(replies.length, 1); |
| + assert.match(replies[0].text, /isn't a command/); |
| + // The typeahead catalog carries the bot commands, flagged for gating. |
| + const cat = commandCatalog(); |
| + assert.ok(cat.some((c) => c.name === "/help" && c.bot === true)); |
| + assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false)); |
| +}); |
| + |
| // ---- type ownership (derived from each app's events map) ---------------- |
| await test("isAppType recognises todo events, not chat", () => { |
| @@ -130,6 +162,18 @@ await test("an app's owned types are the keys of its events map", () => { |
| assert.deepEqual(pollApp.types, ["poll.create", "poll.vote", "poll.close"]); |
| }); |
| +await test("appWantsChatView: chatMessage opts into companion; its absence is takeover", () => { |
| + // The presence of a chatMessage hook is the ONE gate between the two |
| + // presentations — companion (chat + interface) vs takeover (full surface). |
| + assert.equal(typeof todoApp.chatMessage, "function"); |
| + assert.equal(appWantsChatView("todo"), true); // To-do projects events → companion |
| + assert.equal(appWantsChatView("remind"), true); // Reminders too |
| + // A takeover app defines no chatMessage — register one and confirm it's takeover. |
| + defineApp({ name: "takeover-fixture", title: "Fixture", scope: "personal", events: {}, render() {} }); |
| + assert.equal(appWantsChatView("takeover-fixture"), false); |
| + assert.equal(appWantsChatView("no-such-app"), false); // unknown name → not companion |
| +}); |
| + |
| // ---- collection helpers (the immutable list ops reducers build on) ------- |
| await test("addItem appends, dedupes by id, and never mutates the input", () => { |
| @@ -375,6 +419,15 @@ await test("remind: parseReminder handles relative, clock, and explicit times", |
| assert.equal(d.getDate(), 1); |
| }); |
| +await test("remind: parseReminder accepts Slack phrasing (me / front-loaded time / to)", () => { |
| + // "/remind me in 10 seconds to do a thing" — leading "me" dropped, time leads. |
| + assert.deepEqual(parseReminder("me in 10 seconds to do a thing", 0), { text: "do a thing", when: 10000 }); |
| + assert.deepEqual(parseReminder("in 5 minutes to stretch", 1000), { text: "stretch", when: 1000 + 5 * 60000 }); |
| + assert.deepEqual(parseReminder("me to water plants in 1 h", 0), { text: "water plants", when: 3600000 }); |
| + // "meeting" must NOT be read as the "me" keyword. |
| + assert.deepEqual(parseReminder("meeting in 10m", 0), { text: "meeting", when: 10 * 60000 }); |
| +}); |
| + |
| await test("remind: parseReminder rejects text with no time or a bad unit", () => { |
| assert.equal(parseReminder("just a note", 0), null); |
| assert.equal(parseReminder("", 0), null); |
| @@ -542,19 +595,38 @@ await test("notifyLive toasts an event's own notice (no hook needed)", () => { |
| assert.equal(toasts.length, 1); |
| }); |
| -await test("remind: notify prefers the folded item, falls back to the event's notice", () => { |
| - const toasts = []; |
| - setToast((m) => toasts.push(m)); |
| - resetThread("nr"); |
| - foldEvent("nr", { type: "remind.add", id: "r1", text: "Call mom", when: 99 }, { id: 70 }); |
| - notifyLive("nr", { type: "remind.fire", id: "r1" }); |
| - assert.deepEqual(toasts, ["⏰ Reminder — Call mom"], "wording from the folded item"); |
| - // Not folded on this device (fresh open / race): the event's own notice speaks. |
| - notifyLive("nr", { type: "remind.fire", id: "ghost", notice: { title: "⏰ Reminder", body: "From the notice" } }); |
| - assert.equal(toasts[1], "⏰ Reminder — From the notice"); |
| - // No item AND no notice → silence, not a crash. |
| - notifyLive("nr", { type: "remind.fire", id: "ghost2" }); |
| - assert.equal(toasts.length, 2); |
| +await test("remind: chatMessage — fire is a bot message, setting is an action line", () => { |
| + // A fired reminder is a message from the bot (a string → a bubble), not a toast. |
| + assert.equal( |
| + remindApp.chatMessage({ type: "remind.fire", id: "r1", notice: { title: "⏰ Reminder", body: "Call mom" } }), |
| + "You asked me to remind you: “Call mom”", |
| + ); |
| + // Setting one is an action you took → a /me-style line, not a bubble. |
| + const add = remindApp.chatMessage({ type: "remind.add", id: "r1", text: "Call mom", when: 99 }); |
| + assert.equal(add.action, true); |
| + assert.match(add.text, /^set a reminder: “Call mom”/); |
| + // Housekeeping events produce no line. |
| + assert.equal(remindApp.chatMessage({ type: "remind.done", id: "r1" }), null); |
| +}); |
| + |
| +await test("todo: chatMessage renders an add as an action line, others silent", () => { |
| + const add = todoApp.chatMessage({ type: "todo.add", id: "t1", text: "buy milk" }); |
| + assert.equal(add.action, true); |
| + assert.match(add.text, /added “buy milk”/); |
| + assert.equal(todoApp.chatMessage({ type: "todo.done", id: "t1" }), null); |
| +}); |
| + |
| +await test("one message folds into TWO apps (owner state + chat action bubble)", () => { |
| + resetThread("dual"); |
| + // The SAME server message id (70) folds into the owning app (state) AND the chat |
| + // app (an action-line bubble). De-dupe must be per-app, or the chat fold is |
| + // silently dropped (the bug: the Interface updated but no action line appeared). |
| + foldEvent("dual", { type: "todo.add", id: "x1", text: "milk" }, { id: 70 }); |
| + foldHostEvent("dual", "chat", { type: "chat.msg", name: "To-do", text: "added milk", uid: "app:todo", action: true }, { id: 70 }); |
| + assert.equal(appStateFor("dual", "todo").items.length, 1, "owner app folded the event"); |
| + const chat = appStateFor("dual", "chat"); |
| + assert.equal(chat.msgs.length, 1, "chat app also folded the same message id"); |
| + assert.equal(chat.msgs[0].action, true, "and it's flagged as an action line"); |
| }); |
| // ---- chat as an app (the lab) -------------------------------------------- |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 62057d0..e5e9d6f 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -135,6 +135,21 @@ await test("composerToken: only the token before the caret counts", () => { |
| assert.equal(t.query, "par"); |
| }); |
| +await test("composerToken: a leading / opens the command menu", () => { |
| + const t = composerToken("/tod", 4); |
| + assert.equal(t?.kind, "command"); |
| + assert.equal(t.query, "tod"); |
| + assert.equal(t.start, 0); |
| + assert.equal(composerToken("/", 1)?.kind, "command"); // bare "/" browses all commands |
| + assert.equal(composerToken("/", 1)?.query, ""); |
| +}); |
| + |
| +await test("composerToken: / only triggers on the command name, at the start", () => { |
| + assert.equal(composerToken("/todo buy milk", 10), null); // caret in args → no menu |
| + assert.equal(composerToken("hey /todo", 9), null); // "/" mid-line isn't a command |
| + assert.equal(composerToken("/todo", 5)?.query, "todo"); // caret at end of the name |
| +}); |
| + |
| await test("pinnedFirst: floats pinned chats, keeping order within each band", () => { |
| const list = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; |
| const pins = { b: true, d: true }; |
diff --git a/web/pages/index.html b/web/pages/index.html| index fe2da1d..715b125 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -126,9 +126,15 @@ |
| <!-- Shared-app panel surfaces (e.g. a poll) mount here, above the feed. |
| Collapses to nothing when no app has state (see .app-surface). --> |
| <div class="app-surface" id="app-surface"></div> |
| - <!-- A personal app-thread renders full-pane here instead of the chat feed |
| - (shown only in .app-mode; see the framework CSS). --> |
| - <div class="app-pane" id="app-pane"></div> |
| + <!-- App view surfaces (shown only in .app-mode). A personal app-thread is |
| + two Views of one thread: the Chat pane (a DM with its bot) and the |
| + Interface pane (its interactive UI) — side by side on desktop, one at a |
| + time on mobile (see the split CSS). A normal chat thread uses only the |
| + chat pane; #interface-pane stays empty (and hidden). --> |
| + <div class="app-split" id="app-split"> |
| + <div class="app-pane" id="app-pane"></div> |
| + <div class="app-pane interface-pane" id="interface-pane"></div> |
| + </div> |
| <div class="feed" id="feed"></div> |
| <div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div> |
| <!-- Zero-height anchor at the top of the composer stack: the staged- |
diff --git a/web/static/apps.js b/web/static/apps.js| index 9194ef0..bdc28e4 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -21,9 +21,11 @@ |
| // blob can make state survive reload/late-join without re-folding history — the |
| // reducer shape here is deliberately snapshot-ready. |
| +import { BOT_COMMANDS } from "./bot-commands.js"; |
| + |
| const apps = []; |
| const typeOwner = new Map(); // event type → owning app |
| -const commandOwner = new Map(); // "/todo" → owning app |
| +const commandOwner = new Map(); // "/todo" → owning app (app commands; bot commands live in BOT_COMMANDS) |
| // injectStyles adds a <style> once, keyed so re-registration never duplicates |
| // it. This is how an app ships its own CSS *inside its own file* — no edits to |
| @@ -87,7 +89,10 @@ injectStyles( |
| // never listed in the Apps menu, never given a shared panel) |
| // events — the fold, as { "<type>": (state, event) => state } |
| // initialState — () => the empty state (seeded on the first event) |
| -// commands, render, styles (styles injected once → self-contained) |
| +// commands — slash commands the app owns, as |
| +// { "/todo": (args, ctx) => {…} } or, to feed the "/" typeahead, |
| +// { "/todo": { run, desc: "Add a to-do", args: "<task>" } } |
| +// render, styles (styles injected once → self-contained) |
| // snapshot: false — opt out of state snapshots (for an app whose state IS the |
| // log, like chat, where snapshotting would duplicate the whole thread) |
| // incremental: true — the framework does NOT clear the surface before render; |
| @@ -99,6 +104,9 @@ injectStyles( |
| // `events` — one source of truth, so a type can never drift out of sync. |
| export function defineApp(spec) { |
| const app = { scope: "personal", commands: {}, ...spec }; |
| + // Normalise each command to { run, desc, args } so a bare handler function and |
| + // the richer {run,desc,args} form both work — desc/args feed the "/" typeahead. |
| + app.commands = normalizeCommands(app.commands); |
| // Wire types (events) are registered in typeOwner, so anything a member posts |
| // can reach them. hostEvents fold identically but are HOST-ONLY: never |
| // registered, so no wire payload can trigger them — for state mutations the |
| @@ -113,6 +121,14 @@ export function defineApp(spec) { |
| return app; |
| } |
| +function normalizeCommands(cmds) { |
| + const out = {}; |
| + for (const [name, v] of Object.entries(cmds || {})) { |
| + out[name] = typeof v === "function" ? { run: v, desc: "", args: "" } : { run: v.run, desc: v.desc || "", args: v.args || "" }; |
| + } |
| + return out; |
| +} |
| + |
| // makeReducer turns an events map + initialState factory into a fold function |
| // (state, event) → state. It seeds the empty state on the first event, dispatches |
| // each event to its handler by type, and returns state UNCHANGED for any type the |
| @@ -213,6 +229,19 @@ export function personalApps() { |
| return apps.filter((a) => a.scope === "personal"); |
| } |
| +// appWantsChatView decides how a personal app-thread presents. An app that |
| +// implements chatMessage(event) — the hook projecting its events into chat |
| +// bubbles / action lines — opts into the COMPANION view: a Chat conversation of |
| +// the thread alongside its Interface, toggled in the header (the To-do / Reminders |
| +// experience). An app WITHOUT chatMessage TAKES OVER the thread's full surface: |
| +// its render() is the whole thread, no chat view and no toggle (the original |
| +// full-pane model). "Wants a chat view" and "knows how to project into chat" are |
| +// the same fact, so companion stays opt-in and never mandatory — see CLAUDE.md. |
| +export function appWantsChatView(name) { |
| + const app = appByName(name); |
| + return !!(app && typeof app.chatMessage === "function"); |
| +} |
| + |
| // isAppType reports whether some app owns this payload type — i.e. this message |
| // is an app event to fold, not a chat bubble to render. |
| export function isAppType(type) { |
| @@ -222,7 +251,7 @@ export function isAppType(type) { |
| // ---- per-thread state: threadId → (appName → reducerState) -------------- |
| const store = new Map(); |
| -const foldedIds = new Map(); // threadId → Set(server message id) already folded |
| +const foldedIds = new Map(); // threadId → Map(appName → Set(server message id) folded) |
| const seededUpTo = new Map(); // threadId → snapshot horizon (see seedThread) |
| const maxFolded = new Map(); // threadId → highest server message id folded |
| const dirty = new Set(); // threads with folded state newer than the last snapshot |
| @@ -389,9 +418,15 @@ export function foldHostEvent(threadId, appName, event, meta) { |
| function foldInto(threadId, app, event, meta) { |
| const mid = meta && meta.id; |
| if (mid != null) { |
| - let seen = foldedIds.get(threadId); |
| - if (!seen) foldedIds.set(threadId, (seen = new Set())); |
| - if (seen.has(mid)) return null; // already folded this message — skip (no dup) |
| + // De-dupe is PER APP, not per thread: one server message can legitimately fold |
| + // into two apps at once — e.g. an app-thread event folds into its owning app |
| + // (state) AND into the chat app (an action-line bubble). A per-thread set would |
| + // let the first fold "win" and silently drop the second. |
| + let byApp = foldedIds.get(threadId); |
| + if (!byApp) foldedIds.set(threadId, (byApp = new Map())); |
| + let seen = byApp.get(app.name); |
| + if (!seen) byApp.set(app.name, (seen = new Set())); |
| + if (seen.has(mid)) return null; // already folded this message into THIS app — skip (no dup) |
| seen.add(mid); |
| if (app.snapshot !== false && mid <= (seededUpTo.get(threadId) || 0)) return app; // inside the snapshot |
| } |
| @@ -462,6 +497,20 @@ let toastFn = null; |
| export function setToast(fn) { |
| toastFn = fn; |
| } |
| +// The default bot an unknown-command reply is attributed to (Eleven). chat.js |
| +// resolves it from the roster and returns { name, kind } (kind "ai"/"bot" → the |
| +// badge) or null when there's no bot to name. |
| +let defaultBotFn = null; |
| +export function setDefaultBot(fn) { |
| + defaultBotFn = fn; |
| +} |
| +// Open a thread — used ONLY by an action chip a command offers, never by the |
| +// command itself (a command answers in place; leaving the context is the user's |
| +// tap — see CLAUDE.md). chat.js injects openThread. |
| +let openThreadFn = null; |
| +export function setThreadOpener(fn) { |
| + openThreadFn = fn; |
| +} |
| function makeCtx(threadId, app) { |
| return { |
| @@ -506,51 +555,210 @@ function makeCtx(threadId, app) { |
| }, |
| // Register any teardown to run before the next render / on thread reset. |
| onCleanup: (fn) => addCleanup(threadId, app.name, fn), |
| + // Navigate to this app's thread — for an action chip a command offers, so |
| + // moving out of the current context is always the user's tap. |
| + open: () => (openThreadFn ? openThreadFn(threadId) : undefined), |
| }; |
| } |
| // ---- commands ----------------------------------------------------------- |
| -// parseCommand splits "/todo buy milk" into {name:"/todo", args:"buy milk"}, or |
| -// returns null when the text isn't a registered command (no leading "/", or the |
| -// first word isn't owned by any app — so "/unknown …" falls through to chat). |
| +// parseCommand pulls the command out of "/todo buy milk" → {name:"/todo", |
| +// args:"buy milk", app}. `app` is the owning app, or null when the text is |
| +// "/word" but no app owns it (a FAILED command — the caller answers with an |
| +// "unknown command" reply rather than broadcasting it). Returns null only when |
| +// the text isn't a command attempt at all: no leading "/", or a bare "/" / "/ …" |
| +// (a lone slash is punctuation, sent as normal text). |
| export function parseCommand(text) { |
| if (!text || text[0] !== "/") return null; |
| - const sp = text.indexOf(" "); |
| - const name = sp === -1 ? text : text.slice(0, sp); |
| - if (!commandOwner.has(name)) return null; |
| - return { name, args: sp === -1 ? "" : text.slice(sp + 1).trim() }; |
| -} |
| - |
| -// runCommand executes a slash command. Returns true synchronously when the text |
| -// was a handled command (so the composer clears immediately). The emit itself is |
| -// async: a personal app's command is routed to the user's own app-thread — |
| -// created on first use — no matter which chat it was typed in, then a toast |
| -// confirms it landed there. A shared app's command targets the current chat. |
| -export function runCommand(text, activeThreadId) { |
| + const m = /^\/(\S+)/.exec(text); |
| + if (!m) return null; // "/" alone or "/ …" → literal text, not a command |
| + const name = "/" + m[1]; |
| + return { name, args: text.slice(name.length).trim(), app: commandOwner.get(name) || null }; |
| +} |
| + |
| +// runCommand executes a slash command, answering IN PLACE via respond(response) |
| +// — a private "only visible to you" reply the caller renders in the CURRENT |
| +// thread's feed (see buildLocalNotice). Returns true synchronously whenever the |
| +// text was a command attempt (registered OR not), so the composer clears and a |
| +// mistyped "/foo" is never broadcast to the thread. A personal app's command is |
| +// routed to the user's own app-thread (created on first use) no matter which |
| +// chat it was typed in; a shared app's command targets the current chat. The |
| +// handler answers with ctx.reply(...); it never navigates — moving out of |
| +// context is only ever an action chip the user taps (see CLAUDE.md). |
| +export function runCommand(text, activeThreadId, respond) { |
| const cmd = parseCommand(text); |
| - if (!cmd) return false; |
| - const app = commandOwner.get(cmd.name); |
| + if (!cmd) return false; // not a command attempt → let the composer send it |
| + const reply = (r) => { |
| + try { |
| + respond && respond(r); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| + }; |
| + if (!cmd.app) { |
| + // A bot command (e.g. /help, /theme — owned by Eleven, not a client app) is |
| + // SENT so the bot answers it: return false to let the composer post it. Only a |
| + // truly unknown "/word" is caught and answered privately. |
| + if (BOT_COMMANDS[cmd.name]) return false; |
| + reply({ sender: defaultBotFn ? defaultBotFn() : null, text: `${cmd.name} isn't a command. Type / to see what's available.` }); |
| + return true; // swallow — a failed command is answered, not broadcast |
| + } |
| + const app = cmd.app; |
| (async () => { |
| let target = activeThreadId; |
| - if (app.scope === "personal") { |
| - target = resolveAppThread ? await resolveAppThread(app.name) : null; |
| - } |
| + if (app.scope === "personal") target = resolveAppThread ? await resolveAppThread(app.name) : null; |
| if (!target) return; |
| - await app.commands[cmd.name](cmd.args, makeCtx(target, app)); |
| - // Typed from a different chat → confirm where it went (it didn't post here). |
| - if (app.scope === "personal" && target !== activeThreadId && toastFn) { |
| - toastFn(`Added to ${app.title || app.name}`); |
| - } |
| + const ctx = makeCtx(target, app); |
| + // ctx.reply is the private "only visible to you" confirmation, rendered where |
| + // you TYPED. Skip it when you're already IN the app's thread: there the event |
| + // itself lands as a visible action line in the history, so a separate ephemeral |
| + // reply would be redundant. From another chat, it confirms where the action went. |
| + ctx.reply = (r) => { |
| + if (target === activeThreadId) return; |
| + reply({ sender: { name: app.title || app.name, glyph: app.glyph, kind: "app" }, ...r }); |
| + }; |
| + await app.commands[cmd.name].run(cmd.args, ctx); |
| })(); |
| return true; |
| } |
| -// The registered command names (e.g. ["/todo"]) — for typeahead / help later. |
| +// The registered commands as { name, desc, args, bot } — feeds the "/" typeahead. |
| +// App commands (client-run) come first, then bot commands (sent to the bot). The |
| +// caller may hide `bot` ones in threads with no bot (see chat.js typeaheadItems). |
| +export function commandCatalog() { |
| + const out = [...commandOwner.entries()].map(([name, app]) => ({ |
| + name, |
| + desc: (app.commands[name] && app.commands[name].desc) || "", |
| + args: (app.commands[name] && app.commands[name].args) || "", |
| + bot: false, |
| + })); |
| + for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: true }); |
| + return out; |
| +} |
| + |
| +// The registered command names (e.g. ["/todo"]). |
| export function commandNames() { |
| return [...commandOwner.keys()]; |
| } |
| +// buildLocalNotice renders a command's private "only visible to you" reply as a |
| +// CLIENT-ONLY feed row — never sent to the server or other members, gone on |
| +// reload (like Slack's ephemeral replies). Attributed to the answering bot/app, |
| +// with optional trusted action chips ({ label, run }): they reuse the message |
| +// action-chip look but bypass the untrusted-payload whitelist, since we mint |
| +// them locally. Both feeds (chat.js's #feed, the chat app's .chatapp-feed) |
| +// append the returned node into their own feed and scroll to it. |
| +export function buildLocalNotice({ sender, text, actions } = {}) { |
| + const row = document.createElement("div"); |
| + row.className = "local-notice"; |
| + row.setAttribute("role", "status"); // announced to screen readers on insert |
| + row.setAttribute("aria-live", "polite"); |
| + // How long it stays before auto-dismissing: enough to read (a little longer for |
| + // longer text), and paused while hovered/focused so it never vanishes mid-read. |
| + // The ring around the ✕ shows this visually; screen readers hear it first, from |
| + // the sr-only line below — that timing is never shown on screen. |
| + const ttl = Math.min(20000, Math.max(8000, (text || "").length * 90)); |
| + const srNote = document.createElement("span"); |
| + srNote.className = "sr-only"; |
| + srNote.textContent = `Dismisses automatically in about ${Math.round(ttl / 1000)} seconds.`; |
| + row.appendChild(srNote); |
| + const head = document.createElement("div"); |
| + head.className = "local-notice-head"; |
| + if (sender && sender.name) { |
| + if (sender.glyph) { |
| + const g = document.createElement("span"); |
| + g.className = "local-notice-glyph"; |
| + g.textContent = sender.glyph; |
| + head.appendChild(g); |
| + } |
| + const nm = document.createElement("span"); |
| + nm.className = "local-notice-from"; |
| + nm.textContent = sender.name; |
| + head.appendChild(nm); |
| + if (sender.kind === "ai" || sender.kind === "bot") { |
| + const badge = document.createElement("span"); |
| + badge.className = "local-notice-badge"; |
| + badge.textContent = sender.kind === "ai" ? "AI" : "BOT"; |
| + head.appendChild(badge); |
| + } |
| + } |
| + const tag = document.createElement("span"); |
| + tag.className = "local-notice-tag"; |
| + tag.textContent = "Only visible to you"; |
| + head.appendChild(tag); |
| + // Dismiss ✕ with a radial countdown ring around it (SVG; static in-code markup, |
| + // so innerHTML is safe). The ring depletes over `ttl` (duration set below). |
| + const dismissBtn = document.createElement("button"); |
| + dismissBtn.type = "button"; |
| + dismissBtn.className = "local-notice-dismiss"; |
| + dismissBtn.setAttribute("aria-label", "Dismiss"); |
| + // The ✕ is drawn as SVG lines (not a text glyph) so it's geometrically centred |
| + // on the ring's 12,12 — a text ✕ never centres reliably. The whole SVG rotates |
| + // -90° (so the ring depletes from the top); an ✕ is symmetric under that, so it |
| + // stays a centred ✕. |
| + dismissBtn.innerHTML = |
| + '<svg class="local-notice-ring" viewBox="0 0 24 24" aria-hidden="true">' + |
| + '<circle class="ln-track" cx="12" cy="12" r="10"></circle>' + |
| + '<circle class="ln-prog" cx="12" cy="12" r="10"></circle>' + |
| + '<line class="ln-x" x1="8.7" y1="8.7" x2="15.3" y2="15.3"></line>' + |
| + '<line class="ln-x" x1="15.3" y1="8.7" x2="8.7" y2="15.3"></line>' + |
| + "</svg>"; |
| + head.appendChild(dismissBtn); |
| + row.appendChild(head); |
| + const body = document.createElement("div"); |
| + body.className = "local-notice-body"; |
| + body.textContent = text || ""; |
| + row.appendChild(body); |
| + if (Array.isArray(actions) && actions.length) { |
| + const chipRow = document.createElement("div"); |
| + chipRow.className = "chip-row"; |
| + for (const a of actions) { |
| + if (!a || !a.label) continue; |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "action-chip"; |
| + b.textContent = a.label; |
| + b.onclick = () => { |
| + try { |
| + a.run && a.run(); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| + }; |
| + chipRow.appendChild(b); |
| + } |
| + row.appendChild(chipRow); |
| + } |
| + // Auto-dismiss on a pausable timer; the ring's CSS animation pauses in step on |
| + // hover/focus (see .local-notice:hover .ln-prog), so what you see matches when |
| + // it actually goes. A tap on ✕ dismisses immediately. |
| + const prog = dismissBtn.querySelector(".ln-prog"); |
| + if (prog) prog.style.animationDuration = ttl + "ms"; |
| + let remaining = ttl; |
| + let startedAt = Date.now(); |
| + let timer; |
| + const dismiss = () => { |
| + clearTimeout(timer); |
| + row.remove(); |
| + }; |
| + const pause = () => { |
| + clearTimeout(timer); |
| + remaining -= Date.now() - startedAt; |
| + }; |
| + const resume = () => { |
| + startedAt = Date.now(); |
| + timer = setTimeout(dismiss, Math.max(0, remaining)); |
| + }; |
| + timer = setTimeout(dismiss, remaining); |
| + dismissBtn.onclick = dismiss; |
| + row.addEventListener("pointerenter", pause); |
| + row.addEventListener("pointerleave", resume); |
| + row.addEventListener("focusin", pause); |
| + row.addEventListener("focusout", resume); |
| + return row; |
| +} |
| + |
| // ---- message actions ---------------------------------------------------- |
| // An app can contribute an action to the chat message menu (long-press on touch, |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 2352682..8e8a633 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -22,7 +22,7 @@ |
| // |
| // This is the DEFAULT chat experience for group/DM threads; a device can |
| // switch back per-device (⋮ menu → "Use original chat" — see chat.js). |
| -import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces } from "../apps.js"; |
| +import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice } from "../apps.js"; |
| import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js"; |
| @@ -224,6 +224,7 @@ export const app = defineApp({ |
| attachments: raw.attachments || [], |
| reply: e.replyTo ? { id: e.replyTo.id || 0, name: e.replyTo.name || "", snippet: e.replyTo.snippet || "" } : null, |
| actions: raw.actions || [], |
| + action: !!e.action, // an app-thread /me-style action line, not a bubble |
| reactions: [], |
| edited: false, |
| raw, |
| @@ -317,6 +318,11 @@ export const app = defineApp({ |
| if (root.isConnected && root._native && host && host.registerNative) { |
| host.registerNative(root._native); |
| } |
| + // Note: the notices strip is NOT cleared on re-render — a command reply owns |
| + // its own lifecycle now (auto-dismiss countdown + ✕), and clearing here wiped |
| + // the reply the instant its own event folded a bubble into this thread's feed |
| + // (the flash you'd see running /todo inside the Todo thread). New replies still |
| + // replace the previous one (respond() empties the strip before appending). |
| // Chat hosts its sibling SHARED apps (poll, …), exactly like the original |
| // feed's panel above the messages: same .app-surface mount, same framework |
| // renderer, so /poll shows the same card in the same place. |
| @@ -334,7 +340,14 @@ export const app = defineApp({ |
| if (tm) tm.delete(last.name); |
| } |
| const firstBuild = !feed.childElementCount; |
| - keyedList(feed, viewItems(msgs), { |
| + const items = viewItems(msgs); |
| + // The bot's intro ("This is the start of your chat history with To-do…") is a |
| + // PERSISTENT first item — a keyed row that scrolls up as the conversation and |
| + // its action lines grow, marking the beginning, rather than a vanishing |
| + // empty-state. host.chatIntro is null for a normal chat (no intro row). |
| + const introText = host && host.chatIntro && host.chatIntro(ctx.threadId); |
| + if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro" }); |
| + keyedList(feed, items, { |
| key: (m) => m.id, |
| rev: (m) => m.vrev, |
| srev: (m) => m.srev, |
| @@ -435,6 +448,25 @@ function renderItem(m, ctx) { |
| item.appendChild(sep); |
| } |
| + // The persistent "start of your chat history" intro row (keyed first). |
| + if (m.intro) { |
| + const el = document.createElement("div"); |
| + el.className = "chatapp-intro"; |
| + el.textContent = m.text; |
| + item.appendChild(el); |
| + return item; |
| + } |
| + |
| + // An app-thread action ("added 'milk'") — a centered /me-style line, not a |
| + // bubble: a record of your interaction with the bot in the chat history. |
| + if (m.action) { |
| + const line = document.createElement("div"); |
| + line.className = "chatapp-action"; |
| + line.textContent = m.text; |
| + item.appendChild(line); |
| + return item; |
| + } |
| + |
| // Mirror the original feed's attribution (ownMessage): prefer the inline uid, |
| // but fall back to a screen-name match so your own legacy (uid-less) messages |
| // still render as yours — right-aligned, name hidden in group runs, editable. |
| @@ -1836,6 +1868,13 @@ function buildSkeleton(el, ctx) { |
| const feed = document.createElement("div"); |
| feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout |
| + // Private command replies ("only visible to you") render in this strip, just |
| + // below the feed — NOT inside it: the feed is a keyedList container, which |
| + // strands/reorders any foreign child it doesn't own. render() clears it, so a |
| + // reply is transient (gone once the conversation continues or you reopen). |
| + const notices = document.createElement("div"); |
| + notices.className = "chatapp-notices"; |
| + |
| const float = document.createElement("div"); |
| float.className = "composer-float"; |
| const preview = document.createElement("div"); |
| @@ -1881,7 +1920,7 @@ function buildSkeleton(el, ctx) { |
| compose.append(attach, fileInput, field); |
| - root.append(surface, feed, float, taBox, replyBanner, editBanner, compose); |
| + root.append(surface, feed, notices, float, taBox, replyBanner, editBanner, compose); |
| el.appendChild(root); |
| // Keep the feed pinned to the newest message when the composer, its staged |
| @@ -2194,7 +2233,18 @@ function buildSkeleton(el, ctx) { |
| row.className = "ta-item" + (i === typeahead.index ? " active" : ""); |
| row.setAttribute("role", "option"); |
| row.setAttribute("aria-selected", String(i === typeahead.index)); |
| - if (typeahead.kind === "emoji") { |
| + if (typeahead.kind === "command") { |
| + const n = document.createElement("span"); |
| + n.className = "ta-name"; |
| + n.textContent = it.command + (it.args ? " " + it.args : ""); |
| + row.appendChild(n); |
| + if (it.desc) { |
| + const d = document.createElement("span"); |
| + d.className = "ta-desc"; |
| + d.textContent = it.desc; |
| + row.appendChild(d); |
| + } |
| + } else if (typeahead.kind === "emoji") { |
| const g = document.createElement("span"); |
| g.className = "ta-emoji"; |
| g.textContent = it.glyph; |
| @@ -2280,8 +2330,10 @@ function buildSkeleton(el, ctx) { |
| input.style.height = Math.min(input.scrollHeight, 140) + "px"; |
| refreshSend(); |
| updateTypeahead(); |
| - if (input.value) beatTyping(); |
| - else if (lastBeat) stopTypingBeat(); // field emptied — clear the indicator now |
| + // A "/command" in progress is private — never announce typing for it (and |
| + // clear any beat already sent if the text just became a command). |
| + if (input.value && input.value[0] !== "/") beatTyping(); |
| + else if (lastBeat) stopTypingBeat(); |
| }); |
| // The original keyboard-inset rule (.composer.editing on coarse pointers) |
| // keys off focus — mirror it so the composer sits on the keyboard while typing. |
| @@ -2350,9 +2402,16 @@ function buildSkeleton(el, ctx) { |
| refreshSend(); |
| stopTypingBeat(); // command / edit / send all clear the field — end the beat now |
| }; |
| - // A slash command handled by a mini-app emits its event instead of sending |
| - // a chat message — skipped while editing, exactly like the original composer. |
| - if (!editing && runCommand(text, ctx.threadId)) { |
| + // A slash command (mini-app command, or an unknown "/foo") is handled instead |
| + // of sending — it answers in place with a private "only visible to you" reply |
| + // appended to this feed. Skipped while editing, like the original composer. |
| + if ( |
| + !editing && |
| + runCommand(text, ctx.threadId, (r) => { |
| + notices.textContent = ""; // one reply at a time — the latest replaces the last |
| + notices.appendChild(buildLocalNotice(r)); |
| + }) |
| + ) { |
| resetInput(); |
| return; |
| } |
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js| index bbe9558..8b43dd7 100644 |
| --- a/web/static/apps/remind.js |
| +++ b/web/static/apps/remind.js |
| @@ -68,10 +68,17 @@ function parseAbsolute(str) { |
| // "… in 2h" / "in 45m" / … relative from now |
| // "… at 15:30" next occurrence of that clock time |
| // `now` is injectable so the parser is deterministic in tests. |
| +const REMIND_UNIT = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w"; |
| + |
| export function parseReminder(raw, now = Date.now()) { |
| - const s = (raw || "").trim(); |
| + let s = (raw || "").trim(); |
| + if (!s) return null; |
| + // Slack phrasing is "/remind me …" — drop a leading standalone "me" (but not |
| + // "meeting"; \b keeps it a whole word). |
| + s = s.replace(/^me\b\s*/i, "").trim(); |
| if (!s) return null; |
| + // Explicit "<text> | <absolute>" form. |
| const bar = s.indexOf("|"); |
| if (bar >= 0) { |
| const text = s.slice(0, bar).trim(); |
| @@ -79,19 +86,32 @@ export function parseReminder(raw, now = Date.now()) { |
| return text && when ? { text, when } : null; |
| } |
| - let m = s.match(/^(.*?)\s+in\s+(\d+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w)\s*$/i); |
| - if (m && m[1].trim()) { |
| + // The "when" can lead ("in 10s to do X" — Slack) or trail ("do X in 10s"); the |
| + // rest is the reminder text, with a leading "to " dropped either way. |
| + let when = null; |
| + let rest = null; |
| + let m = s.match(new RegExp(`^in\\s+(\\d+)\\s*(${REMIND_UNIT})\\b\\s*(.*)$`, "i")); |
| + if (m) { |
| + const ms = unitMs(m[2]); |
| + if (!ms) return null; |
| + when = now + +m[1] * ms; |
| + rest = m[3]; |
| + } else if ((m = s.match(new RegExp(`^(.*?)\\s+in\\s+(\\d+)\\s*(${REMIND_UNIT})\\s*$`, "i")))) { |
| const ms = unitMs(m[3]); |
| - return ms ? { text: m[1].trim(), when: now + +m[2] * ms } : null; |
| - } |
| - |
| - m = s.match(/^(.*?)\s+at\s+(\d{1,2}):(\d{2})\s*$/i); |
| - if (m && m[1].trim()) { |
| - const when = nextClockTime(+m[2], +m[3], now); |
| - return when ? { text: m[1].trim(), when } : null; |
| + if (!ms) return null; |
| + when = now + +m[2] * ms; |
| + rest = m[1]; |
| + } else if ((m = s.match(/^at\s+(\d{1,2}):(\d{2})\b\s*(.*)$/i))) { |
| + when = nextClockTime(+m[1], +m[2], now); |
| + rest = m[3]; |
| + } else if ((m = s.match(/^(.*?)\s+at\s+(\d{1,2}):(\d{2})\s*$/i))) { |
| + when = nextClockTime(+m[2], +m[3], now); |
| + rest = m[1]; |
| } |
| - return null; |
| + if (when == null) return null; |
| + const text = rest.replace(/^to\s+/i, "").trim(); |
| + return text ? { text, when } : null; |
| } |
| // ---- view helpers ------------------------------------------------------- |
| @@ -165,26 +185,34 @@ export const app = defineApp({ |
| "remind.done": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| }, |
| commands: { |
| - "/remind": (args, ctx) => { |
| - const p = parseReminder(args); |
| - if (p) addReminder(ctx, p.text, p.when); |
| + "/remind": { |
| + desc: "Set a reminder", |
| + args: "<what> <when>", |
| + run: (args, ctx) => { |
| + const p = parseReminder(args); |
| + if (!p) return ctx.reply({ text: "Try: /remind me in 10 minutes to call mum (or /remind call mum at 18:30)" }); |
| + addReminder(ctx, p.text, p.when); |
| + ctx.reply({ text: `I'll remind you: “${p.text}”.`, actions: [{ label: "View reminders", run: ctx.open }] }); |
| + }, |
| }, |
| }, |
| - notify(e, ctx) { |
| - if (e.type !== "remind.fire") return null; |
| - // The offline fallback fire() below already showed a system Notification on |
| - // THIS device, then emitted remind.fire; its echo lands here. Consume the id |
| - // so we don't double-alert. Other devices never populated the set, so they |
| - // still speak (this is a personal app — every device shares our uid, so a |
| - // uid check couldn't tell the firing device apart). |
| - if (firedLocally.delete(e.id)) return null; |
| - const state = ctx.getState(); |
| - const item = state && state.items ? state.items.find((x) => x.id === e.id) : null; |
| - if (item) return { title: "⏰ Reminder", body: item.text }; |
| - // Not folded on this device yet (fresh open, delivery race) — the scheduled |
| - // event carries its own notice, so the alert still speaks. |
| - return e.notice && e.notice.title ? e.notice : null; |
| + // The bot's chat-view line for one event: an app-thread opens as a conversation |
| + // with its bot (Change 3), so its events read as messages. null = no line. |
| + // No `notify` hook: a fired reminder isn't a floating toast — it arrives here as |
| + // a message in the Reminders thread (with a sidebar unread + push when you're |
| + // elsewhere), which is the whole point of the DM model. |
| + chatIntro: |
| + "This is the start of your chat history with Reminders. Type /remind me in 10 minutes to Something — e.g. /remind me in 10 minutes to call mum. You can see and manage your reminders in the Interface view.", |
| + |
| + chatMessage(e) { |
| + // Firing is the bot messaging YOU (a bubble); setting one is an action you took. |
| + if (e.type === "remind.fire") return `You asked me to remind you: “${(e.notice && e.notice.body) || ""}”`; |
| + if (e.type === "remind.add") { |
| + const at = absTime(e.when); |
| + return { text: `set a reminder: “${e.text}”${at ? " — " + at : ""}`, action: true }; |
| + } |
| + return null; |
| }, |
| preview(e) { |
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js| index 40b78c6..26caecd 100644 |
| --- a/web/static/apps/todo.js |
| +++ b/web/static/apps/todo.js |
| @@ -27,12 +27,30 @@ export const app = defineApp({ |
| "todo.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| }, |
| commands: { |
| - "/todo": (args, ctx) => { |
| - const text = args.trim(); |
| - if (text) ctx.emit({ type: "todo.add", id: ctx.id(), text }); |
| + "/todo": { |
| + desc: "Add a to-do", |
| + args: "<task>", |
| + run: (args, ctx) => { |
| + const text = args.trim(); |
| + if (!text) return ctx.reply({ text: "Usage: /todo <task> — e.g. /todo buy milk" }); |
| + ctx.emit({ type: "todo.add", id: ctx.id(), text }); |
| + ctx.reply({ text: `Added “${text}” to your to-do list.`, actions: [{ label: "View to-do", run: ctx.open }] }); |
| + }, |
| }, |
| }, |
| + // Adding a to-do reads as an action in the chat history (a /me-style line); |
| + // done/reopen/remove happen in the Interface view and carry no text, so they |
| + // don't clutter the chat. |
| + chatMessage(e) { |
| + if (e.type === "todo.add") return { text: `added “${e.text}”`, action: true }; |
| + return null; |
| + }, |
| + |
| + // The empty-state intro shown the first time you open this bot's chat. |
| + chatIntro: |
| + "This is the start of your chat history with To-do. Type /todo Something to add a to-do — e.g. /todo buy milk. You can see, add, and check off to-dos in the Interface view.", |
| + |
| // This app's CSS travels with it — the framework injects it once. It reads the |
| // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme. |
| styles: ` |
diff --git a/web/static/bot-commands.js b/web/static/bot-commands.js| new file mode 100644 |
| index 0000000..ff6ffa9 |
| --- /dev/null |
| +++ b/web/static/bot-commands.js |
| @@ -0,0 +1,16 @@ |
| +// Eleven's built-in command floor — the ONE source of truth for the bot's command |
| +// NAMES + one-line help. Two consumers import this, so the two never drift: |
| +// • the sidecar (agent/commands.mjs) attaches each command's deterministic run() |
| +// and answers it when the message reaches the bot; |
| +// • the client (apps.js) registers them so the "/" typeahead lists them and typing |
| +// one is SENT to the thread (the bot answers) rather than caught as an unknown |
| +// command — the client owns app commands (/todo, /remind); the bot owns these. |
| +// Pure data (no DOM, no deps), so both Node (the sidecar) and the browser import it. |
| +export const BOT_COMMANDS = { |
| + "/help": "what I can do", |
| + "/theme": "switch colour theme, e.g. /theme lagoon", |
| + "/themes": "browse the theme picker", |
| + "/dark": "switch to dark mode", |
| + "/light": "switch to light mode", |
| + "/profile": "edit your screen name & photo", |
| +}; |
diff --git a/web/static/chat.js b/web/static/chat.js| index c20232f..e667c47 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -44,7 +44,11 @@ import { |
| setCurrentUser, |
| setAppThreadResolver, |
| setToast, |
| + setDefaultBot, |
| + setThreadOpener, |
| runCommand, |
| + commandCatalog, |
| + buildLocalNotice, |
| isAppType, |
| foldEvent, |
| foldHostEvent, |
| @@ -58,6 +62,7 @@ import { |
| renderSurfaces, |
| renderApp as renderMiniApp, |
| appByName, |
| + appWantsChatView, |
| personalApps, |
| messageActionsFor, |
| renderInline as renderAppLine, |
| @@ -420,6 +425,11 @@ const threadKeys = {}; // thread id -> CryptoKey |
| const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new members |
| const threadName = {}; // thread id -> display name |
| const threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thread |
| +// A thread's chosen VIEW. "chat" (a DM-style conversation) is always the default |
| +// view; an app-thread also offers the "interface" view (its interactive UI). More |
| +// views (custom, per-thread) are planned — a view dropdown, not just this toggle. |
| +// Sticks per thread for the session. Only meaningful when threadApp[id] is set. |
| +const appThreadView = {}; // thread id -> "chat" | "interface" |
| const threadPreview = {}; // thread id -> last-message snippet |
| // Pending-invite links, keyed by the (pending DM) thread id. Only known in the |
| // session that minted them — the secret never reaches the server, so it can't be |
| @@ -1244,7 +1254,8 @@ function renderApp() { |
| text.style.height = "auto"; |
| text.style.height = Math.min(text.scrollHeight, 140) + "px"; |
| if (atBottom) feedEl.scrollTop = feedEl.scrollHeight; |
| - if (text.value.trim()) noteTyping(); |
| + // A "/command" in progress is private — don't announce typing for it. |
| + if (text.value.trim() && text.value[0] !== "/") noteTyping(); |
| else stopTyping(); |
| updateTypeahead(); // :emoji / @mention autocomplete |
| }); |
| @@ -1801,6 +1812,10 @@ const appPaneCache = {}; // threadId -> { root, scrollTop, loaded } |
| // #app-pane slot. No-op unless the outgoing thread was a loaded app-thread — a normal |
| // chat leaves #app-pane empty. |
| function stashAppPane(id) { |
| + // The interface pane is re-rendered on open (not parked), so clear it here — a |
| + // normal chat opened next relies on #interface-pane:empty to hide/full-width. |
| + const iface = document.getElementById("interface-pane"); |
| + if (iface) iface.textContent = ""; |
| const pane = document.getElementById("app-pane"); |
| const entry = appPaneCache[id]; |
| if (!pane || !entry) return; |
| @@ -1844,9 +1859,64 @@ function paintThreadHeader(id) { |
| if (el) el.style.display = isGroup ? "" : "none"; |
| } |
| document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat"; |
| + paintViewToggle(id); |
| paintHeaderAvatar(id); |
| } |
| +// The [Chat | Split | Interface] view toggle sits beside the kebab, but ONLY for |
| +// app-threads. Chat = the conversation; Split = both side by side (desktop only — |
| +// there's no room to split on mobile); Interface = the app's interactive UI (its |
| +// glyph, ⏰ for Reminders). Seed of a fuller view picker (a dropdown) later. |
| +function paintViewToggle(id) { |
| + const head = document.querySelector(".thread-head"); |
| + const menuWrap = document.getElementById("thread-menu-wrap"); |
| + let toggle = document.getElementById("view-toggle"); |
| + const owner = threadApp[id]; |
| + // Only a companion app-thread has more than one view to switch between; a |
| + // takeover app (no chatMessage) owns the whole surface, so it gets no toggle. |
| + if (!owner || !head || !appWantsChatView(owner)) { |
| + if (toggle) toggle.remove(); |
| + return; |
| + } |
| + if (!toggle) { |
| + toggle = document.createElement("div"); |
| + toggle.id = "view-toggle"; |
| + toggle.className = "view-toggle"; |
| + toggle.setAttribute("role", "group"); |
| + toggle.setAttribute("aria-label", "View"); |
| + head.insertBefore(toggle, menuWrap); |
| + } |
| + const view = appThreadView[id] || "split"; |
| + const glyph = (appByName(owner) || {}).glyph || "▦"; |
| + // On a narrow layout there's no split — it collapses to Chat, so Chat reads as |
| + // active and the (desktop-only) Split button is hidden. |
| + const narrow = matchMedia("(max-width: 720px)").matches; |
| + const active = narrow && view === "split" ? "chat" : view; |
| + toggle.textContent = ""; |
| + const mk = (v, label, sym, splitOnly) => { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "view-toggle-btn" + (v === active ? " active" : "") + (splitOnly ? " split-only" : ""); |
| + b.title = label; |
| + b.setAttribute("aria-label", label); |
| + b.setAttribute("aria-pressed", String(v === active)); |
| + b.textContent = sym; |
| + b.onclick = () => setAppThreadView(id, v); |
| + return b; |
| + }; |
| + toggle.append(mk("chat", "Chat", "💬", false), mk("split", "Split", "◫", true), mk("interface", "Interface", glyph, false)); |
| +} |
| + |
| +// Switch an app-thread's view (chat | split | interface). Both panes are already |
| +// mounted, so this only flips the layout — instant, no re-render or re-fetch. |
| +function setAppThreadView(id, view) { |
| + const cur = appThreadView[id] || "split"; |
| + if (cur === view || !threadApp[id]) return; |
| + appThreadView[id] = view; |
| + applyAppThreadView(id); |
| + paintViewToggle(id); |
| +} |
| + |
| async function openThread(id) { |
| // Re-tapping the thread that's already open must not rebuild it — just reveal |
| // the pane (mobile navigates list → thread). No flash, no wiped draft. |
| @@ -1877,6 +1947,7 @@ async function openThread(id) { |
| } |
| document.getElementById("thread-empty").classList.add("hidden"); |
| document.getElementById("thread-view").classList.remove("hidden"); |
| + applyAppThreadView(id); // set/clear the split's .iface class for this thread (never let it linger) |
| paintThreadHeader(id); |
| // A pending DM (invitee not joined yet) shows a waiting-to-join banner with the |
| // share link; a normal chat clears any stale banner. |
| @@ -1884,11 +1955,12 @@ async function openThread(id) { |
| document.getElementById("tt").classList.add("show-thread"); |
| renderThreadList(); |
| - // A personal app-thread (or a chat-app thread) renders its app full-pane |
| - // (no original feed / composer). |
| - const fullPaneApp = activeAppFor(id); |
| - if (fullPaneApp) { |
| - await openAppThreadView(id, myOpen, fullPaneApp); |
| + // A personal app-thread (chat or interface view) or a chat-app thread renders |
| + // full-pane (no original feed / composer). An app-thread defaults to its chat |
| + // view — a DM with its bot — with the header toggle switching to the interface view. |
| + const renderApp = renderAppFor(id); |
| + if (renderApp) { |
| + await openAppThreadView(id, myOpen, renderApp, threadApp[id] || null); |
| return; |
| } |
| setAppThreadMode(false); // a normal chat: restore feed + composer |
| @@ -2984,6 +3056,16 @@ function cancelReply() { |
| // Returns true when the composer should be reset (message sent, or nothing to |
| // send), and false when it should be left intact — i.e. an attachment is still |
| // uploading, so the caller keeps the typed text rather than wiping it. |
| +// Render a command's private "only visible to you" reply into the original feed |
| +// (a client-only row — never sent, gone on reload). The chat mini-app has its |
| +// own copy against .chatapp-feed. |
| +function showLocalNotice(response) { |
| + const feed = document.getElementById("feed"); |
| + if (!feed) return; |
| + feed.appendChild(buildLocalNotice(response)); |
| + feed.scrollTop = feed.scrollHeight; |
| +} |
| + |
| function sendMessage(raw) { |
| const text = emojify((raw || "").trim()); // :tada: → 🎉 before encrypting |
| // Can't send right now (no open thread, or the socket is down / reconnecting) — |
| @@ -2991,10 +3073,11 @@ function sendMessage(raw) { |
| // send button is greyed meanwhile, but Enter can still land here. |
| if (!activeId || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| - // A slash command handled by a mini-app (e.g. "/todo buy milk"): emit its |
| - // event instead of sending a chat message. Skipped while editing so you can |
| + // A slash command (e.g. "/todo buy milk", or an unknown "/foo"): handle it |
| + // instead of sending a chat message, answering in place with a private "only |
| + // visible to you" reply appended to this feed. Skipped while editing so you can |
| // still edit a message whose text starts with "/". |
| - if (editing == null && runCommand(text, activeId)) return true; |
| + if (editing == null && runCommand(text, activeId, showLocalNotice)) return true; |
| // Editing an existing message: re-encrypt the original payload with new text |
| // (preserving its name + any attachments) and send an edit. |
| @@ -3163,6 +3246,15 @@ setChatAppHost({ |
| const e = roster.find((x) => x.is_agent && x.label === name); |
| return e ? e.kind || "bot" : null; |
| }, |
| + // The empty-state intro for an app-thread's chat ("This is the start of your |
| + // chat history with To-do…") — the owning app's chatIntro, or null for a normal |
| + // chat (no intro). |
| + chatIntro: (threadId) => { |
| + const owner = threadApp[threadId]; |
| + if (!owner) return null; |
| + const a = appByName(owner); |
| + return (a && a.chatIntro) || null; |
| + }, |
| icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY }, |
| // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted, |
| // labelled, and run by the host — the app just mounts the returned row. |
| @@ -3347,10 +3439,17 @@ setStateSaver(async (threadId) => { |
| } catch {} |
| }); |
| -// A personal-app command typed in a normal chat files into that app's own thread |
| -// (not the chat), so confirm with a toast — otherwise it looks like the message |
| -// vanished. |
| setToast((msg) => toast(msg)); |
| +// An unknown command ("/blahblah") is answered by a bot, private + in place. |
| +// Attribute it to Eleven if present, else any AI agent, else any bot; null when |
| +// there's no bot to name (the reply then reads as a plain "only visible to you"). |
| +setDefaultBot(() => { |
| + const e = |
| + roster.find((x) => x.is_agent && /eleven/i.test(x.label || "")) || |
| + roster.find((x) => x.is_agent && x.kind === "ai") || |
| + roster.find((x) => x.is_agent); |
| + return e ? { name: e.label, kind: e.kind || "bot" } : null; |
| +}); |
| // createAppThread makes a personal app's single-member thread: a `self` thread |
| // whose name_blob is the ENCRYPTED app id, wrapped only to us. The server sees |
| @@ -3390,6 +3489,18 @@ async function resolveAppThread(appName) { |
| return p; |
| } |
| setAppThreadResolver(resolveAppThread); |
| +// "View <app>" (an action chip on a command reply) opens the app's thread with its |
| +// Interface in view. On desktop the default split already shows it beside the chat; |
| +// on a narrow/mobile layout, switch to the Interface pane so the list is what shows. |
| +setThreadOpener((id) => { |
| + if (threadApp[id] && appWantsChatView(threadApp[id]) && matchMedia("(max-width: 720px)").matches) appThreadView[id] = "interface"; |
| + if (id === activeId) { |
| + applyAppThreadView(id); |
| + paintViewToggle(id); |
| + } else { |
| + openThread(id); |
| + } |
| +}); |
| // "App mode": the app renders full-pane; the chat feed / composer / shared-app |
| // panel are hidden (an app-thread has no chat feed). |
| @@ -3441,28 +3552,147 @@ function activeAppFor(id) { |
| return threadApp[id] || chatAppFor(id); |
| } |
| +// Whether a thread has a bot/agent member — gates the bot slash-commands (/help, |
| +// /theme, …) in the "/" typeahead to threads where a bot can actually answer them. |
| +function threadHasBot(id) { |
| + const t = threads.find((x) => x.id === id); |
| + if (!t) return false; |
| + return (t.members || []).some((uid) => { |
| + const e = roster.find((r) => r.user_id === uid); |
| + return e && e.is_agent; |
| + }); |
| +} |
| + |
| +// The app to RENDER for a thread, honouring its chosen view: an app-thread |
| +// defaults to the chat view (a DM with its bot), or its own interactive UI in the |
| +// "interface" view; a normal group/dm renders the chat app. null = no full-pane. |
| +function renderAppFor(id) { |
| + const owner = threadApp[id]; |
| + if (owner) { |
| + // Takeover (no chatMessage): the app's own interface IS the thread, always. |
| + // Companion: the chat view by default, its interface in the "interface" view. |
| + if (!appWantsChatView(owner)) return owner; |
| + return appThreadView[id] === "interface" ? owner : "chat"; |
| + } |
| + return chatAppFor(id); |
| +} |
| + |
| +// Fold one of an app-thread's own events into the chat view. The app's |
| +// chatMessage() turns the event into either a bot MESSAGE (a string → a bubble |
| +// "from" the app, e.g. a reminder firing) or an ACTION (return { text, action:true } |
| +// → a centered /me-style line, e.g. "added 'milk'"). No text → nothing (e.g. a |
| +// housekeeping event). uid app:<name> so a bubble renders as the bot's, on the left. |
| +function foldAppEventBubble(threadId, ownerApp, event, meta) { |
| + const owner = appByName(ownerApp); |
| + const line = owner && owner.chatMessage ? owner.chatMessage(event) : null; |
| + const text = typeof line === "string" ? line : line && line.text; |
| + if (!text) return; |
| + foldHostEvent( |
| + threadId, |
| + "chat", |
| + { |
| + type: "chat.msg", |
| + name: owner.title || ownerApp, |
| + text, |
| + uid: "app:" + ownerApp, |
| + ts: event.ts || (meta && meta.ts) || Date.now(), |
| + action: typeof line === "object" && !!line.action, |
| + }, |
| + meta, |
| + ); |
| +} |
| + |
| +// Fold one stored message of an app-thread for BOTH views at once: the owning |
| +// app's state (App view) and a bot bubble (Chat view). A plain untyped note you |
| +// typed in the chat view folds as an ordinary chat message. |
| +async function foldAppThreadMessage(threadId, ownerApp, m) { |
| + try { |
| + const data = await decrypt(threadKeys[threadId], m.blob); |
| + if (isAppType(data.type)) { |
| + foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| + foldAppEventBubble(threadId, ownerApp, data, { id: m.id, ts: data.ts }); |
| + } else if (!data.type && (data.text || data.attachments)) { |
| + foldHostEvent(threadId, "chat", { ...data, type: "chat.msg" }, { id: m.id, ts: data.ts }); |
| + } |
| + } catch {} |
| +} |
| + |
| +// Fetch a thread's full message history (oldest→newest), paginating older pages. |
| +// For app-threads only — their logs are bounded, and the Chat view needs every |
| +// event to reconstruct its action-line history. Capped so a runaway thread can't |
| +// stall the open. Returns null if the open was superseded (thread switched). |
| +async function fetchAllThreadMessages(id, gen, cap = 2000) { |
| + const all = []; |
| + let before = 0; // 0 → the most recent page |
| + for (let page = 0; page < cap / 200; page++) { |
| + const q = before ? `&before=${before}&limit=200` : "&limit=200"; |
| + const r = await ( |
| + await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(id)}${q}`) |
| + ).json(); |
| + if (gen !== openSeq) return null; |
| + const msgs = r.messages || []; |
| + if (!msgs.length) break; |
| + all.unshift(...msgs); // each page is ascending; older pages prepend → ascending overall |
| + if (msgs.length < 200) break; // reached the start of history |
| + before = msgs[0].id; // oldest id of this page |
| + } |
| + return all; |
| +} |
| + |
| // Open an app full-pane: fold the thread's event log and render. Used for |
| // personal app-threads (their own dedicated log) and chat-app threads (where |
| // original, untyped messages are folded in synthetically as chat.msg — they |
| // predate typed events). getState() is exactly this fold — one source of |
| // truth, no separate state store. |
| -async function openAppThreadView(id, gen, appName = threadApp[id]) { |
| +async function openAppThreadView(id, gen, appName = threadApp[id], ownerApp = null) { |
| setAppThreadMode(true); |
| const pane = document.getElementById("app-pane"); |
| if (!pane) return; |
| - // Instant restore: this app-thread was opened before, so its folded state is |
| - // still live (WS folds even backgrounded threads) and its rendered root is |
| - // parked. Re-attach it, restore the feed's scroll BEFORE re-rendering so the app |
| - // judges "was at bottom?" from the real offset, then reconcile in place from the |
| - // current state — no wipe, no fetch, no blank. A background catch-up then folds |
| - // anything missed while the socket was down (chat only; personal apps stay live |
| - // through WS, matching their previous behaviour). |
| + // A COMPANION app-thread (its app has a chatMessage hook): two Views of ONE |
| + // folded log — the Chat pane + the app's Interface pane. Both mount; CSS shows |
| + // them split (desktop) or one-at-a-time (mobile). Its log is small and stays |
| + // live through WS, so re-render from state on each open (no park cache); fetch |
| + // history only the first time. A TAKEOVER app (no chatMessage) skips this and |
| + // falls through to the single-pane path below — its interface fills the thread. |
| + if (ownerApp && appWantsChatView(ownerApp)) { |
| + const iface = document.getElementById("interface-pane"); |
| + if (!(appPaneCache[id] && appPaneCache[id].loaded)) { |
| + pane.textContent = ""; |
| + if (iface) iface.textContent = ""; |
| + resetThread(id); |
| + await seedAppState(id); // snapshot → the Interface list, fast |
| + if (gen !== openSeq) return; |
| + try { |
| + // The Chat view is a record of EVERY interaction, so fold the whole event |
| + // log (not just the recent window) into action lines — otherwise older |
| + // adds, compacted into the snapshot, would be missing from the history. |
| + const all = await fetchAllThreadMessages(id, gen); |
| + if (gen !== openSeq || !all) return; |
| + for (const m of all) await foldAppThreadMessage(id, ownerApp, m); |
| + } catch (e) { |
| + console.error("open app-thread", e); |
| + } |
| + appPaneCache[id] = { root: null, scrollTop: null, loaded: true }; |
| + } |
| + if (gen !== openSeq) return; |
| + pane.textContent = ""; |
| + renderMiniApp(id, "chat", pane); // Chat view |
| + if (iface) { |
| + iface.textContent = ""; |
| + renderMiniApp(id, ownerApp, iface); // Interface view |
| + } |
| + applyAppThreadView(id); |
| + return; |
| + } |
| + |
| + // Single-pane full-surface path — the chat-app thread (group/dm) AND a takeover |
| + // app-thread (a personal app with no chat view). The instant park/restore cache |
| + // makes reopening flash-free. A chat-app thread folds its untyped messages |
| + // synthetically as chat.msg; a takeover app folds only its OWN typed events into |
| + // state — getState() is exactly that fold, one source of truth, no separate store. |
| const cached = appPaneCache[id]; |
| if (cached && cached.loaded && cached.root) { |
| - // replaceChildren (not appendChild): stashAppPane already emptied the pane on |
| - // the way out, but this guarantees a single root even if some path ever left |
| - // content behind. |
| pane.replaceChildren(cached.root); |
| cached.root = null; |
| const feed = pane.querySelector(".chatapp-feed"); |
| @@ -3471,13 +3701,9 @@ async function openAppThreadView(id, gen, appName = threadApp[id]) { |
| if (chatAppFor(id)) catchUpActiveMessages(); |
| return; |
| } |
| - |
| - // First open of this thread: no parked root, so build the fold from history and |
| - // render. This still shows the empty pane for the fetch — a true first load, like |
| - // the original feed's first open — but every later open takes the instant path. |
| pane.textContent = ""; |
| resetThread(id); |
| - await seedAppState(id); // snapshot + catch-up: full history, not just the window |
| + await seedAppState(id); |
| if (gen !== openSeq) return; |
| try { |
| const r = await ( |
| @@ -3485,9 +3711,9 @@ async function openAppThreadView(id, gen, appName = threadApp[id]) { |
| ).json(); |
| if (gen !== openSeq) return; |
| for (const m of r.messages || []) { |
| - // The lab consumes the CLASSIC protocol: each stored message folds as a |
| - // host event with its server-side trimmings (chatAppFoldMessage); a personal |
| - // app-thread folds only its own typed events. |
| + // The chat app consumes the classic protocol (chatAppFoldMessage folds each |
| + // message with its server-side trimmings); a takeover app-thread folds only |
| + // its own typed events into state. |
| if (appName === "chat") await chatAppFoldMessage(id, m); |
| else { |
| try { |
| @@ -3504,11 +3730,38 @@ async function openAppThreadView(id, gen, appName = threadApp[id]) { |
| } |
| } |
| +// Reflect an app-thread's chosen view as a class on the thread view: the split CSS |
| +// keys off `.iface` (Interface selected) — desktop hides the chat pane, mobile |
| +// swaps the visible pane. |
| +function applyAppThreadView(id) { |
| + const tv = document.getElementById("thread-view"); |
| + if (!tv) return; |
| + // A data-attribute drives the split CSS across the three views (chat | split | |
| + // interface). A COMPANION app-thread defaults to split; a normal chat OR a |
| + // takeover app-thread has NO view attribute (→ the single pane fills, the empty |
| + // interface pane hides), so neither can inherit a stale one. |
| + if (threadApp[id] && appWantsChatView(threadApp[id])) tv.dataset.appView = appThreadView[id] || "split"; |
| + else delete tv.dataset.appView; |
| +} |
| + |
| // Re-draw whatever the active thread hosts: an app full-pane (personal |
| // app-thread or the chat app), or the shared-app panel above a normal chat feed. |
| // Called after each folded event. |
| function renderActiveSurfaces() { |
| - const appName = activeAppFor(activeId); |
| + const owner = threadApp[activeId]; |
| + if (owner) { |
| + if (appWantsChatView(owner)) { |
| + // Companion: both Views live off the same folded log — redraw each pane. |
| + renderMiniApp(activeId, "chat", document.getElementById("app-pane")); |
| + const iface = document.getElementById("interface-pane"); |
| + if (iface) renderMiniApp(activeId, owner, iface); |
| + } else { |
| + // Takeover: the app owns the single pane; there's no chat view to redraw. |
| + renderMiniApp(activeId, owner, document.getElementById("app-pane")); |
| + } |
| + return; |
| + } |
| + const appName = renderAppFor(activeId); |
| if (appName) renderMiniApp(activeId, appName, document.getElementById("app-pane")); |
| else renderSurfaces(activeId, document.getElementById("app-surface")); |
| } |
| @@ -4475,8 +4728,19 @@ function connectWS() { |
| // light the dot. Apps that opt out of all three stay silent, the old |
| // hardcoded behaviour. |
| if (payload && isAppType(payload.type)) { |
| - if (tid === activeId) await appendMessage(tid, msg, true); |
| - else foldEvent(tid, payload, { id: msg.id, ts: payload.ts }); |
| + const owner = threadApp[tid]; |
| + if (owner) { |
| + // An app-thread's own event: fold into its app (App view) AND as a bot |
| + // bubble (Chat view), then re-render whichever view is open. This is the |
| + // "reminder arrives as a DM" path — the bubble is the alert (no toast). |
| + foldEvent(tid, payload, { id: msg.id, ts: payload.ts }); |
| + foldAppEventBubble(tid, owner, payload, { id: msg.id, ts: payload.ts }); |
| + if (tid === activeId) renderActiveSurfaces(); |
| + } else if (tid === activeId) { |
| + await appendMessage(tid, msg, true); |
| + } else { |
| + foldEvent(tid, payload, { id: msg.id, ts: payload.ts }); |
| + } |
| notifyLive(tid, payload); |
| const pv = appPreview(payload); |
| if (pv) threadPreview[tid] = pv; |
| @@ -4488,11 +4752,11 @@ function connectWS() { |
| renderThreadList(); |
| return; |
| } |
| - // Chat app: a original message (no type; text and/or attachments) also folds |
| - // into the chat app so the lab feed stays live alongside original clients. |
| - // Normal handling (unread, preview, the hidden original feed) continues |
| - // unchanged below. |
| - if (chatAppFor(tid) && payload && !payload.type && (payload.text || payload.attachments)) { |
| + // Chat app: a plain message (no type; text and/or attachments) folds into the |
| + // chat app so the feed stays live alongside original clients. Also for an |
| + // app-thread's chat view — it's a regular DM, so notes you type there just work |
| + // (folded + rendered), whether or not its bot ever replies. |
| + if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) { |
| foldHostEvent(tid, "chat", { ...payload, type: "chat.msg" }, { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |
| } |
| @@ -5122,30 +5386,28 @@ function showNewChat() { |
| } |
| }; |
| - // An "Apps" section at the bottom of the picker: start (or reopen) a personal |
| - // app — its own thread in the sidebar. Returns a block, or null if no apps. |
| - const appsSection = () => { |
| - const list = personalApps(); |
| - if (!list.length) return null; |
| - const wrap = document.createElement("div"); |
| - wrap.appendChild(pickerSection("Apps")); |
| - for (const app of list) { |
| - const b = document.createElement("button"); |
| - b.className = "list-row"; |
| - const g = document.createElement("span"); |
| - g.className = "avatar app-avatar"; |
| - g.textContent = app.glyph || "▦"; |
| - const nm = document.createElement("span"); |
| - nm.textContent = app.title || app.name; |
| - b.append(g, nm); |
| - b.onclick = async () => { |
| - backdrop.remove(); |
| - const id = await resolveAppThread(app.name); |
| - if (id) openThread(id); |
| - }; |
| - wrap.appendChild(b); |
| - } |
| - return wrap; |
| + // Apps (To-do, Reminders, …) share the ONE scrollable list with people and |
| + // helpers, as their own "Apps" group. They aren't roster members you DM, |
| + // though: tapping one opens (or reopens) its personal app-thread via |
| + // resolveAppThread, never createThread. matchingApps filters the registry's |
| + // scope:"personal" set by the same query as the people search. |
| + const matchingApps = (q) => |
| + personalApps().filter((a) => (a.title || a.name).toLowerCase().includes(q)); |
| + const addAppRow = (listEl, app) => { |
| + const b = document.createElement("button"); |
| + b.className = "list-row"; |
| + const g = document.createElement("span"); |
| + g.className = "avatar app-avatar"; |
| + g.textContent = app.glyph || "▦"; |
| + const nm = document.createElement("span"); |
| + nm.textContent = app.title || app.name; |
| + b.append(g, nm); |
| + b.onclick = async () => { |
| + backdrop.remove(); |
| + const id = await resolveAppThread(app.name); |
| + if (id) openThread(id); |
| + }; |
| + listEl.appendChild(b); |
| }; |
| // "people": tap a name → DM. "group": multi-select + name → group. |
| @@ -5222,8 +5484,14 @@ function showNewChat() { |
| p.textContent = "No one else to chat with yet. Ask the admin to invite people."; |
| card.appendChild(p); |
| } |
| - const as = appsSection(); |
| - if (as) card.appendChild(as); |
| + const apps = matchingApps(""); |
| + if (apps.length) { |
| + const list = document.createElement("div"); |
| + list.className = "pick-list"; |
| + list.appendChild(pickerSection("Apps")); |
| + for (const app of apps) addAppRow(list, app); |
| + card.appendChild(list); |
| + } |
| return; |
| } |
| @@ -5237,7 +5505,7 @@ function showNewChat() { |
| } |
| const filter = document.createElement("input"); |
| - filter.placeholder = "Search people"; |
| + filter.placeholder = "Search"; |
| filter.className = "sheet-input"; |
| card.appendChild(filter); |
| @@ -5251,15 +5519,6 @@ function showNewChat() { |
| const renderList = () => { |
| const q = filter.value.trim().toLowerCase(); |
| list.innerHTML = ""; |
| - const matches = others.filter((e) => e.label.toLowerCase().includes(q)); |
| - if (!matches.length) { |
| - const p = document.createElement("p"); |
| - p.className = "sub"; |
| - p.style.padding = "12px 28px"; |
| - p.textContent = "No matches."; |
| - list.appendChild(p); |
| - return; |
| - } |
| const addRow = (e) => { |
| if (mode === "group") { |
| const row = document.createElement("label"); |
| @@ -5290,24 +5549,32 @@ function showNewChat() { |
| list.appendChild(b); |
| } |
| }; |
| - // People first, then bots under their own "Helpers" heading — distinct from |
| - // People on purpose ("not a person"), with the admin's blurb as consent hint. |
| - const people = matches.filter((e) => !e.is_agent); |
| - const helpers = matches.filter((e) => e.is_agent); |
| - if (people.length && helpers.length) list.appendChild(pickerSection("People")); |
| + // Three groups in the one scrollable list: people, then bots/AI under |
| + // "Helpers", then personal apps under "Apps" (people picker only — a group's |
| + // member list can't include an app). People get a heading only when another |
| + // group sits alongside them (a lone people list needs no label); helpers and |
| + // apps always get theirs, since each is deliberately "not a person". |
| + const people = others.filter((e) => !e.is_agent && e.label.toLowerCase().includes(q)); |
| + const helpers = others.filter((e) => e.is_agent && e.label.toLowerCase().includes(q)); |
| + const apps = mode === "people" ? matchingApps(q) : []; |
| + if (!people.length && !helpers.length && !apps.length) { |
| + const p = document.createElement("p"); |
| + p.className = "sub"; |
| + p.style.padding = "12px 28px"; |
| + p.textContent = "No matches."; |
| + list.appendChild(p); |
| + return; |
| + } |
| + if (people.length && (helpers.length || apps.length)) list.appendChild(pickerSection("People")); |
| for (const e of people) addRow(e); |
| if (helpers.length) list.appendChild(pickerSection("Helpers")); |
| for (const e of helpers) addRow(e); |
| + if (apps.length) list.appendChild(pickerSection("Apps")); |
| + for (const app of apps) addAppRow(list, app); |
| }; |
| filter.addEventListener("input", renderList); |
| renderList(); |
| - // Apps live at the bottom of the people picker (start one as its own thread). |
| - if (mode === "people") { |
| - const as = appsSection(); |
| - if (as) card.appendChild(as); |
| - } |
| - |
| // Enter submits: in a group, create it; in the picker, open the top match. |
| submitOnEnter(filter, () => { |
| if (mode === "group") return create.click(); |
| @@ -6008,6 +6275,14 @@ function updateTypeahead() { |
| // Candidates for a composer token — shared by the web menu above and the native |
| // shell's strip (window.lchatNative.typeahead), so both rank identically. |
| function typeaheadItems(tok) { |
| + if (tok.kind === "command") { |
| + const q = tok.query.toLowerCase(); |
| + const hasBot = threadHasBot(activeId); // bot commands only where a bot can answer them |
| + return commandCatalog() |
| + .filter((c) => (!c.bot || hasBot) && c.name.slice(1).toLowerCase().startsWith(q)) |
| + .slice(0, 8) |
| + .map((c) => ({ command: c.name, desc: c.desc, args: c.args, insert: c.name + " " })); |
| + } |
| if (tok.kind === "emoji") { |
| return emojiMatches(tok.query, 8).map((m) => ({ glyph: m.emoji, code: m.code, insert: m.emoji })); |
| } |
| @@ -6028,7 +6303,18 @@ function renderTypeahead() { |
| row.className = "ta-item" + (i === typeahead.index ? " active" : ""); |
| row.setAttribute("role", "option"); |
| row.setAttribute("aria-selected", String(i === typeahead.index)); |
| - if (typeahead.kind === "emoji") { |
| + if (typeahead.kind === "command") { |
| + const n = document.createElement("span"); |
| + n.className = "ta-name"; |
| + n.textContent = it.command + (it.args ? " " + it.args : ""); |
| + row.appendChild(n); |
| + if (it.desc) { |
| + const d = document.createElement("span"); |
| + d.className = "ta-desc"; |
| + d.textContent = it.desc; |
| + row.appendChild(d); |
| + } |
| + } else if (typeahead.kind === "emoji") { |
| const g = document.createElement("span"); |
| g.className = "ta-emoji"; |
| g.textContent = it.glyph; |
diff --git a/web/static/style.css b/web/static/style.css| index a4da35f..223bcf2 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -659,6 +659,57 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .action-chip:hover:not(:disabled) { background: var(--bubble-them); } |
| .action-chip:disabled { color: var(--muted); cursor: default; } |
| +/* Local notice: a command's private "only visible to you" reply — a client-only |
| + feed row (never sent, gone on reload). A dashed, muted card sets it apart from |
| + real message bubbles; the "Only visible to you" tag makes the privacy explicit. */ |
| +.local-notice { |
| + align-self: stretch; margin: 8px 10px; padding: 10px 12px; |
| + background: var(--bubble-them); border: 1px dashed var(--border); border-radius: 14px; |
| +} |
| +.local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } |
| +.local-notice-glyph { font-size: 15px; line-height: 1; } |
| +.local-notice-from { font-weight: 600; font-size: 14px; color: var(--ink); } |
| +.local-notice-badge { |
| + font-size: 10px; font-weight: 600; letter-spacing: 0.03em; line-height: 1; |
| + padding: 2px 5px; border-radius: 6px; background: var(--accent); color: #fff; |
| +} |
| +.local-notice-tag { margin-left: auto; font-size: 11px; color: var(--muted); } |
| +/* Dismiss ✕ with a radial countdown ring: the ring depletes over the notice's |
| + auto-dismiss window (duration set inline per-notice), and pauses on hover/focus |
| + in step with the actual timer. Reduced-motion drops the animation (still auto- |
| + dismisses, just without the sweep). */ |
| +.local-notice-dismiss { |
| + position: relative; flex: none; width: 24px; height: 24px; margin-left: 8px; padding: 0; |
| + border: none; background: transparent; color: var(--muted); cursor: var(--ui-cursor, pointer); |
| +} |
| +.local-notice-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); } |
| +.local-notice-ring .ln-track { fill: none; stroke: var(--border); stroke-width: 2; } |
| +.local-notice-ring .ln-prog { |
| + fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; |
| + stroke-dasharray: 62.83; stroke-dashoffset: 0; /* 2πr, r=10 */ |
| + animation-name: lnCountdown; animation-timing-function: linear; animation-fill-mode: forwards; |
| +} |
| +@keyframes lnCountdown { to { stroke-dashoffset: 62.83; } } |
| +.local-notice:hover .ln-prog, .local-notice:focus-within .ln-prog { animation-play-state: paused; } |
| +.local-notice-ring .ln-x { stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; } |
| +@media (prefers-reduced-motion: reduce) { .local-notice-ring .ln-prog { animation: none; } } |
| +.local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; } |
| +.local-notice .chip-row { margin-top: 8px; } |
| +/* Chat mini-app: command replies sit in this strip below the feed (the feed is a |
| + keyedList container we can't append into). Collapses when there's no reply. */ |
| +/* App-thread action line: a centered, muted /me-style record of an interaction |
| + ("added 'milk'") — deliberately unlike a chat bubble. */ |
| +.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; max-width: 90%; } |
| +/* The "start of your chat history" intro: a calm, centered explainer pinned at |
| + the top of a bot's chat (a persistent first row that scrolls up with history). */ |
| +.chatapp-intro { |
| + align-self: center; max-width: 40ch; text-align: center; |
| + color: var(--muted); font-size: 14px; line-height: 1.5; padding: 18px 16px 22px; |
| +} |
| +.chatapp-notices { flex: none; } |
| +.chatapp-notices:empty { display: none; } |
| +.chatapp-notices .local-notice { margin: 8px 10px 0; } |
| + |
| /* Text selection works everywhere now: desktop reacts with a stationary hold |
| (drag-select cancels it) and touch reacts with a double-tap, so long-press is |
| free for native selection/copy on mobile. */ |
| @@ -939,6 +990,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .ta-emoji { font-size: 20px; width: 24px; text-align: center; flex: none; } |
| .ta-code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .ta-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +/* Command typeahead: the "/todo" name, then a muted one-line description after it. */ |
| +.ta-desc { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| /* an @mention inside a message — just bold; it inherits the bubble ink, so it |
| stays legible in both the grey "them" and accent "me" bubbles. */ |
| .mention { font-weight: 600; } |
| @@ -1353,6 +1406,38 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; position: relative; } |
| .thread-head { display: flex; align-items: center; gap: 6px; } |
| .thread-head-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } |
| +/* App-thread view toggle: a small segmented [💬 | ⏰] control beside the kebab — |
| + Chat (the DM with the app's bot) vs App (the interactive UI). */ |
| +.view-toggle { display: flex; gap: 2px; flex: none; background: var(--bubble-them); border-radius: 10px; padding: 2px; } |
| +.view-toggle-btn { |
| + display: inline-flex; align-items: center; justify-content: center; |
| + width: 32px; height: 30px; border: none; border-radius: 8px; background: transparent; |
| + font-size: 15px; line-height: 1; color: var(--ink); cursor: var(--ui-cursor, pointer); |
| +} |
| +.view-toggle-btn.active { background: var(--panel); box-shadow: 0 1px 4px var(--shadow); } |
| + |
| +/* App-thread Views: the Chat pane (#app-pane) + the Interface pane |
| + (#interface-pane). A normal chat thread has only the chat pane — the interface |
| + pane stays empty and hidden, so it fills the width unchanged. Shown only in |
| + .app-mode; the framework CSS already gives each .app-pane its display/flex. */ |
| +.app-split { display: none; flex: 1 1 auto; min-height: 0; } |
| +.thread-view.app-mode #app-split { display: flex; } |
| +#interface-pane:empty { display: none; } /* a normal chat has no interface pane */ |
| +.view-toggle-btn.split-only { display: none; } /* Split is desktop-only (revealed below) */ |
| +@media (min-width: 721px) { |
| + /* Desktop: three views — chat | split (both, the default) | interface. */ |
| + #app-split { flex-direction: row; } |
| + #interface-pane { border-left: 1px solid var(--border); } |
| + .view-toggle-btn.split-only { display: inline-flex; } |
| + .thread-view[data-app-view="chat"] #interface-pane { display: none; } |
| + .thread-view[data-app-view="interface"] #app-pane { display: none; } |
| +} |
| +@media (max-width: 720px) { |
| + /* Mobile: one pane, no split — chat/split → Chat, interface → Interface. */ |
| + .thread-view.app-mode #interface-pane { display: none; } |
| + .thread-view[data-app-view="interface"] #app-pane { display: none; } |
| + .thread-view[data-app-view="interface"] #interface-pane { display: flex; } |
| +} |
| .thread-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .thread-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .back-btn { display: none; font-size: 24px; padding: 0 8px; } |
| @@ -1372,6 +1457,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .sheet-input { margin-bottom: 10px; } |
| /* full-bleed list inside the sheet: rows span the card edges (which pads 28px) */ |
| .pick-list { margin: 4px -28px 0; max-height: 56vh; overflow-y: auto; } |
| +/* Picker sheets (new chat / add people) had nested scrolling — the sheet AND the |
| + .pick-list both scrolled — plus a stray horizontal scrollbar (overflow-y:auto |
| + promotes overflow-x to auto, so a sub-pixel overflow showed a bar). Make the |
| + sheet a clipped flex column so its head, search, and Apps footer stay put and |
| + ONLY the list scrolls: one clean scroll region. :has() scopes this to sheets |
| + that actually hold a pick-list, leaving every other modal untouched. */ |
| +.sheet:has(.pick-list) { display: flex; flex-direction: column; overflow: hidden; } |
| +.sheet:has(.pick-list) > * { flex: none; } |
| +.sheet:has(.pick-list) .pick-list { |
| + flex: 0 1 auto; min-height: 0; max-height: none; |
| + overflow-y: auto; overflow-x: hidden; |
| +} |
| .list-row { |
| display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; |
| background: transparent; color: var(--ink); border: none; border-bottom: 1px solid var(--border); |
diff --git a/web/static/ui.js b/web/static/ui.js| index 8b7a330..fe492a7 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -44,11 +44,19 @@ export function shouldSend(e, coarse) { |
| // The trigger must sit at a boundary — start of text or just after whitespace — |
| // so "10:30", "http://…", and "email@host" don't pop the menu. Emoji needs at |
| // least one character after ":" (a lone ":" is common punctuation); "@" opens on |
| -// its own so you can browse everyone. Pure, so it's unit-tested in Node. |
| +// its own so you can browse everyone. A leading "/" opens the command menu — a |
| +// command is the whole first token, so "/" only triggers at the very start of |
| +// the input (and closes once you type past the command name into its args). |
| +// Pure, so it's unit-tested in Node. |
| const TOKEN_CHAR = /[a-z0-9_+-]/i; |
| export function composerToken(text, caret) { |
| text = text == null ? "" : String(text); |
| if (caret == null || caret > text.length) caret = text.length; |
| + if (text[0] === "/") { |
| + const sp = text.indexOf(" "); |
| + const nameEnd = sp === -1 ? text.length : sp; // caret past the name → args, no menu |
| + return caret <= nameEnd ? { kind: "command", query: text.slice(1, caret), start: 0 } : null; |
| + } |
| let i = caret; |
| while (i > 0 && TOKEN_CHAR.test(text[i - 1])) i--; |
| const trigger = text[i - 1]; |