<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 威少</title>
    <description>The latest articles on DEV Community by 威少 (@zvzuola).</description>
    <link>https://dev.to/zvzuola</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4004744%2F46831039-f245-4ba7-afbc-8d5fbcf763f4.png</url>
      <title>DEV Community: 威少</title>
      <link>https://dev.to/zvzuola</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zvzuola"/>
    <language>en</language>
    <item>
      <title>acp-components: Turning the AI Agent Workbench into Pluggable Lego</title>
      <dc:creator>威少</dc:creator>
      <pubDate>Sat, 27 Jun 2026 03:36:08 +0000</pubDate>
      <link>https://dev.to/zvzuola/acp-components-turning-the-ai-agent-workbench-into-pluggable-lego-26ek</link>
      <guid>https://dev.to/zvzuola/acp-components-turning-the-ai-agent-workbench-into-pluggable-lego-26ek</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A frontend component library built on the Agent Client Protocol (ACP). With a "data-layer / UI-layer split + orthogonal Platform abstraction" design, it lets you run a multi-agent workbench across Web, desktop, and IDE plugins using one set of components.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why does it exist?
&lt;/h2&gt;

&lt;p&gt;If you've ever built an AI Agent frontend, you've likely hit these walls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-implementing the UI for every host&lt;/strong&gt;: one version for the browser, another for Electron/Tauri, yet another for a VS Code plugin — chat, tool calls, permission dialogs, and file trees all rewritten each time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-agent chaos&lt;/strong&gt;: when you want to connect OpenCode, Codex, and Claude simultaneously, who manages the connections? Who organizes sessions by directory (workspace)? What happens to the file tree when you switch workspaces?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocol comms tangled with state&lt;/strong&gt;: NDJSON streams, session updates, and permission requests all glued inside React components — hard to test, hard to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File I/O and other high-risk capabilities in the wrong place&lt;/strong&gt;: either the frontend hardcodes filesystem calls, or permission decisions leak into the agent communication layer — a muddy security boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;acp-components&lt;/code&gt; targets exactly these four problems. It does &lt;strong&gt;not&lt;/strong&gt; implement an Agent runtime, nor does it take over system permissions on the host's behalf. It provides a &lt;strong&gt;set of embeddable frontend protocol, state, and UI primitives&lt;/strong&gt; that draw those boundaries cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Design: Three Principles That Run Through Everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Principle 1: A clean data/UI split with a framework-agnostic core
&lt;/h3&gt;

&lt;p&gt;The project ships as two packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@acp-components/react  (UI layer: 18 component dirs, 13 fine-grained hooks, Contexts, theme, i18n)
       ↓ depends on
@acp-components/core   (Data layer: createAcpProvider + AcpClient + Transport + vanilla Zustand stores + Actions)
       ↓ built on
