eleven / ocho Public

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>
Paul Campbell 44effc7b9c80eaa59d95c57c0cd30123ac7c1c50 parent d0756b3
6 files changed, +153 −125
  • changelog/2026-07-05-help-and-in-feed-command-replies.md +9 −0
  • test/apps.test.mjs +18 −9
  • web/static/apps.js +43 −62
  • web/static/apps/chat.js +63 −25
  • web/static/shell.js +4 −0
  • web/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;
}