Compare commits

...

10 Commits

Author SHA1 Message Date
claude-code-best
6536757428 feat: 对其他 provider 提供 langfuse 监控 2026-04-19 09:09:27 +08:00
claude-code-best
a0dc4540ca fix: 修复服务器两个 / 的问题 2026-04-19 08:55:55 +08:00
claude-code-best
7e4df5c3e9 build: 更改构建逻辑 2026-04-19 08:45:06 +08:00
claude-code-best
4d939e5722 chore: 更新构建 feature 的问题 2026-04-19 08:27:25 +08:00
claude-code-best
2e9aaf4993 feat: ACP 协议版本 remote control (#293)
* fix: 添加 usage 字段缺失时的防御性防护

第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段,
导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。

- claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE
- LocalAgentTask.tsx: usage 为 undefined 时提前返回
- tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0
- cost-tracker.ts: input_tokens/output_tokens 加 ?? 0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化

补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件
渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 穷鬼模式下跳过 verification agent 以节省 token

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests)

后端新增 3 个测试文件 (70 tests):
- automationState: normalize/snapshot/equals 纯函数
- client-payload: toClientPayload 协议转换
- transport-normalize: normalizePayload + extractContent

前端新增 2 个测试文件 (46 tests):
- utils: formatTime/statusClass/truncate/extractEventText 等
- api-client: getUuid/setUuid/api GET/POST 错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复

- 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递
- 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug
- 权限模式和模型选择持久化到 localStorage
- acp-link 直接连接路径同步支持 permissionMode 透传

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转

- RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading,
  主题系统改进, 组件样式优化
- IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制
  解决 Radix Dialog Portal 挂载时序问题
- ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token }
  后存储 sessionStorage 并跳转 /code/?acp=1
- 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并
  渲染 ACPMain 聊天界面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线

- agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode
- agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境)
- permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool
  统一权限流水线, 替代原来分散的处理逻辑
- permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: acp-link 支持 permissionMode 默认值传递给 agent

客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode,
导致 agent 收到 _meta: undefined, permission 回退到 default。

修复: handleNewSession 使用 fallback 链:
  客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量

使用: ACP_PERMISSION_MODE=auto acp-link claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新文档及说明

* fix: 修复类型错误

* chore: 提交脚本

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:54:22 +08:00
claude-code-best
34154ee3f5 feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)
* fix: 修复超时问题

* feat: 添加 acp-link 代码

* refactor: 样式重构完成

* feat: RCS 添加 ACP 后端支持

- 新增 ACP WebSocket handler (agent 注册、EventBus 订阅)
- 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发)
- 新增 SSE event stream 供外部消费者订阅 channel group 事件
- ACP REST 接口无鉴权 (agents、channel-groups)
- WebSocket 端点保留 token 鉴权
- SPA 路由 /acp/ 指向 acp.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加 ACP 专属前端界面

- 新增 /acp/ SPA 页面 (agent 列表 + 实时交互)
- Agent 列表按 channel group 分组,显示在线状态
- 通过 RCS WebSocket relay 与 agent 通信
- Vite multi-page 构建 (index.html + acp.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 支持 RCS relay 双向通信

- rcs-upstream 新增 messageHandler 转发非控制消息
- server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息
- newSession/loadSession 补充 mcpServers 参数
- 连接成功后显示 ACP Dashboard URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 移除 FileExplorer 及文件操作相关代码

- 删除 FileExplorer 组件
- ACPMain 移除 Files tab,仅保留 Chat 和 History
- client.ts 移除 listDir/readFile/onFileChanges 等方法
- types.ts 移除 FileItem/FileContent/FileChange 等类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复类型问题

* feat: RCS 后端统一 ACP/Bridge 注册逻辑

- store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑
- store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim
- environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id
- session: resolveOwnedWebSessionId 支持无 owner session 自动绑定
- acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册
- acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证
- event-bus: 增加 error 类型 payload 日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 改 REST 注册 + WS identify 两步流程

- rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册
- rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId
- rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定
- server: 修复心跳跳过 relay 虚拟连接的 bug
- server: maxSessions 配置传入 RCS upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 前端统一 Chat 组件 + ACP 聊天界面重构

- 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu
- ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission)
- rcs-chat-adapter: 统一 bridge session SSE 适配器
- ACPClient: 增强 session 管理、permission 流程、streaming 支持
- index.css: 新增 chat 相关样式、动画、布局
- useCommands: 新增快捷命令 hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId

- 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录
- SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件
- App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID
- Dashboard: 统一 session 列表导航,ACP 显示紫色标签
- relay-client: 改用 UUID 认证替代 API token
- EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code)
- index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* build: 更新构建及测试修复

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 17:59:29 +08:00
claude-code-best
29cc74a170 docs: 更新 CLAUDE.md 2026-04-17 21:37:43 +08:00
claude-code-best
d2b66d9d2c docs: update contributors 2026-04-17 12:45:56 +00:00
claude-code-best
d70e7f7f05 feat: 支持 langfuse 工具调用映射 2026-04-17 20:45:14 +08:00
Cheng Zi Feng
72a2093cd6 feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
2026-04-17 16:21:27 +08:00
210 changed files with 23251 additions and 4226 deletions

10
.gitignore vendored
View File

@@ -13,7 +13,6 @@ src/utils/vendor/
# AI tool runtime directories
.agents/
.claude/
.codex/
.omx/
.docs/task/
# Binary / screenshot files (root only)
@@ -30,3 +29,12 @@ __pycache__/
logs
data
.omc
.codex/*
!.codex/agents/
!.codex/agents/**
!.codex/skills/
!.codex/skills/**
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**

78
.impeccable.md Normal file
View File

@@ -0,0 +1,78 @@
# Impeccable Design Context
## Users
**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows.
- DevOps engineers managing remote agents via RCS dashboard
- Development teams collaborating through shared sessions
- Individual developers using terminal CLI daily
**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality.
**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth.
## Brand Personality
**3 words**: Warm, Considered, Human
**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围).
**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference).
**References**:
- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward.
- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility).
**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee."
## Aesthetic Direction
**Theme**: Light + Dark dual mode (user/system preference switch)
**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world.
**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality.
**Color**:
- Primary: Claude orange family (`#D77757` / terra cotta)
- Accent: Warm neutrals tinted toward orange
- Semantic: Success/Error/Warning following Anthropic's established palette
- Dark mode: Warm dark surfaces (not cold blue-black)
**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor.
**Scope**: All Web UI — RCS control panel, documentation site, landing pages.
## Design Principles
1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship.
2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color.
3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place.
4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest.
5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch.
## Existing Design Assets
### Brand Colors (from theme system)
- Claude Orange: `rgb(215,119,87)` / `#D77757`
- Claude Blue: `rgb(87,105,247)` / `#5769F7`
- Permission Blue: `rgb(87,105,247)`
- Auto Accept Violet: `rgb(135,0,255)`
- Plan Mode Teal: `rgb(0,102,102)`
- Success: `rgb(78,186,101)`
- Error: `rgb(255,107,128)`
- Warning: `rgb(255,193,7)`
### Logo
- CCB text + orange play button icon
- Dark/Light SVG variants in `docs/logo/`
- Favicon: Orange circle `#D97706` with white play triangle
### Mascot
- "Clawd" — terminal-art character with multiple poses
- Theme-aware coloring
### Theme System
- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto
- 89+ semantic color tokens
- Full documentation in `packages/@ant/ink/docs/04-theme-system.md`

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmjs.org/

102
CLAUDE.md
View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced**`bunx tsc --noEmit` must pass with zero errors**.
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
## Git Commit Message Convention
@@ -39,8 +39,11 @@ echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files)
bun run build
# Build with Vite (alternative build pipeline)
bun run build:vite
# Test
bun test # run all tests (2453 tests / 137 files / 0 fail)
bun test # run all tests (3175 tests / 207 files / 0 fail)
bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
@@ -74,14 +77,14 @@ bun run docs:dev
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 14internal packages in `packages/` resolved via `workspace:*`
- **Monorepo**: Bun workspaces — 15workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--claude-in-chrome-mcp` / `--chrome-native-host`
@@ -94,7 +97,7 @@ bun run docs:dev
- `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop
@@ -112,8 +115,8 @@ bun run docs:dev
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools/<ToolName>/`** — 55 tool 目录。主要分类:
- **`src/tools.ts`** (392 行) — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`packages/builtin-tools/src/tools/`** — 59子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
@@ -121,7 +124,6 @@ bun run docs:dev
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
@@ -152,9 +154,17 @@ bun run docs:dev
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI |
| `packages/swarm/` | Swarm 解耦模块 |
| `packages/shell/` | Shell 抽象 |
| `packages/@ant/model-provider/` | Model provider 抽象层 |
| `packages/builtin-tools/` | 内置工具集60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
| `packages/agent-tools/` | Agent 工具集 |
| `packages/acp-link/` | ACP 代理服务器WebSocket → ACP agent 桥接) |
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
| `packages/mcp-client/` | MCP 客户端库 |
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI— Web UI 已重构为 React + Vite + Radix UI支持 ACP agent 接入 |
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
| `packages/shell/` | Shell 抽象(非 workspace 包) |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
@@ -163,11 +173,18 @@ bun run docs:dev
### Bridge / Remote Control
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板React 19 + Vite + Radix UI。支持 ACP agent 通过 acp-link 接入ACP WebSocket handler、relay handler、SSE event stream。通过 `bun run rcs` 启动。
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
### ACP Protocol (Agent Client Protocol)
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`AcpAgent 类)、`bridge.ts`Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成REST 注册 + WS identify 两步流程、权限模式透传fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示PlanView 组件,含进度条/状态图标/优先级标签)。
### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
@@ -198,30 +215,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
### Multi-API 兼容层
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改
支持 OpenAI、Gemini、Grok 三种第三方 API通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档
#### OpenAI 兼容层
### 穷鬼模式Budget Mode
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
- 通过 `/poor` 命令切换,持久化到 `settings.json`
- 启用后跳过 `extract_memories``prompt_suggestion``verification_agent`,显著减少 token 消耗。
- 实现在 `src/commands/poor/poorMode.ts`
### Stubbed/Deleted Modules
@@ -247,7 +247,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 2992 tests / 188 files / 0 fail
- **当前状态**: 3175 tests / 207 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
@@ -269,7 +269,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bunx tsc --noEmit
bun run typecheck # equivalent to bun run typecheck
```
**类型规范**
@@ -282,7 +282,7 @@ bunx tsc --noEmit
## Working with This Codebase
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
@@ -292,3 +292,29 @@ bunx tsc --noEmit
- **Biome 配置** — 大量 lint 规则被关闭decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
## Design Context
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UIRCS 控制面板、文档站、着陆页)时必须参考该文件。
### 核心设计原则
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
### 品牌色
- 主色Claude Orange `#D77757`terra cotta
- 辅色Claude Blue `#5769F7`
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
### 目标用户
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
### 视觉参考
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。

View File

@@ -11,6 +11,7 @@ rmSync(outdir, { recursive: true, force: true })
// Default features that match the official CLI build.
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',

2126
bun.lock

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

205
docs/features/acp-link.md Normal file
View File

@@ -0,0 +1,205 @@
# acp-link — ACP 代理服务器
> 源码目录:`packages/acp-link/`
> PR: #292
> 新增时间2026-04-18
## 一、功能概述
`acp-link` 是一个 ACP (Agent Client Protocol) 代理服务器,将 WebSocket 客户端桥接到 ACP agent 的 stdio 接口。它让 ACP agent如 Claude Code可以通过 WebSocket 远程访问,而不仅限于本地 stdio。
### 核心特性
- **WebSocket → stdio 桥接**:将浏览器/远程客户端的 WebSocket 连接转换为 ACP agent 的 stdin/stdout NDJSON 流
- **会话管理**:创建、加载、恢复、列出、关闭会话
- **权限审批流程**:客户端可远程审批 agent 的工具权限请求
- **RCS 集成**:可与 Remote Control Server (RCS) 连接,将 ACP agent 注册到 RCS 并通过 Web UI 交互
- **HTTPS 支持**:内置自签名证书生成,支持安全连接
- **Token 认证**:自动生成或通过环境变量配置认证 token
## 二、架构
### 独立模式
```
┌──────────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
│ 浏览器/客户端 │ ◄──────────────►│ acp-link │ ◄────────────────►│ ACP Agent │
│ (WS Client) │ ws://host:port │ (Proxy Server) │ spawn subprocess │ (Claude等) │
└──────────────────┘ └──────────────────┘ └──────────────┘
```
### RCS 集成模式
```
┌──────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
│ RCS Web UI │ ◄──────────────►│ Remote Control │ ◄─────────────────►│ acp-link │
│ (/code/*) │ ACP Relay WS │ Server (RCS) │ ACP events │ + Agent │
└──────────────┘ └──────────────────┘ └──────────────┘
```
### 文件结构
```
packages/acp-link/
├── src/
│ ├── server.ts # 主服务器WS 连接管理、会话管理、权限处理、消息桥接
│ ├── rcs-upstream.ts # RCS 上游客户端REST 注册 + WS identify 两步流程
│ ├── cert.ts # TLS 证书生成(自签名)
│ ├── logger.ts # 日志模块
│ ├── types.ts # JSON-RPC 和 ACP 协议类型定义
│ ├── cli/
│ │ ├── bin.ts # CLI 入口
│ │ ├── command.ts # 命令行参数解析
│ │ ├── app.ts # 应用启动
│ │ └── context.ts # 上下文配置
│ └── __tests__/ # 测试cert, server, types
├── package.json
└── tsconfig.json
```
## 三、安装与使用
### 基本用法
```bash
# 直接运行(在 monorepo 中)
# 注意claude 本身不支持 ACP需要用 ccb-bun --acp 启动 ACP agent
bun packages/acp-link/src/cli/bin.ts ccb-bun -- --acp
# 指定端口和主机
acp-link --port 9000 --host 0.0.0.0 ccb-bun -- --acp
# 启用 HTTPS自签名证书
acp-link --https ccb-bun -- --acp
# 调试模式
acp-link --debug ccb-bun -- --acp
```
### CLI 参考
```
USAGE
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
acp-link --help
acp-link --version
FLAGS
[--port] Port to listen on [default = 9315]
[--host] Host to bind to [default = localhost]
[--debug] Enable debug logging to file
[--no-auth] Disable authentication (dangerous)
[--https] Enable HTTPS with self-signed cert
-h --help Print help information and exit
-v --version Print version information and exit
ARGUMENTS
command... Agent command followed by its arguments (e.g. "ccb-bun -- --acp")
```
## 四、认证
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
```
ws://localhost:9315/ws?token=<your-token>
```
配置固定 token
```bash
ACP_AUTH_TOKEN=my-fixed-token acp-link ccb-bun -- --acp
```
禁用认证(不推荐,仅用于开发):
```bash
acp-link --no-auth ccb-bun -- --acp
```
## 五、RCS 集成
acp-link 支持将 ACP agent 注册到 Remote Control Server通过 Web UI 远程操控。
### 连接方式
```bash
# 通过环境变量配置 RCS 连接
ACP_RCS_URL=http://localhost:3000 \
ACP_RCS_TOKEN=sk-rcs-your-key \
ACP_RCS_NAME=my-agent \
acp-link ccb-bun -- --acp
```
### 注册流程(两步)
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId替代完整 `register`
```
acp-link RCS
│ │
│── POST /v1/environments/bridge ──►│ (REST 注册)
│◄── { agentId, sessionId } ───────│
│ │
│── WS connect ─────────────────►│ (WebSocket)
│── identify { agentId } ────────►│ (WS 标识)
│◄── registered ─────────────────│
│ │
│── ACP events ─────────────────►│ (双向消息转发)
│◄── user prompts/permissions ───│
```
## 六、权限模式
### permissionMode 传递链
权限模式通过整条链路传递Web UI → RCS → acp-link → ACP agent。
支持的权限模式:
- `default` — 每次请求权限确认
- `auto` — 自动判断
- `acceptEdits` — 自动接受编辑
- `plan` — 规划模式
- `dontAsk` — 不询问
- `bypassPermissions` — 绕过权限(需 sandbox 环境)
### fallback 链
当客户端未显式传递 permissionMode 时,使用以下 fallback 链:
```
客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量
```
示例:
```bash
ACP_PERMISSION_MODE=auto acp-link ccb-bun -- --acp
```
## 七、权限管道2026-04-18 改进)
### 模式同步
`applySessionMode` 在 agent 切换权限模式时同步 `appState.toolPermissionContext.mode`,确保内部权限上下文与 ACP 客户端状态一致。
### 统一权限流水线
`createAcpCanUseTool` 接入 `hasPermissionsToUseTool` 统一权限流水线,替代原来分散的处理逻辑。支持 `onModeChange` 回调,模式变更时实时同步。
### bypass 检测
`bypassPermissions` 模式增加可用性检测 — 仅在非 root 或 sandbox 环境中允许启用,防止权限绕过的安全风险。
## 八、环境变量
| 变量 | 说明 |
|------|------|
| `ACP_AUTH_TOKEN` | 固定认证 token默认自动生成 |
| `ACP_PERMISSION_MODE` | 默认权限模式 fallback |
| `ACP_RCS_URL` | RCS 服务器地址(启用 RCS 集成) |
| `ACP_RCS_TOKEN` | RCS API token |
| `ACP_RCS_NAME` | Agent 名称(在 RCS 中显示) |
| `ACP_RCS_CHANNEL_GROUP` | Channel group ID |
| `ACP_MAX_SESSIONS` | 最大会话数 |

View File

@@ -1,7 +1,7 @@
# KAIROS — 常驻助手模式
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature
> 实现状态:核心框架完整,部分子模块为 stub
> 实现状态:核心框架完整,部分子模块为 stubproactive/sleep 节奏控制已可用
> 引用数154全库最大
## 一、功能概述
@@ -74,8 +74,9 @@ KAIROS 在系统提示中注入两大段落:
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
- 工具名:`Sleep`
- 功能:等待指定时间后响应 tick prompt
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒
-`<tick_tag>` 配合实现心跳式自主工作
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
### 3.3 Bridge 集成
@@ -172,8 +173,10 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UIstub |
| `src/tools/BriefTool/` | — | BriefTool 实现stub |
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入stub |
| `src/memdir/memdir.ts` | — | 记忆目录管理stub |
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务stub |
| `src/proactive/index.ts` | — | Proactive 核心(stubKAIROS 共享) |
| `src/proactive/index.ts` | — | Proactive 核心KAIROS 共享) |
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |

View File

@@ -1,7 +1,7 @@
# PROACTIVE — 主动模式
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
> 实现状态:核心模块全部 Stub布线完整
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐
> 引用数37
## 一、功能概述
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
| 模块 | 文件 | 状态 | 说明 |
|------|------|------|------|
| 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()``deactivateProactive()``isProactiveActive() => false` |
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()``deactivateProactive()``pause/resume``nextTickAt` 调度状态 |
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep` |
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
| REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 |
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt |
| 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 |
### 2.2 系统提示内容
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
### 2.3 数据流
```
activateProactive() [需要实现]
activateProactive()
Tick 调度器启动
@@ -62,20 +62,22 @@ Tick 调度器启动
└── 无事可做 → 必须调用 SleepTool
SleepTool 等待 [需要实现]
SleepTool 等待
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
├── proactive 被关闭 → 立即中断
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
下一个 tick 到达
```
## 三、需要补全的内容
## 三、当前行为补充
| 优先级 | 模块 | 工作量 | 说明 |
|--------|------|--------|------|
| 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
| 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick |
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
| 4 | `src/hooks/useProactive.ts` | 中 | React hookREPL 引用但不存在) |
- `standby`proactive 已开启,当前没有执行中的 turn且已调度下一个 tick。
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。
## 四、关键设计决策
@@ -101,9 +103,11 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
| 文件 | 职责 |
|------|------|
| `src/proactive/index.ts` | 核心逻辑stub |
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 |
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
| `src/screens/REPL.tsx` | REPL tick 集成 |
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 |
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |

View File

@@ -13,17 +13,22 @@
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
│ (/code/*) │ │ └──────────────┘ │
└──────────────────┘ │ ┌──────────────┐ │
│ │ JWT Auth │ │
│ (React + Vite) │ │ ┌──────────────┐ │
└──────────────────┘ │ │ JWT Auth │ │
│ └──────────────┘ │
└──────────────────────┘
┌──────────────────┐ ──────────────┐ │
│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │
│ + ACP Agent │ WebSocket │ └──────────────┘ │
└──────────────────┘ └──────────────────────┘
```
**RCS 是一个纯内存的中间服务**,它的职责是:
- 接收 Claude Code CLI 的环境注册和工作轮询
- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接
- 提供 Web UI 供操作者远程监控和审批
- 通过 WebSocket/SSE 双向传输消息
- 管理会话、环境、权限请求
- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件
## 前置条件
@@ -169,15 +174,70 @@ claude bridge
## Web UI 控制面板
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。
- 查看已注册的运行环境environment 模式
### 技术栈v22026-04-18 重构
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**
- **框架**: React 19 + Vite 构建TypeScript
- **UI 组件**: Radix UI primitivesDialog、Tabs、Select、Popover 等)
- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批
- **AI Elements**: 独立的 AI 交互组件库message、reasoning、tool、code-block、prompt-input 等)
- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`
- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统
### 功能
- 查看已注册的运行环境environment 模式),区分 ACP Agent 和 Claude Code 类型
- 创建和管理会话
- 实时查看对话消息和工具调用
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
- 查看 authoritative task snapshots 驱动的 Tasks 面板
- 审批 Claude Code 的工具权限请求
- 权限模式选择器6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 模型选择器(可选可用模型)
- Plan 可视化(进度条、状态图标、优先级标签)
- ACP QR 扫描自动跳转到 ACP 聊天界面
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
## ACP 支持
RCS 支持 ACP (Agent Client Protocol) agent 通过 `acp-link` 包接入。
### 架构
```
acp-link ──REST注册──► RCS POST /v1/environments/bridge
acp-link ──WS identify──► RCS WebSocket (携带 agentId)
acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
```
### 后端组件
| 文件 | 职责 |
|------|------|
| `src/routes/acp/index.ts` | ACP REST 路由agents 列表、channel groups、relay |
| `src/transport/acp-ws-handler.ts` | ACP WebSocket 处理agent 注册、心跳、消息转发 |
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
### acp-link 连接
详见 [acp-link 文档](./acp-link.md)。
```bash
# 在 RCS 环境中启动 acp-link
# 注意claude 本身不支持 ACP需要用 ccb-bun --acp
ACP_RCS_URL=http://localhost:3000 \
ACP_RCS_TOKEN=sk-rcs-your-key \
ACP_RCS_NAME=my-agent \
acp-link ccb-bun -- --acp
```
ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区分。
## 工作流程详解
```
@@ -215,6 +275,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
9. 双向通信
CLI ──消息/工具调用结果──► RCS ──► Browser
CLI ◄──权限审批/指令───── RCS ◄──── Browser
CLI ──automation_state / task_state──► RCS ──► Browser
10. 心跳保活(每 20 秒)
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
@@ -224,6 +285,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
## 故障排查
### Web UI 看不到当前 Autopilot 状态
- `standby`proactive 已开启,正在等待下一个 tick
- `sleeping`:模型正在 `SleepTool` 等待窗口中
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
### CLI 无法连接
```

View File

@@ -138,6 +138,7 @@
"docs/features/voice-mode",
"docs/features/bridge-mode",
"docs/features/remote-control-self-hosting",
"docs/features/acp-link",
"docs/features/proactive",
"docs/features/ultraplan"
]

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.4.1",
"version": "1.4.4",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -37,6 +37,7 @@
"files": [
"dist",
"scripts/postinstall.cjs",
"scripts/run-parallel.mjs",
"scripts/setup-chrome-mcp.mjs"
],
"scripts": {
@@ -72,20 +73,20 @@
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
"@anthropic-ai/mcpb": "^2.1.2",
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/credential-providers": "^3.1020.0",
"@aws-sdk/client-bedrock": "^3.1032.0",
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
"@aws-sdk/client-sts": "^3.1032.0",
"@aws-sdk/credential-provider-node": "^3.972.32",
"@aws-sdk/credential-providers": "^3.1032.0",
"@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10",
"@biomejs/biome": "^2.4.12",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
@@ -96,7 +97,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
@@ -107,14 +108,14 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1",
"@sentry/node": "^10.49.0",
"@smithy/core": "^3.23.15",
"@smithy/node-http-handler": "^4.5.3",
"@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
@@ -136,7 +137,7 @@
"asciichart": "^1.5.25",
"audio-capture-napi": "workspace:*",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"axios": "^1.15.0",
"bidi-js": "^1.0.3",
"cacache": "^20.0.4",
"chalk": "^5.6.2",
@@ -151,7 +152,7 @@
"execa": "^9.6.1",
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"fuse.js": "^7.3.0",
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
@@ -161,21 +162,21 @@
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.1.1",
"lodash-es": "^4.17.23",
"lru-cache": "^11.2.7",
"marked": "^17.0.5",
"knip": "^6.4.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
"modifiers-napi": "workspace:*",
"openai": "^6.33.0",
"openai": "^6.34.0",
"p-map": "^7.0.4",
"picomatch": "^4.0.4",
"plist": "^3.1.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react": "^19.2.5",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"rollup": "^4.60.2",
"semver": "^7.7.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
@@ -184,10 +185,10 @@
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"tree-kill": "^1.2.2",
"turndown": "^7.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"undici": "^7.24.6",
"turndown": "^7.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"undici": "^7.25.0",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.8",

34
packages/acp-link/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -0,0 +1,89 @@
# acp-link
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
## Installation
### From source
```bash
# From monorepo root
bun install
```
## Usage
```bash
# Via global install
acp-link /path/to/agent
# Via source
bun src/cli/bin.ts /path/to/agent
```
### Examples
```bash
# Basic usage
acp-link /path/to/agent
# With custom port and host
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
# With debug logging
acp-link --debug /path/to/agent
# Enable HTTPS with self-signed certificate
acp-link --https /path/to/agent
# Disable authentication (dangerous)
acp-link --no-auth /path/to/agent
# Pass arguments to the agent (use -- to separate)
acp-link /path/to/agent -- --verbose --model gpt-4
```
## CLI Reference
```
USAGE
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
acp-link --help
acp-link --version
FLAGS
[--port] Port to listen on [default = 9315]
[--host] Host to bind to [default = localhost]
[--debug] Enable debug logging to file
[--no-auth] Disable authentication (dangerous)
[--https] Enable HTTPS with self-signed cert
-h --help Print help information and exit
-v --version Print version information and exit
ARGUMENTS
command... Agent command followed by its arguments
```
## How It Works
1. Listens for WebSocket connections from clients
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
4. Supports session management: create, load, resume, list sessions
5. Handles permission approval flow and heartbeat keepalive
## Authentication
By default, a random token is auto-generated on startup. Pass it as a query parameter:
```
ws://localhost:9315/ws?token=<your-token>
```
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
## License
MIT

View File

@@ -0,0 +1,39 @@
{
"name": "acp-link",
"version": "1.0.1",
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
"author": "claude-code-best",
"type": "module",
"main": "./dist/server.js",
"types": "./dist/server.d.ts",
"bin": {
"acp-link": "dist/cli/bin.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "bun run src/cli/bin.ts",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@hono/node-server": "^1.13.8",
"@hono/node-ws": "^1.0.5",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",
"hono": "^4.7.0",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"selfsigned": "^5.5.0"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}

View File

@@ -0,0 +1,28 @@
import { describe, test, expect } from "bun:test";
import { getLanIPs } from "../cert.js";
describe("getLanIPs", () => {
test("returns an array", () => {
const ips = getLanIPs();
expect(Array.isArray(ips)).toBe(true);
});
test("returns only IPv4 addresses", () => {
const ips = getLanIPs();
for (const ip of ips) {
// IPv4 format: x.x.x.x
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
}
});
test("does not include loopback addresses", () => {
const ips = getLanIPs();
expect(ips).not.toContain("127.0.0.1");
});
test("may be empty in isolated environments", () => {
// This test just ensures it doesn't throw
const ips = getLanIPs();
expect(ips.length).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,75 @@
import { describe, test, expect } from "bun:test";
import type { ServerConfig } from "../server.js";
describe("Server HTTP endpoints", () => {
test("package.json has correct bin and main entries", async () => {
const pkg = await import("../../package.json", { with: { type: "json" } });
expect(pkg.default.name).toBe("acp-link");
expect(pkg.default.main).toBe("./dist/server.js");
expect(pkg.default.bin).toBeDefined();
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
});
test("ServerConfig interface accepts all expected fields", () => {
const config: ServerConfig = {
port: 9315,
host: "localhost",
command: "echo",
args: [],
cwd: "/tmp",
debug: false,
token: "test-token",
https: false,
};
expect(config.port).toBe(9315);
expect(config.token).toBe("test-token");
});
test("ServerConfig allows optional fields to be omitted", () => {
const config: ServerConfig = {
port: 9315,
host: "localhost",
command: "echo",
args: [],
cwd: "/tmp",
};
expect(config.debug).toBeUndefined();
expect(config.token).toBeUndefined();
expect(config.https).toBeUndefined();
});
});
describe("WebSocket message types", () => {
const clientMessageTypes = [
"connect",
"disconnect",
"new_session",
"prompt",
"permission_response",
"cancel",
"set_session_model",
"list_sessions",
"load_session",
"resume_session",
"ping",
];
test("all client message types are recognized", () => {
expect(clientMessageTypes.length).toBe(11);
expect(clientMessageTypes).toContain("ping");
expect(clientMessageTypes).toContain("connect");
expect(clientMessageTypes).toContain("cancel");
});
});
describe("Heartbeat constants", () => {
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
});
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
const HEARTBEAT_INTERVAL_MS = 30_000;
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, test, expect } from "bun:test";
import { isRequest, isResponse, isNotification } from "../types.js";
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
describe("isRequest", () => {
test("returns true for a valid JSON-RPC request", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isRequest(msg)).toBe(true);
});
test("returns true for request with params", () => {
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
expect(isRequest(msg)).toBe(true);
});
test("returns false for response (no method)", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
expect(isRequest(msg)).toBe(false);
});
test("returns false for notification (no id)", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
expect(isRequest(msg)).toBe(false);
});
});
describe("isResponse", () => {
test("returns true for a valid JSON-RPC response with result", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
expect(isResponse(msg)).toBe(true);
});
test("returns true for a valid JSON-RPC error response", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
expect(isResponse(msg)).toBe(true);
});
test("returns false for request (has method)", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isResponse(msg)).toBe(false);
});
test("returns false for notification", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
expect(isResponse(msg)).toBe(false);
});
});
describe("isNotification", () => {
test("returns true for a valid JSON-RPC notification", () => {
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
expect(isNotification(msg)).toBe(true);
});
test("returns true for notification with params", () => {
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
expect(isNotification(msg)).toBe(true);
});
test("returns false for request (has id)", () => {
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
expect(isNotification(msg)).toBe(false);
});
test("returns false for response (no method)", () => {
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
expect(isNotification(msg)).toBe(false);
});
});

View File

@@ -0,0 +1,174 @@
/**
* Self-signed certificate generation for HTTPS support
*/
import { X509Certificate } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir, networkInterfaces } from "node:os";
import { join } from "node:path";
import { generate } from "selfsigned";
/**
* Get all LAN IPv4 addresses
*/
export function getLanIPs(): string[] {
const ips: string[] = [];
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
// Skip internal (loopback) and non-IPv4 addresses
if (!net.internal && net.family === "IPv4") {
ips.push(net.address);
}
}
}
return ips;
}
/**
* Extract IP addresses from certificate's Subject Alternative Name (SAN)
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
*/
function extractSanIPs(x509: X509Certificate): string[] {
const san = x509.subjectAltName;
if (!san) return [];
const ips: string[] = [];
// Parse "IP Address:x.x.x.x" entries from SAN string
const parts = san.split(", ");
for (const part of parts) {
const match = part.match(/^IP Address:(.+)$/);
if (match && match[1]) {
ips.push(match[1]);
}
}
return ips;
}
const CERT_DIR = join(homedir(), ".acp-proxy");
const KEY_PATH = join(CERT_DIR, "key.pem");
const CERT_PATH = join(CERT_DIR, "cert.pem");
// Certificate validity in days
const CERT_VALIDITY_DAYS = 365;
export interface TlsOptions {
key: string;
cert: string;
}
/**
* Get or generate self-signed certificate
* Certificates are cached in ~/.acp-proxy/
*/
export async function getOrCreateCertificate(): Promise<TlsOptions> {
// Ensure directory exists
if (!existsSync(CERT_DIR)) {
mkdirSync(CERT_DIR, { recursive: true });
}
// Check if certificates already exist and are still valid
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
const certPem = readFileSync(CERT_PATH, "utf-8");
const keyPem = readFileSync(KEY_PATH, "utf-8");
try {
const x509 = new X509Certificate(certPem);
const validTo = new Date(x509.validTo);
const now = new Date();
// Check if cert is expired or will expire within 7 days
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry <= 7) {
// Certificate expired or expiring soon
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
} else {
// Check if current LAN IPs are in the certificate's SAN
const currentLanIPs = getLanIPs();
const certSanIPs = extractSanIPs(x509);
// Check if all current LAN IPs are covered by the certificate
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
if (missingIPs.length === 0) {
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
console.log(` Valid for ${daysUntilExpiry} more days`);
return { key: keyPem, cert: certPem };
}
// LAN IP changed, regenerate
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
}
} catch {
// Failed to parse certificate, regenerate
console.log(`⚠️ Invalid certificate, regenerating...`);
}
}
// Generate new self-signed certificate
console.log(`🔐 Generating self-signed certificate...`);
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
// Calculate expiry date
const notAfterDate = new Date();
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
// Build altNames: localhost + loopback + all LAN IPs
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
{ type: 7, ip: "::1" },
];
// Add all current LAN IPs
const lanIPs = getLanIPs();
for (const ip of lanIPs) {
altNames.push({ type: 7, ip });
}
if (lanIPs.length > 0) {
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
}
const pems = await generate(attrs, {
keySize: 2048,
notAfterDate,
algorithm: "sha256",
extensions: [
{
name: "basicConstraints",
cA: true,
},
{
name: "keyUsage",
keyCertSign: true,
digitalSignature: true,
keyEncipherment: true,
},
{
name: "extKeyUsage",
serverAuth: true,
},
{
name: "subjectAltName",
altNames,
},
],
});
// Save certificates
writeFileSync(KEY_PATH, pems.private);
writeFileSync(CERT_PATH, pems.cert);
console.log(`✅ Certificate saved to ${CERT_DIR}`);
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
return {
key: pems.private,
cert: pems.cert,
};
}

View File

@@ -0,0 +1,18 @@
import { buildApplication } from "@stricli/core";
import { createRequire } from "node:module";
import { command } from "./command.js";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json") as { version: string };
export const app = buildApplication(command, {
name: "acp-link",
versionInfo: {
currentVersion: pkg.version,
},
scanner: {
caseStyle: "allow-kebab-for-camel",
allowArgumentEscapeSequence: true,
},
});

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env node
import { run } from "@stricli/core";
import { app } from "./app.js";
import { buildContext } from "./context.js";
await run(app, process.argv.slice(2), buildContext());

View File

@@ -0,0 +1,90 @@
import { buildCommand, numberParser } from "@stricli/core";
import type { LocalContext } from "./context.js";
export const command = buildCommand({
docs: {
brief: "Start the ACP proxy server",
fullDescription:
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
"Use -- to pass arguments to the agent:\n" +
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
},
parameters: {
flags: {
port: {
kind: "parsed",
parse: numberParser,
brief: "Port to listen on",
default: "9315",
},
host: {
kind: "parsed",
parse: String,
brief: "Host to bind to (use 0.0.0.0 for remote access)",
default: "localhost",
},
debug: {
kind: "boolean",
brief: "Enable debug logging to file",
default: false,
},
"no-auth": {
kind: "boolean",
brief: "DANGEROUS: Disable authentication (not recommended)",
default: false,
},
https: {
kind: "boolean",
brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false,
},
},
positional: {
kind: "array",
parameter: {
brief: "Agent command and arguments (use -- before agent flags)",
parse: String,
placeholder: "command",
},
minimum: 1,
},
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
...args: readonly string[]
) {
const port = flags.port;
const host = flags.host;
const debug = flags.debug;
const noAuth = flags["no-auth"];
const https = flags.https;
const [command, ...agentArgs] = args;
const cwd = process.cwd();
// Determine auth token
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
let token: string | undefined;
if (noAuth) {
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
token = undefined;
} else {
token = process.env.ACP_AUTH_TOKEN;
if (!token) {
// Auto-generate random token
const { randomBytes } = await import("node:crypto");
token = randomBytes(32).toString("hex");
}
}
// Initialize logger
const { initLogger } = await import("../logger.js");
initLogger({ debug });
// Import and run the server
const { startServer } = await import("../server.js");
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
},
});

View File

@@ -0,0 +1,10 @@
import type { CommandContext } from "@stricli/core";
export interface LocalContext extends CommandContext {}
export function buildContext(): LocalContext {
return {
process,
};
}

View File

@@ -0,0 +1,83 @@
import pino from "pino";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
let rootLogger: pino.Logger;
export interface LoggerConfig {
debug: boolean;
logDir?: string;
}
/** Pretty-print config for console output */
const PRETTY_CONFIG = {
colorize: true,
translateTime: "SYS:HH:MM:ss.l",
ignore: "pid,hostname",
} as const;
export function initLogger(config: LoggerConfig): pino.Logger {
const { debug, logDir } = config;
if (debug) {
const dir = logDir || join(process.cwd(), ".acp-proxy");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const now = new Date();
const timestamp = now.toISOString()
.replace(/T/, "_")
.replace(/:/g, "-")
.replace(/\..+/, "");
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
// Debug mode: JSON to file + pretty to console (multistream)
rootLogger = pino(
{
level: "trace",
timestamp: pino.stdTimeFunctions.isoTime,
},
pino.transport({
targets: [
{ target: "pino/file", options: { destination: logFile } },
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
],
}),
);
console.log(`📝 Debug logging enabled: ${logFile}`);
} else {
rootLogger = pino(
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
pino.transport({
target: "pino-pretty",
options: { ...PRETTY_CONFIG, destination: 1 },
}),
);
}
return rootLogger;
}
/** Get the root logger (auto-creates a default one if not initialized). */
export function getLogger(): pino.Logger {
if (!rootLogger) {
rootLogger = pino(
{ level: "info" },
pino.transport({
target: "pino-pretty",
options: { ...PRETTY_CONFIG, destination: 1 },
}),
);
}
return rootLogger;
}
/**
* Create a child logger scoped to a module.
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
*/
export function createLogger(module: string): pino.Logger {
return getLogger().child({ module });
}

View File

@@ -0,0 +1,258 @@
import { createLogger } from "./logger.js";
export interface RcsUpstreamConfig {
rcsUrl: string; // e.g. "http://localhost:3000"
apiToken: string;
agentName: string;
channelGroupId?: string;
capabilities?: Record<string, unknown>;
maxSessions?: number;
}
/**
* RCS upstream client — connects acp-link to a Remote Control Server.
*
* Lifecycle:
* 1. connect() — opens WS to RCS
* 2. Sends register message
* 3. Waits for registered response
* 4. Forwards all ACP events via send()
* 5. Reconnects with exponential backoff on failure
*/
export class RcsUpstreamClient {
private static log = createLogger("rcs-upstream");
private ws: WebSocket | null = null;
private registered = false;
private reconnectAttempts = 0;
private closed = false;
private readonly maxReconnectDelay = 30_000;
private readonly baseReconnectDelay = 1_000;
/** Agent ID obtained from REST registration */
private agentId: string | null = null;
/** Session ID from REST registration (ACP agents auto-create a session) */
private sessionId: string | undefined;
/** Handler for incoming ACP messages from RCS relay */
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
constructor(private config: RcsUpstreamConfig) {}
/** Get the agent ID from REST registration */
getAgentId(): string | null {
return this.agentId;
}
/** Set handler for incoming ACP messages from RCS relay */
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
this.messageHandler = handler;
}
/** Register via REST API before establishing WS connection */
private async registerViaRest(): Promise<string> {
const baseUrl = this.config.rcsUrl
.replace(/^ws:\/\//, "http://")
.replace(/^wss:\/\//, "https://")
.replace(/\/acp\/ws.*$/, "")
.replace(/\/$/, "");
const url = `${baseUrl}/v1/environments/bridge`;
RcsUpstreamClient.log.info({ url }, "REST register");
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiToken}`,
},
body: JSON.stringify({
machine_name: this.config.agentName,
worker_type: "acp",
bridge_id: this.config.channelGroupId || undefined,
max_sessions: this.config.maxSessions,
capabilities: this.config.capabilities,
}),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`REST register failed (${resp.status}): ${text}`);
}
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
this.agentId = data.environment_id;
this.sessionId = data.session_id;
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
return data.environment_id;
}
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
private buildWsUrl(): string {
let raw = this.config.rcsUrl;
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
const url = new URL(raw);
const path = url.pathname.replace(/\/+$/, "");
if (!path || path === "/") {
url.pathname = "/acp/ws";
}
if (this.config.apiToken) {
url.searchParams.set("token", this.config.apiToken);
}
return url.toString();
}
/** Open connection to RCS: REST register → WS identify */
async connect(): Promise<void> {
if (this.closed) return;
// Step 1: REST registration
try {
await this.registerViaRest();
} catch (err) {
RcsUpstreamClient.log.error({ err }, "REST registration failed");
if (!this.closed) {
this.scheduleReconnect();
}
return;
}
// Step 2: WebSocket connection with identify
const wsUrl = this.buildWsUrl();
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
RcsUpstreamClient.log.debug("ws open — sending identify");
this.ws!.send(
JSON.stringify({
type: "identify",
agent_id: this.agentId,
}),
);
};
this.ws.onmessage = (event) => {
let data: Record<string, unknown>;
try {
data = JSON.parse(event.data as string);
} catch {
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
return;
}
if (data.type === "identified") {
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
this.registered = true;
this.reconnectAttempts = 0;
const webBase = this.config.rcsUrl
.replace(/^ws:\/\//, "http://")
.replace(/^wss:\/\//, "https://")
.replace(/\/acp\/ws.*$/, "")
.replace(/\/$/, "");
console.log();
if (this.sessionId) {
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
} else {
console.log(` 🔗 Dashboard: ${webBase}/code/`);
}
if (this.agentId) {
console.log(` Agent ID: ${this.agentId}`);
}
console.log();
resolve();
} else if (data.type === "registered") {
// Legacy fallback: server still uses old register flow
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
this.agentId = (data.agent_id as string) || this.agentId;
this.registered = true;
this.reconnectAttempts = 0;
resolve();
} else if (data.type === "error") {
RcsUpstreamClient.log.error({ message: data.message }, "server error");
if (!this.registered) {
reject(new Error(data.message as string));
}
} else if (data.type === "keep_alive") {
// ignore keepalive
} else {
// Forward ACP protocol messages to handler (for RCS relay support)
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
this.messageHandler?.(data);
}
};
this.ws.onerror = () => {
// onclose fires after onerror with the actual close code, so we log there
if (!this.registered) {
reject(new Error("WebSocket connection failed"));
}
};
this.ws.onclose = (event) => {
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
this.registered = false;
this.ws = null;
if (!this.closed) {
this.scheduleReconnect();
}
};
} catch (err) {
RcsUpstreamClient.log.error({ err }, "connect threw");
reject(err);
}
});
}
/** Send an ACP message to RCS for broadcast */
send(message: object): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
return;
}
try {
this.ws.send(JSON.stringify(message));
} catch (err) {
RcsUpstreamClient.log.error({ err }, "send failed");
}
}
/** Check if registered with RCS */
isRegistered(): boolean {
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
/** Close the RCS connection permanently */
async close(): Promise<void> {
this.closed = true;
this.registered = false;
if (this.ws) {
this.ws.close(1000, "client shutdown");
this.ws = null;
}
RcsUpstreamClient.log.info("closed");
}
private scheduleReconnect(): void {
if (this.closed) return;
const delay = Math.min(
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
this.maxReconnectDelay,
);
const jitter = delay * Math.random() * 0.2;
const actualDelay = delay + jitter;
this.reconnectAttempts++;
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
setTimeout(async () => {
if (this.closed) return;
try {
await this.connect();
} catch {
// connect() itself logs the error; nothing to add here
}
}, actualDelay);
}
}

View File

@@ -0,0 +1,895 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createServer as createHttpsServer } from "node:https";
import { Writable, Readable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createNodeWebSocket } from "@hono/node-ws";
import type { WSContext } from "hono/ws";
import type { WebSocket as RawWebSocket } from "ws";
import { createLogger } from "./logger.js";
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
export interface ServerConfig {
port: number;
host: string;
command: string;
args: string[];
cwd: string;
debug?: boolean;
token?: string;
https?: boolean;
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
permissionMode?: string;
}
// Pending permission request
interface PendingPermission {
resolve: (outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string }) => void;
timeout: ReturnType<typeof setTimeout>;
}
// PromptCapabilities from ACP protocol
// Reference: Zed's prompt_capabilities to check image support
interface PromptCapabilities {
audio?: boolean;
embeddedContext?: boolean;
image?: boolean;
}
// SessionModelState from ACP protocol
// Reference: Zed's AgentModelSelector reads from state.available_models
interface SessionModelState {
availableModels: Array<{
modelId: string;
name: string;
description?: string | null;
}>;
currentModelId: string;
}
// AgentCapabilities from ACP protocol
// Reference: Zed's AcpConnection.agent_capabilities
// Matches SDK's AgentCapabilities exactly
interface AgentCapabilities {
_meta?: Record<string, unknown> | null;
loadSession?: boolean;
mcpCapabilities?: {
_meta?: Record<string, unknown> | null;
clientServers?: boolean;
};
promptCapabilities?: PromptCapabilities;
sessionCapabilities?: {
_meta?: Record<string, unknown> | null;
fork?: Record<string, unknown> | null;
list?: Record<string, unknown> | null;
resume?: Record<string, unknown> | null;
};
}
// Track connected clients and their agent connections
interface ClientState {
process: ChildProcess | null;
connection: acp.ClientSideConnection | null;
sessionId: string | null;
pendingPermissions: Map<string, PendingPermission>;
agentCapabilities: AgentCapabilities | null;
promptCapabilities: PromptCapabilities | null;
modelState: SessionModelState | null;
isAlive: boolean;
}
// Module-level state (set when server starts)
let AGENT_COMMAND: string;
let AGENT_ARGS: string[];
let AGENT_CWD: string;
let SERVER_PORT: number;
let SERVER_HOST: string;
let AUTH_TOKEN: string | undefined;
let DEFAULT_PERMISSION_MODE: string | undefined;
const clients = new Map<WSContext, ClientState>();
// Module-scoped child loggers
const logWs = createLogger("ws");
const logAgent = createLogger("agent");
const logSession = createLogger("session");
const logPrompt = createLogger("prompt");
const logPerm = createLogger("perm");
const logRelay = createLogger("relay");
const logServer = createLogger("server");
// RCS upstream client (optional — enabled via ACP_RCS_URL env var)
let rcsUpstream: RcsUpstreamClient | null = null;
/**
* Create a virtual WSContext for RCS relay messages.
* Responses via send() go to RCS upstream (not a local WS).
*/
function createRelayWs(): WSContext {
return {
get readyState() { return 1; }, // always OPEN
send: () => {}, // no-op — responses go through rcsUpstream.send()
close: () => {},
raw: null,
isInner: false,
url: "",
origin: "",
protocol: "",
} as unknown as WSContext;
}
// Permission request timeout (5 minutes)
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
// Heartbeat interval for WebSocket ping/pong (30 seconds)
const HEARTBEAT_INTERVAL_MS = 30_000;
// Generate unique request ID
function generateRequestId(): string {
return `perm_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
// Send a message to the WebSocket client (and optionally forward to RCS upstream)
function send(ws: WSContext, type: string, payload?: unknown): void {
if (ws.readyState === 1) {
// WebSocket.OPEN
ws.send(JSON.stringify({ type, payload }));
}
// Forward to RCS upstream if connected
if (rcsUpstream?.isRegistered()) {
rcsUpstream.send({ type, payload });
}
}
// Create a Client implementation that forwards events to WebSocket
function createClient(ws: WSContext, clientState: ClientState): acp.Client {
return {
async requestPermission(params) {
const requestId = generateRequestId();
logPerm.debug({ requestId, title: params.toolCall.title }, "requested");
const outcomePromise = new Promise<{ outcome: "cancelled" } | { outcome: "selected"; optionId: string }>((resolve) => {
const timeout = setTimeout(() => {
logPerm.warn({ requestId }, "timed out");
clientState.pendingPermissions.delete(requestId);
resolve({ outcome: "cancelled" });
}, PERMISSION_TIMEOUT_MS);
clientState.pendingPermissions.set(requestId, { resolve, timeout });
});
send(ws, "permission_request", {
requestId,
sessionId: params.sessionId,
options: params.options,
toolCall: params.toolCall,
});
const outcome = await outcomePromise;
logPerm.debug({ requestId, outcome: outcome.outcome }, "resolved");
return { outcome };
},
async sessionUpdate(params) {
send(ws, "session_update", params);
},
async readTextFile(params) {
logWs.debug({ path: params.path }, "readTextFile");
return { content: "" };
},
async writeTextFile(params) {
logWs.debug({ path: params.path }, "writeTextFile");
return {};
},
};
}
// Handle permission response from client
function handlePermissionResponse(ws: WSContext, payload: { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } }): void {
const state = clients.get(ws);
if (!state) {
logPerm.warn("response from unknown client");
return;
}
const pending = state.pendingPermissions.get(payload.requestId);
if (!pending) {
logPerm.warn({ requestId: payload.requestId }, "response for unknown request");
return;
}
clearTimeout(pending.timeout);
state.pendingPermissions.delete(payload.requestId);
pending.resolve(payload.outcome);
}
// Cancel all pending permissions for a client (called on disconnect)
function cancelPendingPermissions(clientState: ClientState): void {
for (const [requestId, pending] of clientState.pendingPermissions) {
logPerm.debug({ requestId }, "cancelled on disconnect");
clearTimeout(pending.timeout);
pending.resolve({ outcome: "cancelled" });
}
clientState.pendingPermissions.clear();
}
async function handleConnect(ws: WSContext): Promise<void> {
const state = clients.get(ws);
if (!state) return;
// If already connected to a running agent, just resend status
// This handles frontend reconnections without restarting the agent process
// Check both .killed and .exitCode to detect crashed processes
if (state.connection && state.process && !state.process.killed && state.process.exitCode === null) {
logAgent.info("already connected, resending status");
send(ws, "status", {
connected: true,
agentInfo: { name: AGENT_COMMAND },
capabilities: state.agentCapabilities,
});
return;
}
// Kill existing process if any (only if not healthy)
if (state.process) {
cancelPendingPermissions(state);
state.process.kill();
state.process = null;
state.connection = null;
}
try {
logAgent.info({ command: AGENT_COMMAND, args: AGENT_ARGS }, "spawning");
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
cwd: AGENT_CWD,
stdio: ["pipe", "pipe", "inherit"],
});
state.process = agentProcess;
// Clean up state when agent process exits unexpectedly
agentProcess.on("exit", (code) => {
logAgent.info({ exitCode: code }, "agent process exited");
// Only clear if this is still the current process
if (state.process === agentProcess) {
state.process = null;
state.connection = null;
state.sessionId = null;
}
});
const input = Writable.toWeb(agentProcess.stdin!) as unknown as WritableStream<Uint8Array>;
const output = Readable.toWeb(agentProcess.stdout!) as unknown as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(input, output);
const connection = new acp.ClientSideConnection(
(_agent) => createClient(ws, state),
stream,
);
state.connection = connection;
const initResult = await connection.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
clientInfo: { name: "zed", version: "1.0.0" },
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
const agentCaps = initResult.agentCapabilities;
state.agentCapabilities = agentCaps ? {
_meta: agentCaps._meta,
loadSession: agentCaps.loadSession,
mcpCapabilities: agentCaps.mcpCapabilities,
promptCapabilities: agentCaps.promptCapabilities,
sessionCapabilities: agentCaps.sessionCapabilities,
} : null;
state.promptCapabilities = agentCaps?.promptCapabilities ?? null;
logAgent.info({
protocolVersion: initResult.protocolVersion,
loadSession: !!state.agentCapabilities?.loadSession,
sessionList: !!state.agentCapabilities?.sessionCapabilities?.list,
sessionResume: !!state.agentCapabilities?.sessionCapabilities?.resume,
hasMcp: !!state.agentCapabilities?.mcpCapabilities,
}, "initialized");
send(ws, "status", {
connected: true,
agentInfo: initResult.agentInfo,
capabilities: state.agentCapabilities,
});
connection.closed.then(() => {
logAgent.info("connection closed");
state.connection = null;
state.sessionId = null;
send(ws, "status", { connected: false });
});
} catch (error) {
logAgent.error({ error: (error as Error).message }, "connect failed");
send(ws, "error", { message: `Failed to connect: ${(error as Error).message}` });
}
}
async function handleNewSession(
ws: WSContext,
params: { cwd?: string; permissionMode?: string },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection) {
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleNewSession: not connected to agent");
send(ws, "error", { message: "Not connected to agent" });
return;
}
try {
const sessionCwd = params.cwd || AGENT_CWD;
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
const result = await state.connection.newSession({
cwd: sessionCwd,
mcpServers: [],
...(permissionMode ? { _meta: { permissionMode } } : {}),
});
state.sessionId = result.sessionId;
state.modelState = result.models ?? null;
logSession.info({ sessionId: result.sessionId, cwd: sessionCwd, hasModels: !!result.models }, "created");
send(ws, "session_created", {
...result,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
});
} catch (error) {
logSession.error({ error: (error as Error).message }, "create failed");
send(ws, "error", { message: `Failed to create session: ${(error as Error).message}` });
}
}
// ============================================================================
// Session History Operations
// Reference: Zed's AgentConnection trait - list_sessions, load_session, resume_session
// ============================================================================
async function handleListSessions(
ws: WSContext,
params: { cwd?: string; cursor?: string },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection) {
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleListSessions: not connected to agent");
send(ws, "error", { message: "Not connected to agent" });
return;
}
if (!state.agentCapabilities?.sessionCapabilities?.list) {
send(ws, "error", { message: "Listing sessions is not supported by this agent" });
return;
}
try {
const result = await state.connection.listSessions({
cwd: params.cwd,
cursor: params.cursor,
});
const MAX_SESSIONS = 20;
const sessions = result.sessions.slice(0, MAX_SESSIONS);
logSession.info({ total: result.sessions.length, returned: sessions.length, hasMore: !!result.nextCursor }, "listed");
send(ws, "session_list", {
sessions: sessions.map((s: acp.SessionInfo) => ({
_meta: s._meta,
cwd: s.cwd,
sessionId: s.sessionId,
title: s.title,
updatedAt: s.updatedAt,
})),
nextCursor: result.nextCursor,
_meta: result._meta,
});
} catch (error) {
logSession.error({ error: (error as Error).message }, "list failed");
send(ws, "error", { message: `Failed to list sessions: ${(error as Error).message}` });
}
}
async function handleLoadSession(
ws: WSContext,
params: { sessionId: string; cwd?: string },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection) {
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleLoadSession: not connected to agent");
send(ws, "error", { message: "Not connected to agent" });
return;
}
if (!state.agentCapabilities?.loadSession) {
send(ws, "error", { message: "Loading sessions is not supported by this agent" });
return;
}
try {
const sessionCwd = params.cwd || AGENT_CWD;
const sessionId = params.sessionId;
const result = await state.connection.loadSession({
sessionId,
cwd: sessionCwd,
mcpServers: [],
});
state.sessionId = sessionId;
state.modelState = result.models ?? null;
logSession.info({ sessionId, cwd: sessionCwd }, "loaded");
send(ws, "session_loaded", {
sessionId,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
});
} catch (error) {
logSession.error({ error: (error as Error).message }, "load failed");
send(ws, "error", { message: `Failed to load session: ${(error as Error).message}` });
}
}
async function handleResumeSession(
ws: WSContext,
params: { sessionId: string; cwd?: string },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection) {
logAgent.warn({ hasState: !!state, hasProcess: !!state?.process, processKilled: state?.process?.killed, exitCode: state?.process?.exitCode }, "handleResumeSession: not connected to agent");
send(ws, "error", { message: "Not connected to agent" });
return;
}
if (!state.agentCapabilities?.sessionCapabilities?.resume) {
send(ws, "error", { message: "Resuming sessions is not supported by this agent" });
return;
}
try {
const sessionCwd = params.cwd || AGENT_CWD;
const sessionId = params.sessionId;
const result = await state.connection.unstable_resumeSession({
sessionId,
cwd: sessionCwd,
});
state.sessionId = sessionId;
state.modelState = result.models ?? null;
logSession.info({ sessionId, cwd: sessionCwd }, "resumed");
send(ws, "session_resumed", {
sessionId,
promptCapabilities: state.promptCapabilities,
models: state.modelState,
});
} catch (error) {
logSession.error({ error: (error as Error).message }, "resume failed");
send(ws, "error", { message: `Failed to resume session: ${(error as Error).message}` });
}
}
// Reference: Zed's AcpThread.send() forwards Vec<acp::ContentBlock> to agent
async function handlePrompt(
ws: WSContext,
params: { content: ContentBlock[] },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection || !state.sessionId) {
send(ws, "error", { message: "No active session" });
return;
}
try {
const firstText = params.content.find(b => b.type === "text")?.text;
const images = params.content.filter(b => b.type === "image");
logPrompt.debug({
text: firstText?.slice(0, 100),
imageCount: images.length,
blockCount: params.content.length,
}, "sending");
const result = await state.connection.prompt({
sessionId: state.sessionId,
prompt: params.content as acp.ContentBlock[],
});
logPrompt.info({ stopReason: result.stopReason }, "completed");
send(ws, "prompt_complete", result);
} catch (error) {
logPrompt.error({ error: (error as Error).message }, "failed");
send(ws, "error", { message: `Prompt failed: ${(error as Error).message}` });
}
}
function handleDisconnect(ws: WSContext): void {
const state = clients.get(ws);
if (!state) return;
if (state.process) {
state.process.kill();
state.process = null;
}
state.connection = null;
state.sessionId = null;
send(ws, "status", { connected: false });
}
// Handle cancel request from client
async function handleCancel(ws: WSContext): Promise<void> {
const state = clients.get(ws);
if (!state?.connection || !state.sessionId) {
logWs.warn("cancel requested but no active session");
return;
}
logSession.info({ sessionId: state.sessionId }, "cancel requested");
cancelPendingPermissions(state);
try {
await state.connection.cancel({ sessionId: state.sessionId });
logSession.info({ sessionId: state.sessionId }, "cancel sent");
} catch (error) {
logSession.error({ error: (error as Error).message }, "cancel failed");
}
}
// Reference: Zed's AgentModelSelector.select_model() calls connection.set_session_model()
async function handleSetSessionModel(
ws: WSContext,
params: { modelId: string },
): Promise<void> {
const state = clients.get(ws);
if (!state?.connection || !state.sessionId) {
send(ws, "error", { message: "No active session" });
return;
}
if (!state.modelState) {
send(ws, "error", { message: "Model selection not supported by this agent" });
return;
}
try {
logSession.info({ sessionId: state.sessionId, modelId: params.modelId }, "setting model");
await state.connection.unstable_setSessionModel({
sessionId: state.sessionId,
modelId: params.modelId,
});
state.modelState = { ...state.modelState, currentModelId: params.modelId };
send(ws, "model_changed", { modelId: params.modelId });
logSession.info({ modelId: params.modelId }, "model changed");
} catch (error) {
logSession.error({ error: (error as Error).message }, "set model failed");
send(ws, "error", { message: `Failed to set model: ${(error as Error).message}` });
}
}
// ContentBlock type matching @agentclientprotocol/sdk
interface ContentBlock {
type: string;
text?: string;
data?: string;
mimeType?: string;
uri?: string;
name?: string;
}
interface ProxyMessage {
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
}
export async function startServer(config: ServerConfig): Promise<void> {
const { port, host, command, args, cwd, token, https } = config;
// Set module-level config
AGENT_COMMAND = command;
AGENT_ARGS = args;
AGENT_CWD = cwd;
SERVER_PORT = port;
SERVER_HOST = host;
AUTH_TOKEN = token;
DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE;
// Initialize RCS upstream client if configured
const rcsUrl = process.env.ACP_RCS_URL;
const rcsToken = process.env.ACP_RCS_TOKEN;
if (rcsUrl) {
rcsUpstream = new RcsUpstreamClient({
rcsUrl,
apiToken: rcsToken || "",
agentName: command,
maxSessions: 1,
});
const relayWs = createRelayWs();
const relayState: ClientState = {
process: null,
connection: null,
sessionId: null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
};
clients.set(relayWs, relayState);
rcsUpstream.setMessageHandler(async (msg) => {
try {
logRelay.debug({ type: msg.type }, "processing");
switch (msg.type) {
case "connect":
await handleConnect(relayWs);
break;
case "disconnect":
handleDisconnect(relayWs);
break;
case "new_session":
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
break;
case "prompt":
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
break;
case "permission_response":
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
break;
case "cancel":
await handleCancel(relayWs);
break;
case "set_session_model":
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
break;
case "list_sessions":
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
break;
case "load_session":
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
break;
case "resume_session":
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
break;
case "ping":
send(relayWs, "pong");
break;
default:
logRelay.warn({ type: msg.type }, "unknown message type");
}
} catch (error) {
logRelay.error({ error: (error as Error).message }, "handler error");
}
});
rcsUpstream.connect().catch((err) => {
logRelay.warn({ error: (err as Error).message }, "initial connection failed");
});
logRelay.info({ url: rcsUrl }, "upstream enabled");
}
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "ok" });
});
// WebSocket endpoint with token validation
app.get(
"/ws",
upgradeWebSocket((c) => {
if (AUTH_TOKEN) {
const url = new URL(c.req.url);
const providedToken = url.searchParams.get("token");
if (providedToken !== AUTH_TOKEN) {
logWs.warn("connection rejected: invalid token");
return {
onOpen(_event, ws) {
ws.close(4001, "Unauthorized: Invalid token");
},
onMessage() {},
onClose() {},
};
}
}
return {
onOpen(_event, ws) {
logWs.info("client connected");
const state: ClientState = {
process: null,
connection: null,
sessionId: null,
pendingPermissions: new Map(),
agentCapabilities: null,
promptCapabilities: null,
modelState: null,
isAlive: true,
};
clients.set(ws, state);
const rawWs = ws.raw as RawWebSocket;
rawWs.on("pong", () => {
state.isAlive = true;
});
},
async onMessage(event, ws) {
try {
const data = JSON.parse(event.data.toString());
logWs.debug({ type: data.type }, "received");
switch (data.type) {
case "connect":
await handleConnect(ws);
break;
case "disconnect":
handleDisconnect(ws);
break;
case "new_session":
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
break;
case "prompt":
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
break;
case "permission_response":
handlePermissionResponse(ws, data.payload);
break;
case "cancel":
await handleCancel(ws);
break;
case "set_session_model":
await handleSetSessionModel(ws, data.payload as { modelId: string });
break;
case "list_sessions":
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
break;
case "load_session":
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
break;
case "resume_session":
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
break;
case "ping":
send(ws, "pong");
break;
default:
send(ws, "error", { message: `Unknown message type: ${data.type}` });
}
} catch (error) {
logWs.error({ error: (error as Error).message }, "message error");
send(ws, "error", { message: `Error: ${(error as Error).message}` });
}
},
onClose(_event, ws) {
logWs.info("client disconnected");
const state = clients.get(ws);
if (state) {
cancelPendingPermissions(state);
}
handleDisconnect(ws);
clients.delete(ws);
},
};
}),
);
// Create server with optional HTTPS
let server;
if (https) {
const tlsOptions = await getOrCreateCertificate();
server = serve({
fetch: app.fetch,
port,
hostname: host,
createServer: createHttpsServer,
serverOptions: tlsOptions,
});
} else {
server = serve({ fetch: app.fetch, port, hostname: host });
}
injectWebSocket(server);
// Heartbeat: periodically ping all connected clients
setInterval(() => {
for (const [ws, state] of clients) {
// Skip virtual relay connections (no raw socket, always alive)
if (!ws.raw && state.isAlive) continue;
if (!ws.raw) {
// Connection already closed, clean up
clients.delete(ws);
continue;
}
if (!state.isAlive) {
logWs.info("heartbeat timeout, terminating");
(ws.raw as RawWebSocket).terminate();
continue;
}
state.isAlive = false;
(ws.raw as RawWebSocket).ping();
}
}, HEARTBEAT_INTERVAL_MS);
// Protocol strings based on HTTPS mode
const wsProtocol = https ? "wss" : "ws";
// Get actual LAN IP when binding to 0.0.0.0
let displayHost = host;
if (host === "0.0.0.0") {
const lanIPs = getLanIPs();
displayHost = lanIPs[0] || "localhost";
}
// Build URLs
const localWsUrl = `${wsProtocol}://localhost:${port}/ws`;
const networkWsUrl = `${wsProtocol}://${displayHost}:${port}/ws`;
// Print startup banner
console.log();
console.log(` 🚀 ACP Proxy Server${https ? " (HTTPS)" : ""}`);
console.log();
console.log(` Connection:`);
if (host === "0.0.0.0") {
console.log(` URL: ${networkWsUrl}`);
} else {
console.log(` URL: ${localWsUrl}`);
}
if (AUTH_TOKEN) {
console.log(` Token: ${AUTH_TOKEN}`);
}
console.log();
if (!AUTH_TOKEN) {
console.log(` ⚠️ Authentication disabled (--no-auth)`);
console.log();
}
const agentDisplay = AGENT_ARGS.length > 0
? `${AGENT_COMMAND} ${AGENT_ARGS.join(" ")}`
: AGENT_COMMAND;
console.log(` 📦 Agent: ${agentDisplay}`);
console.log(` CWD: ${AGENT_CWD}`);
console.log();
console.log(` Press Ctrl+C to stop`);
console.log();
logServer.info({
port,
host,
https,
wsEndpoint: `${wsProtocol}://${displayHost}:${port}/ws`,
agent: AGENT_COMMAND,
agentArgs: AGENT_ARGS,
cwd: AGENT_CWD,
authEnabled: !!AUTH_TOKEN,
}, "started");
// Keep the server running
await new Promise(() => {});
}
// Graceful shutdown — close RCS upstream on process exit
process.on("SIGINT", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});
process.on("SIGTERM", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});

View File

@@ -0,0 +1,150 @@
// JSON-RPC 2.0 Types
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: unknown;
}
export interface JsonRpcResponse {
jsonrpc: "2.0";
id: string | number;
result?: unknown;
error?: JsonRpcError;
}
export interface JsonRpcNotification {
jsonrpc: "2.0";
method: string;
params?: unknown;
}
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
export type JsonRpcMessage =
| JsonRpcRequest
| JsonRpcResponse
| JsonRpcNotification;
// Helper to check message types
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
return "method" in msg && "id" in msg;
}
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
return "id" in msg && !("method" in msg);
}
export function isNotification(
msg: JsonRpcMessage,
): msg is JsonRpcNotification {
return "method" in msg && !("id" in msg);
}
// ACP Protocol Types
// Client -> Server messages (from extension to proxy)
export interface ProxyConnectParams {
command: string; // Command to launch the agent (e.g., "claude-agent")
args?: string[]; // Optional arguments
cwd?: string; // Working directory for the agent
}
export interface ProxyMessage {
type: "connect" | "disconnect" | "message";
payload?: ProxyConnectParams | JsonRpcMessage;
}
// Server -> Client messages (from proxy to extension)
export interface ProxyStatus {
type: "status";
connected: boolean;
agentInfo?: {
name?: string;
version?: string;
};
error?: string;
}
export interface ProxyAgentMessage {
type: "agent_message";
payload: JsonRpcMessage;
}
export interface ProxyError {
type: "error";
message: string;
code?: string;
}
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
// ACP Initialization
export interface InitializeParams {
protocolVersion: string;
clientInfo: {
name: string;
version: string;
};
capabilities?: ClientCapabilities;
}
export interface ClientCapabilities {
streaming?: boolean;
toolApproval?: boolean;
}
export interface InitializeResult {
protocolVersion: string;
serverInfo: {
name: string;
version: string;
};
capabilities?: ServerCapabilities;
}
export interface ServerCapabilities {
streaming?: boolean;
tools?: boolean;
}
// ACP Session
export interface SessionSetupParams {
sessionId?: string;
context?: SessionContext;
}
export interface SessionContext {
workingDirectory?: string;
files?: string[];
}
// ACP Prompt
export interface PromptParams {
sessionId: string;
messages: PromptMessage[];
}
export interface PromptMessage {
role: "user" | "assistant";
content: string | ContentPart[];
}
export interface ContentPart {
type: "text" | "image" | "file";
text?: string;
data?: string;
mimeType?: string;
path?: string;
}
// Content streaming notification
export interface ContentNotification {
sessionId: string;
content: string;
done?: boolean;
}

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ES2022",
"module": "NodeNext",
"moduleDetection": "force",
"allowJs": true,
// Node.js module resolution
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
// Output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"resolveJsonModule": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"]
}

View File

@@ -3,8 +3,11 @@ import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
const inputSchema = lazySchema(() =>
z.strictObject({
duration_seconds: z
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
type SleepOutput = { slept_seconds: number; interrupted: boolean }
function isProactiveAutomationEnabled(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return false
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function isProactiveSleepAllowed(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return true
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function hasQueuedWakeSignal(): boolean {
const queue =
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
return queue.hasCommandsInQueue()
}
function shouldInterruptSleep(): boolean {
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
}
export const SleepTool = buildTool({
name: SLEEP_TOOL_NAME,
searchHint: 'wait pause sleep rest idle duration timer',
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
isReadOnly() {
return true
},
interruptBehavior() {
return 'cancel'
},
userFacingName() {
return SLEEP_TOOL_NAME
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
},
async call(input: SleepInput, context) {
// Refuse to sleep when proactive mode is off — prevents the model from
// re-issuing Sleep after an interruption caused by /proactive disable.
if (feature('PROACTIVE') || feature('KAIROS')) {
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
if (!mod.isProactiveActive()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
// Don't enter sleep if proactive was disabled or new work arrived while
// the model was deciding to wait.
if (shouldInterruptSleep()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
}
const { duration_seconds } = input
const startTime = Date.now()
const sleepUntil = startTime + duration_seconds * 1000
if (isProactiveAutomationEnabled()) {
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: sleepUntil,
})
}
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, duration_seconds * 1000)
let timer: ReturnType<typeof setTimeout> | null = null
let wakeCheck: ReturnType<typeof setInterval> | null = null
let settled = false
const cleanup = () => {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
if (wakeCheck !== null) {
clearInterval(wakeCheck)
wakeCheck = null
}
context.abortController.signal.removeEventListener('abort', onAbort)
}
const finish = () => {
if (settled) return
settled = true
cleanup()
resolve()
}
const interrupt = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('interrupted'))
}
const onAbort = () => {
interrupt()
}
timer = setTimeout(finish, duration_seconds * 1000)
// Abort via user interrupt
context.abortController.signal.addEventListener(
'abort',
() => {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
},
{ once: true },
)
if (context.abortController.signal.aborted) {
interrupt()
return
}
context.abortController.signal.addEventListener('abort', onAbort, {
once: true,
})
// Poll proactive state — if deactivated mid-sleep, interrupt early
// so the user doesn't have to wait for the full duration.
const proactiveCheck =
feature('PROACTIVE') || feature('KAIROS')
? setInterval(() => {
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
if (!mod.isProactiveActive()) {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
}
}, 500)
: (null as unknown as ReturnType<typeof setInterval>)
// Poll proactive state and the shared command queue so new work can
// wake Sleep without waiting for the full duration.
wakeCheck = setInterval(() => {
if (shouldInterruptSleep()) {
interrupt()
}
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
})
return {
data: {
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
interrupted: true,
},
}
} finally {
notifyAutomationStateChanged(
isProactiveAutomationEnabled()
? {
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
}
: null,
)
}
},
})

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { SleepTool } from '../SleepTool'
import {
enqueue,
getCommandQueue,
resetCommandQueue,
} from 'src/utils/messageQueueManager.js'
describe('SleepTool', () => {
beforeEach(() => {
resetCommandQueue()
})
test('declares cancel interrupt behavior', () => {
expect(SleepTool.interruptBehavior()).toBe('cancel')
})
test('wakes early when queued work arrives', async () => {
const sleepPromise = SleepTool.call(
{ duration_seconds: 10 },
{ abortController: new AbortController() } as any,
)
setTimeout(() => {
enqueue({
value: 'wake up',
mode: 'prompt',
})
}, 20)
const result = await sleepPromise
expect(result.data.interrupted).toBe(true)
expect(result.data.slept_seconds).toBeLessThan(10)
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: 'wake up',
mode: 'prompt',
})
})
})

View File

@@ -4,10 +4,21 @@ WORKDIR /app
ARG VERSION=0.1.0
# Copy package files for install
COPY packages/remote-control-server/package.json ./package.json
# Install all dependencies (including devDeps for vite build)
RUN bun install
# Copy source code
COPY packages/remote-control-server/src ./src
COPY packages/remote-control-server/tsconfig.json ./tsconfig.json
# Copy web frontend source and build it
COPY packages/remote-control-server/web ./web
RUN bun run build:web
# Build backend
RUN bun build src/index.ts --outfile=dist/server.js --target=bun \
--define "process.env.RCS_VERSION=\"${VERSION}\""
@@ -19,8 +30,9 @@ ENV RCS_VERSION=${VERSION}
WORKDIR /app
# Copy built artifacts
COPY --from=builder /app/dist/server.js ./dist/server.js
COPY packages/remote-control-server/web ./web
COPY --from=builder /app/web/dist ./web/dist
VOLUME /app/data

View File

@@ -99,6 +99,13 @@ volumes:
rcs-data:
```
## ACP 兼容的 remote-control
```sh
ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key acp-link ccb-bun -- --acp
```
## 反向代理配置
使用 Nginx 或 Caddy 反向代理时,需要支持 WebSocket 升级:

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -4,24 +4,60 @@
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"dev:web": "cd web && bunx vite",
"start": "bun run src/index.ts",
"build:web": "cd web && bun run build",
"build:web": "cd web && bunx vite build",
"preview:web": "cd web && bunx vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/react": "^3.0.170",
"ai": "^6.0.168",
"hono": "^4.7.0",
"uuid": "^11.0.0"
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"uuid": "^11.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.555.0",
"motion": "^12.29.2",
"nanoid": "^5.1.6",
"qr-scanner": "^1.4.2",
"radix-ui": "^1.4.3",
"react": "^19",
"react-dom": "^19",
"react-resizable-panels": "^4",
"shiki": "^3.17.0",
"streamdown": "^1.6.8",
"tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0"
"typescript": "^5.7.0",
"vite": "^6.0.0",
"tw-animate-css": "^1.4.0"
}
}

View File

@@ -0,0 +1,182 @@
import { describe, test, expect } from "bun:test";
import {
getAutomationStateSnapshot,
getAutomationStateEventPayload,
automationStatesEqual,
} from "../services/automationState";
import type { AutomationStateResponse } from "../types/api";
// =============================================================================
// normalizeAutomationState (via getAutomationStateSnapshot)
// =============================================================================
describe("normalizeAutomationState", () => {
test("returns undefined when metadata has no automation_state key", () => {
expect(getAutomationStateSnapshot({})).toBeUndefined();
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined();
expect(getAutomationStateSnapshot(null)).toBeUndefined();
expect(getAutomationStateSnapshot(undefined)).toBeUndefined();
});
test("returns disabled state for null automation_state", () => {
const result = getAutomationStateSnapshot({ automation_state: null });
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
test("returns disabled state for non-object automation_state", () => {
for (const val of ["string", 123, true, []]) {
const result = getAutomationStateSnapshot({ automation_state: val });
expect(result?.enabled).toBe(false);
}
});
test("normalizes enabled: true correctly", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true } });
expect(result?.enabled).toBe(true);
});
test("normalizes enabled to false for non-true values", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } });
expect(result?.enabled).toBe(false);
});
test("accepts phase: standby", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } });
expect(result?.phase).toBe("standby");
});
test("accepts phase: sleeping", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } });
expect(result?.phase).toBe("sleeping");
});
test("rejects invalid phase values", () => {
for (const phase of ["running", "idle", "active", "", null]) {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } });
expect(result?.phase).toBeNull();
}
});
test("normalizes next_tick_at as number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } });
expect(result?.next_tick_at).toBe(12345);
});
test("normalizes next_tick_at as null for non-number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } });
expect(result?.next_tick_at).toBeNull();
});
test("normalizes sleep_until as number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } });
expect(result?.sleep_until).toBe(99999);
});
test("normalizes sleep_until as null for non-number", () => {
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } });
expect(result?.sleep_until).toBeNull();
});
test("fully normalizes a complete valid state", () => {
const result = getAutomationStateSnapshot({
automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 },
});
expect(result).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: 100,
sleep_until: 200,
});
});
});
// =============================================================================
// getAutomationStateEventPayload
// =============================================================================
describe("getAutomationStateEventPayload", () => {
test("returns disabled default when no automation_state in metadata", () => {
const result = getAutomationStateEventPayload({});
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
test("returns disabled default for null metadata", () => {
const result = getAutomationStateEventPayload(null);
expect(result).toEqual({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
});
test("returns normalized state when automation_state present", () => {
const result = getAutomationStateEventPayload({
automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 },
});
expect(result).toEqual({
enabled: true,
phase: "standby",
next_tick_at: 50,
sleep_until: 60,
});
});
test("returns a new object each call (not frozen reference)", () => {
const a = getAutomationStateEventPayload({});
const b = getAutomationStateEventPayload({});
expect(a).toEqual(b);
expect(a).not.toBe(b);
});
});
// =============================================================================
// automationStatesEqual
// =============================================================================
describe("automationStatesEqual", () => {
const base: AutomationStateResponse = {
enabled: true,
phase: "standby",
next_tick_at: 100,
sleep_until: 200,
};
test("returns true for identical states", () => {
expect(automationStatesEqual(base, { ...base })).toBe(true);
});
test("returns false when enabled differs", () => {
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false);
});
test("returns false when phase differs", () => {
expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false);
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false);
});
test("returns false when next_tick_at differs", () => {
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false);
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false);
});
test("returns false when sleep_until differs", () => {
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false);
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false);
});
test("returns true when both are disabled defaults", () => {
const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null };
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true);
});
});

View File

@@ -0,0 +1,256 @@
import { describe, test, expect } from "bun:test";
import { toClientPayload } from "../transport/client-payload";
import type { SessionEvent } from "../transport/event-bus";
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): SessionEvent {
return {
id: "evt-1",
payload: null,
direction: "inbound",
seqNum: 1,
createdAt: Date.now(),
...overrides,
};
}
// =============================================================================
// user / user_message
// =============================================================================
describe("toClientPayload — user message", () => {
test("maps user type with content", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-1",
payload: { content: "hello" },
});
const result = toClientPayload(event);
expect(result.type).toBe("user");
expect(result.session_id).toBe("sess-1");
expect((result as any).message.role).toBe("user");
expect((result as any).message.content).toBe("hello");
});
test("maps user_message type same as user", () => {
const event = makeEvent({
type: "user_message",
sessionId: "sess-2",
payload: { content: "world" },
});
const result = toClientPayload(event);
expect(result.type).toBe("user");
expect(result.session_id).toBe("sess-2");
});
test("falls back to message field when content is missing", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-3",
payload: { message: "fallback msg" },
});
const result = toClientPayload(event);
expect((result as any).message.content).toBe("fallback msg");
});
test("falls back to empty string when both content and message missing", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-4",
payload: {},
});
const result = toClientPayload(event);
expect((result as any).message.content).toBe("");
});
test("includes isSynthetic when true", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-5",
payload: { content: "auto", isSynthetic: true },
});
const result = toClientPayload(event);
expect((result as any).isSynthetic).toBe(true);
});
test("does not include isSynthetic when false", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-6",
payload: { content: "manual", isSynthetic: false },
});
const result = toClientPayload(event);
expect((result as any).isSynthetic).toBeUndefined();
});
test("uses payload.uuid when present", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-7",
payload: { content: "hi", uuid: "custom-uuid" },
});
const result = toClientPayload(event);
expect(result.uuid).toBe("custom-uuid");
});
test("falls back to event.id when payload.uuid is missing", () => {
const event = makeEvent({
type: "user",
sessionId: "sess-8",
payload: { content: "hi" },
});
const result = toClientPayload(event);
expect(result.uuid).toBe("evt-1");
});
});
// =============================================================================
// permission_response / control_response
// =============================================================================
describe("toClientPayload — permission response", () => {
test("approved=true maps to allow behavior", () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-1",
payload: { approved: true, request_id: "req-1" },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
const resp = (result as any).response;
expect(resp.subtype).toBe("success");
expect(resp.request_id).toBe("req-1");
expect(resp.response.behavior).toBe("allow");
});
test("approved=false maps to deny behavior with error", () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-2",
payload: { approved: false, request_id: "req-2" },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
const resp = (result as any).response;
expect(resp.subtype).toBe("error");
expect(resp.error).toBe("Permission denied by user");
expect(resp.response.behavior).toBe("deny");
});
test("approved=false includes feedback message when provided", () => {
const event = makeEvent({
type: "permission_response",
sessionId: "sess-3",
payload: { approved: false, request_id: "req-3", message: "please revise" },
});
const result = toClientPayload(event);
expect((result as any).response.message).toBe("please revise");
});
test("passes through existingResponse directly", () => {
const existingResponse = { subtype: "success", custom: true };
const event = makeEvent({
type: "control_response",
sessionId: "sess-4",
payload: { approved: true, response: existingResponse },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_response");
expect((result as any).response).toBe(existingResponse);
});
test("includes updatedInput when approved with updated_input", () => {
const updatedInput = { file_path: "/new/path" };
const event = makeEvent({
type: "permission_response",
sessionId: "sess-5",
payload: { approved: true, request_id: "req-5", updated_input: updatedInput },
});
const result = toClientPayload(event);
expect((result as any).response.response.updatedInput).toEqual(updatedInput);
});
test("includes updatedPermissions when approved with updated_permissions", () => {
const perms = [{ type: "allow", tool: "bash" }];
const event = makeEvent({
type: "permission_response",
sessionId: "sess-6",
payload: { approved: true, request_id: "req-6", updated_permissions: perms },
});
const result = toClientPayload(event);
expect((result as any).response.response.updatedPermissions).toEqual(perms);
});
});
// =============================================================================
// interrupt
// =============================================================================
describe("toClientPayload — interrupt", () => {
test("maps interrupt to control_request with subtype interrupt", () => {
const event = makeEvent({
type: "interrupt",
sessionId: "sess-1",
});
const result = toClientPayload(event);
expect(result.type).toBe("control_request");
expect((result as any).request_id).toBe("evt-1");
expect((result as any).request.subtype).toBe("interrupt");
});
});
// =============================================================================
// control_request
// =============================================================================
describe("toClientPayload — control_request", () => {
test("passes through request_id and request from payload", () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-1",
payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } },
});
const result = toClientPayload(event);
expect(result.type).toBe("control_request");
expect((result as any).request_id).toBe("req-99");
expect((result as any).request.subtype).toBe("permission");
});
test("falls back request to payload when no request field", () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-2",
payload: { request_id: "req-10", custom: "data" },
});
const result = toClientPayload(event);
expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" });
});
test("falls back request_id to event.id when missing", () => {
const event = makeEvent({
type: "control_request",
sessionId: "sess-3",
payload: { request: { subtype: "test" } },
});
const result = toClientPayload(event);
expect((result as any).request_id).toBe("evt-1");
});
});
// =============================================================================
// default fallback
// =============================================================================
describe("toClientPayload — default types", () => {
test("passes through unknown type with type/uuid/session_id/message", () => {
const event = makeEvent({
type: "assistant",
sessionId: "sess-1",
payload: { uuid: "u-1", content: "response text" },
});
const result = toClientPayload(event);
expect(result.type).toBe("assistant");
expect(result.uuid).toBe("u-1");
expect(result.session_id).toBe("sess-1");
expect(result.message).toEqual({ uuid: "u-1", content: "response text" });
});
});

View File

@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
expect(getRes.status).toBe(200);
});
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
storeBindSession(id, "user-1");
await app.request(`/v1/code/sessions/${id}/worker`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
external_metadata: {
automation_state: {
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
},
},
}),
});
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.automation_state).toEqual({
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
});
});
test("GET /web/sessions/:id — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
expect(body.events).toEqual([]);
});
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
publishSessionEvent(
id,
"task_state",
{
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
},
"inbound",
);
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const body = await histRes.json();
expect(body.events).toHaveLength(1);
expect(body.events[0]?.type).toBe("task_state");
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
expect(body.events[0]?.payload.tasks).toEqual([
{ id: "1", subject: "Investigate", status: "pending" },
]);
});
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
body: JSON.stringify({
worker_epoch: 1,
worker_status: "running",
external_metadata: { permission_mode: "default" },
external_metadata: {
permission_mode: "default",
automation_state: {
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
},
},
}),
});
expect(putRes.status).toBe(200);
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
const body = await getRes.json();
expect(body.worker.worker_status).toBe("running");
expect(body.worker.external_metadata.permission_mode).toBe("default");
expect(body.worker.external_metadata.automation_state).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
const events = getEventBus(id).getEventsSince(0);
expect(events.some((event) => event.type === "automation_state")).toBe(true);
expect(events.at(-1)?.payload).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
});
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: true,
request_id: "req-1",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-1\"");
expect(frame).toContain("\"behavior\":\"allow\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: false,
request_id: "req-2",
message: "Need more detail",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-2\"");
expect(frame).toContain("\"subtype\":\"error\"");
expect(frame).toContain("\"behavior\":\"deny\"");
expect(frame).toContain("\"message\":\"Need more detail\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"interrupt\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
expect(frame).toContain("\"subtype\":\"interrupt\"");
reader.cancel();
});
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",

View File

@@ -353,6 +353,14 @@ describe("Transport Service", () => {
expect(result.uuid).toBe("msg_123");
});
test("preserves isSynthetic field", () => {
const result = normalizePayload("user", {
content: "scheduled job: refresh analytics cache",
isSynthetic: true,
});
expect(result.isSynthetic).toBe(true);
});
test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read");
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
expect(result.content).toBe("");
});
test("preserves task_state fields", () => {
const result = normalizePayload("task_state", {
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
});
expect(result.task_list_id).toBe("team-alpha");
expect(result.tasks).toEqual([
{ id: "1", subject: "Task 1", status: "pending" },
]);
});
test("preserves status metadata for conversation reset events", () => {
const result = normalizePayload("status", {
status: "conversation_cleared",
subtype: "status",
message: "conversation_cleared",
});
expect(result.status).toBe("conversation_cleared");
expect(result.subtype).toBe("status");
expect(result.message).toBe("conversation_cleared");
});
test("handles undefined payload", () => {
const result = normalizePayload("user", undefined);
expect(result.content).toBe("");

View File

@@ -0,0 +1,188 @@
import { describe, test, expect } from "bun:test";
const { normalizePayload } = await import("../services/transport");
// extractContent is not exported; we test it via normalizePayload's content field
// =============================================================================
// extractContent (via normalizePayload content field)
// =============================================================================
describe("extractContent", () => {
test("returns empty string for null payload", () => {
const result = normalizePayload("assistant", null);
expect(result.content).toBe("");
});
test("returns empty string for undefined payload", () => {
const result = normalizePayload("assistant", undefined);
expect(result.content).toBe("");
});
test("returns the string for string payload", () => {
const result = normalizePayload("assistant", "hello world");
expect(result.content).toBe("hello world");
});
test("extracts content field from object payload", () => {
const result = normalizePayload("assistant", { content: "direct content" });
expect(result.content).toBe("direct content");
});
test("extracts message.content string from object payload", () => {
const result = normalizePayload("assistant", { message: { content: "msg content" } });
expect(result.content).toBe("msg content");
});
test("extracts text blocks from message.content array", () => {
const payload = {
message: {
content: [
{ type: "text", text: "Hello " },
{ type: "text", text: "World" },
],
},
};
const result = normalizePayload("assistant", payload);
expect(result.content).toBe("Hello World");
});
test("ignores non-text blocks in message.content array", () => {
const payload = {
message: {
content: [
{ type: "image", url: "http://example.com/img.png" },
{ type: "text", text: "only this" },
],
},
};
const result = normalizePayload("assistant", payload);
expect(result.content).toBe("only this");
});
test("returns empty string when no extractable content", () => {
const result = normalizePayload("assistant", { foo: "bar" });
expect(result.content).toBe("");
});
test("prefers direct content over message.content", () => {
const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } });
expect(result.content).toBe("direct");
});
});
// =============================================================================
// normalizePayload — field preservation
// =============================================================================
describe("normalizePayload — field preservation", () => {
test("preserves raw payload", () => {
const payload = { content: "test", extra: true };
const result = normalizePayload("assistant", payload);
expect(result.raw).toBe(payload);
});
test("preserves uuid field", () => {
const result = normalizePayload("assistant", { uuid: "u-123" });
expect(result.uuid).toBe("u-123");
});
test("does not preserve uuid when empty string", () => {
const result = normalizePayload("assistant", { uuid: "" });
expect(result.uuid).toBeUndefined();
});
test("preserves isSynthetic boolean", () => {
const result = normalizePayload("assistant", { isSynthetic: true });
expect(result.isSynthetic).toBe(true);
});
test("preserves status string", () => {
const result = normalizePayload("assistant", { status: "running" });
expect(result.status).toBe("running");
});
test("preserves subtype string", () => {
const result = normalizePayload("assistant", { subtype: "progress" });
expect(result.subtype).toBe("progress");
});
test("preserves tool_name from tool_name field", () => {
const result = normalizePayload("tool", { tool_name: "bash" });
expect(result.tool_name).toBe("bash");
});
test("preserves tool_name from name field", () => {
const result = normalizePayload("tool", { name: "read" });
expect(result.tool_name).toBe("read");
});
test("preserves tool_input from tool_input field", () => {
const input = { command: "ls" };
const result = normalizePayload("tool", { tool_input: input });
expect(result.tool_input).toEqual(input);
});
test("preserves tool_input from input field", () => {
const input = { path: "/tmp" };
const result = normalizePayload("tool", { input });
expect(result.tool_input).toEqual(input);
});
test("preserves request_id", () => {
const result = normalizePayload("permission", { request_id: "req-1" });
expect(result.request_id).toBe("req-1");
});
test("preserves request object", () => {
const req = { subtype: "permission" };
const result = normalizePayload("permission", { request: req });
expect(result.request).toEqual(req);
});
test("preserves approved field", () => {
const result = normalizePayload("permission", { approved: true });
expect(result.approved).toBe(true);
});
test("preserves updated_input", () => {
const input = { command: "rm -rf" };
const result = normalizePayload("permission", { updated_input: input });
expect(result.updated_input).toEqual(input);
});
test("preserves message field for backward compat", () => {
const msg = { role: "user", content: "hi" };
const result = normalizePayload("assistant", { message: msg });
expect(result.message).toEqual(msg);
});
});
// =============================================================================
// normalizePayload — task_state special handling
// =============================================================================
describe("normalizePayload — task_state type", () => {
test("preserves task_list_id (snake_case)", () => {
const result = normalizePayload("task_state", { task_list_id: "tl-1" });
expect(result.task_list_id).toBe("tl-1");
});
test("preserves taskListId (camelCase)", () => {
const result = normalizePayload("task_state", { taskListId: "tl-2" });
expect(result.taskListId).toBe("tl-2");
});
test("preserves tasks array", () => {
const tasks = [{ id: "t1", title: "Task 1" }];
const result = normalizePayload("task_state", { tasks });
expect(result.tasks).toEqual(tasks);
});
test("does not preserve task fields for non-task_state type", () => {
const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] });
expect(result.task_list_id).toBeUndefined();
expect(result.taskListId).toBeUndefined();
expect(result.tasks).toBeUndefined();
});
});

View File

@@ -69,6 +69,19 @@ describe("ws-handler", () => {
expect((events[0] as any).direction).toBe("inbound");
});
test("preserves synthetic flag on inbound user messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
message: { role: "user", content: "scheduled job: refresh analytics cache" },
uuid: "u_synth",
isSynthetic: true,
});
expect(events).toHaveLength(1);
expect((events[0] as any).payload.isSynthetic).toBe(true);
});
test("derives type from message.role for assistant messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
expect(msg.type).toBe("user");
});
test("replays synthetic user metadata back to the bridge", () => {
const bus = getEventBus("s3");
bus.publish({
id: "e1",
sessionId: "s3",
type: "user",
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
direction: "outbound",
});
const ws = createMockWs();
handleWebSocketOpen(ws, "s3");
const msg = JSON.parse(ws.getSentData()[0]);
expect(msg.type).toBe("user");
expect(msg.isSynthetic).toBe(true);
});
test("replaces existing connection for same session", () => {
const ws1 = createMockWs();
const ws2 = createMockWs();

View File

@@ -8,9 +8,16 @@ export const config = {
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
* this many seconds of no received data. Must be shorter than any reverse
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || "30"),
/** Server→client keep_alive data-frame interval (seconds). Keeps reverse
* proxies from closing idle connections. Default 20s. */
wsKeepaliveInterval: parseInt(process.env.RCS_WS_KEEPALIVE_INTERVAL || "20"),
} as const;
export function getBaseUrl(): string {
if (config.baseUrl) return config.baseUrl;
return `http://localhost:${config.port}`;
const url = config.baseUrl || `http://localhost:${config.port}`;
return url.replace(/\/+$/, "");
}

