web: /help + persistent in-feed command replies, and a proper typeahead drop-up (#190)
Bring back /help as a private, client-side command: type /help in any thread and
get an "only visible to you" reply listing the commands you can use — no bot
required. It lists the bot's commands (/theme, …) only where a bot is present,
matching the "/" typeahead, and /help now shows in the typeahead everywhere.
Unknown commands ("/foo") keep their private nudge.
Those private replies now live in the feed as real messages instead of a single
overlay card: they stack like chat bubbles (multiple commands pile up in order),
anchor where you typed them, and persist — no auto-dismiss countdown or ✕. They
ride the feed's keyedList as client-only rows (never sent, gone on reload).
Also fix the composer typeahead: it was a flex band that shoved the messages up
as you typed — now it's a drop-up that OVERLAYS the feed (anchored to
.composer-float), so the chat no longer jumps. And the active row filled as an
inset pill (it inherited the global button's 999px radius); it now fills edge to
edge, clipped to the container's rounded corners.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 files changed,
+153
−125
changelog/2026-07-05-help-and-in-feed-command-replies.md+9 −0test/apps.test.mjs+18 −9web/static/apps.js+43 −62web/static/apps/chat.js+63 −25web/static/shell.js+4 −0web/static/style.css+16 −29
diff --git a/changelog/2026-07-05-help-and-in-feed-command-replies.md b/changelog/2026-07-05-help-and-in-feed-command-replies.md| new file mode 100644 |
| index 0000000..fc8542e |
| --- /dev/null |
| +++ b/changelog/2026-07-05-help-and-in-feed-command-replies.md |
| @@ -0,0 +1,9 @@ |
| +Slash commands got friendlier. Type `/help` in any chat to see the commands |
| +you can use — it replies privately, just to you, so nobody else sees it. Mistype |
| +a command like `/tood` and you'll get the same gentle, private nudge instead of |
| +accidentally sending it to everyone. |
| + |
| +Those "only visible to you" replies now live right in the conversation as |
| +messages rather than a card that floats and fades. They stack like chat bubbles |
| +— run a few commands and each answer stays put — and they no longer disappear on |
| +their own, so you've got time to read them. |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 088bc4a..f702ca5 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -127,21 +127,30 @@ 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. |
| +await test("/help is answered privately (client-side); other bot commands pass through", () => { |
| const replies = []; |
| const respond = (r) => replies.push(r); |
| - assert.equal(runCommand("/help", "chatX", respond), false); |
| + // /help is caught + answered right here with a private notice — no bot needed. |
| + assert.equal(runCommand("/help", "chatX", respond), true); |
| + assert.equal(replies.length, 1); |
| + assert.match(replies[0].text, /Commands you can use/); |
| + assert.match(replies[0].text, /\/help/); |
| + assert.match(replies[0].text, /\/todo/); // an app command, listed everywhere |
| + // No bot injected → /help omits the bot's commands, exactly like the typeahead. |
| + assert.doesNotMatch(replies[0].text, /\/theme/); |
| + // /theme is still Eleven's — the client SENDS it (returns false so the composer |
| + // posts it, and the bot answers), never caught locally. |
| assert.equal(runCommand("/theme lagoon", "chatX", respond), false); |
| - assert.equal(replies.length, 0, "bot commands aren't caught/answered locally"); |
| + assert.equal(replies.length, 1, "a bot command isn't 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. |
| + assert.equal(replies.length, 2); |
| + assert.match(replies[1].text, /isn't a command/); |
| + // The catalog flags /help non-bot (typeahead lists it everywhere); /theme stays |
| + // bot-gated; app commands are non-bot. |
| const cat = commandCatalog(); |
| - assert.ok(cat.some((c) => c.name === "/help" && c.bot === true)); |
| + assert.ok(cat.some((c) => c.name === "/help" && c.bot === false)); |
| + assert.ok(cat.some((c) => c.name === "/theme" && c.bot === true)); |
| assert.ok(cat.some((c) => c.name === "/todo" && c.bot === false)); |
| }); |
diff --git a/web/static/apps.js b/web/static/apps.js| index a8f03dc..28577b9 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -504,6 +504,13 @@ let defaultBotFn = null; |
| export function setDefaultBot(fn) { |
| defaultBotFn = fn; |
| } |
| +// Whether a thread has a bot member — so /help lists the bot's commands (/theme, |
| +// …) only where a bot can actually answer them, exactly like the "/" typeahead. |
| +// shell.js injects its threadHasBot. Absent (or no injection) → assume none. |
| +let threadHasBotFn = null; |
| +export function setThreadHasBot(fn) { |
| + threadHasBotFn = 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). shell.js injects openThread. |
| @@ -563,6 +570,25 @@ function makeCtx(threadId, app) { |
| // ---- commands ----------------------------------------------------------- |
| +// Bot-floor commands the CLIENT now answers directly (not the bot), with a private |
| +// "only visible to you" notice — so they work in EVERY thread, bot present or not. |
| +// They still live in BOT_COMMANDS (the sidecar keeps its own handler + the "/" |
| +// typeahead lists them), but runCommand catches them before sending, and |
| +// commandCatalog flags them non-bot so the typeahead shows them everywhere. |
| +const CLIENT_ANSWERED = new Set(["/help"]); |
| + |
| +// helpNotice lists the commands you can use, as the private "/help" reply. Mirrors |
| +// the typeahead: the bot's commands (/theme, …) show only where a bot can answer |
| +// them, so /help never advertises a command that would do nothing here. |
| +function helpNotice(threadId) { |
| + const hasBot = threadHasBotFn ? !!threadHasBotFn(threadId) : false; |
| + const lines = commandCatalog() |
| + .filter((c) => !c.bot || hasBot) |
| + .map((c) => `${c.name}${c.args ? " " + c.args : ""} — ${c.desc}`); |
| + const text = lines.length ? "Commands you can use:\n" + lines.join("\n") : "No commands are available here yet."; |
| + return { sender: defaultBotFn ? defaultBotFn() : null, text }; |
| +} |
| + |
| // 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 |
| @@ -597,9 +623,14 @@ export function runCommand(text, activeThreadId, respond) { |
| } |
| }; |
| 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. |
| + // /help (and any other CLIENT_ANSWERED floor command) is answered right here |
| + // with a private notice — no bot needed, so it works in every thread. |
| + if (cmd.name === "/help") { |
| + reply(helpNotice(activeThreadId)); |
| + return true; |
| + } |
| + // Any other bot command (/theme, …) is SENT so the bot answers it: return false |
| + // to let the composer post it. Only a truly unknown "/word" is caught + answered. |
| 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 |
| @@ -633,7 +664,9 @@ export function commandCatalog() { |
| 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 }); |
| + // bot:false for the ones the client answers itself (/help) → the typeahead lists |
| + // them in every thread, not only where a bot is present. |
| + for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: !CLIENT_ANSWERED.has(name) }); |
| return out; |
| } |
| @@ -644,25 +677,17 @@ export function commandNames() { |
| // 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 (shell.js's #feed, the chat app's .chatapp-feed) |
| -// append the returned node into their own feed and scroll to it. |
| +// reload (like Slack's ephemeral replies). It PERSISTS: no auto-dismiss, so it |
| +// stacks in the feed like a chat bubble and multiple replies pile up in order. |
| +// 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. The chat app appends |
| +// the returned node into its feed (chat.js interleaves it as a keyed row). |
| 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) { |
| @@ -687,24 +712,6 @@ export function buildLocalNotice({ sender, text, actions } = {}) { |
| 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"; |
| @@ -730,32 +737,6 @@ export function buildLocalNotice({ sender, text, actions } = {}) { |
| } |
| 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; |
| } |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 1a31e8c..a71f015 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, commandCatalog, buildLocalNotice } from "../apps.js"; |
| +import { defineApp, keyedList, runCommand, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp } 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"; |
| @@ -62,6 +62,31 @@ const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse |
| const typing = new Map(); // threadId → Map(name → until) |
| const refreshTyping = new Map(); // threadId → fn redrawing the typing row |
| +// Private command replies ("only visible to you") live in the feed as CLIENT-ONLY |
| +// rows: never sent, gone on reload, and interleaved with real messages so they |
| +// stack like chat bubbles and persist (no auto-dismiss). Each carries a `seq` just |
| +// above the newest message at creation time, so a later real message falls below |
| +// it. render() folds these into its keyedList (see the notice branch in renderItem). |
| +const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }] |
| +const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane |
| +let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread |
| + |
| +// addLocalNotice records a command reply for `threadId` and re-renders the pane so |
| +// it appears immediately. Anchored just above the current newest message so it |
| +// sits where you typed it and never jumps around as the conversation continues. |
| +function addLocalNotice(ctx, spec) { |
| + const msgs = (ctx.getState() && ctx.getState().msgs) || []; |
| + const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0; |
| + const list = localNotices.get(ctx.threadId) || []; |
| + noticeSeq += 1; |
| + // maxSid + a tiny monotonic fraction: strictly between this sid and the next, so |
| + // ordering vs. integer message sids is unambiguous and creation order is kept. |
| + list.push({ id: "ln" + noticeSeq, seq: maxSid + noticeSeq * 1e-6, sender: spec.sender || null, text: spec.text || "", actions: spec.actions || null }); |
| + localNotices.set(ctx.threadId, list); |
| + const refresh = refreshFeed.get(ctx.threadId); |
| + if (refresh) refresh(); |
| +} |
| + |
| function typingNames(threadId, exceptName) { |
| const m = typing.get(threadId); |
| if (!m) return []; |
| @@ -277,7 +302,7 @@ export const app = defineApp({ |
| /* Embedded in the native shell: the shell draws its own composer + banners, so |
| the app shows only the feed (mirrors the original .native-embed layout). */ |
| .chatapp-root.embed > .composer, |
| -.chatapp-root.embed > .typeahead, |
| +.chatapp-root.embed .typeahead, |
| .chatapp-root.embed > .composer-reply, |
| .chatapp-root.embed > .composer-edit { display: none; } |
| /* Embedded: keep the attachment preview visible (a staged photo shows just above |
| @@ -327,16 +352,20 @@ export const app = defineApp({ |
| const fileDropInput = document.getElementById("file-input"); |
| if (fileDropInput) fileDropInput.onchange = root._dropFiles; |
| } |
| - // 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. |
| renderSurfaces(ctx.threadId, root.querySelector(":scope > .app-surface")); |
| + // Let addLocalNotice re-render this pane when a command reply comes in — the |
| + // reply isn't folded state, so nothing else would. Points at the live mount |
| + // (like the hotkeys/native re-points above), self-removed on teardown. |
| + const doRefresh = () => renderApp(ctx.threadId, "chat", el); |
| + refreshFeed.set(ctx.threadId, doRefresh); |
| + ctx.onCleanup(() => { |
| + if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId); |
| + }); |
| + |
| const feed = root.querySelector(".chatapp-feed"); |
| const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60 || !feed.childElementCount; |
| @@ -350,6 +379,15 @@ export const app = defineApp({ |
| } |
| const firstBuild = !feed.childElementCount; |
| const items = viewItems(msgs); |
| + // Client-only command replies ("only visible to you") ride in the feed as keyed |
| + // rows, interleaved by their creation-time seq (see addLocalNotice) so they |
| + // stack like bubbles and stay put as the conversation continues. Merge + sort |
| + // only when some exist, so a normal feed keeps its plain message order. |
| + const notices = localNotices.get(ctx.threadId); |
| + if (notices && notices.length) { |
| + for (const n of notices) items.push({ id: n.id, notice: n, seq: n.seq, vrev: "notice", srev: "" }); |
| + items.sort((a, b) => (a.notice ? a.seq : a.sid) - (b.notice ? b.seq : b.sid)); |
| + } |
| // 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 |
| @@ -466,6 +504,13 @@ function renderItem(m, ctx) { |
| return item; |
| } |
| + // A private "only visible to you" command reply — a client-only feed row that |
| + // persists and stacks like a bubble (see addLocalNotice / buildLocalNotice). |
| + if (m.notice) { |
| + item.appendChild(buildLocalNotice(m.notice)); |
| + 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) { |
| @@ -1877,22 +1922,20 @@ 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"); |
| preview.className = "composer-preview hidden"; |
| float.appendChild(preview); |
| + // The typeahead is a drop-UP that OVERLAYS the feed (not a flex band that shoves |
| + // the messages up as you type), so it lives inside .composer-float — the |
| + // zero-height anchor at the top of the composer stack, exactly like the |
| + // attachment preview above. Positioned absolutely in CSS. |
| const taBox = document.createElement("div"); |
| taBox.className = "typeahead hidden"; |
| taBox.setAttribute("role", "listbox"); |
| + float.appendChild(taBox); |
| const replyBanner = document.createElement("div"); |
| replyBanner.className = "composer-reply hidden"; |
| @@ -1929,7 +1972,7 @@ function buildSkeleton(el, ctx) { |
| compose.append(attach, fileInput, field); |
| - root.append(surface, feed, notices, float, taBox, replyBanner, editBanner, compose); |
| + root.append(surface, feed, float, replyBanner, editBanner, compose); |
| el.appendChild(root); |
| // Keep the feed pinned to the newest message when the composer, its staged |
| @@ -2426,16 +2469,11 @@ function buildSkeleton(el, ctx) { |
| refreshSend(); |
| stopTypingBeat(); // command / edit / send all clear the field — end the beat now |
| }; |
| - // 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)); |
| - }) |
| - ) { |
| + // A slash command (mini-app command, /help, or an unknown "/foo") is handled |
| + // instead of sending — it answers in place with a private "only visible to you" |
| + // reply that stacks in the feed and persists (addLocalNotice). Skipped while |
| + // editing, like the original composer. |
| + if (!editing && runCommand(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) { |
| resetInput(); |
| return; |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index 829001f..a75dae1 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -45,6 +45,7 @@ import { |
| setAppThreadResolver, |
| setToast, |
| setDefaultBot, |
| + setThreadHasBot, |
| setThreadOpener, |
| runCommand, |
| commandCatalog, |
| @@ -2052,6 +2053,9 @@ setDefaultBot(() => { |
| roster.find((x) => x.is_agent); |
| return e ? { name: e.label, kind: e.kind || "bot" } : null; |
| }); |
| +// Lets /help list the bot's commands only where a bot can answer them (same gate |
| +// as the "/" typeahead). |
| +setThreadHasBot((id) => threadHasBot(id)); |
| // 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 |
diff --git a/web/static/style.css b/web/static/style.css| index 7f4371c..67e7ed8 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -660,10 +660,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .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. */ |
| + feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no |
| + auto-dismiss), stacking like a chat bubble. A dashed, muted card sets it apart |
| + from real bubbles; the "Only visible to you" tag makes the privacy explicit. */ |
| .local-notice { |
| - align-self: stretch; margin: 8px 10px; padding: 10px 12px; |
| + align-self: stretch; margin: 4px 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; } |
| @@ -674,29 +675,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| 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%; } |
| @@ -706,9 +686,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| 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 |
| @@ -971,15 +948,24 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| white-space: nowrap; |
| } |
| -/* composer autocomplete (:emoji / @mention) — sits above the composer, in flow */ |
| +/* composer autocomplete (:emoji / @mention / slash) — a drop-UP that OVERLAYS the |
| + feed rather than a flex band that shoves the messages up as you type. It's |
| + anchored to .composer-float (the zero-height anchor at the top of the composer |
| + stack), so it floats over the messages and rises with any reply/edit banner. */ |
| .typeahead { |
| - margin: 0 8px 6px; |
| + position: absolute; |
| + bottom: 6px; |
| + left: 8px; |
| + right: 8px; |
| max-height: 216px; |
| overflow-y: auto; |
| border: 1px solid var(--border); |
| border-radius: 14px; |
| background: var(--panel); |
| box-shadow: var(--shadow); |
| + /* Clip the full-bleed active row to the rounded corners (overflow-y already |
| + makes this a clipping container). */ |
| + overflow-x: hidden; |
| } |
| .typeahead.hidden { display: none; } |
| .ta-item { |
| @@ -991,6 +977,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| background: transparent; |
| color: var(--ink); |
| border: none; |
| + border-radius: 0; /* override the global pill button radius — the active row fills edge to edge */ |
| padding: 8px 12px; |
| font-size: 15px; |
| } |