@agentclientprotocol/sdk (ACP protocol types + ClientSideConnection)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hardest rule: &lt;strong&gt;core has zero React dependency&lt;/strong&gt;. Not just in words — it's enforced in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;core's &lt;code&gt;package.json&lt;/code&gt; has neither &lt;code&gt;react&lt;/code&gt; nor &lt;code&gt;@types/react&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;all four stores use &lt;code&gt;createStore&lt;/code&gt; from &lt;code&gt;zustand/vanilla&lt;/code&gt; (not the React-bound &lt;code&gt;create&lt;/code&gt;), producing a &lt;code&gt;{ getState, setState, subscribe }&lt;/code&gt; triple;&lt;/li&gt;
&lt;li&gt;the React layer subscribes to these vanilla stores via &lt;code&gt;useSyncExternalStore&lt;/code&gt; (through &lt;code&gt;zustand/react&lt;/code&gt;'s &lt;code&gt;useStore&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does this mean? &lt;strong&gt;core can be used without React.&lt;/strong&gt; The README explicitly encourages it: want Vue, Svelte, or Solid? Take core as-is for comms, state, and Actions, and pick your own UI framework. The React layer is merely the "official default implementation."&lt;/p&gt;

&lt;p&gt;Bonus dividends of this split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Actions are pure functions&lt;/strong&gt;: each action takes an &lt;code&gt;AcpClient&lt;/code&gt; as its first argument explicitly — stateless and unit-testable; the &lt;code&gt;agentId → client&lt;/code&gt; mapping lives in the provider closure and never leaks globally;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Components are testable&lt;/strong&gt;: UI components don't directly touch &lt;code&gt;window.prompt&lt;/code&gt; / &lt;code&gt;localStorage&lt;/code&gt;, so they run under jsdom.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Principle 2: A three-tier state model for multi-agent × multi-workspace
&lt;/h3&gt;

&lt;p&gt;In real scenarios you don't run just one agent, and you don't work in just one directory. core organizes state around a clean &lt;strong&gt;three-tier abstraction&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abstraction&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Home&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One independent ACP connection (transport + status + capabilities)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;acpStore.agents: Map&amp;lt;agentId, AgentConnection&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workspace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A directory (cwd), holding sessions from multiple agents&lt;/td&gt;
&lt;td&gt;&lt;code&gt;acpStore.workspaces: Map&amp;lt;cwd, WorkspaceState&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Session&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Belongs to a workspace + agent pair&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SessionMeta.agentId&lt;/code&gt; + &lt;code&gt;SessionMeta.cwd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The clever bit: &lt;strong&gt;the active workspace is derived by looking up &lt;code&gt;SessionMeta.cwd&lt;/code&gt; from the global &lt;code&gt;activeSessionId&lt;/code&gt;&lt;/strong&gt; — you don't maintain "current workspace" separately; switching sessions switches workspaces.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;createAcpProvider&lt;/code&gt; connects all agents in parallel via &lt;code&gt;Promise.allSettled&lt;/code&gt;, non-blocking; the &lt;code&gt;scopedClientRegistry&lt;/code&gt; in its closure isolates clients by agentId, so &lt;strong&gt;one agent's connection failure doesn't drag down the rest&lt;/strong&gt;. On workspace switch, it only fetches session lists for agents that declare the &lt;code&gt;sessionCapabilities.list&lt;/code&gt; capability — idempotent, with pagination cursors.&lt;/p&gt;

&lt;p&gt;This model directly solves: &lt;strong&gt;writing code with OpenCode, reviewing with Codex, asking questions to Claude — three sessions in one frame, archived by directory, no flavor bleed.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Principle 3: Platform and AcpContext are orthogonal; high-risk capabilities stay host-side
&lt;/h3&gt;

&lt;p&gt;This is the most easily overlooked yet most critical design. The project abstracts host-native capabilities into a &lt;code&gt;Platform&lt;/code&gt; interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desktop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Native interaction: open links, directory picker dialog, system notifications&lt;/span&gt;
  &lt;span class="nf"&gt;openLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;openDirectoryPickerDialog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Filesystem: file tree, read, write, watch (write/watch optional)&lt;/span&gt;
  &lt;span class="nf"&gt;readDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;readFileContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;writeFileContent&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;watchFileTree&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Persistence: namespaced KV store + workspace list&lt;/span&gt;
  &lt;span class="nf"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;?);&lt;/span&gt; &lt;span class="nx"&gt;loadWorkspaces&lt;/span&gt;&lt;span class="p"&gt;?();&lt;/span&gt; &lt;span class="nx"&gt;saveWorkspaces&lt;/span&gt;&lt;span class="p"&gt;?();&lt;/span&gt;
  &lt;span class="c1"&gt;// External editor integration, updater (all optional)&lt;/span&gt;
  &lt;span class="nx"&gt;onOpenFile&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;?);&lt;/span&gt; &lt;span class="nx"&gt;updater&lt;/span&gt;&lt;span class="p"&gt;?;&lt;/span&gt; &lt;span class="nx"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;?();&lt;/span&gt; &lt;span class="nx"&gt;exportDebugLogs&lt;/span&gt;&lt;span class="p"&gt;?();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key word is &lt;strong&gt;orthogonal&lt;/strong&gt;: &lt;code&gt;Platform&lt;/code&gt; / &lt;code&gt;usePlatform()&lt;/code&gt; owns native capabilities; &lt;code&gt;AcpContext&lt;/code&gt; / &lt;code&gt;useAcpContext()&lt;/code&gt; owns agent connection and session state — the two never reference each other at the interface or assembly level. Agent transport config lives on &lt;code&gt;AgentConfig.transport&lt;/code&gt; attached to &lt;code&gt;AcpProvider&lt;/code&gt; and is &lt;strong&gt;not&lt;/strong&gt; part of Platform.&lt;/p&gt;

&lt;p&gt;Three reasons for the split:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One component set, many hosts&lt;/strong&gt;: web demo, Tauri desktop, Electron, IDE plugins share one component tree — only the &lt;code&gt;Platform&lt;/code&gt; implementation changes;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-risk capabilities governed by the host&lt;/strong&gt;: security-sensitive operations like file read/write, directory dialogs, and updaters stay host-side; core &lt;strong&gt;never makes security decisions for the host&lt;/strong&gt;. CLAUDE.md spells it out: core does not implement ACP's &lt;code&gt;readTextFile&lt;/code&gt; / &lt;code&gt;writeTextFile&lt;/code&gt; reverse callbacks — file access is a UI-side capability consumed via &lt;code&gt;usePlatform()&lt;/code&gt;, handing security decisions back to the host;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test isolation&lt;/strong&gt;: components don't directly depend on &lt;code&gt;@tauri-apps/plugin-*&lt;/code&gt; or browser APIs, so they're testable under jsdom.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two ready-to-use Platform implementations prove the abstraction is buildable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createWebPlatform()&lt;/code&gt;&lt;/strong&gt; (pure browser): &lt;code&gt;readDirectory&lt;/code&gt; goes through &lt;code&gt;fetch('/api/readdir')&lt;/code&gt; proxied to the bridge server; file-tree watching uses &lt;code&gt;EventSource&lt;/code&gt; subscribing to an SSE stream; persistence is localStorage with a namespaced prefix; the browser has no native directory picker, so it degrades to &lt;code&gt;window.prompt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createTauriPlatform()&lt;/code&gt;&lt;/strong&gt; (desktop): &lt;code&gt;readDirectory&lt;/code&gt; uses &lt;code&gt;@tauri-apps/plugin-fs&lt;/code&gt;'s &lt;code&gt;readDir&lt;/code&gt;; the directory picker uses &lt;code&gt;plugin-dialog&lt;/code&gt;'s native dialog; the workspace list is read/written by Rust via &lt;code&gt;invoke('load_workspaces')&lt;/code&gt; to &lt;code&gt;app_data_dir/workspaces.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The host only needs to wrap a &lt;code&gt;&amp;lt;PlatformProvider platform={...}&amp;gt;&amp;gt;&lt;/code&gt; at the top, which by default auto-mounts &lt;code&gt;&amp;lt;PlatformFileTreeAuto&amp;gt;&lt;/code&gt; — zero-config wiring of &lt;code&gt;platform.readDirectory&lt;/code&gt; / &lt;code&gt;watchFileTree&lt;/code&gt; into &lt;code&gt;fileTreeStore&lt;/code&gt;, &lt;strong&gt;preloading the root tree only for the active workspace and subscribing to watch events only for the active workspace&lt;/strong&gt;, preserving the old tree's expanded state on switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transport: Four Options, Pluggable
&lt;/h2&gt;

&lt;p&gt;The ACP protocol's transport carrier is &lt;strong&gt;NDJSON&lt;/strong&gt; (one JSON message per line). core abstracts transport into a minimal &lt;code&gt;AcpTransport&lt;/code&gt; interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AcpTransport&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// returns { readable, writable }&lt;/span&gt;
  &lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three built-ins plus a custom entry point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StdioTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Desktop primary&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spawn&lt;/code&gt; a child process, take over stdin/stdout, split with &lt;code&gt;ndJsonStream&lt;/code&gt;; &lt;code&gt;disconnect&lt;/code&gt; calls &lt;code&gt;kill()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebSocketTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web demo&lt;/td&gt;
