eleven / messenger Public

web: a parked thread's refresh must not paint into the shared pane (#690)

The welcome/tour emits steps asynchronously; each appendNotice fires the
thread's refreshFeed closure, which re-renders via renderApp(threadId,'chat',
#app-pane) — the shared pane. mountedCtx/refreshFeed persist for a PARKED
thread, so a step firing after you navigated away rendered the welcome (Eleven)
into whatever chat now occupies #app-pane (the reported 'Eleven in my inviter
DM'). render() already guards hotkeys/native against a parked re-render but not
the feed. Stamp the mount root with its threadId and skip the refresh when the
pane belongs to another thread — the notice is already stored and paints on the
thread's next open. (Groups were fixed earlier via the not-loaded path; this is
the DM/loaded-thread path.)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8de8eaf0561d4e9b9fcd8110092c4efc6f87c379 parent e63f91a
2 files changed, +20 −1
  • changelog/2026-07-11-welcome-leak-fix.md +4 −0
  • web/static/apps/chat.js +16 −1
diff --git a/changelog/2026-07-11-welcome-leak-fix.md b/changelog/2026-07-11-welcome-leak-fix.md
new file mode 100644
index 0000000..6897dd1
--- /dev/null
+++ b/changelog/2026-07-11-welcome-leak-fix.md
@@ -0,0 +1,4 @@
+🤖 **Fixed: Eleven's welcome messages could appear in another chat.** If you
+opened a chat while Eleven's onboarding conversation was still going, a late
+welcome step could paint itself into the chat you'd switched to. Onboarding
+steps now stay in Eleven's conversation where they belong.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index d198f5b..aa14410 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -582,6 +582,11 @@ export const app = defineApp({
if (!root) {
root = buildSkeleton(el, ctx);
}
+ // Stamp the mount with its thread so a stray refresh can tell whether the
+ // pane still belongs to it (see doRefresh). #app-pane is shared across
+ // threads, so an async welcome/tour step firing after you've navigated
+ // away would otherwise re-render its thread into whatever's on screen now.
+ root.dataset.thread = ctx.threadId;
// Point the single document-level hotkey listener at THIS mount. On the
// instant-restore path buildSkeleton is skipped, so this — not the build —
// is what keeps the shortcuts live after switching threads. Guard on
@@ -613,7 +618,17 @@ export const app = defineApp({
// Let addLocalNotice re-render this pane when a command reply comes in — the
// reply isn't folded state, so nothing else would. Points at the live mount
// (like the hotkeys/native re-points above), self-removed on teardown.
- const doRefresh = () => renderApp(ctx.threadId, "chat", el);
+ const doRefresh = () => {
+ // A parked thread's refresh (a late welcome/tour notice, a scripted
+ // typing beat) must not paint into the shared #app-pane once another
+ // thread has taken it over — that's how Eleven's onboarding messages
+ // leaked into the chat you'd navigated to. The notice is already stored
+ // in localNotices and paints when this thread is next opened; skip the
+ // live render when the pane no longer belongs to us.
+ const cur = el.querySelector(":scope > .chatapp-root");
+ if (cur && cur.dataset.thread && cur.dataset.thread !== ctx.threadId) return;
+ renderApp(ctx.threadId, "chat", el);
+ };
refreshFeed.set(ctx.threadId, doRefresh);
// Keep the live mount ctx so the shell can inject a private notice into this
// thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour).