web: iMessage-style typing bubble in DMs (+ bots re-beat while composing) (#122)
DMs now show a grey incoming-style bubble with three pulsing dots while the other person types — the typer can only be them, so the named line added nothing. Groups keep "X is typing…", where knowing who matters. The bubble carries a screen-reader-only summary since the dots say nothing aloud. Also fixes the main way the indicator 'disappeared' in practice: the bot sidecar sent a single beat (15s expiry) then started a generation that often runs far longer, so "Claude is typing" died mid-thought. It now re-beats every 10s for the whole run and sends an explicit stop beat when a run ends without a message. Verified end-to-end with a live two-member reproduction (local server + real Chromium receiver + raw-WS typist): DM bubble appears/clears on message arrival, group line names one and two typers, expiry-0 beats clear promptly.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+86
−7
agent/runtime.mjs+20 −4changelog/2026-07-03-typing-bubble.md+1 −0web/static/chat.js+28 −3web/static/style.css+37 −0
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index 941baaa..e6dd702 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -213,7 +213,22 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| } |
| async function reply(threadId, entry) { |
| - sendTyping(threadId, entry.key); // best-effort "…is typing" hint |
| + // Best-effort "…is typing" hint, re-beaten for the whole generation: a |
| + // single beat's 15s expiry lapses under a long thinking run, so members |
| + // watched the indicator die well before the reply landed. |
| + sendTyping(threadId, entry.key); |
| + const beat = setInterval(() => sendTyping(threadId, entry.key), 10_000); |
| + try { |
| + await replyInner(threadId, entry); |
| + } finally { |
| + clearInterval(beat); |
| + // An errored/empty run sends no message (the guaranteed clear signal) — |
| + // stop the indicator explicitly rather than leaving it to lapse. |
| + sendTyping(threadId, entry.key, 0); |
| + } |
| + } |
| + |
| + async function replyInner(threadId, entry) { |
| const context = await buildContext(threadId, entry.key); |
| let text; |
| let actions = []; |
| @@ -271,10 +286,11 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) }); |
| } |
| - async function sendTyping(threadId, key) { |
| + async function sendTyping(threadId, key, expiry = 15) { |
| try { |
| - // Same shape chat.js emits (see hub.go's "typing" relay). |
| - send({ type: "typing", thread_id: threadId, blob: await encrypt(key, { type: "typing", name, expiry: 15 }) }); |
| + // Same shape chat.js emits (see hub.go's "typing" relay). expiry 0 is the |
| + // explicit "stopped typing" signal. |
| + send({ type: "typing", thread_id: threadId, blob: await encrypt(key, { type: "typing", name, expiry }) }); |
| } catch {} |
| } |
diff --git a/changelog/2026-07-03-typing-bubble.md b/changelog/2026-07-03-typing-bubble.md| new file mode 100644 |
| index 0000000..28341b9 |
| --- /dev/null |
| +++ b/changelog/2026-07-03-typing-bubble.md |
| @@ -0,0 +1 @@ |
| +In one-to-one chats you'll now see the familiar animated dots bubble while the other person writes; groups still say who's typing by name. Bots also keep the indicator going for as long as they're composing, instead of it fading out mid-thought. |
diff --git a/web/static/chat.js b/web/static/chat.js| index 77e72b5..af140a9 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -3416,11 +3416,15 @@ function bumpThread(id) { |
| // The name + expiry ride encrypted inside the blob; the server is a blind relay |
| // (see hub.go's "typing" case) and fans it only to the thread's other members. |
| -// Paint the "X is typing…" line from the live set. It lives INSIDE the feed as |
| +// Paint the typing indicator from the live set. It lives INSIDE the feed as |
| // its last row (appended below the newest message) rather than in a band between |
| // feed and composer: a band resized the feed as it toggled — jolting the stream — |
| // and the native shells (iOS/macOS) float their composer over the page bottom, |
| // which covered it entirely. In-stream it scrolls with the messages. |
| +// |
| +// DMs get an iMessage-style incoming bubble with pulsing dots — the typer can |
| +// only be the one other person, so a name adds nothing. Groups keep the named |
| +// "X is typing…" line, where knowing who matters. |
| function renderTyping() { |
| const names = [...typingUntil.keys()]; |
| if (names.length === 0) { |
| @@ -3431,11 +3435,32 @@ function renderTyping() { |
| if (!feed) return; |
| if (!typingEl) { |
| typingEl = document.createElement("div"); |
| - typingEl.className = "typing"; |
| typingEl.setAttribute("aria-live", "polite"); |
| } |
| + const dm = (threads.find((x) => x.id === activeId) || {}).kind === "dm"; |
| + const label = typingSummary(names); |
| + // Only rebuild the contents on a real change — recreating the dots on every |
| + // ~10s beat would visibly restart their animation. |
| + if (typingEl.dataset.mode !== (dm ? "bubble" : "text") || typingEl.dataset.label !== label) { |
| + typingEl.dataset.mode = dm ? "bubble" : "text"; |
| + typingEl.dataset.label = label; |
| + typingEl.className = dm ? "typing typing-bubble" : "typing"; |
| + typingEl.textContent = ""; // textContent: names are untrusted |
| + if (dm) { |
| + for (let i = 0; i < 3; i++) { |
| + const dot = document.createElement("span"); |
| + dot.className = "dot"; |
| + typingEl.appendChild(dot); |
| + } |
| + const sr = document.createElement("span"); // dots say nothing to a screen reader |
| + sr.className = "sr-only"; |
| + sr.textContent = label; |
| + typingEl.appendChild(sr); |
| + } else { |
| + typingEl.textContent = label; |
| + } |
| + } |
| const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| - typingEl.textContent = typingSummary(names); // textContent: names are untrusted |
| feed.appendChild(typingEl); // (re-)appending keeps it below the newest message |
| if (wasAtBottom) feed.scrollTop = feed.scrollHeight; |
| } |
diff --git a/web/static/style.css b/web/static/style.css| index 64a3d40..dce4923 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -870,6 +870,43 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| +/* DM variant: an iMessage-style incoming bubble with pulsing dots — the typer |
| + can only be the one other person, so no name (groups keep the line above). */ |
| +.typing.typing-bubble { |
| + display: flex; |
| + align-items: center; |
| + gap: 5px; |
| + padding: 13px 14px; |
| + border-radius: var(--radius); |
| + border-bottom-left-radius: 6px; |
| + background: var(--bubble-them); |
| + overflow: visible; /* the dots bob upward, out of the content box */ |
| +} |
| +.typing-bubble .dot { |
| + width: 8px; |
| + height: 8px; |
| + border-radius: 50%; |
| + background: color-mix(in srgb, var(--ink) 45%, transparent); |
| + animation: typing-dot 1.3s ease-in-out infinite; |
| +} |
| +.typing-bubble .dot:nth-child(2) { animation-delay: 0.18s; } |
| +.typing-bubble .dot:nth-child(3) { animation-delay: 0.36s; } |
| +@keyframes typing-dot { |
| + 0%, 60%, 100% { opacity: 0.35; transform: translateY(0); } |
| + 30% { opacity: 1; transform: translateY(-3px); } |
| +} |
| +@media (prefers-reduced-motion: reduce) { |
| + .typing-bubble .dot { animation: none; opacity: 0.5; } |
| +} |
| +/* screen-reader-only text (the bubble's dots carry no words) */ |
| +.sr-only { |
| + position: absolute; |
| + width: 1px; |
| + height: 1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| + white-space: nowrap; |
| +} |
| /* composer autocomplete (:emoji / @mention) — sits above the composer, in flow */ |
| .typeahead { |