View File

@@ -4,15 +4,20 @@ import { logger } from "hono/logger";
import { serveStatic } from "hono/bun";
import { config } from "./config";
import { closeAllConnections } from "./transport/ws-handler";
import { closeAllAcpConnections } from "./transport/acp-ws-handler";
import { closeAllRelayConnections } from "./transport/acp-relay-handler";
import { startDisconnectMonitor } from "./services/disconnect-monitor";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import acpRoutes from "./routes/acp";
// Routes
import v1Environments from "./routes/v1/environments";
import v1EnvironmentsWork from "./routes/v1/environments.work";
import v1Sessions from "./routes/v1/sessions";
import v1SessionIngress, { websocket } from "./routes/v1/session-ingress";
import v1SessionIngress from "./routes/v1/session-ingress";
import { websocket } from "./transport/ws-shared";
import v2CodeSessions from "./routes/v2/code-sessions";
import v2Worker from "./routes/v2/worker";
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
@@ -28,14 +33,27 @@ const app = new Hono();
// Middleware
app.use("*", logger());
app.use("*", async (c, next) => {
// Normalize double slashes in path (e.g. //v1/environments/bridge → /v1/environments/bridge)
const path = new URL(c.req.url).pathname;
if (path.includes("//")) {
const normalized = path.replace(/\/+/g, "/");
const url = new URL(c.req.url);
url.pathname = normalized;
return app.fetch(new Request(url.toString(), c.req.raw));
}
await next();
});
app.use("/web/*", cors());
// Health check
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
// Static files — serve web/ directory under /code path
// Static files — serve built web UI under /code path
// Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback)
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDir = resolve(__dirname, "../web");
const distDir = resolve(__dirname, "../web/dist");
const webDir = existsSync(resolve(distDir, "index.html")) ? distDir : resolve(__dirname, "../web");
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
@@ -70,6 +88,10 @@ app.route("/web", webSessions);
app.route("/web", webControl);
app.route("/web", webEnvironments);
// ACP protocol routes
console.log("[RCS] ACP support enabled");
app.route("/acp", acpRoutes);
const port = config.port;
const host = config.host;
@@ -77,6 +99,8 @@ console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
console.log("[RCS] API key configuration loaded");
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
console.log(`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`);
console.log(`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`);
// Start disconnect monitor
startDisconnectMonitor();
@@ -87,15 +111,17 @@ export default {
fetch: app.fetch,
websocket: {
...websocket,
idleTimeout: 255, // WS idle timeout (seconds) — must be inside websocket object
idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence
},
idleTimeout: 255, // HTTP server idle timeout (seconds) — needed for long-polling endpoints
idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds)
};
// Graceful shutdown
async function gracefulShutdown(signal: string) {
console.log(`\n[RCS] Received ${signal}, shutting down...`);
closeAllConnections();
closeAllAcpConnections();
closeAllRelayConnections();
process.exit(0);
}

View File

@@ -0,0 +1,214 @@
import { Hono } from "hono";
import { upgradeWebSocket } from "../../transport/ws-shared";
import { apiKeyAuth } from "../../auth/middleware";
import { validateApiKey } from "../../auth/api-key";
import {
handleAcpWsOpen,
handleAcpWsMessage,
handleAcpWsClose,
} from "../../transport/acp-ws-handler";
import {
handleRelayOpen,
handleRelayMessage,
handleRelayClose,
} from "../../transport/acp-relay-handler";
import {
storeListAcpAgents,
storeListAcpAgentsByChannelGroup,
storeGetEnvironment,
} from "../../store";
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
import { log, error as logError } from "../../logger";
const app = new Hono();
/** Maximum WebSocket message size: 10 MB */
const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
/** Response shape for an ACP agent */
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
if (!env) return null;
return {
id: env.id,
agent_name: env.machineName,
channel_group_id: env.bridgeId,
status: env.status === "active" ? "online" : "offline",
max_sessions: env.maxSessions,
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
created_at: env.createdAt.getTime() / 1000,
};
}
/** GET /acp/agents — List all registered ACP agents (UUID or API key auth) */
app.get("/agents", async (c) => {
// Require at least UUID auth
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
}
const agents = storeListAcpAgents();
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
});
/** GET /acp/channel-groups — List all channel groups with member agents (UUID or API key auth) */
app.get("/channel-groups", async (c) => {
const uuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!uuid && !(token && validateApiKey(token))) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
}
const agents = storeListAcpAgents();
const groupMap = new Map<string, typeof agents>();
for (const agent of agents) {
const groupId = agent.bridgeId || "default";
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId)!.push(agent);
}
const groups = [...groupMap.entries()].map(([id, members]) => ({
channel_group_id: id,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
}));
return c.json(groups);
});
/** GET /acp/channel-groups/:id — Specific channel group detail (no auth for web UI) */
app.get("/channel-groups/:id", async (c) => {
const groupId = c.req.param("id")!;
const members = storeListAcpAgentsByChannelGroup(groupId);
if (members.length === 0) {
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
}
return c.json({
channel_group_id: groupId,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
});
});
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (no auth for web UI) */
app.get("/channel-groups/:id/events", async (c) => {
const groupId = c.req.param("id")!;
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
return createAcpSSEStream(c, groupId, fromSeqNum);
});
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
app.get(
"/ws",
upgradeWebSocket(async (c) => {
// Authenticate via API key in query param or header
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
if (!token || !validateApiKey(token)) {
log("[ACP-WS] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
ws.close(4003, "unauthorized");
},
};
}
// Generate unique wsId for this connection
const { v4: uuid } = await import("uuid");
const wsId = `acp_ws_${uuid().replace(/-/g, "")}`;
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
return {
onOpen(_evt: any, ws: any) {
handleAcpWsOpen(ws, wsId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-WS] Message too large on wsId=${wsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleAcpWsMessage(ws, wsId, data);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleAcpWsClose(ws, wsId, closeEvt?.code, closeEvt?.reason);
},
onError(evt: any, ws: any) {
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
handleAcpWsClose(ws, wsId, 1006, "websocket error");
},
};
}),
);
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
app.get(
"/relay/:agentId",
upgradeWebSocket(async (c) => {
// Authenticate via UUID (web frontend) or API key (legacy)
const clientUuid = c.req.query("uuid");
const authHeader = c.req.header("Authorization");
const queryToken = c.req.query("token");
const token = authHeader?.replace("Bearer ", "") || queryToken;
const hasUuid = !!clientUuid;
const hasApiKey = !!token && validateApiKey(token);
if (!hasUuid && !hasApiKey) {
log("[ACP-Relay] Upgrade rejected: unauthorized");
return {
onOpen(_evt: any, ws: any) {
ws.close(4003, "unauthorized");
},
};
}
const agentId = c.req.param("agentId")!;
const { v4: uuid } = await import("uuid");
const relayWsId = `relay_${uuid().replace(/-/g, "")}`;
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
return {
onOpen(_evt: any, ws: any) {
handleRelayOpen(ws, relayWsId, agentId);
},
onMessage(evt: any, ws: any) {
const data =
typeof evt.data === "string"
? evt.data
: new TextDecoder().decode(evt.data as ArrayBuffer);
if (data.length > MAX_WS_MESSAGE_SIZE) {
logError(`[ACP-Relay] Message too large on relayWsId=${relayWsId}: ${data.length} bytes`);
ws.close(1009, "message too large");
return;
}
handleRelayMessage(ws, relayWsId, data);
},
onClose(evt: any, ws: any) {
const closeEvt = evt as unknown as CloseEvent;
handleRelayClose(ws, relayWsId, closeEvt?.code, closeEvt?.reason);
},
onError(evt: any, ws: any) {
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
handleRelayClose(ws, relayWsId, 1006, "websocket error");
},
};
}),
);
export default app;

View File

@@ -1,6 +1,6 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
import { validateApiKey } from "../../auth/api-key";
import { verifyWorkerJwt } from "../../auth/jwt";
import {
@@ -11,8 +11,6 @@ import {
} from "../../transport/ws-handler";
import { getSession, resolveExistingSessionId } from "../../services/session";
const { upgradeWebSocket, websocket } = createBunWebSocket();
const app = new Hono();
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */

View File

@@ -1,7 +1,13 @@
import { Hono } from "hono";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
import {
automationStatesEqual,
getAutomationStateEventPayload,
} from "../../services/automationState";
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
import { getEventBus } from "../../transport/event-bus";
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
import { v4 as uuid } from "uuid";
const app = new Hono();
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
}
const body = await c.req.json();
const prevAutomationState = getAutomationStateEventPayload(
storeGetSessionWorker(sessionId)?.externalMetadata,
);
if (body.worker_status) {
updateSessionStatus(sessionId, body.worker_status);
} else {
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
externalMetadata: body.external_metadata,
requiresActionDetails: body.requires_action_details,
});
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
getEventBus(sessionId).publish({
id: uuid(),
sessionId,
type: "automation_state",
payload: nextAutomationState,
direction: "inbound",
});
}
return c.json({
status: "ok",

View File

@@ -1,6 +1,7 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getAutomationStateSnapshot } from "../../services/automationState";
import {
createSession,
getSession,
@@ -10,7 +11,7 @@ import {
resolveOwnedWebSessionId,
toWebSessionResponse,
} from "../../services/session";
import { storeBindSession } from "../../store";
import { storeBindSession, storeGetSessionWorker } from "../../store";
import { createWorkItem } from "../../services/work-dispatch";
import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus";
@@ -68,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json(toWebSessionResponse(session), 200);
const worker = storeGetSessionWorker(sessionId);
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
const response = toWebSessionResponse(session);
return c.json(
automationState === undefined ? response : { ...response, automation_state: automationState },
200,
);
});
/** GET /web/sessions/:id/history — Historical events for session */

View File