&lt;td&gt;Native browser &lt;code&gt;WebSocket&lt;/code&gt;, &lt;code&gt;onmessage&lt;/code&gt; parses and enqueues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HttpTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stateless/short-lived&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt;-based, &lt;code&gt;AbortController&lt;/code&gt; for cancellation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{ type: 'custom', transport }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any host&lt;/td&gt;
&lt;td&gt;Implement &lt;code&gt;AcpTransport&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Custom transport isn't an empty promise — the repo ships a &lt;strong&gt;production-grade Tauri IPC implementation&lt;/strong&gt;, &lt;code&gt;TauriIpcTransport&lt;/code&gt; (147 lines): &lt;code&gt;connect()&lt;/code&gt; first &lt;code&gt;invoke('start_agent')&lt;/code&gt; to have Rust spawn the child process, then &lt;code&gt;listen('agent-output'/'agent-closed'/'agent-error')&lt;/code&gt; to register events, &lt;code&gt;JSON.parse&lt;/code&gt;-ing the payload and pushing it into a &lt;code&gt;ReadableStream&lt;/code&gt;; &lt;code&gt;WritableStream.write&lt;/code&gt; encodes messages as &lt;code&gt;JSON.stringify + '\n'&lt;/code&gt; and writes back to stdin via &lt;code&gt;invoke('write_to_agent')&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent stdout ──&amp;gt; Rust (line by line) ──&amp;gt; Tauri event ──&amp;gt; ReadableStream
Agent stdin  &amp;lt;── Rust (write) &amp;lt;── Tauri command &amp;lt;── WritableStream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Following this pattern, Electron IPC (&lt;code&gt;ipcRenderer.invoke&lt;/code&gt; / &lt;code&gt;on&lt;/code&gt;) and iframe &lt;code&gt;postMessage&lt;/code&gt; are trivially the same idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming UX: 16ms Batching + Virtual Scrolling
&lt;/h2&gt;

&lt;p&gt;Whether a chat feels good is 80% about streaming rendering. Here are a few concrete engineering details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-session text-chunk batching&lt;/strong&gt;: agent text chunks come in at high frequency; writing the store on every chunk would thrash React re-renders. core batches high-frequency text chunks for the same session within a &lt;code&gt;BATCH_WINDOW_MS = 16&lt;/code&gt; (about one frame) window into a single store write. &lt;strong&gt;The key is per-session buffer + flush timer&lt;/strong&gt; — otherwise one session's frequent &lt;code&gt;tool_call&lt;/code&gt; would prematurely flush another session's accumulating text. Non-text blocks (e.g. &lt;code&gt;tool_use&lt;/code&gt;) flush first, then write, preserving message order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;O(1) text-block merge fast path&lt;/strong&gt;: &lt;code&gt;appendContent&lt;/code&gt; checks whether the last message matches the messageId; on a hit it merges in place; adjacent text blocks without annotations concatenate directly as &lt;code&gt;lastBlock.text + block.text&lt;/code&gt;, reducing part fragmentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;react-virtuoso long-list virtualization&lt;/strong&gt;: &lt;code&gt;ChatView&lt;/code&gt; virtualizes by "user turn → agent turn" groups; thousands of messages stay smooth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-grained hook slice subscriptions&lt;/strong&gt;: &lt;code&gt;useSessionUsage&lt;/code&gt; subscribes only to the usage slice; when usage updates, &lt;code&gt;ChatView&lt;/code&gt; (subscribing to messages + isStreaming) doesn't budge. Selectors return a stable empty array (&lt;code&gt;EMPTY_MESSAGES&lt;/code&gt;) so &lt;code&gt;Object.is&lt;/code&gt; holds and spurious re-renders are avoided.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  18 Component Directories Out of the Box
&lt;/h2&gt;

