eleven / messenger Public

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>
Paul Campbell 0706d5915f5d6087535e1beb9262a111ce05d900 parent cdf9da3
4 files changed, +86 −7
  • agent/runtime.mjs +20 −4
  • changelog/2026-07-03-typing-bubble.md +1 −0
  • web/static/chat.js +28 −3
  • web/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 {