Skip to content

fix(desktop): eliminate perceived delay when sending messages#3441

Merged
esengine merged 1 commit into
esengine:main-v2from
HUQIANTAO:fix/desktop-optimistic-message-display
Jun 7, 2026
Merged

fix(desktop): eliminate perceived delay when sending messages#3441
esengine merged 1 commit into
esengine:main-v2from
HUQIANTAO:fix/desktop-optimistic-message-display

Conversation

@HUQIANTAO

Copy link
Copy Markdown
Contributor

Problem

When the user sends a message in the desktop app, there is a noticeable delay (0.3–3 s depending on model TTFT) before anything appears in the conversation area. During this gap:

  1. The Composer clears its input (the text vanishes)
  2. The Transcript stays completely blank — no user message, no assistant indicator
  3. Only when the backend emits the first text or reasoning token does the user message finally appear

This creates a "dead zone" where the user's message has seemingly vanished into the void.

Root Cause

In useController.ts, the reducer holds the user's text in pendingUser and only flushes it to items (the rendered list) on the first non-turn_started/non-turn_done event:

// Line 147 (before fix)
if (s.pendingUser !== undefined && e.kind !== "turn_started" && e.kind !== "turn_done") {
  s = flushPendingUser(s);
}

The turn_started handler never touched pendingUser:

// Line 155 (before fix)
case "turn_started":
  return { ...s, running: true, turnActive: true, currentAssistant: undefined, ... };

So the user message only becomes visible when the first text/reasoning event arrives from the backend — which could be hundreds of milliseconds to several seconds later.

Fix

Two changes in applyEvent:

  1. Flush pendingUser on turn_started — the user message appears the instant the backend acknowledges the turn, not when the first token arrives.

  2. Pre-create an empty assistant bubble with a blinking cursor — gives immediate visual feedback that the model is processing (similar to ChatGPT/Claude's typing indicator). The Composer's status bar already shows "thinking… 0s", and now the Transcript also shows the cursor.

case "turn_started": {
  let cur: State = s;
  if (cur.pendingUser !== undefined) cur = flushPendingUser(cur);
  const { items, id, seq } = ensureAssistant(cur);
  return { ...cur, items, currentAssistant: id, seq, live: { id, text: "", reasoning: "" }, running: true, turnActive: true, turnStartAt: Date.now(), turnTokens: 0 };
}

The guard condition is simplified from e.kind !== "turn_started" && e.kind !== "turn_done" to just e.kind !== "turn_done" — the turn_done exclusion is kept for the cancel-before-reply edge case.

Before / After

Moment Before After
User hits Enter Composer clears, Transcript blank Composer clears, Transcript shows user message + blinking cursor
Backend processes (0.3–3 s) Blank — user wonders "did it send?" User message visible, cursor pulsing
First token arrives User message + assistant text suddenly appear Assistant text streams into the pre-existing bubble

Verification

  • pnpm typecheck ✅ clean
  • pnpm buildbuilt in 10.58s
  • No new dependencies
  • No i18n changes
  • No CSS changes (.cursor animation already exists at styles.css:1172)
  • Diff: 1 file, +11 / −3

@github-actions github-actions Bot added desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development labels Jun 7, 2026
@esengine esengine closed this Jun 7, 2026
@esengine esengine reopened this Jun 7, 2026
When the user sends a message, the UI waits until the backend emits the
first text/reasoning token before showing anything in the Transcript.
During this gap (0.3-3 s depending on model TTFT) the Composer clears
itself but the conversation area stays blank - the message appears to
have vanished.

Root cause: the reducer holds the user text in pendingUser and only
flushes it to items on the first non-turn_started/non-turn_done event.
The turn_started handler itself never touched pendingUser.

Fix:
- Flush pendingUser on turn_started so the user message appears
  immediately when the backend acknowledges the turn.
- Pre-create an empty assistant bubble with a blinking cursor on
  turn_started, giving instant visual feedback that the model is
  working (similar to ChatGPT/Claude typing indicator).
- The guard that excluded turn_started from flushing is no longer
  needed; keep only the turn_done exclusion for the cancel-before-
  reply edge case.

Perceived latency drops from backend TTFT to 0 ms.
@HUQIANTAO HUQIANTAO force-pushed the fix/desktop-optimistic-message-display branch from d0e96c0 to 0b3d53a Compare June 7, 2026 11:19
@esengine esengine merged commit 04412a7 into esengine:main-v2 Jun 7, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants