eleven / ocho Public

web: command replies calm down to a one-line record after ~30s (#205)

* web: command replies calm down to a one-line record after ~30s

A slash-command reply ("I'll remind you: …") is loud only for a moment: after
NOTICE_COMPACT_AFTER_MS the full "only visible to you" card cross-fades down to
a compact one-liner — glyph · who · what happened · action link — so it stops
taking up room in the feed while its action stays one tap away.

- apps.js: buildLocalNotice now renders full-then-compact. A reply carries `at`
  (creation time) and an optional terse `short`; after the threshold it fades
  out, swaps to the compact line, and fades back in (reduced-motion → instant).
  A reply reopened long after it landed rebuilds straight into compact, no timer.
  The pure bits — noticeParts() (from/what) and noticeIsCompact(at, now) — are
  exported and unit-tested (the DOM build isn't node-testable).
- chat.js: addLocalNotice records `short` + `at`.
- remind/todo: give their command replies a `short` ("added “…”").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: reply card reads like a chat reply + a countdown progress line

Refines the calm-down card from the earlier commit:
- Chat-reply look: the sender (+ AI/BOT badge) and the "Only visible to you"
  tag now sit OUTSIDE/above the bubble, like a real message's name row; the
  reply text is a chat-style bubble. The compact one-liner is unchanged.
- A thin progress line drains along the bubble's bottom edge over the loud
  window, so the ~30s countdown is visible before it collapses. Driven by a
  scaleX transition set to the exact time remaining; skipped under
  reduced-motion (the collapse still fires on the timer).

Also gitignore the deploy build artifact (lchat-linux-amd64).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: only confirmations calm down — content replies (/help) stay put

A quick confirmation ("Added …", "I'll remind you …") is transient and should
shrink away; /help is genuine content and should persist like a real message.
The signal is already in the reply: a confirmation gives a terse `short` (what
it collapses to), a content reply gives none. So gate the countdown + shrink on
noticeCalmsDown(spec) === has-a-short. /help now renders as a persistent
chat-reply bubble; todo/remind confirmations still count down and collapse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a3e2c0876e9627125df1c0512b052fdcf289d216 parent c4b9032
8 files changed, +236 −29
  • .gitignore +2 −0
  • changelog/2026-07-05-reply-card-calms-down.md +1 −0
  • test/apps.test.mjs +35 −0
  • web/static/apps.js +152 −21
  • web/static/apps/chat.js +4 −1
  • web/static/apps/remind.js +3 −1
  • web/static/apps/todo.js +3 −1
  • web/static/style.css +36 −5
diff --git a/.gitignore b/.gitignore
index b831903..50f5dc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
# Compiled binary
/lchat
+# Linux deploy build artifact (deploy/build-linux.sh output)
+/lchat-linux-amd64
# SQLite database (contains user data — never commit)
*.db
diff --git a/changelog/2026-07-05-reply-card-calms-down.md b/changelog/2026-07-05-reply-card-calms-down.md
new file mode 100644
index 0000000..2736608
--- /dev/null
+++ b/changelog/2026-07-05-reply-card-calms-down.md
@@ -0,0 +1 @@
+Replies to a slash command now read like a proper chat reply — the responder's name and the “Only visible to you” note sit above the bubble, just like a message. And a quick confirmation (“Added ‘milk’”, “I'll remind you…”) no longer lingers: a thin line along the bottom counts it down, then it quietly shrinks to a compact one-liner — like “⏰ Reminders added ‘milk’ · View reminders” — with its action still one tap away. Genuine replies that are real content, like the /help command list, stay put as normal messages.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index a6e6b9e..e4b675e 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -28,6 +28,10 @@ import {
removeItem,
keyedList,
appWantsChatView,
+ noticeParts,
+ noticeIsCompact,
+ noticeCalmsDown,
+ NOTICE_COMPACT_AFTER_MS,
} 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";
@@ -630,6 +634,37 @@ await test("notifyLive: app events never floating-toast; a hidden page gets a sy
}
});
+await test("command reply: only confirmations (those with a `short`) calm down", () => {
+ // A confirmation supplies a terse `short` → it counts down and shrinks.
+ assert.equal(noticeCalmsDown({ text: "Added “milk” to your to-do list.", short: "added “milk”" }), true);
+ // A genuine content reply like /help gives no `short` → it stays a real message.
+ assert.equal(noticeCalmsDown({ sender: { name: "Eleven" }, text: "Commands you can use…" }), false);
+ assert.equal(noticeCalmsDown({ short: " " }), false); // whitespace-only doesn't count
+ assert.equal(noticeCalmsDown({}), false);
+ assert.equal(noticeCalmsDown(), false);
+});
+
+await test("command reply: compact one-liner parts + the loud→calm age threshold", () => {
+ // The calmed-down one-liner prefers the reply's own terse `short`, else its text.
+ assert.deepEqual(
+ noticeParts({ sender: { name: "Reminders" }, text: "I'll remind you: “fart”.", short: "added “fart”" }),
+ { from: "Reminders", what: "added “fart”" },
+ );
+ assert.deepEqual(
+ noticeParts({ sender: { name: "Eleven" }, text: "Commands you can use…" }),
+ { from: "Eleven", what: "Commands you can use…" },
+ );
+ assert.deepEqual(noticeParts({}), { from: "", what: "" });
+
+ // A fresh reply is loud; once aged past the threshold it renders compact. A reply
+ // with no timestamp never auto-collapses (stays as first built).
+ const now = 1_000_000;
+ assert.equal(noticeIsCompact(now, now), false);
+ assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS + 1, now), false);
+ assert.equal(noticeIsCompact(now - NOTICE_COMPACT_AFTER_MS, now), true);
+ assert.equal(noticeIsCompact(0, now), false);
+});
+
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(
diff --git a/web/static/apps.js b/web/static/apps.js
index 060bd11..c5e11e7 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -686,19 +686,58 @@ 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). 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");
+// A CONFIRMATION reply is loud for a while, then calms down: after this long, the
+// full card collapses to a one-line record. Exported + with pure sibling helpers
+// so the decision is testable without a DOM.
+export const NOTICE_COMPACT_AFTER_MS = 30000;
+
+// Only a confirmation calms down — a genuine content reply (like /help) is a real
+// chat message and stays put. The signal is already in the reply: a confirmation
+// gives a terse `short` (what it shrinks to, e.g. `added "milk"`); /help gives
+// none. So "has a short" == "is a transient confirmation that counts down."
+export function noticeCalmsDown(spec = {}) {
+ return !!(spec && spec.short && String(spec.short).trim());
+}
+
+// The two pieces the calmed-down one-liner shows: who answered, and a terse "what
+// happened" — the reply's own `short` if it gave one (e.g. `added "milk"`), else
+// its full text. Pure so it's unit-tested directly.
+export function noticeParts({ sender, text, short } = {}) {
+ return { from: (sender && sender.name) || "", what: (short || text || "").trim() };
+}
+
+// Whether a notice created at `at` (epoch ms) should already render compact by
+// `now` — true once it's aged past the threshold. A reply reopened long after it
+// landed (its node rebuilt on thread reopen) thus starts compact, no timer.
+export function noticeIsCompact(at, now) {
+ return !!at && now - at >= NOTICE_COMPACT_AFTER_MS;
+}
+
+function runNoticeAction(a) {
+ try {
+ a && a.run && a.run();
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+// Whether animations are allowed (honours the OS "reduce motion" setting).
+function motionOK() {
+ try {
+ return !(typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches);
+ } catch {
+ return true;
+ }
+}
+
+// The loud card, styled like a chat reply: the sender (+ AI/BOT badge) and the
+// "Only visible to you" tag sit OUTSIDE the bubble (above it, like a message's
+// name row), the reply text is a chat-style bubble, and any trusted action chips
+// ({ label, run } — minted locally, so they bypass the untrusted-payload
+// whitelist) sit below. `remaining` (ms until it calms down) drives a thin
+// progress line draining along the bubble's bottom edge.
+function fillFullNotice(row, spec = {}, remaining = 0) {
+ const { sender, text, actions } = spec;
const head = document.createElement("div");
head.className = "local-notice-head";
if (sender && sender.name) {
@@ -724,10 +763,27 @@ export function buildLocalNotice({ sender, text, actions } = {}) {
tag.textContent = "Only visible to you";
head.appendChild(tag);
row.appendChild(head);
+
+ const bubble = document.createElement("div");
+ bubble.className = "local-notice-bubble";
const body = document.createElement("div");
body.className = "local-notice-body";
body.textContent = text || "";
- row.appendChild(body);
+ bubble.appendChild(body);
+ // A thin line along the bubble's bottom edge that drains over the time left
+ // before the reply calms down — a visual countdown. Skipped under reduced-motion
+ // (the collapse still happens on the timer, just without the animation).
+ if (remaining > 0 && motionOK() && typeof requestAnimationFrame !== "undefined") {
+ const bar = document.createElement("div");
+ bar.className = "local-notice-progress";
+ bubble.appendChild(bar);
+ requestAnimationFrame(() => {
+ bar.style.transition = `transform ${remaining}ms linear`;
+ bar.style.transform = "scaleX(0)";
+ });
+ }
+ row.appendChild(bubble);
+
if (Array.isArray(actions) && actions.length) {
const chipRow = document.createElement("div");
chipRow.className = "chip-row";
@@ -737,17 +793,92 @@ export function buildLocalNotice({ sender, text, actions } = {}) {
b.type = "button";
b.className = "action-chip";
b.textContent = a.label;
- b.onclick = () => {
- try {
- a.run && a.run();
- } catch (e) {
- console.error(e);
- }
- };
+ b.onclick = () => runNoticeAction(a);
chipRow.appendChild(b);
}
row.appendChild(chipRow);
}
+}
+
+// The calmed-down one-liner: glyph · sender · what happened · action link(s).
+function fillCompactNotice(row, spec = {}) {
+ row.classList.add("local-notice--compact");
+ const { sender, actions } = spec;
+ const { from, what } = noticeParts(spec);
+ if (sender && sender.glyph) {
+ const g = document.createElement("span");
+ g.className = "local-notice-glyph";
+ g.textContent = sender.glyph;
+ row.appendChild(g);
+ }
+ if (from) {
+ const nm = document.createElement("span");
+ nm.className = "lc-from";
+ nm.textContent = from;
+ row.appendChild(nm);
+ }
+ if (what) {
+ const summary = document.createElement("span");
+ summary.className = "lc-summary";
+ summary.textContent = what;
+ row.appendChild(summary);
+ }
+ if (Array.isArray(actions)) {
+ for (const a of actions) {
+ if (!a || !a.label) continue;
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "lc-action";
+ b.textContent = a.label;
+ b.onclick = () => runNoticeAction(a);
+ row.appendChild(b);
+ }
+ }
+}
+
+// Cross-fade the loud card down to the compact line: fade out, swap the contents,
+// fade the one-liner back in. Reduced-motion makes the fade instant (CSS). Skips
+// a node that's been detached in the meantime (navigated away — the rebuilt node
+// will already start compact).
+function collapseNotice(row, spec) {
+ if (!row.isConnected) return;
+ row.setAttribute("aria-live", "off"); // don't re-announce the collapsed copy
+ row.classList.add("local-notice--collapsing");
+ setTimeout(() => {
+ row.replaceChildren();
+ row.classList.remove("local-notice--collapsing");
+ fillCompactNotice(row, spec);
+ }, 200);
+}
+
+// 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). It PERSISTS: no auto-dismiss, so it
+// stacks in the feed like a chat bubble and multiple replies pile up in order.
+// It's loud at first, then calms: a fresh reply shows the full card and, after
+// NOTICE_COMPACT_AFTER_MS, cross-fades to a compact one-line record; a reply
+// already older than that (rebuilt when you reopen the thread) starts compact.
+// The chat app appends the returned node into its feed (chat.js keys it as a row).
+export function buildLocalNotice(spec = {}) {
+ const row = document.createElement("div");
+ row.className = "local-notice";
+ row.setAttribute("role", "status"); // announced to screen readers on insert
+ row.setAttribute("aria-live", "polite");
+ const at = spec.at || 0;
+ const now = Date.now();
+ // Only confirmations calm down; a content reply (/help) renders full and stays.
+ const calms = noticeCalmsDown(spec);
+ if (calms && noticeIsCompact(at, now)) {
+ fillCompactNotice(row, spec);
+ } else {
+ // remaining>0 only for a confirmation still inside its loud window; the
+ // progress line and the collapse timer both run for that long, staying in sync.
+ const remaining = calms && at ? Math.max(0, NOTICE_COMPACT_AFTER_MS - (now - at)) : 0;
+ fillFullNotice(row, spec, remaining);
+ if (remaining > 0 && typeof setTimeout !== "undefined") {
+ setTimeout(() => collapseNotice(row, spec), remaining);
+ }
+ }
return row;
}
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index f0214a9..0d18df7 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -81,7 +81,10 @@ function addLocalNotice(ctx, spec) {
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 });
+ // `at` (creation time) drives the loud→compact collapse in buildLocalNotice: a
+ // fresh reply shows the full card then calms down; one rebuilt long after it
+ // landed (thread reopened) starts compact. `short` is the terse one-liner text.
+ list.push({ id: "ln" + noticeSeq, seq: maxSid + noticeSeq * 1e-6, sender: spec.sender || null, text: spec.text || "", short: spec.short || "", actions: spec.actions || null, at: Date.now() });
localNotices.set(ctx.threadId, list);
const refresh = refreshFeed.get(ctx.threadId);
if (refresh) refresh();
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index d06200c..cfec61f 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -193,7 +193,9 @@ export const app = defineApp({
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 }] });
+ // `short` is what the reply collapses to once it calms down (~30s): a terse
+ // "Reminders added “…”" one-liner, vs. the loud "I'll remind you: …" card.
+ ctx.reply({ text: `I'll remind you: “${p.text}”.`, short: `added “${p.text}”`, actions: [{ label: "View reminders", run: ctx.open }] });
},
},
},
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index 6e9db15..a4470be 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -34,7 +34,9 @@ export const app = defineApp({
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 }] });
+ // `short` is the terse one-liner the reply calms down to (~30s): "To-do
+ // added “…”", vs. the loud "Added “…” to your to-do list." card.
+ ctx.reply({ text: `Added “${text}” to your to-do list.`, short: `added “${text}”`, actions: [{ label: "View to-do", run: ctx.open }] });
},
},
},
diff --git a/web/static/style.css b/web/static/style.css
index eb04291..4d2d688 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -779,13 +779,15 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* Local notice: a command's private "only visible to you" reply — a client-only
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. */
+ auto-dismiss). It reads like a chat reply: the sender (+ AI/BOT badge) and the
+ "Only visible to you" tag sit ABOVE, outside the bubble (like a message's name
+ row); the reply text is a chat-style bubble. */
.local-notice {
- align-self: stretch; margin: 4px 10px; padding: 10px 12px;
- background: var(--bubble-them); border: 1px dashed var(--border); border-radius: 14px;
+ align-self: stretch; margin: 6px 10px 8px;
+ display: flex; flex-direction: column; align-items: flex-start;
+ transition: opacity 0.18s ease;
}
-.local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
+.local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; align-self: stretch; }
.local-notice-glyph { font-size: 15px; line-height: 1; }
.local-notice-from { font-weight: 600; font-size: 14px; color: var(--ink); }
.local-notice-badge {
@@ -793,8 +795,37 @@ 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); }
+.local-notice-bubble {
+ position: relative; overflow: hidden; max-width: 90%;
+ padding: 10px 12px; border-radius: 14px; background: var(--bubble-them);
+}
.local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; }
.local-notice .chip-row { margin-top: 8px; }
+/* The countdown line along the bubble's bottom edge, draining left→empty over the
+ loud window (scaleX animated in JS to the exact time remaining). */
+.local-notice-progress {
+ position: absolute; left: 0; bottom: 0; height: 3px; width: 100%;
+ transform-origin: left center; transform: scaleX(1);
+ background: var(--accent); opacity: 0.5;
+}
+/* A reply is loud only for a while: after ~30s the card cross-fades down to a
+ compact one-line record (glyph · who · what happened · action). */
+.local-notice--collapsing { opacity: 0; }
+.local-notice--compact {
+ display: flex; flex-direction: row; align-items: baseline; flex-wrap: wrap; gap: 5px;
+ margin: 2px 10px; padding: 3px 12px;
+ background: transparent; border: none; /* shed the bubble */
+ font-size: 13px; color: var(--muted);
+}
+.local-notice--compact .local-notice-glyph { font-size: 14px; align-self: center; }
+.local-notice--compact .lc-from { font-weight: 600; color: var(--ink); }
+.local-notice--compact .lc-summary { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.local-notice--compact .lc-action {
+ flex: 0 0 auto; background: none; border: none; padding: 0; font: inherit; font-weight: 600;
+ color: var(--link); cursor: var(--ui-cursor, pointer);
+}
+.local-notice--compact .lc-action::before { content: "·"; margin-right: 5px; color: var(--muted); font-weight: 400; }
+@media (prefers-reduced-motion: reduce) { .local-notice { transition: none; } }
/* 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%; }