Skip to content

fix(dashboard): kill chat-tab freeze under long sessions / concurrent jobs#560

Merged
esengine merged 1 commit into
mainfrom
fix/dashboard-chat-jank-issue-551
May 10, 2026
Merged

fix(dashboard): kill chat-tab freeze under long sessions / concurrent jobs#560
esengine merged 1 commit into
mainfrom
fix/dashboard-chat-jank-issue-551

Conversation

@esengine

Copy link
Copy Markdown
Owner

A user reported the embedded dashboard's chat tab triggering Chrome's
"Page not responding" dialog during long active sessions, especially
with concurrent jobs streaming at the same time. Profiled the chat
feed's hot path on a synthetic long session.

Root cause — two cooperating bugs

  1. Per-delta re-render of every historical message.
    Every `assistant_delta` event called `setStreaming` synchronously.
    The model streams ~20 deltas/sec; concurrent jobs add more events
    on top. Each setState re-rendered `ChatPanel`, which `.map`'d
    over every historical `ChatMessage` with no memoization. So
    each delta re-ran `marked.parse` per message, plus
    `hljs.highlight` on every `ToolCard` — on UNCHANGED content.
    Long session = N messages grows monotonically = main thread starves.
  2. Memo-defeating prop instability across streaming transitions.
    The per-row streaming prop was passed as
    `streaming && streaming.id === m.id` — that produces `null` when
    no turn is streaming and `false` when one is streaming but not
    this message. Memo's shallow compare treats `null !== false` as a
    change, so even after memoizing, every historical row would
    re-render the moment a turn started or stopped.

Fix (`dashboard/src/`, +46 / −11)

  • Memoize `ChatMessage` via `memo` from `preact/compat`. Historical
    messages keep stable `msg` references across deltas → bail out.
  • Cast the per-row streaming prop to a boolean so historical rows
    see a stable `false` across the streaming-on/off transition.
  • rAF-coalesce `assistant_delta` in `chat.ts`. Accumulated text
    lives in a ref and we flush at most once per animation frame —
    capping the streaming-bubble re-render rate at the display refresh
    rate regardless of delta volume. `assistant_final` + SSE reconnect
    • unmount each cancel the pending flush so the buffer can't leak
      across turns.

Test plan

  • `npx tsc --noEmit` clean
  • `npm run build` (dashboard bundle compiles)
  • `npx vitest run` — 2406 passed, 2 skipped (no test churn)
  • Smoke: open a long session in Chrome, ask the model for a 8k+
    char response, verify no "Page not responding" prompt during
    streaming
  • Smoke: with 3+ concurrent `run_background` jobs running, leave
    the dashboard tab open for several minutes — confirm no freeze

Closes #551

… jobs

Chrome's "Page not responding" dialog fires on the dashboard chat tab
once a session accumulates enough messages. Repro condition: long
streaming turn or several concurrent jobs broadcasting at once.

Two cooperating bugs on the chat feed's hot path:

1. Every assistant_delta event hit setState synchronously. The model
   streams ~20 deltas/sec; each one re-rendered ChatPanel, which
   .map'd over every historical ChatMessage with no memoization, so
   each one re-ran marked.parse + (for tool messages) hljs syntax
   highlight on UNCHANGED content. Long sessions = N grows = main
   thread starves.
2. The `streaming` prop fed to historical ChatMessage flipped between
   `null` (no streaming) and `false` (streaming-but-not-this-message)
   across turns, so even with memo, `null !== false` would force
   every historical message to re-render every time streaming
   started or stopped.

Fix:

- Wrap ChatMessage in `memo` from preact/compat so historical messages
  with stable `msg` refs short-circuit re-renders.
- Cast the per-row streaming prop to a boolean so historical
  messages get a stable `false` and memo's shallow compare actually
  bails out across streaming-on/off transitions.
- Coalesce assistant_delta into a single rAF-batched setStreaming.
  Multiple deltas in the same frame collapse into one state commit;
  the streaming bubble's re-render rate is capped at the display
  refresh rate. assistant_final + SSE reconnect + unmount each
  cancel the pending flush so the buffer can't leak across turns.

Closes #551
@esengine esengine merged commit de4b342 into main May 10, 2026
2 checks passed
@esengine esengine deleted the fix/dashboard-chat-jank-issue-551 branch May 10, 2026 00:36
esengine added a commit that referenced this pull request May 13, 2026
)

Reported in #721: typing in the dashboard composer feels laggy on long
sessions. Root cause is that every keystroke commits to `input` state,
which forces ChatPanel to walk its entire vnode tree on each frame.

ChatMessage was already wrapped in memo (#560), so historical bubbles
short-circuit individually — but Preact still has to map() over every
message, evaluate the per-row `Boolean(streaming && streaming.id === m.id)`
discriminator, and diff each ChatMessage element. For a session with
100+ messages the per-keystroke work is enough to drop frames.

Move the message-list render into a `<ChatFeed>` sub-component and wrap
it in memo. With `messages` and `streaming` refs both stable while the
user is typing, memo bails the whole feed out — keystrokes no longer
touch the feed at all. SideRail and ChatStatusBar get the same
treatment so /overview poll churn and input edits don't repaint the
right-rail progress bars and statusbar.

Closes #721
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
… jobs (esengine#560)

Chrome's "Page not responding" dialog fires on the dashboard chat tab
once a session accumulates enough messages. Repro condition: long
streaming turn or several concurrent jobs broadcasting at once.

Two cooperating bugs on the chat feed's hot path:

1. Every assistant_delta event hit setState synchronously. The model
   streams ~20 deltas/sec; each one re-rendered ChatPanel, which
   .map'd over every historical ChatMessage with no memoization, so
   each one re-ran marked.parse + (for tool messages) hljs syntax
   highlight on UNCHANGED content. Long sessions = N grows = main
   thread starves.
2. The `streaming` prop fed to historical ChatMessage flipped between
   `null` (no streaming) and `false` (streaming-but-not-this-message)
   across turns, so even with memo, `null !== false` would force
   every historical message to re-render every time streaming
   started or stopped.

Fix:

- Wrap ChatMessage in `memo` from preact/compat so historical messages
  with stable `msg` refs short-circuit re-renders.
- Cast the per-row streaming prop to a boolean so historical
  messages get a stable `false` and memo's shallow compare actually
  bails out across streaming-on/off transitions.
- Coalesce assistant_delta into a single rAF-batched setStreaming.
  Multiple deltas in the same frame collapse into one state commit;
  the streaming bubble's re-render rate is capped at the display
  refresh rate. assistant_final + SSE reconnect + unmount each
  cancel the pending flush so the buffer can't leak across turns.

Closes esengine#551
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…sengine#729)

Reported in esengine#721: typing in the dashboard composer feels laggy on long
sessions. Root cause is that every keystroke commits to `input` state,
which forces ChatPanel to walk its entire vnode tree on each frame.

ChatMessage was already wrapped in memo (esengine#560), so historical bubbles
short-circuit individually — but Preact still has to map() over every
message, evaluate the per-row `Boolean(streaming && streaming.id === m.id)`
discriminator, and diff each ChatMessage element. For a session with
100+ messages the per-keystroke work is enough to drop frames.

Move the message-list render into a `<ChatFeed>` sub-component and wrap
it in memo. With `messages` and `streaming` refs both stable while the
user is typing, memo bails the whole feed out — keystrokes no longer
touch the feed at all. SideRail and ChatStatusBar get the same
treatment so /overview poll churn and input edits don't repaint the
right-rail progress bars and statusbar.

Closes esengine#721
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard tab becomes unresponsive during long sessions / concurrent jobs

1 participant