<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Electron on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/electron/</link>
    <description>Code is cheap, let&#39;s talk</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <copyright>© 2026 ferstar · [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)</copyright>
    <lastBuildDate>Mon, 31 Aug 2026 23:40:00 +0800</lastBuildDate>
    <ttl>60</ttl><atom:link href="https://blog.ferstar.org/en/tags/electron/index.xml" rel="self" type="application/rss+xml" /><image>
      <url>https://blog.ferstar.org/site-logo.png</url>
      <title>Code is cheap, let&#39;s talk</title>
      <link>https://blog.ferstar.org/</link>
    </image>
    
    <item>
      <title>Decoupling Heavy IO from UI Finalization: Eliminating Input Freezes in Desktop Agents</title>
      <link>https://blog.ferstar.org/en/posts/desktop-agent-streaming-lifecycle-reconciliation/</link>
      <pubDate>Mon, 31 Aug 2026 23:40:00 +0800</pubDate>
      
      <guid isPermaLink="true">https://blog.ferstar.org/en/posts/desktop-agent-streaming-lifecycle-reconciliation/</guid>
      <description>Desktop agents often lock the chat input in a frozen loading state after generation completes; decouple disk IO from UI lifecycles via bypass emission channels, optimistic unlocking, and periodic stale reconciliation; completely eliminate input freezes while maintaining deterministic state consistency.</description><content:encoded><![CDATA[<blockquote><p>I am not a native English speaker; this article was translated by AI.</p>
</blockquote><p>When building desktop or web-based agent clients, there is a recurring, annoying UX friction:</p>
<blockquote><p><strong>The model has finished streaming its last token on screen, but the input box stays disabled with a “Task in progress…” placeholder. You cannot focus the cursor. It takes several seconds to unlock, and switching away from the window mid-stream can sometimes freeze it in a loading state permanently.</strong></p>
</blockquote><p>This looks like a simple frontend state bug, but tracing through the stack reveals an issue of asynchronous disk persistence blocking the event dispatch pipeline.</p>
<pre class="not-prose mermaid">
flowchart TD
  subgraph Backend[Agent Host Process / Runtime]
    M[Receive Final Text Chunk] --> MC[Emit MessageComplete]
    MC --> P[Heavy Async Persistence: SQLite & Archiving]
    P --> AE[Emit AgentEnd Terminal Event]
  end

  subgraph Legacy[Legacy Serial Pattern]
    L1[Wait for Persistence to Finish] --> L2[Notify Frontend via Long IPC Queue]
    L2 --> L3[Clear isStreaming / Noticeable UI Freeze]
  end

  subgraph Optimized[Decoupled & Reconciled Pattern]
    MC -->|Optimistic: No Pending Tools| UI1[Unlock Input Box Instantly]
    AE -->|Bypass Fast Channel| UI1
    T[10s Periodic Stale Reconciliation] -.->|Handles Dropouts & Blur Edge Cases| UI1
  end
</pre>

<hr>

<h2 class="relative group">1. Why Does the Input Lock Up?
    <div id="1-why-does-the-input-lock-up" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-why-does-the-input-lock-up" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>From model output to disk storage, messages pass through three layers:</p>
<ol>
<li><strong>Sampling Runtime</strong>: Consumes the SSE stream, producing text deltas and <code>MessageComplete</code>.</li>
<li><strong>Host Process (Node.js / IPC Layer)</strong>: Manages cross-process communication, SQLite writes, image materialization, and JSONL archiving.</li>
<li><strong>Renderer Process (Frontend UI)</strong>: Manages reactive states like <code>isStreaming</code> and input enablement in React/Vue.</li>
</ol>
<p>Tracing the logs revealed three main bottlenecks:</p>

<h3 class="relative group">Culprit 1: Terminal Events Blocked Behind Heavy I/O
    <div id="culprit-1-terminal-events-blocked-behind-heavy-io" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#culprit-1-terminal-events-blocked-behind-heavy-io" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The legacy implementation waited for all disk writes in the turn to finish before sending <code>agent_end</code> to the UI:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// Legacy flow: Heavy I/O blocks terminal notifications
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">persistTurnToSqlite</span><span class="p">(</span><span class="nx">turnData</span><span class="p">);</span>      <span class="c1">// 50~200ms
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">materializeImagesToDisk</span><span class="p">(</span><span class="nx">images</span><span class="p">);</span>    <span class="c1">// 500ms~2s
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">appendSessionJsonl</span><span class="p">(</span><span class="nx">largePayload</span><span class="p">);</span>   <span class="c1">// 100~500ms
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Only now is the UI notified
</span></span></span><span class="line"><span class="cl"><span class="nx">emitToRenderer</span><span class="p">(</span><span class="s1">'agent_end'</span><span class="p">,</span> <span class="nx">session</span><span class="p">);</span></span></span></code></pre></div></div>
<p>While the user is already reading the final response, the host process is still grinding through disk writes. In large sessions, this easily introduces noticeable delays.</p>

<h3 class="relative group">Culprit 2: Head-of-Line Blocking in IPC Queues
    <div id="culprit-2-head-of-line-blocking-in-ipc-queues" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#culprit-2-head-of-line-blocking-in-ipc-queues" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>To preserve message ordering, events pass through an ordered queue (Emit Chain). If the model emits diagnostic logs during finalization, <code>agent_end</code> gets queued behind them.</p>

<h3 class="relative group">Culprit 3: Window Defocus Drops
    <div id="culprit-3-window-defocus-drops" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#culprit-3-window-defocus-drops" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>When a user switches windows mid-stream, Chromium throttles background timers. If a cross-process packet drops during that transition, the UI permanently misses <code>agent_end</code>, leaving the input locked.</p>
<hr>

<h2 class="relative group">2. The Solution
    <div id="2-the-solution" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2-the-solution" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>We updated the IPC and frontend state management across three areas:</p>

<h3 class="relative group">1. Optimistic Unlocking
    <div id="1-optimistic-unlocking" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-optimistic-unlocking" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Input availability should not wait on disk writes. Once the frontend receives <code>MessageComplete</code> and verifies:</p>
<ul>
<li>The reply is a genuine terminal turn (<code>EndTurn</code>);</li>
<li>No background tools are currently executing;</li>
<li>The user has not requested a stop.</li>
</ul>
<p>It <strong>unlocks the input box immediately</strong>, dropping perceived recovery latency to zero.</p>

<h3 class="relative group">2. Bypass Channel for Terminal Events
    <div id="2-bypass-channel-for-terminal-events" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2-bypass-channel-for-terminal-events" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Lifecycle events (<code>agent_end</code>, <code>error</code>, <code>cancelled</code>) have higher priority than regular stream deltas. We introduced a fast-track bypass in the IPC bridge:</p>
<ul>
<li>Terminal events skip the standard ordered queue and dispatch directly to the renderer.</li>
<li>Even if SQLite writes or archiving queues are backed up, the UI lifecycle updates without delay.</li>
</ul>

<h3 class="relative group">3. 10-Second Low-Overhead Reconciliation Loop
    <div id="3-10-second-low-overhead-reconciliation-loop" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3-10-second-low-overhead-reconciliation-loop" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>To handle orphaned states from window blur or dropped packets, the frontend runs a 10-second reconciliation check:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=></span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">timer</span> <span class="o">=</span> <span class="nx">setInterval</span><span class="p">(()</span> <span class="o">=></span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// Check for sessions with no activity for >10s still marked as streaming
</span></span></span><span class="line"><span class="cl">    <span class="nx">reconcileStaleStreamingSessions</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span> <span class="mi">10</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">()</span> <span class="o">=></span> <span class="nx">clearInterval</span><span class="p">(</span><span class="nx">timer</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span> <span class="p">[]);</span></span></span></code></pre></div></div>
<p>Any stuck sessions are idempotently cleaned up and synchronized.</p>
<hr>

<h2 class="relative group">3. Takeaway
    <div id="3-takeaway" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3-takeaway" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>With these changes in place, the input box is ready the moment generation finishes, and focus freezes from window switching are gone.</p>
<p>In rich-client apps, keep user interactions optimistic and fast, run heavy persistence asynchronously in the background, and use periodic reconciliation to ensure eventual consistency.</p>
]]></content:encoded>
      
    </item>
    
  </channel>
</rss>
