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>
8 files changed,
+236
−29
.gitignore+2 −0changelog/2026-07-05-reply-card-calms-down.md+1 −0test/apps.test.mjs+35 −0web/static/apps.js+152 −21web/static/apps/chat.js+4 −1web/static/apps/remind.js+3 −1web/static/apps/todo.js+3 −1web/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%; } |