&lt;p&gt;Not a shell — a complete workbench. &lt;code&gt;packages/react/src/components&lt;/code&gt; has ~24 named components (18 by directory count):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skeleton&lt;/strong&gt;: &lt;code&gt;Workbench&lt;/code&gt; three-column resizable layout + &lt;code&gt;AcpProvider&lt;/code&gt; + accessible &lt;code&gt;ResizeHandle&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions&lt;/strong&gt;: &lt;code&gt;SessionList&lt;/code&gt; (workspace→agent→session three-level grouping, fork/delete/load more), &lt;code&gt;Sidebar&lt;/code&gt; (session-list/file-tree toggle);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat family&lt;/strong&gt;: &lt;code&gt;ChatView&lt;/code&gt; + &lt;code&gt;MessageBubble&lt;/code&gt; + &lt;code&gt;ChatComposer&lt;/code&gt; (&lt;code&gt;/&lt;/code&gt; triggers command palette, attachment upload) + &lt;code&gt;ToolCallCard&lt;/code&gt; + &lt;code&gt;StreamingIndicator&lt;/code&gt; + &lt;code&gt;ThoughtView&lt;/code&gt; + &lt;code&gt;PlanView&lt;/code&gt; + &lt;code&gt;UserMessage&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files&lt;/strong&gt;: &lt;code&gt;FileTree&lt;/code&gt; (directories sorted on top), &lt;code&gt;FileViewer&lt;/code&gt; (Monaco lazy-loaded, theme-aware);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dialogs&lt;/strong&gt;: &lt;code&gt;PermissionDialog&lt;/code&gt; (allow_once/allow_always/deny), &lt;code&gt;LoginDialog&lt;/code&gt; (EnvVar auth, 300s timeout);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misc&lt;/strong&gt;: &lt;code&gt;DiffView&lt;/code&gt;, &lt;code&gt;CommandPalette&lt;/code&gt;, &lt;code&gt;SessionConfigPanel&lt;/code&gt;, &lt;code&gt;Select&lt;/code&gt; (portal-rendered), &lt;code&gt;Dropdown&lt;/code&gt; family (4 placements), &lt;code&gt;SettingsMenu&lt;/code&gt;, &lt;code&gt;ConnectionStatus&lt;/code&gt; + &lt;code&gt;UsageBar&lt;/code&gt; (SVG ring token progress).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every component follows the same design contract: state is driven by core's vanilla stores and fine-grained hooks, native capabilities are injected via &lt;code&gt;usePlatform()&lt;/code&gt;, and the components themselves are responsible only for rendering and interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Agents, Two Examples, One Bridge
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;examples/demo&lt;/strong&gt;: Vite + React 19 pure-browser app, WebSocket to a local bridge server. &lt;code&gt;vite.config.ts&lt;/code&gt; aliases straight to source — no build step during dev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;examples/server&lt;/strong&gt;: a Node bridge service. One HTTP server mounts three things — a WebSocket↔stdio NDJSON bridge (letting the browser drive a local agent's child process), an HTTP filesystem API (&lt;code&gt;/api/readdir&lt;/code&gt;, &lt;code&gt;/api/readfile&lt;/code&gt;), and an SSE file-watch stream (&lt;code&gt;/api/watch&lt;/code&gt;). The root &lt;code&gt;package.json&lt;/code&gt; ships one-click launch scripts for &lt;strong&gt;OpenCode / Codex (&lt;code&gt;@zed-industries/codex-acp&lt;/code&gt;) / Claude (&lt;code&gt;@agentclientprotocol/claude-agent-acp&lt;/code&gt;)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;examples/tauri&lt;/strong&gt;: a Tauri 2 desktop template with a full Rust backend (348 lines, 5 tauri commands); &lt;code&gt;start_agent&lt;/code&gt; handles Windows &lt;code&gt;.cmd&lt;/code&gt; completion, &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; to hide the console, and 4 threads bridging stdin/stdout/stderr/exit. Production builds can even bundle the agent binary into &lt;code&gt;bundle.resources&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Screenshots
&lt;/h2&gt;

&lt;p&gt;One component set, two host forms — the Web workbench in the browser on the left, the Tauri-packaged desktop app on the right. Both share every component of &lt;code&gt;@acp-components/react&lt;/code&gt; and the state layer of &lt;code&gt;@acp-components/core&lt;/code&gt;; only the &lt;code&gt;Platform&lt;/code&gt; implementation (&lt;code&gt;createWebPlatform&lt;/code&gt; ↔ &lt;code&gt;createTauriPlatform&lt;/code&gt;) and the transport (WebSocket ↔ Tauri IPC) differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Demo (browser)&lt;/strong&gt; — connects to a local bridge server over WebSocket, with the file tree driven by SSE:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q4kb7o7mty7w7kltoa0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q4kb7o7mty7w7kltoa0.png" alt="ACP Web Demo" width="800" height="636"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tauri Desktop&lt;/strong&gt; — connects to the Rust-spawned agent child process directly via Tauri IPC, with directory selection via a native dialog:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhh6hiofngoedtw3jh3nu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhh6hiofngoedtw3jh3nu.png" alt="ACP Tauri Desktop" width="800" height="637"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering Baseline: Not a Toy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript 5.9 strict&lt;/strong&gt; + ES2022 + bundler resolution, &lt;code&gt;noUnusedLocals&lt;/code&gt;/&lt;code&gt;noFallthroughCasesInSwitch&lt;/code&gt; on;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vite 6 library mode&lt;/strong&gt;, dual ESM/CJS output + &lt;code&gt;tsc --emitDeclarationOnly&lt;/code&gt; for &lt;code&gt;.d.ts&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zustand 5&lt;/strong&gt; vanilla/react split; &lt;strong&gt;React 19&lt;/strong&gt; (peer-compatible with 18);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCSS Modules&lt;/strong&gt; &lt;code&gt;camelCaseOnly&lt;/code&gt;, 24 &lt;code&gt;.module.scss&lt;/code&gt; files, design tokens all via &lt;code&gt;var(--acp-*)&lt;/code&gt;, hardcoded colors forbidden;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18next 26 + react-i18next 17&lt;/strong&gt;, built-in en-US / zh-CN, detection order platform storage → localStorage → navigator.language, with &lt;code&gt;customLocales&lt;/code&gt; to extend any language;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual themes&lt;/strong&gt;: &lt;code&gt;data-acp-theme="dark|light"&lt;/code&gt;, Dark "Midnight" (GitHub Dark + One Dark Pro blue), Light "Dawn" (GitHub Light + bright blue), including global reset, themed scrollbars, focus-visible focus ring, and &lt;code&gt;prefers-reduced-motion&lt;/code&gt; accessibility degradation;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vitest 3 + @testing-library/react 16 + jsdom 25&lt;/strong&gt; testing baseline in place.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  In One Sentence
&lt;/h2&gt;

&lt;p&gt;The selling point of &lt;code&gt;acp-components&lt;/code&gt; isn't "yet another chat UI" — it's &lt;strong&gt;a set of pluggable contracts with clean boundaries&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;core is React-free, so you can pair it with any frontend framework;&lt;/li&gt;
&lt;li&gt;a three-tier multi-agent × multi-workspace state model handles real complexity;&lt;/li&gt;
&lt;li&gt;Platform and AcpContext are orthogonal, with high-risk capabilities governed host-side;&lt;/li&gt;
&lt;li&gt;four pluggable transports, with Tauri IPC as a production-grade example;&lt;/li&gt;
&lt;li&gt;18 component directories + a complete bridge service + a Tauri desktop template — runnable out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're wrestling with "how do I slot an agent workbench into my Web/desktop/IDE host," spin up the demo with &lt;code&gt;pnpm dev&lt;/code&gt;, or read &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; directly. Protocol-native, cleanly layered, restrained at the boundaries — that's what it aims to deliver.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub repository&lt;/strong&gt;: &lt;a href="https://github.com/zvzuola/acp-components" rel="noopener noreferrer"&gt;https://github.com/zvzuola/acp-components&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built on the Agent Client Protocol — transport pluggable, UI replaceable, platform extensible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>acp</category>
      <category>agents</category>
      <category>react</category>
    </item>
  </channel>
</rss>