@@ -0,0 +1,64 @@
import type { AutomationStateResponse } from "../types/api";
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
return { ...state };
}
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
if (!raw || typeof raw !== "object") {
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
}
const state = raw as Record<string, unknown>;
return {
enabled: state.enabled === true,
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
};
}
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
if (!metadata || typeof metadata !== "object") {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
return undefined;
}
return metadata.automation_state;
}
export function getAutomationStateSnapshot(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse | undefined {
const raw = readAutomationStateValue(metadata);
if (raw === undefined) {
return undefined;
}
return normalizeAutomationState(raw);
}
export function getAutomationStateEventPayload(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse {
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
}
export function automationStatesEqual(
a: AutomationStateResponse,
b: AutomationStateResponse,
): boolean {
return (
a.enabled === b.enabled &&
a.phase === b.phase &&
a.next_tick_at === b.next_tick_at &&
a.sleep_until === b.sleep_until
);
}

View File

@@ -1,5 +1,5 @@
import { log, error as logError } from "../logger";
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
import { storeListSessions } from "../store";
import { config } from "../config";
import { updateSessionStatus } from "./session";
@@ -10,6 +10,14 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
// Check environment heartbeat timeout
const envs = storeListActiveEnvironments();
for (const env of envs) {
// Skip ACP agents — they use WS keepalive, not polling
if (env.workerType === "acp") {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
log(`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeMarkAcpAgentOffline(env.id);
}
continue;
}
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" });

View File

@@ -1,6 +1,7 @@
import { config } from "../config";
import {
storeCreateEnvironment,
storeCreateSession,
storeGetEnvironment,
storeUpdateEnvironment,
storeListActiveEnvironments,
@@ -18,6 +19,8 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
status: row.status,
username: row.username,
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
worker_type: row.workerType,
capabilities: row.capabilities,
};
}
@@ -34,9 +37,21 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
workerType,
bridgeId: req.bridge_id,
username: req.username,
capabilities: req.capabilities,
});
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active" };
let sessionId: string | undefined;
// ACP agents: auto-create a session so they appear in the dashboard sessions list
if (workerType === "acp") {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
}
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
}
export function deregisterEnvironment(envId: string) {

View File

@@ -2,6 +2,8 @@ import {
storeCreateSession,
storeGetSession,
storeIsSessionOwner,
storeGetSessionOwners,
storeBindSession,
storeUpdateSession,
storeListSessions,
storeListSessionsByUsername,
@@ -106,6 +108,16 @@ export function resolveOwnedWebSessionId(sessionId: string, uuid: string): strin
return compatibleCodeSessionId;
}
// Auto-bind: if the session exists but has no owner, claim it for the requesting user
const existingId = resolveExistingSessionId(sessionId);
if (existingId) {
const owners = storeGetSessionOwners(existingId);
if (!owners || owners.size === 0) {
storeBindSession(existingId, uuid);
return existingId;
}
}
return null;
}

View File

@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
};
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
if (typeof p.status === "string") normalized.status = p.status;
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
// Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name;
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
// Preserve message field for backward compat
if (p.message) normalized.message = p.message;
if (type === "task_state") {
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
}
return normalized;
}

View File

@@ -17,6 +17,7 @@ export interface EnvironmentRecord {
maxSessions: number;
workerType: string;
bridgeId: string | null;
capabilities: Record<string, unknown> | null;
status: string;
username: string | null;
lastPollAt: Date | null;
@@ -97,6 +98,21 @@ export function storeDeleteToken(token: string): boolean {
// ---------- Environment ----------
/** Find an active environment by machineName (optionally filtered by workerType) */
export function storeFindEnvironmentByMachineName(
machineName: string,
workerType?: string,
): EnvironmentRecord | undefined {
for (const rec of environments.values()) {
if (rec.machineName === machineName && rec.status === "active") {
if (!workerType || rec.workerType === workerType) {
return rec;
}
}
}
return undefined;
}
export function storeCreateEnvironment(req: {
secret: string;
machineName?: string;
@@ -107,7 +123,25 @@ export function storeCreateEnvironment(req: {
workerType?: string;
bridgeId?: string;
username?: string;
capabilities?: Record<string, unknown>;
}): EnvironmentRecord {
// ACP: reuse existing active record by machineName
if (req.workerType === "acp" && req.machineName) {
const existing = storeFindEnvironmentByMachineName(req.machineName, "acp");
if (existing) {
Object.assign(existing, {
status: "active",
lastPollAt: new Date(),
updatedAt: new Date(),
maxSessions: req.maxSessions ?? existing.maxSessions,
bridgeId: req.bridgeId ?? existing.bridgeId,
capabilities: req.capabilities ?? existing.capabilities,
username: req.username ?? existing.username,
});
return existing;
}
}
const id = `env_${uuid().replace(/-/g, "")}`;
const now = new Date();
const record: EnvironmentRecord = {
@@ -120,6 +154,7 @@ export function storeCreateEnvironment(req: {
maxSessions: req.maxSessions ?? 1,
workerType: req.workerType ?? "claude_code",
bridgeId: req.bridgeId ?? null,
capabilities: req.capabilities ?? null,
status: "active",
username: req.username ?? null,
lastPollAt: now,
@@ -134,7 +169,7 @@ export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
return environments.get(id);
}
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt">>): boolean {
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt" | "capabilities" | "machineName" | "maxSessions" | "bridgeId">>): boolean {
const rec = environments.get(id);
if (!rec) return false;
Object.assign(rec, patch, { updatedAt: new Date() });
@@ -272,6 +307,10 @@ export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
return owners ? owners.has(uuid) : false;
}
export function storeGetSessionOwners(sessionId: string): Set<string> | undefined {
return sessionOwners.get(sessionId);
}
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
const result: SessionRecord[] = [];
for (const [sessionId, owners] of sessionOwners) {
@@ -325,6 +364,43 @@ export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemReco
return true;
}
// ---------- ACP Agent (reuses EnvironmentRecord with workerType="acp") ----------
/** List all ACP agents (environments with workerType="acp") */
export function storeListAcpAgents(): EnvironmentRecord[] {
return [...environments.values()].filter((e) => e.workerType === "acp");
}
/** List ACP agents by channel group (stored in bridgeId field) */
export function storeListAcpAgentsByChannelGroup(channelGroupId: string): EnvironmentRecord[] {
return [...environments.values()].filter(
(e) => e.workerType === "acp" && e.bridgeId === channelGroupId,
);
}
/** List online ACP agents */
export function storeListOnlineAcpAgents(): EnvironmentRecord[] {
return [...environments.values()].filter(
(e) => e.workerType === "acp" && e.status === "active",
);
}
/** Mark an ACP agent as offline */
export function storeMarkAcpAgentOffline(id: string): boolean {
const rec = environments.get(id);
if (!rec || rec.workerType !== "acp") return false;
Object.assign(rec, { status: "offline", updatedAt: new Date() });
return true;
}
/** Mark an ACP agent as online (on reconnect) */
export function storeMarkAcpAgentOnline(id: string): boolean {
const rec = environments.get(id);
if (!rec || rec.workerType !== "acp") return false;
Object.assign(rec, { status: "active", lastPollAt: new Date(), updatedAt: new Date() });
return true;
}
// ---------- Reset (for tests) ----------
export function storeReset() {

View File

@@ -0,0 +1,151 @@
import type { WSContext } from "hono/ws";
import {
findAcpConnectionByAgentId,
sendToAgentWs,
} from "./acp-ws-handler";
import { getAcpEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { log, error as logError } from "../logger";
// Per-relay connection state
interface RelayConnectionEntry {
agentId: string;
unsub: (() => void) | null;
keepalive: ReturnType<typeof setInterval> | null;
ws: WSContext;
openTime: number;
}
const relayConnections = new Map<string, RelayConnectionEntry>(); // key: relayWsId
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000;
/** Send a JSON message to relay WS */
function sendToRelayWs(ws: WSContext, msg: object): void {
if (ws.readyState !== 1) return;
try {
ws.send(JSON.stringify(msg));
} catch (err) {
logError("[ACP-Relay] send error:", err);
}
}
/** Called from onOpen — finds target agent and bridges connection */
export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: string): void {
log(`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`);
// Check if agent is online
const agentConn = findAcpConnectionByAgentId(agentId);
if (!agentConn) {
log(`[ACP-Relay] Agent ${agentId} not found or offline`);
sendToRelayWs(ws, { type: "error", message: "Agent not found or offline" });
ws.close(4004, "agent not found");
return;
}
// Keepalive interval
const keepalive = setInterval(() => {
const entry = relayConnections.get(relayWsId);
if (!entry || entry.ws.readyState !== 1) {
clearInterval(keepalive);
return;
}
sendToRelayWs(entry.ws, { type: "keep_alive" });
}, RELAY_KEEPALIVE_INTERVAL_MS);
// Subscribe to channel group EventBus — forward agent responses to frontend
const channelGroupId = agentConn.channelGroupId;
const bus = getAcpEventBus(channelGroupId);
const unsub = bus.subscribe((event: SessionEvent) => {
if (ws.readyState !== 1) return;
if (event.direction !== "inbound") return;
// Handle agent disconnect specially: send status to frontend
if (event.type === "agent_disconnect") {
sendToRelayWs(ws, { type: "status", payload: { connected: false } });
return;
}
// Forward agent responses to the frontend WebSocket
sendToRelayWs(ws, event.payload as object);
});
relayConnections.set(relayWsId, {
agentId,
unsub,
keepalive,
ws,
openTime: Date.now(),
});
// Don't send a synthetic status message here!
// The frontend sends a "connect" command, which acp-link processes
// and responds with a real status message including capabilities.
// Sending a fake status would make the frontend think it's connected
// before the agent process is actually ready.
log(`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`);
}
/** Called from onMessage — forwards frontend messages to acp-link */
export function handleRelayMessage(ws: WSContext, relayWsId: string, data: string): void {
const entry = relayConnections.get(relayWsId);
if (!entry) return;
const lines = data.split("\n").filter((l) => l.trim());
for (const line of lines) {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
logError("[ACP-Relay] parse error:", line);
continue;
}
// Ignore keepalive responses
if (msg.type === "keep_alive") continue;
// Forward to acp-link agent
const sent = sendToAgentWs(entry.agentId, msg);
if (!sent) {
sendToRelayWs(ws, { type: "error", message: "Agent connection lost" });
return;
}
}
}
/** Called from onClose — cleans up relay connection */
export function handleRelayClose(ws: WSContext, relayWsId: string, code?: number, reason?: string): void {
const entry = relayConnections.get(relayWsId);
if (!entry) return;
const duration = Math.round((Date.now() - entry.openTime) / 1000);
log(`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
if (entry.unsub) {
entry.unsub();
}
if (entry.keepalive) {
clearInterval(entry.keepalive);
}
relayConnections.delete(relayWsId);
}
/** Close all relay connections (for graceful shutdown) */
export function closeAllRelayConnections(): void {
if (relayConnections.size === 0) return;
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`);
for (const [relayWsId, entry] of relayConnections) {
try {
if (entry.unsub) entry.unsub();
if (entry.keepalive) clearInterval(entry.keepalive);
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
}
} catch {
// ignore errors during shutdown
}
}
relayConnections.clear();
log("[ACP-Relay] All relay connections closed");
}

View File

@@ -0,0 +1,80 @@
import { log } from "../logger";
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getAcpEventBus } from "./event-bus";
/** Create SSE response stream for an ACP channel group */
export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNum = 0) {
const bus = getAcpEventBus(channelGroupId);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Send historical events if reconnecting
if (fromSeqNum > 0) {
const missed = bus.getEventsSince(fromSeqNum);
for (const event of missed) {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
channel_group_id: channelGroupId,
});
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
}
}
// Send initial keepalive
controller.enqueue(encoder.encode(": keepalive\n\n"));
// Subscribe to new events
const unsub = bus.subscribe((event) => {
const data = JSON.stringify({
type: event.type,
payload: event.payload,
direction: event.direction,
seqNum: event.seqNum,
channel_group_id: channelGroupId,
});
try {
log(`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`);
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
} catch {
unsub();
}
});
// Keepalive interval
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
clearInterval(keepalive);
unsub();
}
}, 15000);
// Cleanup on abort
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
try {
controller.close();
} catch {
// already closed
}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -0,0 +1,313 @@
import type { WSContext } from "hono/ws";
import { v4 as uuid } from "uuid";
import { getAcpEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import {
storeCreateEnvironment,
storeGetEnvironment,
storeMarkAcpAgentOffline,
storeMarkAcpAgentOnline,
storeUpdateEnvironment,
} from "../store";
import { config } from "../config";
import { log, error as logError } from "../logger";
// Per-connection state
interface AcpConnectionEntry {
agentId: string | null; // Set after register message
channelGroupId: string;
unsub: (() => void) | null;
keepalive: ReturnType<typeof setInterval> | null;
ws: WSContext;
openTime: number;
lastClientActivity: number;
capabilities: Record<string, unknown> | null;
}
const connections = new Map<string, AcpConnectionEntry>(); // key: wsId
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000;
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
/** Send a JSON message to a WS connection (NDJSON format) */
function sendToWs(ws: WSContext, msg: object): void {
if (ws.readyState !== 1) return;
try {
ws.send(JSON.stringify(msg) + "\n");
} catch (err) {
logError("[ACP-WS] send error:", err);
}
}
/** Called from onOpen — initializes connection tracking */
export function handleAcpWsOpen(ws: WSContext, wsId: string): void {
log(`[ACP-WS] Connection opened: wsId=${wsId}`);
const keepalive = setInterval(() => {
const entry = connections.get(wsId);
if (!entry || entry.ws.readyState !== 1) {
clearInterval(keepalive);
return;
}
const silenceMs = Date.now() - entry.lastClientActivity;
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
log(`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`);
try {
entry.ws.close(1000, "client inactive");
} catch {
clearInterval(keepalive);
}
return;
}
sendToWs(entry.ws, { type: "keep_alive" });
}, SERVER_KEEPALIVE_INTERVAL_MS);
connections.set(wsId, {
agentId: null,
channelGroupId: "",
unsub: null,
keepalive,
ws,
openTime: Date.now(),
lastClientActivity: Date.now(),
capabilities: null,
});
}
/** Handle register message — legacy WS-only registration (still supported) */
function handleRegister(wsId: string, msg: Record<string, unknown>): void {
const entry = connections.get(wsId);
if (!entry) return;
if (entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Already registered" });
return;
}
const agentName = (msg.agent_name as string) || "unknown";
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
const channelGroupId = (msg.channel_group_id as string) || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
const acpLinkVersion = (msg.acp_link_version as string) || null;
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
// Create EnvironmentRecord with workerType="acp"
const secret = config.apiKeys[0] || "";
const record = storeCreateEnvironment({
secret,
machineName: agentName,
workerType: "acp",
bridgeId: channelGroupId,
maxSessions,
capabilities: capabilities || undefined,
} as Parameters<typeof storeCreateEnvironment>[0]);
// Store ACP-specific metadata via environment update
storeUpdateEnvironment(record.id, {
status: "active",
} as Parameters<typeof storeUpdateEnvironment>[1]);
entry.agentId = record.id;
entry.channelGroupId = channelGroupId;
entry.capabilities = capabilities || null;
// Subscribe to channel group EventBus — broadcast events to this WS
const bus = getAcpEventBus(channelGroupId);
const unsub = bus.subscribe((event: SessionEvent) => {
if (entry.ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
// Forward outbound events as raw ACP messages
sendToWs(entry.ws, event.payload as object);
});
entry.unsub = unsub;
log(`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`);
sendToWs(entry.ws, {
type: "registered",
agent_id: record.id,
channel_group_id: channelGroupId,
});
}
/** Handle identify message — binds WS to an existing agent registered via REST */
function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
const entry = connections.get(wsId);
if (!entry) return;
if (entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Already identified" });
return;
}
const agentId = msg.agent_id as string;
if (!agentId) {
sendToWs(entry.ws, { type: "error", message: "Missing agent_id" });
return;
}
// Look up the environment record (created via REST registration)
const record = storeGetEnvironment(agentId);
if (!record || record.workerType !== "acp") {
sendToWs(entry.ws, { type: "error", message: "Agent not found" });
return;
}
// Update status to active
storeMarkAcpAgentOnline(agentId);
const channelGroupId = record.bridgeId || `group_${uuid().replace(/-/g, "").slice(0, 12)}`;
entry.agentId = record.id;
entry.channelGroupId = channelGroupId;
entry.capabilities = record.capabilities || null;
// Subscribe to channel group EventBus — broadcast events to this WS
const bus = getAcpEventBus(channelGroupId);
const unsub = bus.subscribe((event: SessionEvent) => {
if (entry.ws.readyState !== 1) return;
if (event.direction !== "outbound") return;
sendToWs(entry.ws, event.payload as object);
});
entry.unsub = unsub;
log(`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`);
sendToWs(entry.ws, {
type: "identified",
agent_id: record.id,
channel_group_id: channelGroupId,
});
}
/** Called from onMessage — processes NDJSON lines */
export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): void {
const entry = connections.get(wsId);
if (!entry) return;
entry.lastClientActivity = Date.now();
const lines = data.split("\n").filter((l) => l.trim());
for (const line of lines) {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
logError("[ACP-WS] parse error:", line);
continue;
}
// Handle keepalive
if (msg.type === "keep_alive") {
// Update last activity timestamp (only if registered)
if (entry.agentId) {
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
}
continue;
}
// Handle registration (legacy WS-only)
if (msg.type === "register") {
handleRegister(wsId, msg);
continue;
}
// Handle identify (REST registration + WS binding)
if (msg.type === "identify") {
handleIdentify(wsId, msg);
continue;
}
// Not registered yet — reject
if (!entry.agentId) {
sendToWs(entry.ws, { type: "error", message: "Not registered. Send register message first." });
continue;
}
// Update agent activity
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
// Pass-through: publish to channel group EventBus as inbound
const bus = getAcpEventBus(entry.channelGroupId);
bus.publish({
id: uuid(),
sessionId: entry.channelGroupId,
type: (msg.type as string) || "acp_message",
payload: msg,
direction: "inbound",
});
}
}
/** Called from onClose — marks agent offline and cleans up */
export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, reason?: string): void {
const entry = connections.get(wsId);
if (!entry) return;
const duration = Math.round((Date.now() - entry.openTime) / 1000);
log(`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
if (entry.unsub) {
entry.unsub();
}
if (entry.keepalive) {
clearInterval(entry.keepalive);
}
// Mark agent as offline (don't delete record — allow reconnect)
if (entry.agentId) {
storeMarkAcpAgentOffline(entry.agentId);
// Notify all relay connections that this agent is gone
if (entry.channelGroupId) {
const bus = getAcpEventBus(entry.channelGroupId);
bus.publish({
id: uuid(),
sessionId: entry.channelGroupId,
type: "agent_disconnect",
payload: { agentId: entry.agentId },
direction: "inbound",
});
}
}
connections.delete(wsId);
}
/** Find an active ACP connection by agent ID */
export function findAcpConnectionByAgentId(agentId: string): AcpConnectionEntry | null {
for (const entry of connections.values()) {
if (entry.agentId === agentId && entry.ws.readyState === 1) {
return entry;
}
}
return null;
}
/** Send a JSON message directly to an agent's WebSocket connection */
export function sendToAgentWs(agentId: string, msg: object): boolean {
const entry = findAcpConnectionByAgentId(agentId);
if (!entry) return false;
sendToWs(entry.ws, msg);
return true;
}
/** Gracefully close all ACP WebSocket connections */
export function closeAllAcpConnections(): void {
if (connections.size === 0) return;
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`);
for (const [wsId, entry] of connections) {
try {
if (entry.unsub) entry.unsub();
if (entry.keepalive) clearInterval(entry.keepalive);
if (entry.ws.readyState === 1) {
entry.ws.close(1001, "server_shutdown");
}
if (entry.agentId) {
storeMarkAcpAgentOffline(entry.agentId);
}
} catch {
// ignore errors during shutdown
}
}
connections.clear();
log("[ACP-WS] All connections closed");
}

View File

@@ -0,0 +1,80 @@
import type { SessionEvent } from "./event-bus";
/**
* Convert an internal session event into the SDK/control message shape that
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
*/
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
const payload = event.payload as Record<string, unknown> | null;
const messageUuid =
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
if (event.type === "user" || event.type === "user_message") {
return {
type: "user",
uuid: messageUuid,
session_id: event.sessionId,
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
},
};
}
if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (existingResponse) {
return { type: "control_response", response: existingResponse };
}
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
return {
type: "control_response",
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
...(approved
? {
response: {
behavior: "allow" as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
if (event.type === "interrupt") {
return {
type: "control_request",
request_id: event.id,
request: { subtype: "interrupt" },
};
}
if (event.type === "control_request") {
return {
type: "control_request",
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
}
return {
type: event.type,
uuid: messageUuid,
session_id: event.sessionId,
message: payload,
};
}

View File

@@ -12,6 +12,8 @@ export interface SessionEvent {
type Subscriber = (event: SessionEvent) => void;
const MAX_EVENTS_PER_BUS = 5000;
export class EventBus {
private subscribers = new Set<Subscriber>();
private events: SessionEvent[] = [];
@@ -35,7 +37,14 @@ export class EventBus {
createdAt: Date.now(),
};
this.events.push(full);
log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
// Evict oldest events when exceeding limit
if (this.events.length > MAX_EVENTS_PER_BUS) {
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2));
}
log(
`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`,
event.type === "error" ? `payload=${JSON.stringify(event.payload)}` : "",
);
for (const cb of this.subscribers) {
try {
cb(full);
@@ -85,3 +94,23 @@ export function removeEventBus(sessionId: string) {
export function getAllEventBuses(): Map<string, EventBus> {
return buses;
}
/** Global registry of per-channel-group ACP event buses */
const acpBuses = new Map<string, EventBus>();
export function getAcpEventBus(channelGroupId: string): EventBus {
let bus = acpBuses.get(channelGroupId);
if (!bus) {
bus = new EventBus();
acpBuses.set(channelGroupId, bus);
}
return bus;
}
export function removeAcpEventBus(channelGroupId: string) {
const bus = acpBuses.get(channelGroupId);
if (bus) {
bus.close();
acpBuses.delete(channelGroupId);
}
}

View File

@@ -2,6 +2,7 @@ import { log, error as logError } from "../logger";
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getEventBus } from "./event-bus";
import { toClientPayload } from "./client-payload";
export interface SSEWriter {
send(event: SessionEvent): void;
@@ -118,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
}
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
if (
event.type === "permission_response" ||
event.type === "control_response" ||
event.type === "control_request" ||
event.type === "interrupt"
) {
return toClientPayload(event);
}
const normalized =
event.payload && typeof event.payload === "object"
? (event.payload as Record<string, unknown>)

View File

@@ -3,6 +3,8 @@ import { getEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { publishSessionEvent } from "../services/transport";
import { log, error as logError } from "../logger";
import { toClientPayload } from "./client-payload";
import { config } from "../config";
// Per-connection cleanup, keyed by sessionId (only one WS per session)
interface CleanupEntry {
@@ -10,94 +12,34 @@ interface CleanupEntry {
keepalive: ReturnType<typeof setInterval>;
ws: WSContext;
openTime: number;
lastClientActivity: number;
}
const cleanupBySession = new Map<string, CleanupEntry>();
// Track all active WS connections for graceful shutdown
const activeConnections = new Set<WSContext>();
// Bridge sends keep_alive data frames every 120s. Send server-side keep_alive
// every 60s to ensure the connection stays alive even without user messages.
const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
// Server-side keepalive interval (configurable via RCS_WS_KEEPALIVE_INTERVAL).
// Sends data frames to keep reverse proxies from closing idle connections.
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000;
// If no client data received within this threshold, the connection is
// considered dead. Set to 3x keepalive to tolerate one missed interval.
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
/**
* Convert internal EventBus event -> SDK message for bridge client.
*/
function toSDKMessage(event: SessionEvent): string {
const payload = event.payload as Record<string, unknown> | null;
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
let msg: Record<string, unknown>;
if (event.type === "user" || event.type === "user_message") {
msg = {
type: "user",
uuid: messageUuid,
session_id: event.sessionId,
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
},
};
} else if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (existingResponse) {
msg = { type: "control_response", response: existingResponse };
} else {
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
msg = {
type: "control_response",
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
...(approved
? {
response: {
behavior: "allow" as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
} else if (event.type === "interrupt") {
msg = {
type: "control_request",
request_id: event.id,
request: { subtype: "interrupt" },
};
} else if (event.type === "control_request") {
msg = {
type: "control_request",
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
} else {
msg = {
type: event.type,
uuid: messageUuid,
session_id: event.sessionId,
message: payload,
};
}
// NDJSON format: each message MUST end with \n so the child process's
// line-based parser can split messages correctly.
return JSON.stringify(msg) + "\n";
return JSON.stringify(toClientPayload(event)) + "\n";
}
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
const openTime = Date.now();
const lastClientActivity = Date.now();
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
activeConnections.add(ws);
@@ -144,6 +86,17 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
clearInterval(keepalive);
return;
}
// Check if client is still alive — close if no data received for too long
const silenceMs = Date.now() - lastClientActivity;
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
log(`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`);
try {
ws.close(1000, "client inactive");
} catch {
clearInterval(keepalive);
}
return;
}
try {
ws.send('{"type":"keep_alive"}\n');
} catch {
@@ -151,13 +104,18 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
}
}, SERVER_KEEPALIVE_INTERVAL_MS);
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime });
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime, lastClientActivity });
}
/**
* Called from onMessage — bridge sends newline-delimited JSON.
*/
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
// Track client activity for dead-connection detection
const entry = cleanupBySession.get(sessionId);
if (entry) {
entry.lastClientActivity = Date.now();
}
const lines = data.split("\n").filter((l) => l.trim());
for (const line of lines) {
try {
@@ -236,7 +194,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
}
payload = { message: msg.message, uuid: msg.uuid, content: text };
} else if (eventType === "user" || eventType === "system") {
payload = { message: msg.message, uuid: msg.uuid };
payload = {
message: msg.message,
uuid: msg.uuid,
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
};
} else if (eventType === "control_request") {
payload = { request_id: msg.request_id, request: msg.request };
} else if (eventType === "control_response") {

View File

@@ -0,0 +1 @@
export { upgradeWebSocket, websocket } from "hono/bun";

View File

@@ -19,6 +19,7 @@ export interface RegisterEnvironmentRequest {
max_sessions?: number;
worker_type?: string;
bridge_id?: string;
capabilities?: Record<string, unknown>;
}
export interface RegisterEnvironmentResponse {
@@ -70,6 +71,14 @@ export interface SessionResponse {
username: string | null;
created_at: number;
updated_at: number;
automation_state?: AutomationStateResponse;
}
export interface AutomationStateResponse {
enabled: boolean;
phase: "standby" | "sleeping" | null;
next_tick_at: number | null;
sleep_until: number | null;
}
// --- v2 Code Sessions ---
@@ -97,6 +106,8 @@ export interface EnvironmentResponse {
status: string;
username: string | null;
last_poll_at: number | null;
worker_type?: string;
capabilities?: Record<string, unknown> | null;
}
export interface SessionSummaryResponse {

View File

@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
export type SessionEventType =
| "user"
| "assistant"
| "automation_state"
| "permission_request"
| "permission_response"
| "control_request"
@@ -49,6 +50,7 @@ export type SessionEventType =
export interface NormalizedEventPayload {
content: string;
raw?: unknown;
isSynthetic?: boolean;
[key: string]: unknown;
}

View File

@@ -1,89 +0,0 @@
/**
* Remote Control — API Client (UUID-based auth)
*/
const BASE = ""; // same origin
function generateUuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback for non-secure contexts (HTTP without localhost)
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
);
}
export function getUuid() {
let uuid = localStorage.getItem("rcs_uuid");
if (!uuid) {
uuid = generateUuid();
localStorage.setItem("rcs_uuid", uuid);
}
return uuid;
}
export function setUuid(uuid) {
localStorage.setItem("rcs_uuid", uuid);
}
async function api(method, path, body) {
const headers = { "Content-Type": "application/json" };
const uuid = getUuid();
// Append uuid as query param for auth
const sep = path.includes("?") ? "&" : "?";
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
const opts = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const data = await res.json();
if (!res.ok) {
const err = data.error || { type: "unknown", message: res.statusText };
throw new Error(err.message || err.type);
}
return data;
}
export function apiBind(sessionId) {
return api("POST", "/web/bind", { sessionId });
}
export function apiFetchSessions() {
return api("GET", "/web/sessions");
}
export function apiFetchAllSessions() {
return api("GET", "/web/sessions/all");
}
export function apiFetchSession(id) {
return api("GET", `/web/sessions/${id}`);
}
export function apiFetchSessionHistory(id) {
return api("GET", `/web/sessions/${id}/history`);
}
export function apiFetchEnvironments() {
return api("GET", "/web/environments");
}
export function apiSendEvent(sessionId, body) {
return api("POST", `/web/sessions/${sessionId}/events`, body);
}
export function apiSendControl(sessionId, body) {
return api("POST", `/web/sessions/${sessionId}/control`, body);
}
export function apiInterrupt(sessionId) {
return api("POST", `/web/sessions/${sessionId}/interrupt`);
}
export function apiCreateSession(body) {
return api("POST", "/web/sessions", body);
}

View File

@@ -1,702 +0,0 @@
/**
* Remote Control — Main App (Router + Orchestrator)
* UUID-based auth — no login required
*/
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
import { connectSSE, disconnectSSE } from "./sse.js";
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
// ============================================================
// State
// ============================================================
let currentSessionId = null;
let currentSessionStatus = null;
let dashboardInterval = null;
let cachedEnvs = [];
function generateMessageUuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
// ============================================================
// Router
// ============================================================
function getPathSessionId() {
const match = window.location.pathname.match(/^\/code\/([^/]+)/);
return match ? match[1] : null;
}
function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function showPage(name) {
const pages = ["dashboard", "session"];
for (const p of pages) {
const el = document.getElementById(`page-${p}`);
if (el) el.classList.toggle("hidden", p !== name);
}
}
function navigate(path) {
history.pushState(null, "", path);
handleRoute();
}
window.navigate = navigate;
function applySessionStatus(status) {
currentSessionStatus = status || null;
const badge = document.getElementById("session-status");
if (badge) {
badge.textContent = status || "";
badge.className = `status-badge status-${statusClass(status)}`;
}
const closed = isClosedSessionStatus(status);
const input = document.getElementById("msg-input");
if (input) {
input.disabled = closed;
input.placeholder = closed ? "Session is closed" : "Type a message...";
}
const actionBtn = document.getElementById("action-btn");
if (actionBtn) {
actionBtn.disabled = closed;
actionBtn.title = closed ? "Session is closed" : "";
}
if (closed) {
removeLoading();
window.__updateActionBtn?.(false);
}
}
function handleSessionEvent(event) {
if (event?.type === "session_status" && typeof event.payload?.status === "string") {
applySessionStatus(event.payload.status);
if (isClosedSessionStatus(event.payload.status)) {
disconnectSSE();
}
}
appendEvent(event);
}
async function syncClosedSessionState(err, actionLabel) {
if (!(err instanceof Error)) {
alert(`${actionLabel}: unknown error`);
return;
}
if (!currentSessionId || !/session is /i.test(err.message)) {
alert(`${actionLabel}: ${err.message}`);
return;
}
try {
const session = await apiFetchSession(currentSessionId);
applySessionStatus(session.status);
if (isClosedSessionStatus(session.status)) {
appendEvent({ type: "session_status", payload: { status: session.status } });
return;
}
} catch {
// Fall back to the original error if the refresh also fails.
}
alert(`${actionLabel}: ${err.message}`);
}
async function handleRoute() {
// Ensure we have a UUID
getUuid();
// Check for UUID import from QR scan (?uuid=xxx)
const importUuid = getUrlParam("uuid");
if (importUuid) {
setUuid(importUuid);
const url = new URL(window.location);
url.searchParams.delete("uuid");
history.replaceState(null, "", url);
}
// Check for CLI session bind (?sid=xxx)
const sid = getUrlParam("sid");
if (sid) {
try {
await apiBind(sid);
const url = new URL(window.location);
url.searchParams.delete("sid");
history.replaceState(null, "", `/code/${sid}`);
showPage("session");
stopDashboardRefresh();
renderSessionDetail(sid);
return;
} catch (err) {
console.error("Failed to bind session:", err);
alert("Session not found or bind failed: " + err.message);
history.replaceState(null, "", "/code/");
}
}
// Path-based routing: /code/session_xxx → session detail
const pathSessionId = getPathSessionId();
if (pathSessionId) {
try { await apiBind(pathSessionId); } catch { /* may already be bound */ }
showPage("session");
stopDashboardRefresh();
renderSessionDetail(pathSessionId);
return;
}
// Default: /code → dashboard
currentSessionId = null;
currentSessionStatus = null;
showPage("dashboard");
disconnectSSE();
renderDashboard();
startDashboardRefresh();
}
window.addEventListener("popstate", handleRoute);
// ============================================================
// Dashboard
// ============================================================
async function renderDashboard() {
try {
const [sessions, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]);
cachedEnvs = envs || [];
renderEnvironmentList(cachedEnvs);
renderSessionList(sessions);
} catch (err) {
console.error("Dashboard render error:", err);
}
}
function renderEnvironmentList(envs) {
const container = document.getElementById("env-list");
if (!envs || envs.length === 0) {
container.innerHTML = '<div class="empty-state">No active environments</div>';
return;
}
container.innerHTML = envs.map((e) => `
<div class="env-card">
<div>
<div class="env-name">${esc(e.machine_name || e.id)}</div>
<div class="env-dir">${esc(e.directory || "")}</div>
</div>
<div style="text-align:right">
<span class="status-badge status-${statusClass(e.status)}">${esc(e.status)}</span>
<div class="env-branch">${e.branch ? esc(e.branch) : ""}</div>
</div>
</div>`).join("");
}
function renderSessionList(sessions) {
const container = document.getElementById("session-list");
if (!sessions || sessions.length === 0) {
container.innerHTML = '<div class="empty-state">No sessions</div>';
return;
}
sessions.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
container.innerHTML = sessions.map((s) => `
<div class="session-card" onclick="navigate('/code/${esc(s.id)}')">
<div>
<div class="session-title-text">${esc(s.title || s.id)}</div>
<div class="session-id-text">${esc(s.id)}</div>
</div>
<span class="status-badge status-${statusClass(s.status)}">${esc(s.status)}</span>
<span class="meta-item">${formatTime(s.created_at || s.updated_at)}</span>
</div>`).join("");
}
function startDashboardRefresh() {
stopDashboardRefresh();
dashboardInterval = setInterval(renderDashboard, 10000);
}
function stopDashboardRefresh() {
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
}
// ============================================================
// Session Detail
// ============================================================
async function renderSessionDetail(id) {
currentSessionId = id;
// Reset task state for new session and init panel
resetTaskState();
const taskPanelEl = document.getElementById("task-panel");
if (taskPanelEl) initTaskPanel(taskPanelEl);
try {
const session = await apiFetchSession(id);
document.getElementById("session-title").textContent = session.title || session.id;
document.getElementById("session-id").textContent = session.id;
document.getElementById("session-env").textContent = session.environment_id || "";
document.getElementById("session-time").textContent = formatTime(session.created_at);
applySessionStatus(session.status);
} catch (err) {
alert("Failed to load session: " + err.message);
navigate("/code/");
return;
}
document.getElementById("event-stream").innerHTML = "";
document.getElementById("permission-area").innerHTML = "";
document.getElementById("permission-area").classList.add("hidden");
// Load historical events before connecting to live stream
resetReplayState();
let lastSeqNum = 0;
try {
const { events } = await apiFetchSessionHistory(id);
if (events && events.length > 0) {
for (const event of events) {
appendEvent(event, { replay: true });
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
}
}
} catch (err) {
console.warn("Failed to load session history:", err);
}
// Re-render any still-unresolved permission prompts from history
renderReplayPendingRequests();
if (isClosedSessionStatus(currentSessionStatus)) {
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
disconnectSSE();
return;
}
connectSSE(id, handleSessionEvent, lastSeqNum);
}
// ============================================================
// Control Bar
// ============================================================
function setupControlBar() {
const input = document.getElementById("msg-input");
const actionBtn = document.getElementById("action-btn");
const iconSend = document.getElementById("action-icon-send");
const iconStop = document.getElementById("action-icon-stop");
function setBtnState(loading) {
actionBtn.classList.toggle("loading", loading);
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
iconSend.classList.toggle("hidden", loading);
iconStop.classList.toggle("hidden", !loading);
}
window.__updateActionBtn = setBtnState;
actionBtn.addEventListener("click", () => {
if (isLoading()) {
doInterrupt();
} else {
sendMessage();
}
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage(); }
});
}
async function doInterrupt() {
if (!currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
const btn = document.getElementById("action-btn");
btn.disabled = true;
try {
await apiInterrupt(currentSessionId);
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
} catch (err) {
await syncClosedSessionState(err, "Interrupt failed");
} finally {
btn.disabled = isClosedSessionStatus(currentSessionStatus);
}
}
async function sendMessage() {
const input = document.getElementById("msg-input");
const text = input.value.trim();
if (!text || !currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
input.value = "";
const uuid = generateMessageUuid();
try {
await apiSendEvent(currentSessionId, {
type: "user",
uuid,
content: text,
message: { content: text },
});
} catch (err) {
input.value = text;
await syncClosedSessionState(err, "Failed to send");
}
}
// ============================================================
// Permission Actions (exposed globally for onclick)
// ============================================================
window._approvePerm = async function (requestId, btn) {
btn.disabled = true;
try {
await apiSendControl(currentSessionId, { type: "permission_response", approved: true, request_id: requestId });
removePermissionPrompt(btn);
showLoading();
} catch (err) { alert("Failed to approve: " + err.message); btn.disabled = false; }
};
window._rejectPerm = async function (requestId, btn) {
btn.disabled = true;
try {
await apiSendControl(currentSessionId, { type: "permission_response", approved: false, request_id: requestId });
removePermissionPrompt(btn);
} catch (err) { alert("Failed to reject: " + err.message); btn.disabled = false; }
};
// ============================================================
// AskUserQuestion interactions
// ============================================================
window._selectOption = function (btn, qIdx, oIdx, multiSelect) {
const panel = btn.closest(".ask-panel");
if (!panel) return;
if (!panel._answers) panel._answers = {};
if (multiSelect) {
// Toggle multi-select
btn.classList.toggle("selected");
if (!panel._answers[qIdx]) panel._answers[qIdx] = [];
const arr = panel._answers[qIdx];
const pos = arr.indexOf(oIdx);
if (pos >= 0) arr.splice(pos, 1);
else arr.push(oIdx);
} else {
// Single select — deselect siblings
const siblings = panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`);
siblings.forEach((s) => s.classList.remove("selected"));
btn.classList.add("selected");
panel._answers[qIdx] = oIdx;
}
};
window._submitOther = function (btn, qIdx) {
const row = btn.closest(".ask-other-row");
const input = row.querySelector(".ask-other-input");
const text = input.value.trim();
if (!text) return;
const panel = btn.closest(".ask-panel");
if (!panel) return;
if (!panel._answers) panel._answers = {};
panel._answers[qIdx] = text;
// Deselect any option buttons
panel.querySelectorAll(`.ask-option[data-qidx="${qIdx}"]`).forEach((s) => s.classList.remove("selected"));
input.value = "";
btn.textContent = "Sent!";
setTimeout(() => { btn.textContent = "Send"; }, 1000);
};
window._switchAskTab = function (btn, idx) {
const panel = btn.closest(".ask-panel");
if (!panel) return;
panel.querySelectorAll(".ask-tab").forEach((t) => t.classList.remove("active"));
panel.querySelectorAll(".ask-tab-page").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
const page = panel.querySelector(`.ask-tab-page[data-tab="${idx}"]`);
if (page) page.classList.add("active");
const total = panel.querySelectorAll(".ask-tab").length;
const prog = panel.querySelector(".ask-progress");
if (prog) prog.textContent = `${idx + 1} / ${total}`;
};
window._submitAnswers = async function (requestId, btn) {
btn.disabled = true;
const panel = btn.closest(".ask-panel");
const rawAnswers = panel?._answers || {};
const questions = panel?._questions || [];
// Build updatedInput: merge original input with user's answers
const answers = {};
for (const [qIdx, val] of Object.entries(rawAnswers)) {
const q = questions[parseInt(qIdx)];
if (!q) continue;
if (typeof val === "string") {
// "Other" free-text answer
answers[qIdx] = val;
} else if (typeof val === "number") {
// Selected option index — use label text
const opt = q.options?.[val];
answers[qIdx] = opt?.label || String(val);
} else if (Array.isArray(val)) {
// Multi-select — join labels
answers[qIdx] = val.map((i) => q.options?.[i]?.label || String(i));
}
}
try {
await apiSendControl(currentSessionId, {
type: "permission_response",
approved: true,
request_id: requestId,
updated_input: { questions, answers },
});
removePermissionPrompt(btn);
showLoading();
} catch (err) { alert("Failed to submit: " + err.message); btn.disabled = false; }
};
function removePermissionPrompt(btn) {
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
if (prompt) prompt.remove();
const area = document.getElementById("permission-area");
if (area && area.children.length === 0) area.classList.add("hidden");
}
// ============================================================
// ExitPlanMode interactions
// ============================================================
window._selectPlanOption = function (btn, value) {
const panel = btn.closest(".plan-panel");
if (!panel) return;
// Deselect all siblings
panel.querySelectorAll(".plan-option").forEach((o) => o.classList.remove("selected"));
btn.classList.add("selected");
panel._selectedValue = value;
// Show/hide feedback textarea
const feedbackArea = panel.querySelector(".plan-feedback-area");
if (feedbackArea) {
feedbackArea.classList.toggle("visible", value === "no");
}
};
window._submitPlanResponse = async function (requestId, btn) {
const panel = btn.closest(".plan-panel");
if (!panel) return;
const selectedValue = panel._selectedValue;
if (!selectedValue) {
alert("Please select an option first.");
return;
}
btn.disabled = true;
try {
if (selectedValue === "no") {
// Rejection with optional feedback
const feedbackInput = panel.querySelector(".plan-feedback-input");
const feedback = feedbackInput ? feedbackInput.value.trim() : "";
await apiSendControl(currentSessionId, {
type: "permission_response",
approved: false,
request_id: requestId,
...(feedback ? { message: feedback } : {}),
});
removePermissionPrompt(btn);
} else {
// Approval with permission mode
const modeMap = {
"yes-accept-edits": "acceptEdits",
"yes-default": "default",
};
const mode = modeMap[selectedValue] || "default";
const planContent = panel._planContent || "";
await apiSendControl(currentSessionId, {
type: "permission_response",
approved: true,
request_id: requestId,
...(planContent ? { updated_input: { plan: planContent } } : {}),
updated_permissions: [
{ type: "setMode", mode, destination: "session" },
],
});
removePermissionPrompt(btn);
showLoading();
}
} catch (err) {
alert("Failed to submit: " + err.message);
btn.disabled = false;
}
};
// ============================================================
// New Session Dialog
// ============================================================
function setupNewSessionDialog() {
const btn = document.getElementById("new-session-btn");
const dialog = document.getElementById("new-session-dialog");
const cancelBtn = document.getElementById("ns-cancel");
const createBtn = document.getElementById("ns-create");
const errorEl = document.getElementById("ns-error");
const titleInput = document.getElementById("ns-title");
const envSelect = document.getElementById("ns-env");
btn.addEventListener("click", () => {
envSelect.innerHTML = '<option value="">-- None --</option>';
for (const e of cachedEnvs) {
const opt = document.createElement("option");
opt.value = e.id;
opt.textContent = `${e.machine_name || e.id} (${e.branch || "no branch"})`;
envSelect.appendChild(opt);
}
errorEl.classList.add("hidden");
titleInput.value = "";
dialog.classList.remove("hidden");
});
cancelBtn.addEventListener("click", () => dialog.classList.add("hidden"));
createBtn.addEventListener("click", async () => {
createBtn.disabled = true;
errorEl.classList.add("hidden");
try {
const body = {};
if (titleInput.value.trim()) body.title = titleInput.value.trim();
if (envSelect.value) body.environment_id = envSelect.value;
const session = await apiCreateSession(body);
dialog.classList.add("hidden");
navigate(`/code/${session.id}`);
} catch (err) {
errorEl.textContent = err.message || "Failed to create session";
errorEl.classList.remove("hidden");
} finally {
createBtn.disabled = false;
}
});
}
// ============================================================
// Identity Panel (QR code display + scan)
// ============================================================
function setupIdentityPanel() {
const btn = document.getElementById("nav-identity");
const panel = document.getElementById("identity-panel");
const closeBtn = panel.querySelector(".panel-close");
const uuidDisplay = document.getElementById("uuid-display");
const qrContainer = document.getElementById("qr-display");
// Show panel and generate QR code
btn.addEventListener("click", () => {
const uuid = getUuid();
uuidDisplay.textContent = uuid;
const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`;
qrContainer.innerHTML = "";
if (typeof QRCode !== "undefined") {
new QRCode(qrContainer, { text: qrUrl, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.M });
// qrcodejs generates both canvas and img, hide the duplicate img
const img = qrContainer.querySelector("img");
if (img) img.remove()
}
panel.classList.remove("hidden");
});
closeBtn.addEventListener("click", () => panel.classList.add("hidden"));
// Click outside to close
panel.addEventListener("click", (e) => {
if (e.target === panel) panel.classList.add("hidden");
});
// Copy UUID to clipboard
document.getElementById("uuid-copy-btn").addEventListener("click", () => {
const uuid = getUuid();
navigator.clipboard.writeText(uuid).then(() => {
const btn = document.getElementById("uuid-copy-btn");
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
});
});
// Scan QR from uploaded image
document.getElementById("qr-scan-btn").addEventListener("click", () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
if (typeof jsQR !== "undefined") {
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code && code.data) {
try {
const url = new URL(code.data);
const importedUuid = url.searchParams.get("uuid");
if (importedUuid) {
setUuid(importedUuid);
panel.classList.add("hidden");
navigate("/code/");
renderDashboard();
return;
}
} catch {
// Not a valid URL — try using raw data as UUID
if (code.data.length >= 32) {
setUuid(code.data);
panel.classList.add("hidden");
navigate("/code/");
renderDashboard();
return;
}
}
alert("No valid UUID found in QR code");
} else {
alert("No QR code found in image");
}
}
};
img.src = URL.createObjectURL(file);
};
input.click();
});
}
// ============================================================
// Task Panel Toggle
// ============================================================
function setupTaskPanelToggle() {
window.__toggleTaskPanel = toggleTaskPanel;
const toggleBtn = document.getElementById("task-panel-toggle");
if (toggleBtn) {
toggleBtn.addEventListener("click", () => toggleTaskPanel());
}
}
// ============================================================
// Init
// ============================================================
document.addEventListener("DOMContentLoaded", () => {
setupControlBar();
setupNewSessionDialog();
setupIdentityPanel();
setupTaskPanelToggle();
handleRoute();
});

View File

@@ -1,116 +0,0 @@
/* === CSS Variables — Anthropic Design System === */
:root {
/* Core palette — warm terracotta system */
--bg-primary: #FAF9F6;
--bg-card: #FFFFFF;
--bg-dark: #1A1612;
--bg-dark-hover: #2A2520;
--bg-dark-elevated: #332E28;
--bg-input: #F2EFEA;
--bg-input-focus: #FFFFFF;
--bg-user-msg: #D97757;
--bg-assistant-msg: #FFFFFF;
--bg-tool-card: #F5F3EF;
--bg-permission: #FFF9F0;
--text-primary: #1A1612;
--text-secondary: #6B6560;
--text-light: #FFFFFF;
--text-muted: #9B9590;
--text-inverse: #FAF9F6;
--border: #E8E4DF;
--border-light: #F0ECE7;
--border-focus: #D97757;
--accent: #D97757;
--accent-hover: #C4684A;
--accent-subtle: #FDF0EB;
--green: #3B8A6A;
--green-bg: #E8F5EE;
--yellow: #C49A2C;
--yellow-bg: #FFF8E8;
--orange: #D07A3A;
--orange-bg: #FFF3E8;
--red: #C44040;
--red-bg: #FDE8E8;
--blue: #4A7FC4;
--blue-bg: #E8F0FD;
--radius: 14px;
--radius-sm: 10px;
--radius-xs: 6px;
--shadow-sm: 0 1px 2px rgba(26, 22, 18, 0.04);
--shadow: 0 1px 3px rgba(26, 22, 18, 0.06), 0 2px 8px rgba(26, 22, 18, 0.04);
--shadow-md: 0 4px 16px rgba(26, 22, 18, 0.08), 0 1px 4px rgba(26, 22, 18, 0.04);
--shadow-lg: 0 8px 32px rgba(26, 22, 18, 0.10), 0 2px 8px rgba(26, 22, 18, 0.06);
--font-display: "Bricolage Grotesque", system-ui, -apple-system, sans-serif;
--font-sans: "Figtree", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "Fira Code", "SF Mono", Menlo, monospace;
--max-width: 880px;
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* === Reset === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-size: 15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
position: relative;
}
/* Subtle warm ambient light */
body::before {
content: '';
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background:
radial-gradient(ellipse at 20% 50%, rgba(217, 119, 87, 0.03) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(217, 119, 87, 0.02) 0%, transparent 50%),
radial-gradient(ellipse at 50% 80%, rgba(59, 138, 106, 0.02) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
body > * { position: relative; z-index: 1; }
a {
color: var(--accent);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover { color: var(--accent-hover); }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }
.hidden { display: none !important; }
/* === Selection === */
::selection {
background: rgba(217, 119, 87, 0.2);
color: var(--text-primary);
}
/* === Focus Ring === */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* === Scrollbar === */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }

View File

@@ -1,234 +0,0 @@
/* === Navbar — Anthropic === */
nav {
background: var(--bg-card);
color: var(--text-primary);
position: sticky;
top: 0;
z-index: 100;
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 32px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
color: var(--text-primary);
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: -0.01em;
transition: opacity var(--transition-fast);
}
.nav-logo:hover { opacity: 0.7; text-decoration: none; }
.nav-logo svg { flex-shrink: 0; }
.nav-links { display: flex; align-items: center; gap: 8px; }
.nav-link {
color: var(--text-secondary);
font-size: 0.88rem;
font-weight: 500;
background: none;
border: none;
padding: 6px 12px;
border-radius: var(--radius-xs);
transition: all var(--transition-fast);
letter-spacing: -0.005em;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-input);
text-decoration: none;
}
.btn-text { background: none; border: none; color: inherit; }
/* === Buttons — Anthropic === */
.btn-primary {
background: var(--accent);
color: var(--text-light);
border: none;
border-radius: var(--radius-sm);
padding: 11px 22px;
font-size: 0.92rem;
font-weight: 600;
letter-spacing: -0.005em;
transition: all var(--transition-fast);
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.2);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.3);
transform: translateY(-1px);
}
.btn-primary:active { transform: translateY(0); box-shadow: none; }
.btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-danger {
background: var(--red);
color: var(--text-light);
border: none;
border-radius: var(--radius-sm);
padding: 11px 18px;
font-size: 0.85rem;
font-weight: 600;
transition: all var(--transition-fast);
}
.btn-danger:hover { background: #B33838; transform: translateY(-1px); }
.btn-danger:active { transform: translateY(0); }
.btn-sm { padding: 8px 16px; font-size: 0.85rem; }
.btn-outline {
background: transparent;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 16px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.btn-outline:hover {
background: var(--bg-input);
border-color: var(--text-muted);
}
.btn-approve {
background: var(--green);
color: var(--text-light);
border: none;
border-radius: var(--radius-sm);
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 600;
transition: all var(--transition-fast);
}
.btn-approve:hover { background: #347A5E; transform: translateY(-1px); }
.btn-approve:active { transform: translateY(0); }
.btn-reject {
background: transparent;
color: var(--red);
border: 1.5px solid var(--red);
border-radius: var(--radius-sm);
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 600;
transition: all var(--transition-fast);
}
.btn-reject:hover { background: var(--red-bg); transform: translateY(-1px); }
.btn-reject:active { transform: translateY(0); }
/* === Status Badge — Anthropic === */
.status-badge {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-active, .status-running { background: var(--green-bg); color: var(--green); }
.status-idle { background: var(--yellow-bg); color: var(--yellow); }
.status-inactive { background: #F0ECE7; color: var(--text-secondary); }
.status-requires_action { background: var(--orange-bg); color: var(--orange); }
.status-archived { background: #F0ECE7; color: var(--text-secondary); }
.status-error { background: var(--red-bg); color: var(--red); }
.status-default { background: #F0ECE7; color: var(--text-muted); }
/* === Dialog — Anthropic === */
.dialog-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(26, 22, 18, 0.3);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: fadeIn var(--transition-fast) ease-out;
}
.dialog-card {
background: var(--bg-card);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 32px;
width: 100%;
max-width: 440px;
border: 1px solid var(--border-light);
animation: slideUp var(--transition-base) ease-out;
}
.dialog-card h3 {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 20px;
letter-spacing: -0.01em;
}
.dialog-card label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
margin-top: 16px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dialog-card input,
.dialog-card select {
width: 100%;
padding: 10px 14px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
font-size: 0.92rem;
color: var(--text-primary);
outline: none;
transition: all var(--transition-fast);
}
.dialog-card input:focus,
.dialog-card select:focus {
border-color: var(--accent);
background: var(--bg-input-focus);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
.dialog-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 24px;
}
/* === Animations === */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}

View File

@@ -0,0 +1,466 @@
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui/button";
import { StatusDot } from "./ui/connection-status";
import { ThemeToggle } from "./ui/theme-toggle";
import { Label } from "./ui/label";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "./ui/input-group";
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp";
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp";
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
import { useQRScanner, type QRCodeData } from "../src/hooks";
// Get token from URL query param (for pre-filled URLs from server)
function getTokenFromUrl(): string | undefined {
try {
const url = new URL(window.location.href);
return url.searchParams.get("token") || undefined;
} catch {
return undefined;
}
}
// Infer WebSocket URL from current page URL (for pre-filled links from server)
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
function inferProxyUrlFromPage(): string | undefined {
try {
const url = new URL(window.location.href);
// Only infer if we have a token param (indicates user came from server-printed URL)
if (!url.searchParams.has("token")) {
return undefined;
}
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${url.host}/ws`;
} catch {
return undefined;
}
}
// Get initial settings from defaults, with optional URL overrides
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
const settings = { ...DEFAULT_SETTINGS };
// Override from URL if enabled (for pre-filled links from server)
if (inferFromUrl) {
const urlToken = getTokenFromUrl();
const inferredUrl = inferProxyUrlFromPage();
if (urlToken) {
settings.token = urlToken;
}
if (inferredUrl) {
settings.proxyUrl = inferredUrl;
}
}
return settings;
}
export interface ACPConnectProps {
onClientReady?: (client: ACPClient | null) => void;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
/** Handler for browser tool calls (only Chrome extension can execute these) */
browserToolHandler?: (params: BrowserToolParams) => Promise<BrowserToolResult>;
/** Show token input field (for remote access) */
showTokenInput?: boolean;
/** Infer proxy URL and token from page URL (for PWA) */
inferFromUrl?: boolean;
/** Placeholder for proxy URL input */
placeholder?: string;
/** Show QR code scan button (for mobile) */
showScanButton?: boolean;
}
export function ACPConnect({
onClientReady,
expanded,
onExpandedChange,
browserToolHandler,
showTokenInput = false,
inferFromUrl = false,
placeholder = "Proxy server URL",
showScanButton = false,
}: ACPConnectProps) {
const [settings, setSettings] = useState<ACPSettings>(() => getInitialSettings(inferFromUrl));
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
const [error, setError] = useState<string | null>(null);
const [isShaking, setIsShaking] = useState(false);
const [client, setClient] = useState<ACPClient | null>(null);
const [maxHeight, setMaxHeight] = useState<number>(200);
const contentRef = useRef<HTMLDivElement>(null);
const hasAutoCollapsedRef = useRef(false);
const pendingAutoConnectRef = useRef(false);
// Store initial settings in a ref to avoid eslint warning about empty deps
const initialSettingsRef = useRef<ACPSettings>(settings);
// QR Scanner hook
const handleQRScan = useCallback((data: QRCodeData) => {
// Mark for auto-connect (will be triggered by settings useEffect)
pendingAutoConnectRef.current = true;
// Update settings - this will trigger auto-connect via useEffect
setSettings((prev) => ({
...prev,
proxyUrl: data.url,
token: data.token,
}));
}, []);
const handleQRError = useCallback((errorMsg: string) => {
setError(errorMsg);
}, []);
const { isScanning, videoRef, startScanning, stopScanning, scanFromFile } = useQRScanner({
onScan: handleQRScan,
onError: handleQRError,
});
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
useLayoutEffect(() => {
if (expanded && contentRef.current) {
setMaxHeight(contentRef.current.scrollHeight);
}
}, [expanded, isScanning]);
// File input ref for album scanning
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle file selection from album
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await scanFromFile(file);
stopScanning(); // Close the scanner overlay after album scan
}
// Reset input to allow re-selecting the same file
e.target.value = "";
},
[scanFromFile, stopScanning]
);
// Open file picker
const handleSelectFromAlbum = useCallback(() => {
fileInputRef.current?.click();
}, []);
// Initialize client once on mount using initial settings from ref
useEffect(() => {
const acpClient = new ACPClient(initialSettingsRef.current);
acpClient.setConnectionStateHandler((state, err) => {
setConnectionState(state);
setError(err || null);
});
setClient(acpClient);
return () => {
acpClient.disconnect();
};
}, []);
// Register browser tool handler when it changes
useEffect(() => {
if (client && browserToolHandler) {
client.setBrowserToolCallHandler(browserToolHandler);
}
}, [client, browserToolHandler]);
// Update client settings when settings change, and auto-connect if pending
useEffect(() => {
if (client) {
client.updateSettings(settings);
// Auto-connect after QR scan (when pendingAutoConnectRef is set)
if (pendingAutoConnectRef.current) {
pendingAutoConnectRef.current = false;
client.connect().catch((e) => {
// Ignore disconnect requested - user cancelled intentionally
if (e instanceof DisconnectRequestedError) {
return;
}
setError((e as Error).message);
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
onExpandedChange(true);
});
}
}
}, [settings, client, onExpandedChange]);
// Notify parent when client is ready and auto-collapse on connect
useEffect(() => {
const isConnected = connectionState === "connected";
onClientReady?.(isConnected ? client : null);
// Auto-collapse when connected for the first time
if (isConnected && !hasAutoCollapsedRef.current) {
hasAutoCollapsedRef.current = true;
onExpandedChange(false);
}
// Reset auto-collapse flag when disconnected
if (connectionState === "disconnected") {
hasAutoCollapsedRef.current = false;
}
}, [connectionState, client, onClientReady, onExpandedChange]);
const handleConnect = useCallback(async () => {
// Prevent duplicate connect calls if already connecting or connected
if (!client || connectionState === "connecting" || connectionState === "connected") {
return;
}
setError(null);
setIsShaking(false);
try {
await client.connect();
} catch (e) {
// Ignore disconnect requested - user cancelled intentionally
if (e instanceof DisconnectRequestedError) {
return;
}
const errorMessage = (e as Error).message;
setError(errorMessage);
// Trigger shake animation
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
// Ensure panel is expanded to show error
onExpandedChange(true);
}
}, [client, connectionState, onExpandedChange]);
const handleDisconnect = useCallback(() => {
client?.disconnect();
}, [client]);
const updateSetting = <K extends keyof ACPSettings>(key: K, value: ACPSettings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
// Clear error when starting to scan
const handleStartScanning = useCallback(() => {
setError(null);
startScanning();
}, [startScanning]);
const isConnected = connectionState === "connected";
const isConnecting = connectionState === "connecting";
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isConnected && !isConnecting) {
e.preventDefault();
handleConnect();
}
}, [isConnected, isConnecting, handleConnect]);
// Format URL for display
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, "");
// Get status label
const statusLabels: Record<ConnectionState, string> = {
disconnected: "Disconnected",
connecting: "Connecting...",
connected: "Connected",
error: "Error",
};
return (
<div className="bg-background/80 backdrop-blur-sm">
<div className="max-w-md mx-auto border-b">
{/* Status Bar - Always visible */}
<button
onClick={() => onExpandedChange(!expanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<StatusDot state={connectionState} />
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
{isConnected && displayUrl && (
<span className="text-xs text-muted-foreground"> {displayUrl}</span>
)}
</div>
<div className="flex items-center gap-1">
<div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
<ChevronDown
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
expanded ? "rotate-180" : ""
}`}
/>
</div>
</button>
{/* Expandable Settings Panel */}
<div
className="overflow-hidden transition-all duration-200 ease-out"
style={{
maxHeight: expanded ? maxHeight : 0,
opacity: expanded ? 1 : 0,
}}
>
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? "animate-shake" : ""}`}>
{/* Hidden file input for album scanning */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
{isScanning && createPortal(
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<video
ref={videoRef}
className="flex-1 w-full object-cover"
/>
<Button
onClick={stopScanning}
variant="ghost"
size="sm"
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
>
<X className="h-5 w-5" />
</Button>
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
<Button
onClick={handleSelectFromAlbum}
variant="secondary"
size="sm"
className="h-9 px-4"
>
<Image className="h-4 w-4 mr-2" />
Select from Album
</Button>
<span className="text-sm text-white/80">
or point camera at QR code
</span>
</div>
</div>,
document.body
)}
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
<div className={`space-y-3 ${isScanning ? "invisible" : ""}`}>
{/* Server URL */}
<div className="space-y-1.5">
<Label htmlFor="proxy-url">Server</Label>
<div className="flex gap-2">
{showScanButton && !isConnected && !isConnecting && (
<Button
onClick={handleStartScanning}
variant="outline"
size="sm"
className="h-9 px-3"
title="Scan QR code"
type="button"
>
<ScanLine className="h-4 w-4" />
</Button>
)}
<InputGroup className="flex-1" data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<Globe />
</InputGroupAddon>
<InputGroupInput
id="proxy-url"
value={settings.proxyUrl}
onChange={(e) => updateSetting("proxyUrl", e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
disabled={isConnected || isConnecting}
aria-invalid={!!error}
/>
</InputGroup>
{!isConnected ? (
<Button
onClick={handleConnect}
disabled={isConnecting}
size="sm"
className="h-9 px-4"
type="button"
>
{isConnecting ? "..." : "Connect"}
</Button>
) : (
<Button
onClick={handleDisconnect}
variant="destructive"
size="sm"
className="h-9 px-4"
type="button"
>
Disconnect
</Button>
)}
</div>
</div>
{/* Auth Token - only shown if enabled */}
{showTokenInput && (
<div className="space-y-1.5">
<Label htmlFor="auth-token">
Auth Token
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
</Label>
<InputGroup data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<KeyRound />
</InputGroupAddon>
<InputGroupInput
id="auth-token"
value={settings.token || ""}
onChange={(e) => updateSetting("token", e.target.value || undefined)}
onKeyDown={handleInputKeyDown}
placeholder="For remote access"
disabled={isConnected || isConnecting}
type="password"
aria-invalid={!!error}
className="font-mono"
/>
</InputGroup>
</div>
)}
{/* Working Directory */}
<div className="space-y-1.5">
<Label htmlFor="working-dir">
Working Directory
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
</Label>
<InputGroup data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<FolderOpen />
</InputGroupAddon>
<InputGroupInput
id="working-dir"
value={settings.cwd || ""}
onChange={(e) => updateSetting("cwd", e.target.value || undefined)}
onKeyDown={handleInputKeyDown}
placeholder="/path/to/project"
disabled={isConnected || isConnecting}
aria-invalid={!!error}
className="font-mono"
/>
</InputGroup>
</div>
</div>
{/* Error Message */}
{error && (
<div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">
{error}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import type { ACPClient } from "../src/acp/client";
import type { AgentSessionInfo } from "../src/acp/types";
import { ChatInterface } from "./ChatInterface";
import { cn } from "../src/lib/utils";
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
interface ACPMainProps {
client: ACPClient;
agentId?: string;
}
/**
* Main container — Anthropic sidebar + chat layout.
* Sidebar: sectioned by recency, orange active state, warm raised bg.
*/
export function ACPMain({ client, agentId }: ACPMainProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Handle session selection
const handleSelectSession = useCallback(async (session: AgentSessionInfo) => {
try {
if (client.supportsLoadSession) {
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
} else if (client.supportsResumeSession) {
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
} else {
throw new Error("Loading or resuming sessions is not supported by this agent.");
}
} catch (error) {
console.error("Failed to load/resume session:", error);
}
}, [client]);
return (
<div className="flex h-full w-full">
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
<div
className={cn(
"hidden md:flex flex-col border-r border-border/60 bg-surface-1/50 transition-all duration-200 flex-shrink-0",
sidebarCollapsed ? "w-12" : "w-64",
)}
>
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-4">
{!sidebarCollapsed && (
<span className="text-xs font-display font-semibold text-text-muted uppercase tracking-widest px-1"></span>
)}
<div className={cn("flex items-center gap-0.5", sidebarCollapsed && "mx-auto")}>
{!sidebarCollapsed && (
<button
type="button"
onClick={() => {
// ChatInterface handles new session internally
}}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
title="新会话"
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
>
{sidebarCollapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* 会话列表 */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto">
<SidebarSessionList client={client} onSelectSession={handleSelectSession} />
</div>
)}
</div>
{/* 聊天区域 */}
<div className="flex-1 flex flex-col min-w-0">
<ChatInterface client={client} agentId={agentId} />
</div>
</div>
);
}
// =============================================================================
// 侧边栏会话列表 — Anthropic 分段式(今天/昨天/更早)
// =============================================================================
function SidebarSessionList({
client,
onSelectSession,
}: {
client: ACPClient;
onSelectSession: (session: AgentSessionInfo) => void;
}) {
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [activeId, setActiveId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
if (!client.supportsSessionList) {
setLoading(false);
return;
}
setLoading(true);
try {
const response = await client.listSessions();
setSessions(response.sessions);
} catch (err) {
console.warn("[SidebarSessionList] Failed to load:", err);
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => {
if (client.getState() === "connected" && client.supportsSessionList) {
loadSessions();
}
}, [client, loadSessions]);
useEffect(() => {
const handler = (state: string) => {
if (state === "connected") {
setTimeout(loadSessions, 200);
}
};
client.setConnectionStateHandler(handler);
return () => client.removeConnectionStateHandler(handler);
}, [client, loadSessions]);
useEffect(() => {
const interval = setInterval(loadSessions, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
const sorted = useMemo(
() =>
[...sessions].sort((a, b) => {
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return dateB - dateA;
}),
[sessions],
);
if (loading && sessions.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-muted font-display">...</span>
</div>
);
}
if (sessions.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-muted font-display"></span>
</div>
);
}
// 按日期分组
const groups = groupByRecency(sorted);
return (
<nav className="py-1" aria-label="历史会话">
{groups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <div className="mx-3 my-2 border-t border-border/40" />}
<div className="px-4 py-2">
<span className="text-[10px] font-display font-semibold uppercase tracking-widest text-text-muted/70">
{group.label}
</span>
</div>
{group.sessions.map((session) => (
<button
key={session.sessionId}
type="button"
onClick={() => {
setActiveId(session.sessionId);
onSelectSession(session);
}}
className={cn(
"w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors rounded-none",
session.sessionId === activeId
? "bg-brand/8 text-text-primary"
: "text-text-secondary hover:bg-surface-2/60 hover:text-text-primary",
)}
title={session.title || session.sessionId}
>
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
<span className="text-[13px] font-display truncate leading-snug">
{session.title && session.title.trim() ? session.title : "新会话"}
</span>
</button>
))}
</div>
))}
</nav>
);
}
// =============================================================================
// 按日期分组:今天 / 昨天 / 更早
// =============================================================================
interface SessionGroup {
label: string;
sessions: AgentSessionInfo[];
}
function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const groups: SessionGroup[] = [
{ label: "今天", sessions: [] },
{ label: "昨天", sessions: [] },
{ label: "更早", sessions: [] },
];
for (const session of sessions) {
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
if (date >= today) {
groups[0].sessions.push(session);
} else if (date >= yesterday) {
groups[1].sessions.push(session);
} else {
groups[2].sessions.push(session);
}
}
return groups.filter((g) => g.sessions.length > 0);
}

View File

@@ -0,0 +1,871 @@
import { useState, useEffect, useCallback, useRef } from "react";
import imageCompression from "browser-image-compression";
import type { ACPClient } from "../src/acp/client";
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types";
import { ChatView } from "./chat/ChatView";
import { ChatInput } from "./chat/ChatInput";
import { PermissionPanel } from "./chat/PermissionPanel";
import { ModelSelectorPopover } from "./model-selector";
import { useCommands } from "../src/hooks/useCommands";
// Image compression options
// Claude API has a 5MB limit, so we target 2MB to be safe
const IMAGE_COMPRESSION_OPTIONS = {
maxSizeMB: 2, // Max output size in MB
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
useWebWorker: true, // Non-blocking compression
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
};
// Convert data URL to Blob without using fetch()
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
function dataUrlToBlob(dataUrl: string): Blob {
// Parse the data URL: data:[<mediatype>][;base64],<data>
const commaIndex = dataUrl.indexOf(",");
if (commaIndex === -1) {
throw new Error("Invalid data URL: missing comma separator");
}
const header = dataUrl.slice(0, commaIndex);
const base64Data = dataUrl.slice(commaIndex + 1);
// Extract MIME type from header (e.g., "data:image/png;base64")
const mimeMatch = header.match(/^data:([^;,]+)/);
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
// Decode base64 to binary
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
}
import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react";
import { Button } from "./ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
// =============================================================================
// Type Definitions - imported from shared types module
// =============================================================================
interface ChatInterfaceProps {
client: ACPClient;
agentId?: string;
}
// =============================================================================
// Permission Mode Selector
// =============================================================================
const PERMISSION_MODES = [
{ value: "default", label: "默认", description: "手动审批权限请求" },
{ value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" },
{ value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" },
{ value: "plan", label: "规划模式", description: "仅规划,不执行工具" },
{ value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" },
{ value: "auto", label: "自动判断", description: "AI 自动判断是否批准" },
] as const;
function PermissionModeSelector({
mode,
onModeChange,
}: {
mode: string;
onModeChange: (mode: string) => void;
}) {
const [open, setOpen] = useState(false);
const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
>
<Shield className="h-3 w-3" />
<span className="max-w-24 truncate">{current.label}</span>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="start">
{PERMISSION_MODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => {
onModeChange(m.value);
setOpen(false);
}}
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-surface-2 transition-colors"
>
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
{mode === m.value && <Check className="h-3.5 w-3.5 text-brand" />}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{m.label}</div>
<div className="text-xs text-text-muted">{m.description}</div>
</div>
</button>
))}
</PopoverContent>
</Popover>
);
}
// =============================================================================
// Helper Functions
// =============================================================================
// Map ACP status string to our status type
function mapToolStatus(status: string): ToolCallStatus {
if (status === "completed") return "complete";
if (status === "failed") return "error";
return "running";
}
// Find tool call index in entries (search from end, like Zed)
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
return i;
}
}
return -1;
}
// =============================================================================
// ChatInterface Component
// =============================================================================
export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
const [entries, setEntries] = useState<ThreadEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [sessionReady, setSessionReady] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default");
// Reference: Zed's supports_images() checks prompt_capabilities.image
const [supportsImages, setSupportsImages] = useState(false);
const { commands: availableCommands } = useCommands(client);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const resetThreadState = useCallback(() => {
setEntries([]);
setIsLoading(false);
setSessionReady(false);
}, []);
const storageKey = agentId ? `acp_last_session_${agentId}` : null;
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
const shouldResetEntries = options?.resetEntries ?? true;
if (shouldResetEntries) {
setEntries([]);
setIsLoading(false);
}
setActiveSessionId(sessionId);
setSessionReady(true);
setSupportsImages(client.supportsImages);
// Persist session ID for restoration on remount
if (storageKey) {
try { localStorage.setItem(storageKey, sessionId); } catch {}
}
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
}, [client, storageKey]);
// =============================================================================
// Permission Request Handler
// =============================================================================
const handlePermissionRequest = useCallback((request: PermissionRequestPayload) => {
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
return;
}
console.log("[ChatInterface] Permission request:", request);
setEntries((prev) => {
// Find matching tool call (search from end)
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
if (toolCallIndex >= 0) {
// Update existing tool call's status
return prev.map((entry, index) => {
if (index !== toolCallIndex) return entry;
if (entry.type !== "tool_call") return entry;
if (entry.toolCall.status !== "running") return entry;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: "waiting_for_confirmation" as const,
permissionRequest: {
requestId: request.requestId,
options: request.options,
},
},
};
});
} else {
// No matching tool call - create standalone permission request as new entry
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
const permissionToolCall: ToolCallEntry = {
type: "tool_call",
toolCall: {
id: request.toolCall.toolCallId,
title: request.toolCall.title || "Permission Request",
status: "waiting_for_confirmation",
permissionRequest: {
requestId: request.requestId,
options: request.options,
},
isStandalonePermission: true,
},
};
return [...prev, permissionToolCall];
}
});
}, []);
// =============================================================================
// Session Update Handler (Zed-style: check last entry type)
// =============================================================================
const handleSessionUpdate = useCallback((sessionId: string, update: SessionUpdate) => {
if (activeSessionIdRef.current && sessionId !== activeSessionIdRef.current) {
return;
}
// Handle agent message chunk
if (update.sessionUpdate === "agent_message_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is AssistantMessage, append to it
if (lastEntry?.type === "assistant_message") {
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
// If last chunk is same type (message), append text
if (lastChunk?.type === "message") {
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [
...lastEntry.chunks.slice(0, -1),
{ type: "message", text: lastChunk.text + text },
],
},
];
}
// Otherwise add new message chunk
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [...lastEntry.chunks, { type: "message", text }],
},
];
}
// Create new AssistantMessage entry
const newEntry: AssistantMessageEntry = {
type: "assistant_message",
id: `assistant-${Date.now()}`,
chunks: [{ type: "message", text }],
};
return [...prev, newEntry];
});
}
// Handle agent thought chunk (NEW - was missing before)
else if (update.sessionUpdate === "agent_thought_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is AssistantMessage, append to it
if (lastEntry?.type === "assistant_message") {
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
// If last chunk is same type (thought), append text
if (lastChunk?.type === "thought") {
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [
...lastEntry.chunks.slice(0, -1),
{ type: "thought", text: lastChunk.text + text },
],
},
];
}
// Otherwise add new thought chunk
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [...lastEntry.chunks, { type: "thought", text }],
},
];
}
// Create new AssistantMessage entry with thought
const newEntry: AssistantMessageEntry = {
type: "assistant_message",
id: `assistant-${Date.now()}`,
chunks: [{ type: "thought", text }],
};
return [...prev, newEntry];
});
}
// Handle user message chunk (NEW - was missing before)
else if (update.sessionUpdate === "user_message_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is UserMessage, append to it
if (lastEntry?.type === "user_message") {
return [
...prev.slice(0, -1),
{
...lastEntry,
content: lastEntry.content + text,
},
];
}
// Create new UserMessage entry
const newEntry: UserMessageEntry = {
type: "user_message",
id: `user-${Date.now()}`,
content: text,
};
return [...prev, newEntry];
});
}
// Handle tool call (UPSERT - update if exists, create if not)
else if (update.sessionUpdate === "tool_call") {
const toolCallData: ToolCallData = {
id: update.toolCallId,
title: update.title,
status: mapToolStatus(update.status),
content: update.content,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
};
setEntries((prev) => {
// UPSERT: Check if tool call already exists
const existingIndex = findToolCallIndex(prev, update.toolCallId);
if (existingIndex >= 0) {
// UPDATE existing tool call
return prev.map((entry, index) => {
if (index !== existingIndex) return entry;
if (entry.type !== "tool_call") return entry;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
...toolCallData,
},
};
});
}
// CREATE new tool call entry
const newEntry: ToolCallEntry = {
type: "tool_call",
toolCall: toolCallData,
};
return [...prev, newEntry];
});
}
// Handle tool call update (partial update)
else if (update.sessionUpdate === "tool_call_update") {
setEntries((prev) => {
const existingIndex = findToolCallIndex(prev, update.toolCallId);
if (existingIndex < 0) {
// Tool call not found - create a failed tool call entry (like Zed)
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
const failedEntry: ToolCallEntry = {
type: "tool_call",
toolCall: {
id: update.toolCallId,
title: update.title || "Tool call not found",
status: "error",
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
},
};
return [...prev, failedEntry];
}
return prev.map((entry, index) => {
if (index !== existingIndex) return entry;
if (entry.type !== "tool_call") return entry;
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
const mergedContent = update.content
? [...(entry.toolCall.content || []), ...update.content]
: entry.toolCall.content;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: newStatus,
...(update.title && { title: update.title }),
content: mergedContent,
...(update.rawInput && { rawInput: update.rawInput }),
...(update.rawOutput && { rawOutput: update.rawOutput }),
},
};
});
});
}
// Handle plan update (replace entire plan)
else if (update.sessionUpdate === "plan") {
setEntries((prev) => {
// Empty entries → remove existing plan
if (update.entries.length === 0) {
return prev.filter((e) => e.type !== "plan");
}
// Find last plan entry
const lastPlanIndex = prev.reduce(
(acc, entry, i) => (entry.type === "plan" ? i : acc),
-1,
);
if (lastPlanIndex >= 0) {
// Update existing plan in place
return prev.map((entry, index) =>
index === lastPlanIndex
? { ...entry, entries: update.entries }
: entry,
);
}
// Create new plan entry
const newPlanEntry: PlanDisplayEntry = {
type: "plan",
id: `plan-${Date.now()}`,
entries: update.entries,
};
return [...prev, newPlanEntry];
});
}
}, []);
// =============================================================================
// Setup Effect
// =============================================================================
useEffect(() => {
client.setSessionCreatedHandler((sessionId) => {
console.log("[ChatInterface] Session created:", sessionId);
activateSession(sessionId);
});
client.setSessionLoadedHandler((sessionId) => {
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
activateSession(sessionId, { resetEntries: false });
});
client.setSessionSwitchingHandler((sessionId) => {
console.log("[ChatInterface] Switching to session:", sessionId);
setActiveSessionId(sessionId);
resetThreadState();
});
client.setSessionUpdateHandler((sessionId: string, update: SessionUpdate) => {
handleSessionUpdate(sessionId, update);
});
client.setPromptCompleteHandler((stopReason) => {
console.log("[ChatInterface] Prompt complete:", stopReason);
// Always set isLoading=false when prompt completes
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
setIsLoading(false);
});
client.setPermissionRequestHandler(handlePermissionRequest);
client.setErrorMessageHandler((msg) => {
console.error("[ChatInterface] Agent error:", msg);
setErrorMessage(msg);
// Clear any existing timer
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
// Auto-clear after 5 seconds
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
});
// Restore last session or create a new one
const lastSessionId = storageKey ? localStorage.getItem(storageKey) : null;
if (lastSessionId && (client.supportsLoadSession || client.supportsResumeSession)) {
console.log("[ChatInterface] Restoring session:", lastSessionId);
const restore = async () => {
try {
if (client.supportsLoadSession) {
await client.loadSession({ sessionId: lastSessionId });
} else {
await client.resumeSession({ sessionId: lastSessionId });
}
} catch (err) {
console.warn("[ChatInterface] Failed to restore session, creating new one:", err);
client.createSession(undefined, permissionMode);
}
};
restore();
} else {
client.createSession(undefined, permissionMode);
}
return () => {
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
client.setSessionCreatedHandler(() => {});
client.setSessionLoadedHandler(() => {});
client.setSessionSwitchingHandler(null);
client.setSessionUpdateHandler(() => {});
client.setPromptCompleteHandler(() => {});
client.setPermissionRequestHandler(() => {});
client.setErrorMessageHandler(() => {});
};
}, [activateSession, client, handlePermissionRequest, handleSessionUpdate, resetThreadState]);
// =============================================================================
// User Actions
// =============================================================================
// Reference: Zed's ConnectionView.reset() + set_server_state() + _external_thread()
// Creates a new session by clearing current state and calling new_session
// This is the core of Zed's NewThread action
const handleNewSession = useCallback(() => {
console.log("[ChatInterface] Creating new session...");
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
// Cancel any ongoing request before creating new session
if (isLoading) {
client.cancel();
}
// 1. Clear all entries (like Zed's set_server_state which creates new view)
resetThreadState();
setActiveSessionId(null);
// 3. Create new session (like Zed's initial_state -> connection.new_session())
// The session_created handler will set sessionReady=true when ready
client.createSession(undefined, permissionMode);
}, [client, isLoading, resetThreadState, permissionMode]);
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
// 2. Send cancel notification to agent
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
const handleCancel = () => {
console.log("[ChatInterface] Cancel requested");
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
setEntries((prev) =>
prev.map((entry) => {
if (entry.type !== "tool_call") return entry;
// Check if status should be canceled (matches Zed's logic)
const shouldCancel =
entry.toolCall.status === "running" ||
entry.toolCall.status === "waiting_for_confirmation";
if (!shouldCancel) return entry;
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: "canceled" as ToolCallStatus,
permissionRequest: undefined, // Clear any pending permission request
},
};
}),
);
// Send cancel notification to server (which forwards to agent)
client.cancel();
// Note: Do NOT set isLoading=false here!
// Wait for prompt_complete with stopReason="cancelled" from the agent
};
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
client.respondToPermission(requestId, optionId);
// Determine new status based on option kind
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
// Update the tool call status in entries
setEntries((prev) =>
prev.map((entry) => {
if (entry.type !== "tool_call") return entry;
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
// For standalone permission requests, mark as complete immediately when approved
// For regular tool calls, mark as running (agent will update to complete later)
let newStatus: ToolCallStatus;
if (isRejected) {
newStatus = "rejected";
} else if (entry.toolCall.isStandalonePermission) {
newStatus = "complete";
} else {
newStatus = "running";
}
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: newStatus,
permissionRequest: undefined,
isStandalonePermission: undefined,
},
};
}),
);
}, [client]);
// =============================================================================
// Render
// =============================================================================
// Collect pending permissions from tool call entries
const pendingPermissions: PendingPermission[] = entries
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
.map((e) => ({
requestId: e.toolCall.permissionRequest!.requestId,
toolName: e.toolCall.title,
toolInput: e.toolCall.rawInput || {},
description: e.toolCall.title,
options: e.toolCall.permissionRequest!.options,
}));
// Handle permission respond for unified PermissionPanel
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
// Find the matching permission request to get the real optionId
const perm = pendingPermissions.find((p) => p.requestId === requestId);
let optionId: string | null = null;
let optionKind: PermissionOption["kind"] | null = null;
if (perm?.options && perm.options.length > 0) {
if (approved) {
// Pick the first allow option (prefer allow_once, then allow_always)
const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always");
if (allowOpt) {
optionId = allowOpt.optionId;
optionKind = allowOpt.kind;
}
} else {
// Pick the first reject option
const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always");
if (rejectOpt) {
optionId = rejectOpt.optionId;
optionKind = rejectOpt.kind;
}
}
}
// Fallback: if no matching option found, use null (cancelled)
if (!optionId) {
optionKind = approved ? "allow_once" : "reject_once";
}
handlePermissionResponse(requestId, optionId, optionKind);
}, [handlePermissionResponse, pendingPermissions]);
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
const text = message.text.trim();
const images = message.images || [];
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
const contentBlocks: ContentBlock[] = [];
if (text) {
contentBlocks.push({ type: "text", text });
}
// Convert images to ContentBlock
const userImages: UserMessageImage[] = [];
for (const img of images) {
try {
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
let blob: Blob;
if (dataUrl.startsWith("data:")) {
blob = dataUrlToBlob(dataUrl);
} else {
const response = await fetch(dataUrl);
blob = await response.blob();
}
let finalBlob: Blob = blob;
let finalMimeType = img.mimeType;
if (blob.size > 2 * 1024 * 1024) {
const imageFile = new File([blob], "image.jpg", { type: blob.type });
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
finalMimeType = "image/jpeg";
}
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const commaIndex = result.indexOf(",");
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
};
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
reader.readAsDataURL(finalBlob);
});
const imageContent: ImageContent = {
type: "image",
mimeType: finalMimeType,
data: base64Data,
};
contentBlocks.push(imageContent);
userImages.push({
mimeType: finalMimeType,
data: base64Data,
});
} catch (error) {
console.error("[ChatInterface] Failed to process image:", error);
}
}
if (contentBlocks.length === 0) return;
// Add user message entry
const userEntry: UserMessageEntry = {
type: "user_message",
id: `user-${Date.now()}`,
content: text,
images: userImages.length > 0 ? userImages : undefined,
};
setEntries((prev) => [...prev, userEntry]);
setIsLoading(true);
try {
await client.sendPrompt(contentBlocks);
} catch (error) {
console.error("[ChatInterface] Failed to send prompt:", error);
setIsLoading(false);
}
}, [isLoading, sessionReady, client]);
return (
<div className="flex flex-col h-full">
{/* Chat messages — unified ChatView */}
<ChatView
entries={entries}
isLoading={isLoading && !sessionReady ? false : isLoading}
onPermissionRespond={(requestId, optionId, optionKind) => {
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
}}
emptyTitle={sessionReady ? "开始对话" : undefined}
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
/>
{/* Permission panel — fixed above input */}
<PermissionPanel
requests={pendingPermissions}
onRespond={handlePermissionPanelRespond}
/>
{/* Error banner */}
{errorMessage && (
<div className="mx-auto max-w-3xl w-full px-4 sm:px-8 pb-1">
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
<span>{errorMessage}</span>
<button
type="button"
onClick={() => setErrorMessage(null)}
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
>
{"\u00D7"}
</button>
</div>
</div>
)}
{/* Model selector + New thread + ChatInput */}
<div className="flex-shrink-0">
<div className="max-w-3xl mx-auto w-full px-4 sm:px-8 pb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
<PermissionModeSelector mode={permissionMode} onModeChange={(m: string) => { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} />
<ModelSelectorPopover client={client} />
</div>
{entries.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-text-muted hover:text-brand font-display gap-1"
onClick={handleNewSession}
>
<Plus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>New Thread</TooltipContent>
</Tooltip>
)}
</div>
<ChatInput
onSubmit={handleChatInputSubmit}
isLoading={isLoading}
onInterrupt={handleCancel}
disabled={!sessionReady}
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
supportsImages={supportsImages}
commands={availableCommands.length > 0 ? availableCommands : undefined}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { cn } from "../src/lib/utils";
import { User, Bot, Wrench, Loader2 } from "lucide-react";
export interface ToolCall {
id: string;
title: string;
status: "running" | "complete" | "error";
}
export interface ChatMessageData {
id: string;
role: "user" | "agent";
content: string;
toolCalls?: ToolCall[];
isStreaming?: boolean;
}
interface ChatMessageProps {
message: ChatMessageData;
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"flex gap-3 p-4 rounded-lg",
isUser ? "bg-muted/50" : "bg-background"
)}
>
<div
className={cn(
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
isUser ? "bg-primary text-primary-foreground" : "bg-secondary"
)}
>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="text-sm font-medium">
{isUser ? "You" : "Agent"}
</div>
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
{message.isStreaming && (
<span className="inline-block w-1.5 h-4 ml-0.5 bg-foreground animate-pulse" />
)}
</div>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="space-y-1.5 pt-2">
{message.toolCalls.map((tool) => (
<ToolCallDisplay key={tool.id} toolCall={tool} />
))}
</div>
)}
</div>
</div>
);
}
interface ToolCallDisplayProps {
toolCall: ToolCall;
}
function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
return (
<div
className={cn(
"flex items-center gap-2 text-xs px-2 py-1.5 rounded border",
toolCall.status === "running" && "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
toolCall.status === "complete" && "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
toolCall.status === "error" && "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
)}
>
{toolCall.status === "running" ? (
<Loader2 className="w-3 h-3 animate-spin text-yellow-600 dark:text-yellow-400" />
) : (
<Wrench className={cn(
"w-3 h-3",
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
toolCall.status === "error" && "text-red-600 dark:text-red-400"
)} />
)}
<span className="truncate">{toolCall.title}</span>
<span className={cn(
"ml-auto text-[10px] uppercase font-medium",
toolCall.status === "running" && "text-yellow-600 dark:text-yellow-400",
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
toolCall.status === "error" && "text-red-600 dark:text-red-400"
)}>
{toolCall.status}
</span>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Clock, RefreshCw } from "lucide-react";
import type { ACPClient } from "../src/acp/client";
import type { AgentSessionInfo } from "../src/acp/types";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { Button } from "./ui/button";
import { cn } from "../src/lib/utils";
// Reference: Zed's TimeBucket in thread_history.rs
type TimeBucket = "today" | "yesterday" | "thisWeek" | "pastWeek" | "all";
// Reference: Zed's Display impl for TimeBucket
const BUCKET_LABELS: Record<TimeBucket, string> = {
today: "Today",
yesterday: "Yesterday",
thisWeek: "This Week",
pastWeek: "Past Week",
all: "All", // Zed uses "All", not "Older"
};
// Reference: Zed's TimeBucket::from_dates (line 1028-1051)
// Rust's IsoWeek includes year, so we need to compare both year and week number
function getTimeBucket(date: Date): TimeBucket {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const entryDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (entryDate.getTime() === today.getTime()) return "today";
if (entryDate.getTime() === yesterday.getTime()) return "yesterday";
// This week: same ISO week AND year
const todayIsoWeek = getISOWeekYear(today);
const entryIsoWeek = getISOWeekYear(entryDate);
if (todayIsoWeek.year === entryIsoWeek.year && todayIsoWeek.week === entryIsoWeek.week) {
return "thisWeek";
}
// Past week: (reference - 7days).iso_week()
const lastWeekDate = new Date(today);
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
const lastWeekIsoWeek = getISOWeekYear(lastWeekDate);
if (lastWeekIsoWeek.year === entryIsoWeek.year && lastWeekIsoWeek.week === entryIsoWeek.week) {
return "pastWeek";
}
return "all";
}
// Returns ISO week number AND ISO week year (important for year boundaries)
function getISOWeekYear(date: Date): { week: number; year: number } {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year
}
// Reference: Zed's formatted_time in HistoryEntryElement (line 904-921)
// Exact format: Xd, Xh ago, Xm ago, Just now, Unknown
function formatRelativeTime(date: Date | null): string {
if (!date) return "Unknown"; // Zed uses "Unknown" for missing updatedAt
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return "Just now";
}
interface ThreadHistoryProps {
client: ACPClient;
// Returns Promise to allow loading state tracking; resolves when session is loaded
onSelectSession: (session: AgentSessionInfo) => void | Promise<void>;
}
interface GroupedSessions {
bucket: TimeBucket;
sessions: AgentSessionInfo[];
}
export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
const [searchQuery, setSearchQuery] = useState("");
// Start with isLoading=true to prevent flash of "no threads" message
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
// Track which session is currently being loaded to show loading state and prevent double-clicks
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
// Check if session history is supported
const supportsHistory = client.supportsSessionHistory;
const loadSessions = useCallback(async () => {
if (!client.supportsSessionList) {
setError("Session list not supported by this agent");
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await client.listSessions();
setSessions(response.sessions);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
}, [client]);
useEffect(() => {
if (supportsHistory) {
loadSessions();
} else {
// Not supported, clear loading state
setIsLoading(false);
}
}, [supportsHistory, loadSessions]);
// Filter and group sessions
// Reference: Zed's add_list_separators and filter_search_results
const groupedSessions = useMemo((): GroupedSessions[] => {
let filtered = sessions;
// Simple search filter (Zed uses fuzzy matching, we use substring for simplicity)
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = sessions.filter(
(s) => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query)
);
}
// Sort by updatedAt descending (most recent first)
// Zed expects the API to return sorted data, but we ensure it client-side
const sorted = [...filtered].sort((a, b) => {
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return dateB - dateA; // Descending
});
// Group by time bucket (preserving sort order within each bucket)
const groups = new Map<TimeBucket, AgentSessionInfo[]>();
for (const session of sorted) {
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
const bucket = getTimeBucket(date);
if (!groups.has(bucket)) groups.set(bucket, []);
groups.get(bucket)!.push(session);
}
// Return in chronological bucket order
const bucketOrder: TimeBucket[] = ["today", "yesterday", "thisWeek", "pastWeek", "all"];
return bucketOrder
.filter((b) => groups.has(b))
.map((bucket) => ({ bucket, sessions: groups.get(bucket)! }));
}, [sessions, searchQuery]);
const handleSelectSession = useCallback(
async (session: AgentSessionInfo) => {
// Prevent double-clicks while loading
if (loadingSessionId) return;
setLoadingSessionId(session.sessionId);
try {
await onSelectSession(session);
} finally {
setLoadingSessionId(null);
}
},
[onSelectSession, loadingSessionId]
);
if (!supportsHistory) {
return (
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Session history is not supported by this agent.</p>
</div>
);
}
const flatItems = groupedSessions.flatMap((g) => g.sessions);
return (
<div className="flex flex-col h-full">
{/* Search header - Reference: Zed's search_editor */}
<div className="flex items-center gap-2 p-2 border-b border-border">
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
placeholder="Search threads..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 border-0 focus-visible:ring-0 shadow-none"
/>
<Button
variant="ghost"
size="sm"
onClick={loadSessions}
disabled={isLoading}
className="shrink-0"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
</Button>
</div>
{/* Session list */}
<ScrollArea className="flex-1 min-h-0">
{error && (
<div className="p-4 text-center text-destructive text-sm">{error}</div>
)}
{!error && isLoading && sessions.length === 0 && (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<RefreshCw className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
<p className="text-muted-foreground text-sm">Loading threads...</p>
</div>
)}
{!error && !isLoading && sessions.length === 0 && (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<p className="text-muted-foreground text-sm">
You don't have any past threads yet.
</p>
</div>
)}
{!error && sessions.length > 0 && groupedSessions.length === 0 && (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<p className="text-muted-foreground text-sm">
No threads match your search.
</p>
</div>
)}
{/* p-2 ensures rounded corners of buttons are not clipped */}
<div className="p-2">
{groupedSessions.map((group, groupIndex) => (
<div key={group.bucket}>
{/* Bucket separator - Reference: Zed's BucketSeparator */}
<div className={cn("px-2 pb-1", groupIndex > 0 && "pt-3")}>
<span className="text-xs text-muted-foreground font-medium">
{BUCKET_LABELS[group.bucket]}
</span>
</div>
{/* Session entries */}
{group.sessions.map((session) => {
const globalIdx = flatItems.indexOf(session);
const isSelected = globalIdx === selectedIndex;
const isLoadingThis = loadingSessionId === session.sessionId;
const isAnyLoading = loadingSessionId !== null;
const date = session.updatedAt ? new Date(session.updatedAt) : null;
return (
<button
key={session.sessionId}
disabled={isAnyLoading}
onClick={() => {
setSelectedIndex(globalIdx);
handleSelectSession(session);
}}
className={cn(
// min-w-0 is required for truncate to work in flex containers
"w-full min-w-0 flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors",
"hover:bg-accent",
isSelected && "bg-accent",
isAnyLoading && !isLoadingThis && "opacity-50 cursor-not-allowed",
isLoadingThis && "bg-accent"
)}
>
{/* min-w-0 + truncate ensures long titles are clipped with ellipsis */}
<span className="text-sm truncate flex-1 min-w-0">
{session.title && session.title.trim() ? session.title : "New Thread"}
</span>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{isLoadingThis ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
formatRelativeTime(date)
)}
</span>
</button>
);
})}
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { Button } from "../ui/button";
import { cn } from "../../src/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useState,
} from "react";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language?: string;
showLineNumbers?: boolean;
};
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const lines = code.split("\n");
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="border-0">
{showLineNumbers && (
<td className="w-10 select-none pr-4 text-right align-top text-muted-foreground text-xs">
{i + 1}
</td>
)}
<td className="p-0">
<pre className="m-0 p-0 text-sm whitespace-pre font-mono">
<code className="text-sm">{line || "\u00A0"}</code>
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@@ -0,0 +1,181 @@
"use client";
import { Button } from "../ui/button";
import { cn } from "../../src/lib/utils";
import { ArrowDownIcon, UserIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 sm:px-8 sm:py-12 min-w-0", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-4 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-text-muted">{icon}</div>}
<div className="space-y-2">
<h3 className="font-semibold text-base font-display text-text-primary">{title}</h3>
{description && (
<p className="text-text-muted text-sm leading-relaxed max-w-xs">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
/**
* Button to scroll to the bottom of the conversation.
* Can be used standalone or within ConversationScrollButtons container.
* When used standalone, it handles its own visibility based on isAtBottom.
* When used in ConversationScrollButtons, the container manages visibility.
*/
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
<Button
className={cn(
"rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
title="Scroll to bottom"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
);
};
/**
* Data attribute used to mark the last user message element.
* ChatInterface adds this attribute to the last user message for scroll targeting.
*/
export const LAST_USER_MESSAGE_ATTR = "data-last-user-message";
export type ConversationScrollToLastUserMessageButtonProps = ComponentProps<typeof Button>;
/**
* Button to scroll to the last user message in the conversation.
* Reference: Issue #3 - Provide a feature to locate the last human message
*/
export const ConversationScrollToLastUserMessageButton = ({
className,
...props
}: ConversationScrollToLastUserMessageButtonProps) => {
const handleScrollToLastUserMessage = useCallback(() => {
// Find the last user message element by data attribute
const lastUserMessage = document.querySelector(`[${LAST_USER_MESSAGE_ATTR}="true"]`);
if (lastUserMessage) {
lastUserMessage.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<Button
className={cn(
"rounded-full",
className
)}
onClick={handleScrollToLastUserMessage}
size="icon"
type="button"
variant="outline"
title="Scroll to last user message"
{...props}
>
<UserIcon className="size-4" />
</Button>
);
};
export type ConversationScrollButtonsProps = ComponentProps<"div"> & {
/** Whether there are user messages to scroll to */
hasUserMessages?: boolean;
};
/**
* Container for scroll navigation buttons.
* Renders scroll-to-last-user-message and scroll-to-bottom buttons side by side.
* Reference: Issue #3 - Provide a feature to locate the last human message
*/
export const ConversationScrollButtons = ({
className,
hasUserMessages = false,
...props
}: ConversationScrollButtonsProps) => {
const { isAtBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
<div
className={cn(
"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2",
className
)}
{...props}
>
{hasUserMessages && <ConversationScrollToLastUserMessageButton />}
<ConversationScrollButton />
</div>
);
};

View File

@@ -0,0 +1,9 @@
export * from "./code-block";
export * from "./conversation";
export * from "./message";
export * from "./permission-request";
export * from "./prompt-input";
export * from "./reasoning";
export * from "./shimmer";
export * from "./tool";

View File

@@ -0,0 +1,465 @@
"use client";
import { Button } from "../ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "../ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { cn } from "../../src/lib/utils";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, lazy, memo, Suspense, useContext, useEffect, useState } from "react";
const LazyStreamdown = lazy(() => import("streamdown").then((m) => ({ default: m.Streamdown })));
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[85%] min-w-0 flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit max-w-full flex-col gap-2 overflow-hidden text-sm break-words",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
style={{ overflowWrap: "anywhere" }}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = {
children?: string;
className?: string;
mode?: "static" | "streaming";
};
export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => (
<Suspense
fallback={
<div className={cn("whitespace-pre-wrap break-words", className)}>
{children}
</div>
}
>
<LazyStreamdown
className={cn(
"size-full break-words [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{children}
</LazyStreamdown>
</Suspense>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className
)}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,74 @@
"use client";
import { cn } from "../../src/lib/utils";
import { Button } from "../ui/button";
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
import type { PermissionOption } from "../../src/acp/types";
// Get button variant based on option kind
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
switch (kind) {
case "allow_once":
case "allow_always":
return "default";
case "reject_once":
case "reject_always":
return "destructive";
default:
return "outline";
}
}
// Get button icon based on option kind
function getButtonIcon(kind: PermissionOption["kind"]) {
switch (kind) {
case "allow_once":
case "allow_always":
return <CheckIcon className="size-4" />;
case "reject_once":
case "reject_always":
return <XIcon className="size-4" />;
default:
return null;
}
}
// Permission buttons component - used inside Tool component
export interface ToolPermissionButtonsProps {
requestId: string;
options: PermissionOption[];
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
className?: string;
}
export function ToolPermissionButtons({ requestId, options, onRespond, className }: ToolPermissionButtonsProps) {
const handleOptionClick = (option: PermissionOption) => {
onRespond(requestId, option.optionId, option.kind);
};
return (
<div className={cn("p-3 border-t border-warning-border/30 bg-warning-bg/50", className)}>
<div className="flex items-center gap-2 mb-2">
<ShieldAlertIcon className="size-4 text-warning-text" />
<span className="text-xs font-medium text-warning-text">
Permission Required
</span>
</div>
<div className="flex flex-wrap gap-2">
{options.map((option) => (
<Button
key={option.optionId}
variant={getButtonVariant(option.kind)}
size="sm"
onClick={() => handleOptionClick(option)}
className="gap-1.5"
>
{getButtonIcon(option.kind)}
{option.name}
</Button>
))}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import { cn } from "../../src/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useCallback, useContext, useEffect, useState } from "react";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
// Respect prefers-reduced-motion: skip animation auto-close
const prefersReducedMotion = typeof window !== "undefined"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
useEffect(() => {
if (!prefersReducedMotion && defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed, prefersReducedMotion]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: ReactNode;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View File

@@ -0,0 +1,47 @@
"use client";
import { cn } from "../../src/lib/utils";
import { motion } from "motion/react";
import {
type ElementType,
type JSX,
memo,
} from "react";
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
return (
<MotionComponent
animate={{ opacity: [0.5, 1, 0.5] }}
className={cn(
"relative inline-block text-muted-foreground",
className
)}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "easeInOut",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View File

@@ -0,0 +1,171 @@
"use client";
import { Badge } from "../ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import { cn } from "../../src/lib/utils";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full max-w-full overflow-hidden rounded-md border", className)}
{...props}
/>
);
// Extended state type to include our custom states
export type ExtendedToolState = ToolUIPart["state"] | "waiting-for-confirmation" | "rejected";
export type ToolHeaderProps = {
title?: string;
type: ToolUIPart["type"];
state: ExtendedToolState;
className?: string;
};
const getStatusBadge = (status: ExtendedToolState) => {
const labels: Record<ExtendedToolState, string> = {
"input-streaming": "Pending",
"input-available": "Running",
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"output-available": "Completed",
"output-error": "Error",
"output-denied": "Denied",
"waiting-for-confirmation": "Awaiting Approval",
"rejected": "Rejected",
};
const icons: Record<ExtendedToolState, ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"waiting-for-confirmation": <ClockIcon className="size-4 text-yellow-600" />,
"rejected": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
title,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-2">
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")}
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4 max-w-full", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50 overflow-hidden">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2 p-4 max-w-full overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-hidden rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div className="p-2">{errorText}</div>}
{Output}
</div>
</div>
);
};

View File

@@ -0,0 +1,340 @@
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from "react";
import { cn } from "../../src/lib/utils";
import { Send, Square, Paperclip, Slash } from "lucide-react";
import type { ChatInputMessage, UserMessageImage } from "../../src/lib/types";
import type { AvailableCommand } from "../../src/acp/types";
import { CommandMenu } from "./CommandMenu";
import imageCompression from "browser-image-compression";
// 图片压缩配置
const IMAGE_COMPRESSION_OPTIONS = {
maxSizeMB: 2,
maxWidthOrHeight: 2048,
useWebWorker: true,
fileType: "image/jpeg" as const,
};
// =============================================================================
// Anthropic 风格聊天输入框 — 底部居中浮动卡片,橙色焦点环
// =============================================================================
interface ChatInputProps {
onSubmit: (message: ChatInputMessage) => void;
isLoading?: boolean;
onInterrupt?: () => void;
disabled?: boolean;
placeholder?: string;
/** 是否支持图片上传 */
supportsImages?: boolean;
/** Agent 提供的可用 slash 命令 */
commands?: AvailableCommand[];
className?: string;
}
export function ChatInput({
onSubmit,
isLoading = false,
onInterrupt,
disabled = false,
placeholder = "给 Claude 发送消息…",
supportsImages = false,
commands,
className,
}: ChatInputProps) {
const [text, setText] = useState("");
const [images, setImages] = useState<UserMessageImage[]>([]);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [commandFilter, setCommandFilter] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = useCallback(() => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || disabled) return;
onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined });
setText("");
setImages([]);
setShowCommandMenu(false);
setCommandFilter("");
// 重置 textarea 高度
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [text, images, disabled, onSubmit]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showCommandMenu) {
if (e.key === "Escape") {
e.preventDefault();
setShowCommandMenu(false);
return;
}
// Arrow keys and Enter are handled by CommandMenu via document-level listener
// Don't submit or move cursor when menu is open
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
return;
}
if (e.key === "Tab") {
e.preventDefault();
setShowCommandMenu(false);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (isLoading) {
onInterrupt?.();
} else {
handleSubmit();
}
}
},
[handleSubmit, isLoading, onInterrupt, showCommandMenu],
);
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setText(value);
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
if (value.startsWith("/") && commands && commands.length > 0) {
setShowCommandMenu(true);
setCommandFilter(value.slice(1).split(/\s/)[0] || "");
} else if (showCommandMenu) {
setShowCommandMenu(false);
setCommandFilter("");
}
// 自动调整高度
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [commands, showCommandMenu]);
// 粘贴图片
const handlePaste = useCallback(async (e: ClipboardEvent) => {
if (!supportsImages) return;
const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/"));
if (files.length === 0) return;
e.preventDefault();
const newImages = await processImageFiles(files);
setImages((prev) => [...prev, ...newImages]);
}, [supportsImages]);
// 选择文件
const handleFileSelect = useCallback(async () => {
if (!fileInputRef.current) return;
const files = fileInputRef.current.files;
if (!files || files.length === 0) return;
const newImages = await processImageFiles(Array.from(files));
setImages((prev) => [...prev, ...newImages]);
// 清空 input 以便重复选择
fileInputRef.current.value = "";
}, []);
const removeImage = useCallback((index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleCommandSelect = useCallback((command: AvailableCommand) => {
setText(`/${command.name} `);
setShowCommandMenu(false);
setCommandFilter("");
textareaRef.current?.focus();
}, []);
const toggleCommandMenu = useCallback(() => {
if (showCommandMenu) {
setShowCommandMenu(false);
setCommandFilter("");
} else {
if (!text.startsWith("/")) {
setText("/" + text);
}
setShowCommandMenu(true);
setCommandFilter(text.startsWith("/") ? text.slice(1).split(/\s/)[0] || "" : "");
textareaRef.current?.focus();
}
}, [showCommandMenu, text]);
const canSend = (text.trim() || images.length > 0) && !disabled;
return (
<div className={cn("w-full max-w-3xl mx-auto px-4 sm:px-8 pb-4 pt-2", className)}>
<div className="relative">
{/* Slash command menu — floating above input */}
{showCommandMenu && commands && commands.length > 0 && (
<CommandMenu
commands={commands}
filter={commandFilter}
onSelect={handleCommandSelect}
onClose={() => {
setShowCommandMenu(false);
setCommandFilter("");
}}
className="absolute bottom-full left-0 right-0 mb-1 z-50"
/>
)}
<div className={cn(
"rounded-xl border border-border bg-surface-2 overflow-hidden",
"focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all",
)}>
{/* 图片预览 */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{images.map((img, i) => (
<div key={i} className="relative group">
<img
src={`data:${img.mimeType};base64,${img.data}`}
alt={`Attached image ${i + 1}`}
className="h-14 w-14 object-cover rounded-lg border border-border"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -top-1.5 -right-1.5 min-h-[32px] min-w-[32px] h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove image ${i + 1}`}
>
{"\u00D7"}
</button>
</div>
))}
</div>
)}
{/* 输入区域 — Anthropic 单行紧凑布局 */}
<div className="flex items-end gap-2 px-3 py-2.5">
{/* 左侧附件按钮 */}
{supportsImages && (
<>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
disabled={disabled}
>
<Paperclip className="h-4 w-4" />
<span className="sr-only">Attach file</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
</>
)}
{/* Slash 命令按钮 */}
{commands && commands.length > 0 && (
<button
type="button"
onClick={toggleCommandMenu}
className={cn(
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors",
showCommandMenu
? "bg-brand/15 text-brand"
: "text-text-muted hover:text-text-secondary hover:bg-surface-1/50",
)}
disabled={disabled}
title="命令列表"
>
<Slash className="h-4 w-4" />
</button>
)}
{/* Textarea — Poppins font */}
<textarea
ref={textareaRef}
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none border-none bg-transparent outline-none",
"text-sm text-text-primary placeholder:text-text-muted font-display",
"max-h-[200px] min-h-[24px] leading-normal",
)}
/>
{/* 右侧发送/取消按钮 */}
<button
type="button"
onClick={isLoading ? onInterrupt : handleSubmit}
disabled={!isLoading && !canSend}
className={cn(
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all",
isLoading
? "bg-text-primary text-surface-2 hover:bg-text-secondary"
: canSend
? "bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]"
: "bg-surface-1 text-text-muted",
)}
>
{isLoading ? (
<Square className="h-3.5 w-3.5" fill="currentColor" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>{/* end relative */}
{/* 提示文本 */}
<div className="text-center mt-1.5">
<span className="text-[11px] text-text-muted font-display">
Enter Shift+Enter
</span>
</div>
</div>
);
}
// =============================================================================
// 图片处理工具
// =============================================================================
async function processImageFiles(files: File[]): Promise<UserMessageImage[]> {
const results: UserMessageImage[] = [];
for (const file of files) {
try {
let blob: Blob = file;
let mimeType = file.type;
if (file.size > 2 * 1024 * 1024) {
const compressed = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS);
blob = compressed;
mimeType = "image/jpeg";
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(",");
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
};
reader.onerror = () => reject(new Error("FileReader error"));
reader.readAsDataURL(blob);
});
results.push({ mimeType, data: base64 });
} catch (err) {
console.error("Failed to process image:", err);
}
}
return results;
}

View File

@@ -0,0 +1,173 @@
import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { UserBubble, AssistantBubble } from "./MessageBubble";
import { ToolCallGroup } from "./ToolCallGroup";
import { PlanDisplay } from "./PlanView";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
// =============================================================================
// 统一聊天视图 — Anthropic 编辑式排版
// 无气泡间距,用垂直 rhythm 区分消息块
// =============================================================================
interface ChatViewProps {
entries: ThreadEntry[];
isLoading?: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
emptyTitle?: string;
emptyDescription?: string;
}
export function ChatView({
entries,
isLoading = false,
onPermissionRespond,
emptyTitle = "开始对话",
emptyDescription = "输入消息开始聊天",
}: ChatViewProps) {
// 将相邻的 ToolCallEntry 合并为一组
const grouped = groupToolCalls(entries);
const hasMessages = entries.length > 0;
// 检查是否正在加载(最后一个条目是用户消息)
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === "user_message";
return (
<Conversation className="flex-1">
<ConversationContent>
{!hasMessages ? (
<ConversationEmptyState
title={emptyTitle}
description={emptyDescription}
/>
) : (
<>
{grouped.map((item, i) => {
if (item.type === "single") {
return (
<div key={`entry-${i}`} className={cn(entrySpacing(entries, i))}>
<EntryRenderer entry={item.entry} isLoading={isLoading} onPermissionRespond={onPermissionRespond} />
</div>
);
}
// 工具调用组 — 紧贴在助手消息下方
return (
<div key={`group-${i}`} className="-mt-2">
<ToolCallGroup entries={item.entries} onPermissionRespond={onPermissionRespond} />
</div>
);
})}
{/* 思考指示器 — Anthropic 打字动画 */}
{showThinking && (
<div className="flex gap-4 items-start">
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
</svg>
</div>
<div className="flex items-center gap-1 pt-2">
<span className="chat-typing-indicator" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</div>
</div>
)}
</>
)}
<ConversationScrollButtons hasUserMessages={entries.some((e) => e.type === "user_message")} />
</ConversationContent>
</Conversation>
);
}
// =============================================================================
// 间距逻辑 — 用户消息前后间距大,工具调用紧贴
// =============================================================================
function entrySpacing(entries: ThreadEntry[], index: number): string {
const entry = entries[index];
// 用户消息前后大留白 — Claude.ai 式宽松间距
if (entry?.type === "user_message") {
return "pt-10 pb-3";
}
// 助手消息 — 工具调用紧贴,否则多留白
if (entry?.type === "assistant_message") {
const next = entries[index + 1];
if (next?.type === "tool_call") {
return "pt-3 pb-1";
}
return "pt-3 pb-8";
}
// Plan 条目
if (entry?.type === "plan") {
return "pt-3 pb-3";
}
return "py-2";
}
// =============================================================================
// 单条目渲染器
// =============================================================================
function EntryRenderer({
entry,
isLoading,
onPermissionRespond,
}: {
entry: ThreadEntry;
isLoading: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}) {
switch (entry.type) {
case "user_message":
return <UserBubble entry={entry} />;
case "assistant_message":
return <AssistantBubble entry={entry} isStreaming={isLoading} />;
case "tool_call":
return (
<ToolCallGroup
entries={[entry as ToolCallEntry]}
onPermissionRespond={onPermissionRespond}
/>
);
case "plan":
return <PlanDisplay entry={entry as PlanDisplayEntry} />;
default:
return null;
}
}
// =============================================================================
// 工具调用分组逻辑
// =============================================================================
type GroupedItem =
| { type: "single"; entry: ThreadEntry }
| { type: "tool_group"; entries: ToolCallEntry[] };
function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
const result: GroupedItem[] = [];
let currentToolGroup: ToolCallEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length === 1) {
result.push({ type: "single", entry: currentToolGroup[0] });
} else if (currentToolGroup.length > 1) {
result.push({ type: "tool_group", entries: currentToolGroup });
}
currentToolGroup = [];
};
for (const entry of entries) {
if (entry.type === "tool_call") {
currentToolGroup.push(entry);
} else {
flushToolGroup();
result.push({ type: "single", entry });
}
}
flushToolGroup();
return result;
}

View File

@@ -0,0 +1,137 @@
import { useMemo, useRef, useEffect, useState } from "react";
import { cn } from "../../src/lib/utils";
import type { AvailableCommand } from "../../src/acp/types";
// =============================================================================
// Slash command picker — floating above ChatInput
// =============================================================================
interface CommandMenuProps {
commands: AvailableCommand[];
/** Text after "/" used for filtering */
filter: string;
onSelect: (command: AvailableCommand) => void;
onClose: () => void;
className?: string;
}
/**
* Prefix match — checks if the text starts with the query.
*/
function prefixMatch(query: string, text: string): boolean {
if (!query) return true;
return text.toLowerCase().startsWith(query.toLowerCase());
}
export function CommandMenu({
commands,
filter,
onSelect,
onClose,
className,
}: CommandMenuProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Filter commands by current input
const filtered = useMemo(() => {
if (!filter) return commands;
return commands.filter(
(cmd) => prefixMatch(filter, cmd.name),
);
}, [commands, filter]);
// Reset active index when filter changes
useEffect(() => {
setActiveIndex(0);
}, [filter]);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
// Handle keyboard navigation (ArrowUp/ArrowDown/Enter) via document-level listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (filtered.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % filtered.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const cmd = filtered[activeIndex];
if (cmd) onSelect(cmd);
}
};
document.addEventListener("keydown", handleKeyDown, true); // capture phase
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [filtered, activeIndex, onSelect]);
// Scroll active item into view
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const active = container.querySelector("[data-active='true']");
active?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
<div
ref={containerRef}
className={cn(
"rounded-xl border border-border bg-surface-2 shadow-lg",
className,
)}
>
<div className="max-h-[320px] overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="text-xs text-text-muted font-display py-3 text-center">
</div>
) : (
filtered.map((cmd, index) => (
<button
key={cmd.name}
type="button"
data-active={index === activeIndex}
onClick={() => onSelect(cmd)}
onMouseEnter={() => setActiveIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 cursor-pointer rounded-lg mx-1 text-left",
"transition-colors",
index === activeIndex
? "bg-brand/10 text-text-primary"
: "text-text-secondary hover:bg-surface-1/50",
)}
style={{ width: "calc(100% - 8px)" }}
>
<span className="text-sm font-display font-medium text-brand">
/{cmd.name}
</span>
<span className="text-xs text-text-muted truncate flex-1">
{cmd.description}
</span>
{cmd.input?.hint && (
<span className="text-[10px] text-text-muted italic">
{cmd.input.hint}
</span>
)}
</button>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useRef, useEffect, useCallback } from "react";
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
import { cn, esc } from "../../src/lib/utils";
import { MessageResponse } from "../ai-elements/message";
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
import { ChevronDown } from "lucide-react";
// 用户消息折叠最大高度px
const COLLAPSED_MAX_HEIGHT = 200;
// =============================================================================
// 用户消息 — 右对齐,品牌色淡底,可折叠
// =============================================================================
interface UserBubbleProps {
entry: UserMessageEntry;
}
export function UserBubble({ entry }: UserBubbleProps) {
const [expanded, setExpanded] = useState(false);
const [overflowing, setOverflowing] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const checkOverflow = useCallback(() => {
const el = contentRef.current;
if (!el) return;
setOverflowing(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 4);
}, []);
useEffect(() => {
checkOverflow();
}, [checkOverflow, entry.content]);
return (
<div className="flex justify-end">
<div className="max-w-[85%] sm:max-w-[70%]">
{/* 图片附件 */}
{entry.images && entry.images.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2 justify-end">
{entry.images.map((img, i) => (
<ImageThumbnail key={i} image={img} />
))}
</div>
)}
{/* 文本内容 — 品牌色淡底 + 折叠 */}
{entry.content && (
<div className="relative rounded-2xl rounded-br-md bg-user-bubble border border-user-bubble-border overflow-hidden">
<div
ref={contentRef}
className={cn(
"px-5 py-3 text-sm text-white whitespace-pre-wrap font-display leading-relaxed",
!expanded && overflowing && `max-h-[${COLLAPSED_MAX_HEIGHT}px]`,
)}
style={!expanded && overflowing ? { maxHeight: `${COLLAPSED_MAX_HEIGHT}px` } : undefined}
>
{esc(entry.content)}
</div>
{/* 折叠渐变遮罩 + 展开按钮 */}
{!expanded && overflowing && (
<div className="absolute bottom-0 inset-x-0 flex flex-col items-center pt-8 bg-gradient-to-t from-user-bubble via-user-bubble/80 to-transparent">
<button
type="button"
onClick={() => setExpanded(true)}
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-display font-medium text-white/90 hover:bg-white/15 transition-colors"
>
<span></span>
<ChevronDown className="h-3 w-3" />
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 助手消息 — 左对齐,无背景卡片,编辑式排版
// =============================================================================
interface AssistantBubbleProps {
entry: AssistantMessageEntry;
isStreaming?: boolean;
}
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
return (
<div className="flex gap-4 items-start">
{/* Orange triangle avatar */}
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
</svg>
</div>
{/* 内容 — 无卡片背景,直接排版 */}
<div className="flex-1 min-w-0 space-y-4">
{/* Sender label */}
<span className="text-sm font-semibold text-text-primary font-display">Claude</span>
{entry.chunks.map((chunk, i) => {
if (chunk.type === "thought") {
const isLastChunk = i === entry.chunks.length - 1;
const isThoughtStreaming = isStreaming && isLastChunk;
return (
<Reasoning key={i} isStreaming={isThoughtStreaming}>
<ReasoningTrigger />
<ReasoningContent>
<div className="text-sm text-text-secondary leading-relaxed">
{chunk.text}
</div>
</ReasoningContent>
</Reasoning>
);
}
// 普通消息块 — 直接输出,无包裹卡片
return (
<div key={i} className="message-content text-text-primary leading-[1.75]">
<MessageResponse>{chunk.text}</MessageResponse>
</div>
);
})}
</div>
</div>
);
}
// =============================================================================
// 图片缩略图 — 点击放大
// =============================================================================
function ImageThumbnail({ image }: { image: UserMessageImage }) {
const dataUrl = `data:${image.mimeType};base64,${image.data}`;
return (
<button
type="button"
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
onClick={() => {
const w = window.open("");
if (w) {
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
}
}}
>
<img
src={dataUrl}
alt="Uploaded image"
className="h-20 w-20 object-cover"
/>
</button>
);
}

View File

@@ -0,0 +1,76 @@
import type { PendingPermission } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { ShieldAlert, Check, X } from "lucide-react";
// =============================================================================
// 权限请求面板 — 固定在输入框上方Anthropic warm token style
// =============================================================================
interface PermissionPanelProps {
requests: PendingPermission[];
onRespond?: (requestId: string, approved: boolean) => void;
className?: string;
}
export function PermissionPanel({ requests, onRespond, className }: PermissionPanelProps) {
if (requests.length === 0) return null;
return (
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
<div className="space-y-2">
{requests.map((req) => (
<PermissionCard
key={req.requestId}
request={req}
onRespond={onRespond}
/>
))}
</div>
</div>
);
}
// =============================================================================
// 单个权限卡片 — warm warning tokens + left-border accent
// =============================================================================
interface PermissionCardProps {
request: PendingPermission;
onRespond?: (requestId: string, approved: boolean) => void;
}
function PermissionCard({ request, onRespond }: PermissionCardProps) {
return (
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 bg-warning-bg/50 px-4 py-3">
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-warning-text">
{request.toolName}
</div>
{request.description && (
<div className="text-xs text-warning-text/80 mt-0.5 truncate">
{request.description}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => onRespond?.(request.requestId, true)}
className="h-8 px-3 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-light transition-colors flex items-center gap-1.5"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onRespond?.(request.requestId, false)}
className="h-8 px-3 rounded-lg border border-warning-border/30 text-warning-text text-xs font-medium hover:bg-warning-bg transition-colors flex items-center gap-1.5"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import type { PlanDisplayEntry } from "../../src/lib/types";
import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from "../../src/acp/types";
import { cn } from "../../src/lib/utils";
import { CheckCircle2, Loader2, Circle } from "lucide-react";
// =============================================================================
// Plan 展示组件 — 执行计划可视化
// =============================================================================
interface PlanDisplayProps {
entry: PlanDisplayEntry;
}
export function PlanDisplay({ entry }: PlanDisplayProps) {
const [collapsed, setCollapsed] = useState(false);
const { entries } = entry;
if (entries.length === 0) return null;
const completed = entries.filter((e) => e.status === "completed").length;
const total = entries.length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="pl-10">
<div className="rounded-xl border border-border bg-brand/5 overflow-hidden">
{/* Header */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm hover:bg-surface-1/50 transition-colors"
onClick={() => setCollapsed(!collapsed)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={cn("transition-transform text-text-muted flex-shrink-0", collapsed && "rotate-90")}
>
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
<span className="text-xs font-display font-medium text-text-secondary">
</span>
<span className="text-[10px] text-text-muted font-mono">
{completed}/{total}
</span>
{/* Progress bar */}
<div className="flex-1 h-1 rounded-full bg-surface-1 overflow-hidden ml-1 mr-2">
<div
className="h-full rounded-full bg-brand/70 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-[10px] text-text-muted font-mono">
{percentage}%
</span>
</button>
{/* Entry list */}
{!collapsed && (
<div className={cn(
"border-t border-border px-3 py-1.5 space-y-0.5",
total > 5 && "max-h-64 overflow-y-auto",
)}>
{entries.map((planEntry, i) => (
<PlanEntryRow key={i} entry={planEntry} />
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 单条 Plan 条目
// =============================================================================
function PlanEntryRow({ entry }: { entry: PlanEntry }) {
return (
<div className="flex items-start gap-2 py-1.5 px-1">
<span className="flex-shrink-0 mt-0.5">
<StatusIcon status={entry.status} />
</span>
<span className={cn(
"text-xs leading-relaxed flex-1",
entry.status === "completed" ? "text-text-muted line-through" : "text-text-secondary",
entry.status === "in_progress" && "text-text-primary font-medium",
)}>
{entry.content}
</span>
<PriorityBadge priority={entry.priority} />
</div>
);
}
// =============================================================================
// 状态图标
// =============================================================================
function StatusIcon({ status }: { status: PlanEntryStatus }) {
switch (status) {
case "completed":
return <CheckCircle2 className="h-3.5 w-3.5 text-status-active" />;
case "in_progress":
return <Loader2 className="h-3.5 w-3.5 text-brand animate-spin" style={{ animationDuration: "2s" }} />;
case "pending":
return <Circle className="h-3.5 w-3.5 text-text-muted" />;
}
}
// =============================================================================
// 优先级标签
// =============================================================================
function PriorityBadge({ priority }: { priority: PlanEntryPriority }) {
const styles: Record<PlanEntryPriority, string> = {
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
medium: "bg-brand/10 text-brand dark:bg-brand/20",
low: "bg-surface-1 text-text-muted",
};
const labels: Record<PlanEntryPriority, string> = {
high: "高",
medium: "中",
low: "低",
};
return (
<span className={cn(
"text-[9px] font-display rounded-full px-1.5 py-0.5 flex-shrink-0 leading-none",
styles[priority],
)}>
{labels[priority]}
</span>
);
}

View File

@@ -0,0 +1,141 @@
import { cn } from "../../src/lib/utils";
import { Plus, MessageSquare, ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";
import type { SessionListItem } from "../../src/lib/types";
// =============================================================================
// 会话侧边栏 — Anthropic 分段式:今天/昨天/更早 + 橙色活跃态
// =============================================================================
interface SessionSidebarProps {
sessions: SessionListItem[];
activeId?: string | null;
onSelect?: (id: string) => void;
onNew?: () => void;
className?: string;
}
export function SessionSidebar({
sessions,
activeId,
onSelect,
onNew,
className,
}: SessionSidebarProps) {
const [collapsed, setCollapsed] = useState(false);
// 按日期分组
const groups = groupByRecency(sessions);
return (
<div
className={cn(
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200",
collapsed ? "w-12" : "w-64",
className,
)}
>
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
{!collapsed && (
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider"></span>
)}
<div className="flex items-center gap-1">
{!collapsed && onNew && (
<button
type="button"
onClick={onNew}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* 会话列表 — 分段 */}
{!collapsed && (
<nav className="flex-1 overflow-y-auto py-2" aria-label="历史会话">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5">
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
{group.label}
</span>
</div>
{group.sessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => onSelect?.(session.id)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
session.id === activeId
? "bg-brand/10 text-text-primary"
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary",
)}
title={session.title || session.id}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-text-muted" />
<span className="text-sm font-display truncate">
{session.title || session.id.slice(0, 8)}
</span>
</button>
))}
</div>
))}
{sessions.length === 0 && (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-muted font-display"></span>
</div>
)}
</nav>
)}
</div>
);
}
// =============================================================================
// 按日期分组
// =============================================================================
interface SessionGroup {
label: string;
sessions: SessionListItem[];
}
function groupByRecency(sessions: SessionListItem[]): SessionGroup[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const groups: SessionGroup[] = [
{ label: "今天", sessions: [] },
{ label: "昨天", sessions: [] },
{ label: "更早", sessions: [] },
];
for (const session of sessions) {
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
if (date >= today) {
groups[0].sessions.push(session);
} else if (date >= yesterday) {
groups[1].sessions.push(session);
} else {
groups[2].sessions.push(session);
}
}
return groups.filter((g) => g.sessions.length > 0);
}

View File

@@ -0,0 +1,208 @@
import { useState } from "react";
import type { ToolCallEntry, ToolCallData } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { ToolPermissionButtons } from "../ai-elements/permission-request";
// =============================================================================
// 工具调用折叠组 — Anthropic: subtle card, left-border accent, compact layout
// =============================================================================
interface ToolCallGroupProps {
entries: ToolCallEntry[];
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}
export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupProps) {
const [expanded, setExpanded] = useState(false);
if (entries.length === 0) return null;
// 单个工具调用 — 默认折叠,不展开内容详情
if (entries.length === 1) {
return (
<div className="pl-10">
<SingleToolCard
tool={entries[0].toolCall}
compact
onPermissionRespond={onPermissionRespond}
/>
</div>
);
}
// 多个工具调用 — 折叠组
const summary = buildSummary(entries);
return (
<div className="pl-10">
<div className="rounded-lg border border-border bg-surface-2/50 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-1/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={cn("transition-transform text-text-muted", expanded && "rotate-90")}
>
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
<span className="text-xs text-text-muted font-display">{summary}</span>
</button>
{/* 展开内容 */}
{expanded && (
<div className="border-t border-border divide-y divide-border">
{entries.map((entry, i) => (
<SingleToolCard
key={entry.toolCall.id || i}
tool={entry.toolCall}
compact
onPermissionRespond={onPermissionRespond}
/>
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 单个工具卡片 — compact, left-accent, inline status
// =============================================================================
interface SingleToolCardProps {
tool: ToolCallData;
compact?: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}
function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardProps) {
const [expanded, setExpanded] = useState(!compact);
const statusIcon = (() => {
switch (tool.status) {
case "running":
return <span className="text-status-running text-[10px]">&#9654;</span>;
case "complete":
return <span className="text-status-active text-[10px]">&#10003;</span>;
case "error":
return <span className="text-status-error text-[10px]">&#10005;</span>;
case "waiting_for_confirmation":
return <span className="text-brand text-[10px]">&#9083;</span>;
case "canceled":
return <span className="text-text-muted text-[10px]">&#8212;</span>;
case "rejected":
return <span className="text-status-error text-[10px]">&#10005;</span>;
default:
return null;
}
})();
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
return (
<div className={cn("px-3 py-2", compact && "py-1.5")}>
{/* 标题行 — 单行紧凑 */}
<button
type="button"
className="flex w-full items-center gap-1.5 text-left group"
onClick={() => setExpanded(!expanded)}
>
{statusIcon}
<span className="text-xs font-display font-medium text-text-secondary group-hover:text-text-primary transition-colors truncate">
{tool.title}
</span>
{tool.status === "running" && (
<span className="text-[10px] text-status-running animate-pulse">running</span>
)}
</button>
{/* 权限请求按钮 */}
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
<div className="mt-1.5 ml-4">
<ToolPermissionButtons
requestId={tool.permissionRequest.requestId}
options={tool.permissionRequest.options}
onRespond={onPermissionRespond || (() => {})}
/>
</div>
)}
{/* 展开详情 */}
{expanded && (
<div className="mt-1.5 ml-4 space-y-1.5">
{tool.rawInput && Object.keys(tool.rawInput).length > 0 && (
<div>
<pre className="text-[11px] bg-surface-1 rounded-md p-2 overflow-x-auto font-mono max-h-36 text-text-secondary">
{truncate(JSON.stringify(tool.rawInput, null, 2), 2000)}
</pre>
</div>
)}
{hasOutput && (
<div>
<pre className={cn(
"text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36",
tool.status === "error" ? "bg-status-error/10 text-status-error" : "bg-surface-1 text-text-secondary",
)}>
{formatOutput(tool)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
// =============================================================================
// 工具函数
// =============================================================================
/** 构建统计摘要 */
function buildSummary(entries: ToolCallEntry[]): string {
const toolCounts = new Map<string, number>();
for (const entry of entries) {
const name = simplifyToolName(entry.toolCall.title);
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
}
const parts: string[] = [];
for (const [name, count] of toolCounts) {
parts.push(count === 1 ? name : `${count}${name}`);
}
if (parts.length === 0) return `${entries.length} 个工具调用`;
if (parts.length === 1) return parts[0];
return `${entries.length} 个工具: ${parts.join("、")}`;
}
/** 简化工具名称 */
function simplifyToolName(title: string): string {
const match = title.match(/^(\w+)/);
return match ? match[1] : title;
}
/** 格式化工具输出 */
function formatOutput(tool: ToolCallData): string {
if (tool.content && tool.content.length > 0) {
const texts = tool.content
.filter((c): c is Extract<typeof c, { type: "content" }> => c.type === "content")
.filter((c) => c.content.type === "text" && "text" in c.content)
.map((c) => (c.content as { text: string }).text);
if (texts.length > 0) return truncate(texts.join("\n"), 2000);
}
if (tool.rawOutput && Object.keys(tool.rawOutput).length > 0) {
return truncate(JSON.stringify(tool.rawOutput, null, 2), 2000);
}
return "";
}
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + "..." : str;
}

View File

@@ -0,0 +1,8 @@
export { ChatView } from "./ChatView";
export { UserBubble, AssistantBubble } from "./MessageBubble";
export { ToolCallGroup } from "./ToolCallGroup";
export { PlanDisplay } from "./PlanView";
export { ChatInput } from "./ChatInput";
export { PermissionPanel } from "./PermissionPanel";
export { SessionSidebar } from "./SessionSidebar";
export { CommandMenu } from "./CommandMenu";

View File

@@ -0,0 +1,6 @@
export * from "./ACPConnect";
export * from "./ACPMain";
export * from "./ChatInterface";
export * from "./ChatMessage";
export * from "./ThreadHistory";
export * from "./model-selector";

View File

@@ -0,0 +1,108 @@
import { useState, useMemo } from "react";
import { Check } from "lucide-react";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "../ui/command";
import type { ModelInfo } from "../../src/acp/types";
import { cn } from "../../src/lib/utils";
interface ModelSelectorPickerProps {
models: ModelInfo[];
currentModelId: string | null;
onSelect: (model: ModelInfo) => void;
/** Whether to show the search input (default: true) */
showSearch?: boolean;
/** Whether we're on a mobile device (disables auto-selection) */
isMobile?: boolean;
}
/**
* Fuzzy search implementation for model filtering.
* Reference: Zed's fuzzy_search() in model_selector.rs
*/
function fuzzyMatch(query: string, text: string): boolean {
if (!query) return true;
const lowerQuery = query.toLowerCase();
const lowerText = text.toLowerCase();
// Simple fuzzy match - check if all query chars appear in order
let queryIdx = 0;
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
if (lowerText[i] === lowerQuery[queryIdx]) {
queryIdx++;
}
}
return queryIdx === lowerQuery.length;
}
/**
* Model picker using cmdk Command component.
* Reference: Zed's AcpModelPickerDelegate with fuzzy search support.
*/
export function ModelSelectorPicker({
models,
currentModelId,
onSelect,
showSearch = true,
isMobile = false,
}: ModelSelectorPickerProps) {
const [search, setSearch] = useState("");
// On mobile, don't auto-select first item (no keyboard navigation needed)
// Use a non-existent value to prevent any item from being selected
const [selectedValue, setSelectedValue] = useState(isMobile ? "__none__" : undefined);
// Filter models using fuzzy search
const filteredModels = useMemo(() => {
if (!search) return models;
return models.filter((model) =>
fuzzyMatch(search, model.name) ||
fuzzyMatch(search, model.modelId)
);
}, [models, search]);
return (
<Command shouldFilter={false} value={selectedValue} onValueChange={setSelectedValue}>
{showSearch && (
<CommandInput
placeholder="Select a model…"
value={search}
onValueChange={setSearch}
/>
)}
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup>
{filteredModels.map((model) => (
<CommandItem
key={model.modelId}
value={model.modelId}
onSelect={() => onSelect(model)}
className="flex items-center justify-between gap-2"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{model.name}</span>
{model.description && (
<span className="text-xs text-muted-foreground truncate">
{model.description}
</span>
)}
</div>
<Check
className={cn(
"h-4 w-4 shrink-0",
currentModelId === model.modelId ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { ModelSelectorPicker } from "./ModelSelectorPicker";
import type { ACPClient } from "../../src/acp/client";
import type { ModelInfo } from "../../src/acp/types";
import { useModels } from "../../src/hooks/useModels";
interface ModelSelectorPopoverProps {
/** ACPClient instance for model state management */
client: ACPClient;
/** Callback when a model is selected */
onModelSelect?: (modelId: string) => void;
}
/**
* Model selector popover component.
* Reference: Zed's AcpModelSelectorPopover that shows current model and allows switching.
*/
export function ModelSelectorPopover({
client,
onModelSelect,
}: ModelSelectorPopoverProps) {
const [open, setOpen] = useState(false);
const {
supportsModelSelection,
availableModels,
currentModel,
setModel,
isLoading,
} = useModels(client);
// Always show the button — disable dropdown when no models available
const hasModels = supportsModelSelection && availableModels.length > 0;
// Check if we're on a mobile device (touch-only)
const isMobile = typeof window !== "undefined" &&
window.matchMedia("(hover: none) and (pointer: coarse)").matches;
const handleSelect = async (model: ModelInfo) => {
try {
await setModel(model.modelId);
onModelSelect?.(model.modelId);
setOpen(false);
} catch (error) {
console.error("[ModelSelector] Failed to set model:", error);
}
};
return (
<Popover open={open} onOpenChange={hasModels ? setOpen : undefined}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
disabled={!hasModels || isLoading}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : null}
<span className="max-w-32 truncate">
{currentModel?.name ?? "Select Model"}
</span>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="end">
<ModelSelectorPicker
models={availableModels}
currentModelId={currentModel?.modelId ?? null}
onSelect={handleSelect}
showSearch={!isMobile}
isMobile={isMobile}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
export { ModelSelectorPopover } from "./ModelSelectorPopover";
export { ModelSelectorPicker } from "./ModelSelectorPicker";

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../src/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,84 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../src/lib/utils"
import { Separator } from "./separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -0,0 +1,61 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../src/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,93 @@
import * as React from "react"
import { cn } from "../../src/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,34 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "../../src/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

Some files were not shown because too many files have changed in this diff Show More