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>
3 files changed,
+40
−42
changelog/2026-07-05-reply-card-calms-down.md+1 −1web/static/apps.js+35 −27web/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%; } |