eleven / ocho Public

web: drop the reply countdown line; collapse with a smooth blind-up (#211)

* web: order the chat list by recency, not an imperative bump

Bug: an unpinned chat only floated to the top when a message arrived while you
were looking elsewhere — the active chat and your own sends never moved it,
because ordering relied on the imperative bumpThread (which is skipped for the
active thread) and otherwise kept server load-order.

Fix: make the render sort by recency, the single source of truth — mirroring the
server's ORDER BY. pinnedFirst now orders each band (pinned, then the rest)
most-recent-first by last_at, falling back to created_at so a brand-new empty
chat sorts by when it was made. last_at is already updated on every activity
(active or not), so every chat with newer activity rises. Stable sort keeps
same-second ties in place.

- ui.js: pinnedFirst gains a `lastOf` accessor (defaults to a no-op, so existing
  callers/tests are unchanged); sorts each band newest-first.
- shell.js: renderThreadList passes last_at||created_at; bumpThread removed
  (function + its call) — the sort supersedes it.
- This also floats the Reminders thread to the top when a reminder fires
  (supersedes PR #206): the fired event updates last_at, so the sort lifts it.

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

* web: drop the reply countdown line; collapse with a smooth blind-up

Two refinements to the calm-down confirmation card:
- Remove the progress countdown line — it didn't add anything.
- Replace the cross-fade collapse (which snapped the height and jerked the feed)
  with a "blind up": swap in the compact one-liner, then animate the row's height
  from full down to compact, so the chat stream below slides up smoothly.
  transitionend clears the inline styles (timeout backstop); reduced-motion (or a
  detached node) swaps instantly.

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 c56ee0063d704e0c726ee91d6358865093dc111e parent a54fcc8
3 files changed, +40 −42
  • changelog/2026-07-05-reply-card-calms-down.md +1 −1
  • web/static/apps.js +35 −27
  • web/static/style.css +4 −14
diff --git a/changelog/2026-07-05-reply-card-calms-down.md b/changelog/2026-07-05-reply-card-calms-down.md
index 2736608..1979d9c 100644
--- a/changelog/2026-07-05-reply-card-calms-down.md
+++ b/changelog/2026-07-05-reply-card-calms-down.md
@@ -1 +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.
+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: after a moment 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/web/static/apps.js b/web/static/apps.js
index 1f8d0cf..ee8ca73 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -734,9 +734,8 @@ function motionOK() {
// "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) {
+// whitelist) sit below.
+function fillFullNotice(row, spec = {}) {
const { sender, text, actions } = spec;
const head = document.createElement("div");
head.className = "local-notice-head";
@@ -770,18 +769,6 @@ function fillFullNotice(row, spec = {}, remaining = 0) {
body.className = "local-notice-body";
body.textContent = text || "";
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) {
@@ -836,19 +823,40 @@ function fillCompactNotice(row, spec = {}) {
}
}
-// 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).
+// Collapse the loud card down to the compact one-liner with a "blind up": swap in
+// the compact content, then animate the row's HEIGHT from its full height down to
+// the compact height so the feed below slides up smoothly instead of jerking.
+// Reduced-motion (or a detached node — navigated away, the rebuilt node starts
+// compact) just swaps instantly.
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(() => {
+ const swap = () => {
row.replaceChildren();
- row.classList.remove("local-notice--collapsing");
fillCompactNotice(row, spec);
- }, 200);
+ };
+ if (!motionOK()) {
+ swap();
+ return;
+ }
+ const startH = row.offsetHeight; // full height, before the swap
+ swap();
+ const endH = row.offsetHeight; // compact height, after the swap
+ if (endH >= startH) return; // nothing to roll up
+ row.style.overflow = "hidden";
+ row.style.height = startH + "px";
+ void row.offsetHeight; // force a reflow so the start height sticks
+ row.style.transition = "height 0.28s ease";
+ row.style.height = endH + "px";
+ const cleanup = () => {
+ row.style.removeProperty("height");
+ row.style.removeProperty("overflow");
+ row.style.removeProperty("transition");
+ };
+ // transitionend normally clears the inline styles; a timeout is the backstop
+ // if it doesn't fire (e.g. the tab was hidden for the whole animation).
+ row.addEventListener("transitionend", cleanup, { once: true });
+ setTimeout(cleanup, 400);
}
// buildLocalNotice renders a command's private "only visible to you" reply as a
@@ -856,7 +864,7 @@ function collapseNotice(row, spec) {
// 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
+// NOTICE_COMPACT_AFTER_MS, blind-collapses 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 = {}) {
@@ -871,10 +879,10 @@ export function buildLocalNotice(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.
+ fillFullNotice(row, spec);
+ // A confirmation still inside its loud window schedules its collapse for the
+ // time remaining before it's due to calm down.
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);
}
diff --git a/web/static/style.css b/web/static/style.css
index 4d2d688..452b810 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -785,7 +785,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.local-notice {
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; align-self: stretch; }
.local-notice-glyph { font-size: 15px; line-height: 1; }
@@ -796,21 +795,13 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.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);
+ 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; }
+/* A confirmation is loud only for a while: after ~30s it "blinds up" (its height
+ animates down — see collapseNotice) to this compact one-line record
+ (glyph · who · what happened · action). */
.local-notice--compact {
display: flex; flex-direction: row; align-items: baseline; flex-wrap: wrap; gap: 5px;
margin: 2px 10px; padding: 3px 12px;
@@ -825,7 +816,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
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%; }