DEV Community

Cover image for acp-components: Turning the AI Agent Workbench into Pluggable Lego
威少
威少

Posted on

acp-components: Turning the AI Agent Workbench into Pluggable Lego

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.

Why does it exist?

If you've ever built an AI Agent frontend, you've likely hit these walls:

  • Re-implementing the UI for every host: 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.
  • Multi-agent chaos: 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?
  • Protocol comms tangled with state: NDJSON streams, session updates, and permission requests all glued inside React components — hard to test, hard to maintain.
  • File I/O and other high-risk capabilities in the wrong place: either the frontend hardcodes filesystem calls, or permission decisions leak into the agent communication layer — a muddy security boundary.

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

Core Design: Three Principles That Run Through Everything

Principle 1: A clean data/UI split with a framework-agnostic core

The project ships as two packages:

@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)
Enter fullscreen mode Exit fullscreen mode

The hardest rule: core has zero React dependency. Not just in words — it's enforced in code:

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

What does this mean? core can be used without React. 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."

Bonus dividends of this split:

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

Principle 2: A three-tier state model for multi-agent × multi-workspace

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 three-tier abstraction:

Abstraction Meaning Home
Agent One independent ACP connection (transport + status + capabilities) acpStore.agents: Map<agentId, AgentConnection>
Workspace A directory (cwd), holding sessions from multiple agents acpStore.workspaces: Map<cwd, WorkspaceState>
Session Belongs to a workspace + agent pair SessionMeta.agentId + SessionMeta.cwd

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

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

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

Principle 3: Platform and AcpContext are orthogonal; high-risk capabilities stay host-side

This is the most easily overlooked yet most critical design. The project abstracts host-native capabilities into a Platform interface:

interface Platform {
  platform: 'desktop' | 'web';
  // Native interaction: open links, directory picker dialog, system notifications
  openLink(url); openDirectoryPickerDialog(); notify();
  // Filesystem: file tree, read, write, watch (write/watch optional)
  readDirectory(path); readFileContent(path);
  writeFileContent?(path, content); watchFileTree?(cb);
  // Persistence: namespaced KV store + workspace list
  storage(name?); loadWorkspaces?(); saveWorkspaces?();
  // External editor integration, updater (all optional)
  onOpenFile?(path, line?); updater?; restart?(); exportDebugLogs?();
}
Enter fullscreen mode Exit fullscreen mode

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

Three reasons for the split:

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

Two ready-to-use Platform implementations prove the abstraction is buildable:

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

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

Transport: Four Options, Pluggable

The ACP protocol's transport carrier is NDJSON (one JSON message per line). core abstracts transport into a minimal AcpTransport interface:

interface AcpTransport {
  connect(): Promise<Stream>;   // returns { readable, writable }
  disconnect(): void;
  onClose?(handler): () => void;
  onError?(handler): () => void;
}
Enter fullscreen mode Exit fullscreen mode

Three built-ins plus a custom entry point:

Transport Scenario Implementation
StdioTransport Desktop primary spawn a child process, take over stdin/stdout, split with ndJsonStream; disconnect calls kill()
WebSocketTransport Web demo Native browser WebSocket, onmessage parses and enqueues
HttpTransport Stateless/short-lived fetch-based, AbortController for cancellation
{ type: 'custom', transport } Any host Implement AcpTransport

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

Agent stdout ──> Rust (line by line) ──> Tauri event ──> ReadableStream
Agent stdin  <── Rust (write) <── Tauri command <── WritableStream
Enter fullscreen mode Exit fullscreen mode

Following this pattern, Electron IPC (ipcRenderer.invoke / on) and iframe postMessage are trivially the same idea.

Streaming UX: 16ms Batching + Virtual Scrolling

Whether a chat feels good is 80% about streaming rendering. Here are a few concrete engineering details:

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

18 Component Directories Out of the Box

Not a shell — a complete workbench. packages/react/src/components has ~24 named components (18 by directory count):

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

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

Three Agents, Two Examples, One Bridge

  • examples/demo: Vite + React 19 pure-browser app, WebSocket to a local bridge server. vite.config.ts aliases straight to source — no build step during dev.
  • examples/server: 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 (/api/readdir, /api/readfile), and an SSE file-watch stream (/api/watch). The root package.json ships one-click launch scripts for OpenCode / Codex (@zed-industries/codex-acp) / Claude (@agentclientprotocol/claude-agent-acp).
  • examples/tauri: a Tauri 2 desktop template with a full Rust backend (348 lines, 5 tauri commands); start_agent handles Windows .cmd completion, CREATE_NO_WINDOW to hide the console, and 4 threads bridging stdin/stdout/stderr/exit. Production builds can even bundle the agent binary into bundle.resources.

Screenshots

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 @acp-components/react and the state layer of @acp-components/core; only the Platform implementation (createWebPlatformcreateTauriPlatform) and the transport (WebSocket ↔ Tauri IPC) differ.

Web Demo (browser) — connects to a local bridge server over WebSocket, with the file tree driven by SSE:

ACP Web Demo

Tauri Desktop — connects to the Rust-spawned agent child process directly via Tauri IPC, with directory selection via a native dialog:

ACP Tauri Desktop

Engineering Baseline: Not a Toy

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

In One Sentence

The selling point of acp-components isn't "yet another chat UI" — it's a set of pluggable contracts with clean boundaries:

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

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


GitHub repository: https://github.com/zvzuola/acp-components

Built on the Agent Client Protocol — transport pluggable, UI replaceable, platform extensible.

Top comments (0)