mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34154ee3f5 | ||
|
|
29cc74a170 |
62
CLAUDE.md
62
CLAUDE.md
@@ -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 (3066 tests / 205 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 — 14 个 internal packages in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace 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,16 @@ 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/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | 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 Server(Docker 部署,含 Web UI) |
|
||||
| `packages/swarm/` | Swarm 解耦模块 |
|
||||
| `packages/shell/` | Shell 抽象 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
@@ -163,7 +172,7 @@ 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`。
|
||||
- **`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 控制面板。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
@@ -198,30 +207,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `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 文档。
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
@@ -247,7 +233,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 2992 tests / 188 files / 0 fail
|
||||
- **当前状态**: 3066 tests / 205 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 +255,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 +268,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`。
|
||||
|
||||
34
packages/acp-link/.gitignore
vendored
Normal file
34
packages/acp-link/.gitignore
vendored
Normal 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
|
||||
89
packages/acp-link/README.md
Normal file
89
packages/acp-link/README.md
Normal 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
|
||||
39
packages/acp-link/package.json
Normal file
39
packages/acp-link/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "acp-link",
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
28
packages/acp-link/src/__tests__/cert.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
packages/acp-link/src/__tests__/server.test.ts
Normal file
75
packages/acp-link/src/__tests__/server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
69
packages/acp-link/src/__tests__/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
174
packages/acp-link/src/cert.ts
Normal file
174
packages/acp-link/src/cert.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
18
packages/acp-link/src/cli/app.ts
Normal file
18
packages/acp-link/src/cli/app.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
7
packages/acp-link/src/cli/bin.ts
Normal file
7
packages/acp-link/src/cli/bin.ts
Normal 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());
|
||||
|
||||
90
packages/acp-link/src/cli/command.ts
Normal file
90
packages/acp-link/src/cli/command.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
10
packages/acp-link/src/cli/context.ts
Normal file
10
packages/acp-link/src/cli/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommandContext } from "@stricli/core";
|
||||
|
||||
export interface LocalContext extends CommandContext {}
|
||||
|
||||
export function buildContext(): LocalContext {
|
||||
return {
|
||||
process,
|
||||
};
|
||||
}
|
||||
|
||||
83
packages/acp-link/src/logger.ts
Normal file
83
packages/acp-link/src/logger.ts
Normal 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 });
|
||||
}
|
||||
258
packages/acp-link/src/rcs-upstream.ts
Normal file
258
packages/acp-link/src/rcs-upstream.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
889
packages/acp-link/src/server.ts
Normal file
889
packages/acp-link/src/server.ts
Normal file
@@ -0,0 +1,889 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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 },
|
||||
): 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 result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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 }) || {});
|
||||
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 }) || {});
|
||||
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);
|
||||
});
|
||||
150
packages/acp-link/src/types.ts
Normal file
150
packages/acp-link/src/types.ts
Normal 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;
|
||||
}
|
||||
37
packages/acp-link/tsconfig.json
Normal file
37
packages/acp-link/tsconfig.json
Normal 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__"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 升级:
|
||||
|
||||
23
packages/remote-control-server/components.json
Normal file
23
packages/remote-control-server/components.json
Normal 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": {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ 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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -33,9 +38,11 @@ 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 +77,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 +88,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 +100,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);
|
||||
}
|
||||
|
||||
|
||||
214
packages/remote-control-server/src/routes/acp/index.ts
Normal file
214
packages/remote-control-server/src/routes/acp/index.ts
Normal 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;
|
||||
@@ -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 */
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
313
packages/remote-control-server/src/transport/acp-ws-handler.ts
Normal file
313
packages/remote-control-server/src/transport/acp-ws-handler.ts
Normal 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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -11,15 +12,20 @@ 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.
|
||||
@@ -33,6 +39,7 @@ function toSDKMessage(event: SessionEvent): string {
|
||||
/** 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);
|
||||
|
||||
@@ -79,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 {
|
||||
@@ -86,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 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { upgradeWebSocket, websocket } from "hono/bun";
|
||||
@@ -19,6 +19,7 @@ export interface RegisterEnvironmentRequest {
|
||||
max_sessions?: number;
|
||||
worker_type?: string;
|
||||
bridge_id?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RegisterEnvironmentResponse {
|
||||
@@ -105,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,826 +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,
|
||||
getActivityMode,
|
||||
removeLoading,
|
||||
resetReplayState,
|
||||
renderReplayPendingRequests,
|
||||
setAutomationActivity,
|
||||
showLoading,
|
||||
} from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import {
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
reduceAutomationState,
|
||||
renderAutomationIcon,
|
||||
shouldPulseAutomationIndicator,
|
||||
} from "./automation.js";
|
||||
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
let automationState = createAutomationState();
|
||||
let automationPulseTimer = null;
|
||||
|
||||
function generateMessageUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function renderAutomationIndicator() {
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
const indicator = getAutomationIndicator(automationState);
|
||||
if (!indicator.visible) {
|
||||
indicatorEl.className = "automation-pill hidden";
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.innerHTML = "";
|
||||
indicatorEl.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
|
||||
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
|
||||
if (indicatorEl.dataset.pulsing === "true") {
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
}
|
||||
indicatorEl.innerHTML = `
|
||||
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
|
||||
<span class="automation-pill-label">${esc(indicator.label)}</span>
|
||||
`;
|
||||
indicatorEl.title = indicator.title;
|
||||
}
|
||||
|
||||
function syncAutomationUI() {
|
||||
renderAutomationIndicator();
|
||||
setAutomationActivity(getAutomationActivity(automationState));
|
||||
}
|
||||
|
||||
function stopAutomationPulse() {
|
||||
if (automationPulseTimer) {
|
||||
clearTimeout(automationPulseTimer);
|
||||
automationPulseTimer = null;
|
||||
}
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (indicatorEl) {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
}
|
||||
}
|
||||
|
||||
function pulseAutomationIndicator() {
|
||||
if (!getAutomationIndicator(automationState).visible) return;
|
||||
|
||||
stopAutomationPulse();
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
indicatorEl.dataset.pulsing = "true";
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
automationPulseTimer = setTimeout(() => {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
automationPulseTimer = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function resetAutomationIndicator() {
|
||||
automationState = createAutomationState();
|
||||
stopAutomationPulse();
|
||||
syncAutomationUI();
|
||||
}
|
||||
|
||||
function applyAutomationEvent(event, { replay = false } = {}) {
|
||||
automationState = reduceAutomationState(automationState, event);
|
||||
syncAutomationUI();
|
||||
if (!replay && shouldPulseAutomationIndicator(event)) {
|
||||
pulseAutomationIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutomationSnapshot(snapshot) {
|
||||
if (snapshot === undefined) return;
|
||||
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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?.("idle");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionEvent(event) {
|
||||
if (event?.type === "session_status" && typeof event.payload?.status === "string") {
|
||||
applySessionStatus(event.payload.status);
|
||||
if (isClosedSessionStatus(event.payload.status)) {
|
||||
disconnectSSE();
|
||||
}
|
||||
}
|
||||
applyAutomationEvent(event);
|
||||
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)) {
|
||||
const closedEvent = { type: "session_status", payload: { status: session.status } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
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;
|
||||
resetAutomationIndicator();
|
||||
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;
|
||||
resetAutomationIndicator();
|
||||
let session = null;
|
||||
|
||||
// Reset task state for new session and init panel
|
||||
resetTaskState();
|
||||
const taskPanelEl = document.getElementById("task-panel");
|
||||
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||
|
||||
try {
|
||||
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");
|
||||
applyAutomationSnapshot(session?.automation_state);
|
||||
|
||||
// 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) {
|
||||
applyAutomationEvent(event, { replay: true });
|
||||
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)) {
|
||||
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
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(mode) {
|
||||
const working = mode === "working";
|
||||
actionBtn.classList.toggle("loading", working);
|
||||
actionBtn.dataset.mode = mode || "idle";
|
||||
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", working);
|
||||
iconStop.classList.toggle("hidden", !working);
|
||||
}
|
||||
|
||||
window.__updateActionBtn = setBtnState;
|
||||
setBtnState(getActivityMode());
|
||||
|
||||
actionBtn.addEventListener("click", () => {
|
||||
if (getActivityMode() === "working") {
|
||||
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);
|
||||
} 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");
|
||||
const requestId = prompt?.dataset?.requestId || null;
|
||||
if (prompt) prompt.remove();
|
||||
if (requestId) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
|
||||
if (row.dataset.pendingRequestId === requestId) row.remove();
|
||||
});
|
||||
}
|
||||
const area = document.getElementById("permission-area");
|
||||
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||
}
|
||||
|
||||
function appendLocalSystemMessage(text) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||
stream.appendChild(row);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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);
|
||||
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
|
||||
} 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();
|
||||
});
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* Remote Control — Automation helpers
|
||||
*
|
||||
* Centralizes detection of non-human inputs so the web UI can hide
|
||||
* internal prompts while still surfacing session state.
|
||||
*/
|
||||
|
||||
export const PROACTIVE_ENABLED_TEXT =
|
||||
"Proactive mode enabled — model will work autonomously between ticks";
|
||||
export const PROACTIVE_DISABLED_TEXT = "Proactive mode disabled";
|
||||
|
||||
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
|
||||
|
||||
const HIDDEN_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"output-file",
|
||||
"reason",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"status",
|
||||
"summary",
|
||||
"system-reminder",
|
||||
"task-id",
|
||||
"task-notification",
|
||||
"task-type",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"tool-use-id",
|
||||
"ultraplan",
|
||||
"worktree",
|
||||
"worktreeBranch",
|
||||
"worktreePath",
|
||||
]);
|
||||
|
||||
const PRIMARY_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"system-reminder",
|
||||
"task-notification",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"ultraplan",
|
||||
]);
|
||||
|
||||
const WORKING_AUTOMATION_TAGS = new Set(
|
||||
[...PRIMARY_AUTOMATION_TAGS].filter(
|
||||
(tag) => tag !== "local-command-caveat" && tag !== "system-reminder",
|
||||
),
|
||||
);
|
||||
|
||||
const XML_ONLY_BLOCK_PATTERN =
|
||||
/^(?:\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*)+$/;
|
||||
const XML_BLOCK_PATTERN =
|
||||
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy;
|
||||
|
||||
function normalizeAutomationStatePayload(payload) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: payload.enabled === true,
|
||||
phase: payload.phase === "standby" || payload.phase === "sleeping" ? payload.phase : null,
|
||||
next_tick_at: typeof payload.next_tick_at === "number" ? payload.next_tick_at : null,
|
||||
sleep_until: typeof payload.sleep_until === "number" ? payload.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractEventText(payload) {
|
||||
if (!payload) return "";
|
||||
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((block) => block && typeof block === "object" && block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function getOpeningTagNames(text) {
|
||||
const trimmed = String(text).trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
XML_BLOCK_PATTERN.lastIndex = 0;
|
||||
const tags = [];
|
||||
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
|
||||
const match = XML_BLOCK_PATTERN.exec(trimmed);
|
||||
if (!match) return [];
|
||||
tags.push(match[1]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function isAutomationEnvelopeText(text) {
|
||||
const trimmed = typeof text === "string" ? text.trim() : "";
|
||||
if (!trimmed) return false;
|
||||
if (!XML_ONLY_BLOCK_PATTERN.test(trimmed)) return false;
|
||||
|
||||
const tagNames = getOpeningTagNames(trimmed);
|
||||
return (
|
||||
tagNames.length > 0 &&
|
||||
tagNames.every((tagName) => HIDDEN_AUTOMATION_TAGS.has(tagName)) &&
|
||||
tagNames.some((tagName) => PRIMARY_AUTOMATION_TAGS.has(tagName))
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenAutomationUserPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.isSynthetic === true) return true;
|
||||
return isAutomationEnvelopeText(extractEventText(payload));
|
||||
}
|
||||
|
||||
export function shouldHideAutomationUserEvent(payload, direction = "inbound") {
|
||||
return direction === "inbound" && isHiddenAutomationUserPayload(payload);
|
||||
}
|
||||
|
||||
export function shouldStartAutomationWorkFromUserEvent(payload, direction = "inbound") {
|
||||
if (!shouldHideAutomationUserEvent(payload, direction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = extractEventText(payload).trim();
|
||||
if (!text || !XML_ONLY_BLOCK_PATTERN.test(text)) {
|
||||
return payload?.isSynthetic === true;
|
||||
}
|
||||
|
||||
const tagNames = getOpeningTagNames(text);
|
||||
return tagNames.some((tagName) => WORKING_AUTOMATION_TAGS.has(tagName));
|
||||
}
|
||||
|
||||
export function createAutomationState() {
|
||||
return {
|
||||
proactive: false,
|
||||
autoRun: false,
|
||||
hasAuthority: false,
|
||||
enabled: false,
|
||||
phase: null,
|
||||
nextTickAt: null,
|
||||
sleepUntil: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAuthoritativeAutomationState(state, payload) {
|
||||
const normalized = normalizeAutomationStatePayload(payload);
|
||||
state.hasAuthority = true;
|
||||
state.enabled = normalized.enabled;
|
||||
state.phase = normalized.phase;
|
||||
state.nextTickAt = normalized.next_tick_at;
|
||||
state.sleepUntil = normalized.sleep_until;
|
||||
state.proactive = normalized.enabled;
|
||||
state.autoRun = false;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function reduceAutomationState(state, event) {
|
||||
const next = state ? { ...state } : createAutomationState();
|
||||
if (!event || typeof event !== "object") return next;
|
||||
|
||||
const type = event.type || "unknown";
|
||||
const payload = event.payload || {};
|
||||
const direction = event.direction || "inbound";
|
||||
|
||||
if (type === "automation_state") {
|
||||
return applyAuthoritativeAutomationState(next, payload);
|
||||
}
|
||||
|
||||
if (type === "session_status") {
|
||||
if (CLOSED_SESSION_STATUSES.has(payload.status)) {
|
||||
if (next.hasAuthority) {
|
||||
return applyAuthoritativeAutomationState(next, null);
|
||||
}
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.hasAuthority) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const text = extractEventText(payload).trim();
|
||||
if (text === PROACTIVE_ENABLED_TEXT) {
|
||||
next.proactive = true;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
if (text === PROACTIVE_DISABLED_TEXT) {
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "result" || type === "result_success" || type === "error" || type === "interrupt") {
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "user" && shouldHideAutomationUserEvent(payload, direction)) {
|
||||
next.autoRun = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shouldPulseAutomationIndicator(event) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
|
||||
if (event.type === "automation_state") {
|
||||
return event.payload?.enabled === true;
|
||||
}
|
||||
|
||||
if (event.type === "assistant") {
|
||||
const text = extractEventText(event.payload || {}).trim();
|
||||
return text === PROACTIVE_ENABLED_TEXT;
|
||||
}
|
||||
|
||||
return event.type === "user" && shouldHideAutomationUserEvent(event.payload || {}, event.direction || "inbound");
|
||||
}
|
||||
|
||||
export function getAutomationIndicator(state) {
|
||||
if (state?.hasAuthority) {
|
||||
if (!state.enabled) {
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "sleeping",
|
||||
title: "Claude Code is in proactive mode and currently sleeping until the next wake-up or user message.",
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.proactive) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.autoRun) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Auto Run",
|
||||
tone: "auto-run",
|
||||
title: "Claude Code is processing an automatic background trigger.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutomationActivity(state) {
|
||||
if (!state?.hasAuthority || !state.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: state.nextTickAt,
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: state.sleepUntil,
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderAutomationIcon(variant = "active", { className = "", decorative = true } = {}) {
|
||||
const classes = ["clawd-icon", `clawd-icon-${variant}`, className].filter(Boolean).join(" ");
|
||||
const ariaAttrs = decorative ? 'aria-hidden="true"' : 'role="img" aria-label="Claude Code status"';
|
||||
|
||||
return `
|
||||
<span class="${classes}" ${ariaAttrs}>
|
||||
<svg viewBox="0 0 40 30" fill="none">
|
||||
<path class="clawd-arm clawd-arm-left" d="M8.5 13.4C6.6 12.8 5.4 11.4 4.8 9.4C4.6 8.6 4.9 7.7 5.6 7.3C6.3 6.9 7.2 7 7.8 7.6L10.8 10.6L8.5 13.4Z" />
|
||||
<path class="clawd-arm clawd-arm-right" d="M31.5 13.4C33.4 12.8 34.6 11.4 35.2 9.4C35.4 8.6 35.1 7.7 34.4 7.3C33.7 6.9 32.8 7 32.2 7.6L29.2 10.6L31.5 13.4Z" />
|
||||
<path class="clawd-shell" d="M10 12.2C10 7.9 13.5 4.4 17.8 4.4H22.2C26.5 4.4 30 7.9 30 12.2V17.3C30 21 27 24 23.3 24H16.7C13 24 10 21 10 17.3V12.2Z" />
|
||||
<circle class="clawd-eye clawd-eye-left" cx="17.2" cy="13.4" r="1.55" />
|
||||
<circle class="clawd-eye clawd-eye-right" cx="22.8" cy="13.4" r="1.55" />
|
||||
<path class="clawd-eye-line clawd-eye-line-left" d="M15.9 13.6C16.3 12.8 17 12.4 17.9 12.4" />
|
||||
<path class="clawd-eye-line clawd-eye-line-right" d="M22.1 12.4C23 12.4 23.7 12.8 24.1 13.6" />
|
||||
<path class="clawd-foot clawd-foot-left" d="M14.3 25.1C14.3 24 15.2 23.1 16.3 23.1C17.4 23.1 18.3 24 18.3 25.1V25.8H14.3V25.1Z" />
|
||||
<path class="clawd-foot clawd-foot-right" d="M21.7 25.1C21.7 24 22.6 23.1 23.7 23.1C24.8 23.1 25.7 24 25.7 25.1V25.8H21.7V25.1Z" />
|
||||
</svg>
|
||||
<span class="clawd-z clawd-z-1">Z</span>
|
||||
<span class="clawd-z clawd-z-2">Z</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
PROACTIVE_DISABLED_TEXT,
|
||||
PROACTIVE_ENABLED_TEXT,
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
isAutomationEnvelopeText,
|
||||
reduceAutomationState,
|
||||
shouldHideAutomationUserEvent,
|
||||
shouldStartAutomationWorkFromUserEvent,
|
||||
} from "./automation.js";
|
||||
|
||||
describe("automation helpers", () => {
|
||||
test("keeps real user text visible", () => {
|
||||
expect(shouldHideAutomationUserEvent({ content: "hello from a human" }, "inbound")).toBe(false);
|
||||
});
|
||||
|
||||
test("hides internal xml wrappers without synthetic metadata", () => {
|
||||
expect(isAutomationEnvelopeText("<tick>2:56:47 PM</tick>")).toBe(true);
|
||||
expect(isAutomationEnvelopeText("<system-reminder>\nDo useful work.\n</system-reminder>")).toBe(true);
|
||||
expect(
|
||||
isAutomationEnvelopeText(
|
||||
"<task-notification><summary>Finished</summary><output-file>/tmp/out.log</output-file></task-notification>",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat slash-command scaffolding as active work", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{
|
||||
content:
|
||||
"<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>",
|
||||
isSynthetic: true,
|
||||
},
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps true automatic triggers eligible for loading state", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<tick>2:56:47 PM</tick>", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("hides synthetic automatic prompts even when they are plain text", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps mixed human text with tags visible", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "Please keep this: <system-reminder>not metadata</system-reminder>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows autopilot while proactive mode remains active", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_ENABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Working on background maintenance." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_DISABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("shows auto run until an automatic trigger settles", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Auto Run");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("active");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Completed scheduled refresh." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("authoritative automation_state drives standby and sleeping states", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
});
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: 123456,
|
||||
iconVariant: "standby",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 999999,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state).tone).toBe("sleeping");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("sleeping");
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: 999999,
|
||||
iconVariant: "sleeping",
|
||||
});
|
||||
});
|
||||
|
||||
test("authoritative disabled snapshot suppresses heuristic auto-run fallback", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
expect(getAutomationActivity(state)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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); }
|
||||
@@ -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); }
|
||||
}
|
||||
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal file
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
241
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main container — Anthropic sidebar + chat layout.
|
||||
* Sidebar: sectioned by recency, orange active state, warm raised bg.
|
||||
*/
|
||||
export function ACPMain({ client }: 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 bg-surface-1 transition-all duration-200 flex-shrink-0",
|
||||
sidebarCollapsed ? "w-12" : "w-64",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider px-1">会话</span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!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} />
|
||||
</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-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.sessionId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(session.sessionId);
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
session.sessionId === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-2 border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-2 border-l-transparent",
|
||||
)}
|
||||
title={session.title || session.sessionId}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
{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);
|
||||
}
|
||||
717
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
717
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
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 } 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 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions - imported from shared types module
|
||||
// =============================================================================
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
client: ACPClient;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 }: 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);
|
||||
// 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 activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
||||
}, [client]);
|
||||
|
||||
// =============================================================================
|
||||
// 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 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Create session
|
||||
client.createSession();
|
||||
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();
|
||||
}, [client, isLoading, resetThreadState]);
|
||||
|
||||
// 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) => {
|
||||
const kind = approved ? "accept_once" : "reject_once";
|
||||
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
|
||||
}, [handlePermissionResponse]);
|
||||
|
||||
// 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 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-3 sm:px-4 pb-1 flex items-center justify-between">
|
||||
<ModelSelectorPopover client={client} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal file
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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-4 p-4 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-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 border-l-3 border-l-warning-border 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
@@ -0,0 +1,186 @@
|
||||
"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, 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)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
334
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
334
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
}
|
||||
// Let cmdk handle arrow keys and Enter for selection
|
||||
// Tab also closes the menu
|
||||
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-3 sm:px-4 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="附件"
|
||||
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 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"
|
||||
>
|
||||
{"\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" />
|
||||
</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;
|
||||
}
|
||||
166
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
166
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ThreadEntry, ToolCallEntry } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
import { ToolCallGroup } from "./ToolCallGroup";
|
||||
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-3 items-start">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<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];
|
||||
// 用户消息前面多留白
|
||||
if (entry?.type === "user_message") {
|
||||
return "pt-6 pb-2";
|
||||
}
|
||||
// 助手消息后面多留白(除非紧跟工具调用)
|
||||
if (entry?.type === "assistant_message") {
|
||||
const next = entries[index + 1];
|
||||
if (next?.type === "tool_call") {
|
||||
return "pt-2 pb-1";
|
||||
}
|
||||
return "pt-2 pb-4";
|
||||
}
|
||||
return "py-1";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单条目渲染器
|
||||
// =============================================================================
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match — checks if all query chars appear in order in the text.
|
||||
* Same algorithm as ModelSelectorPicker.
|
||||
*/
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerText = text.toLowerCase();
|
||||
let queryIdx = 0;
|
||||
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||
queryIdx++;
|
||||
}
|
||||
}
|
||||
return queryIdx === lowerQuery.length;
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
commands,
|
||||
filter,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}: CommandMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => fuzzyMatch(filter, cmd.name) || fuzzyMatch(filter, cmd.description),
|
||||
);
|
||||
}, [commands, filter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList className="max-h-[320px]">
|
||||
<CommandEmpty className="text-xs text-text-muted font-display py-3">
|
||||
没有匹配的命令
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd.name}
|
||||
value={cmd.name}
|
||||
onSelect={() => onSelect(cmd)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 cursor-pointer",
|
||||
"rounded-lg mx-1",
|
||||
"data-[selected=true]:bg-brand/8 data-[selected=true]:text-text-primary",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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";
|
||||
|
||||
// =============================================================================
|
||||
// 用户消息 — 右对齐,深色反转背景,无气泡边框
|
||||
// Anthropic: right-aligned, inverted dark bg, rounded-xl with bottom-right notch
|
||||
// =============================================================================
|
||||
|
||||
interface UserBubbleProps {
|
||||
entry: UserMessageEntry;
|
||||
}
|
||||
|
||||
export function UserBubble({ entry }: UserBubbleProps) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] sm:max-w-[75%]">
|
||||
{/* 图片附件 */}
|
||||
{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="rounded-2xl rounded-br-md bg-bg-inverted px-4 py-2.5 text-sm text-text-inverted whitespace-pre-wrap font-display leading-relaxed">
|
||||
{esc(entry.content)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 助手消息 — 左对齐,无背景卡片,编辑式排版
|
||||
// Anthropic: avatar + plain text, no bubble/card wrapper, serif body font
|
||||
// =============================================================================
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
entry: AssistantMessageEntry;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
return (
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Orange triangle avatar */}
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* 内容 — 无卡片背景,直接排版 */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
{/* Sender label */}
|
||||
<span className="text-sm font-medium 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">
|
||||
{chunk.text}
|
||||
</div>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
// 普通消息块 — 直接输出,无包裹卡片
|
||||
return (
|
||||
<div key={i} className="message-content text-text-primary leading-loose">
|
||||
<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="用户上传的图片"
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 border-l-3 border-l-warning-border 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>
|
||||
);
|
||||
}
|
||||
@@ -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 border-l-2",
|
||||
session.id === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-transparent",
|
||||
)}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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}
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 多个工具调用 — 折叠组
|
||||
const summary = buildSummary(entries);
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-lg border border-border border-l-3 border-l-brand/50 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]">▶</span>;
|
||||
case "complete":
|
||||
return <span className="text-status-active text-[10px]">✓</span>;
|
||||
case "error":
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
case "waiting_for_confirmation":
|
||||
return <span className="text-brand text-[10px]">⍻</span>;
|
||||
case "canceled":
|
||||
return <span className="text-text-muted text-[10px]">—</span>;
|
||||
case "rejected":
|
||||
return <span className="text-status-error text-[10px]">✕</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")}>
|
||||
{/* 标题行 — 单行紧凑 */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 权限请求按钮 */}
|
||||
{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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ChatView } from "./ChatView";
|
||||
export { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
export { ToolCallGroup } from "./ToolCallGroup";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { PermissionPanel } from "./PermissionPanel";
|
||||
export { SessionSidebar } from "./SessionSidebar";
|
||||
export { CommandMenu } from "./CommandMenu";
|
||||
6
packages/remote-control-server/web/components/index.ts
Normal file
6
packages/remote-control-server/web/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./ACPConnect";
|
||||
export * from "./ACPMain";
|
||||
export * from "./ChatInterface";
|
||||
export * from "./ChatMessage";
|
||||
export * from "./ThreadHistory";
|
||||
export * from "./model-selector";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ModelSelectorPopover } from "./ModelSelectorPopover";
|
||||
export { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
|
||||
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal file
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal 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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
61
packages/remote-control-server/web/components/ui/button.tsx
Normal file
61
packages/remote-control-server/web/components/ui/button.tsx
Normal 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 }
|
||||
|
||||
93
packages/remote-control-server/web/components/ui/card.tsx
Normal file
93
packages/remote-control-server/web/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
183
packages/remote-control-server/web/components/ui/command.tsx
Normal file
183
packages/remote-control-server/web/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ConnectionState } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
// Shared styles for connection state dots
|
||||
const connectionDotStyles: Record<ConnectionState, string> = {
|
||||
disconnected: "bg-gray-400",
|
||||
connecting: "bg-yellow-400 animate-pulse",
|
||||
connected: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]",
|
||||
error: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]",
|
||||
};
|
||||
|
||||
// Shared labels for connection states
|
||||
const connectionStateLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display label for a connection state
|
||||
*/
|
||||
export function getConnectionStateLabel(state: ConnectionState): string {
|
||||
return connectionStateLabels[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* A small dot indicator for connection state
|
||||
* Used in status bars and headers
|
||||
*/
|
||||
export function StatusDot({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn("w-2 h-2 rounded-full", connectionDotStyles[state], className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A status indicator with dot and label
|
||||
* Used in cards and detailed views
|
||||
*/
|
||||
export function StatusIndicator({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-2 text-sm font-normal", className)}>
|
||||
<StatusDot state={state} />
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete status bar section with dot, label, and optional URL
|
||||
*/
|
||||
export function ConnectionStatusBar({
|
||||
state,
|
||||
displayUrl,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
displayUrl?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<StatusDot state={state} />
|
||||
<span className="text-sm font-medium">
|
||||
{getConnectionStateLabel(state)}
|
||||
</span>
|
||||
{state === "connected" && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{displayUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
144
packages/remote-control-server/web/components/ui/dialog.tsx
Normal file
144
packages/remote-control-server/web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/index.ts
Normal file
22
packages/remote-control-server/web/components/ui/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export * from "./badge"
|
||||
export * from "./connection-status"
|
||||
export * from "./button-group"
|
||||
export * from "./button"
|
||||
export * from "./card"
|
||||
export * from "./collapsible"
|
||||
export * from "./command"
|
||||
export * from "./dialog"
|
||||
export * from "./dropdown-menu"
|
||||
export * from "./hover-card"
|
||||
export * from "./input-group"
|
||||
export * from "./input"
|
||||
export * from "./label"
|
||||
export * from "./resizable"
|
||||
export * from "./scroll-area"
|
||||
export * from "./select"
|
||||
export * from "./separator"
|
||||
export * from "./tabs"
|
||||
export * from "./textarea"
|
||||
export * from "./theme-toggle"
|
||||
export * from "./tooltip"
|
||||
export * from "./popover"
|
||||
171
packages/remote-control-server/web/components/ui/input-group.tsx
Normal file
171
packages/remote-control-server/web/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Button } from "./button"
|
||||
import { Input } from "./input"
|
||||
import { Textarea } from "./textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/input.tsx
Normal file
22
packages/remote-control-server/web/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/label.tsx
Normal file
22
packages/remote-control-server/web/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
|
||||
47
packages/remote-control-server/web/components/ui/popover.tsx
Normal file
47
packages/remote-control-server/web/components/ui/popover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
{/*
|
||||
Workaround for Radix ScrollArea bug #926:
|
||||
The Viewport's inner div uses display:table which breaks text-overflow:ellipsis.
|
||||
We override it to display:block using the [style] selector.
|
||||
See: https://github.com/radix-ui/primitives/issues/926
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div[style]]:!block"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
||||
188
packages/remote-control-server/web/components/ui/select.tsx
Normal file
188
packages/remote-control-server/web/components/ui/select.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
98
packages/remote-control-server/web/components/ui/tabs.tsx
Normal file
98
packages/remote-control-server/web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
forceMount,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
|
||||
forceMount?: true;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
forceMount={forceMount}
|
||||
className={cn(
|
||||
"flex-1 outline-none",
|
||||
// When forceMount is used, hide inactive tabs
|
||||
forceMount && "data-[state=inactive]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme, type Theme } from "../../src/lib/theme";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
const themeOptions: { value: Theme; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{themeOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={theme === option.value ? "bg-accent" : ""}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="ml-2">{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
60
packages/remote-control-server/web/components/ui/tooltip.tsx
Normal file
60
packages/remote-control-server/web/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
@@ -4,149 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Remote Control — Claude Code</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Figtree:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" />
|
||||
<link rel="stylesheet" href="/code/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Nav Bar -->
|
||||
<nav id="navbar">
|
||||
<div class="nav-inner">
|
||||
<a href="/code/" class="nav-logo">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M10 1L12.2 7.8L19 10L12.2 12.2L10 19L7.8 12.2L1 10L7.8 7.8L10 1Z" fill="#D97757"/>
|
||||
</svg>
|
||||
Remote Control
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/code/" class="nav-link" id="nav-dashboard">Dashboard</a>
|
||||
<button id="nav-identity" class="nav-link btn-text" title="Identity & QR">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||
<path d="M6 8C7.66 8 9 6.66 9 5C9 3.34 7.66 2 6 2C4.34 2 3 3.34 3 5C3 6.66 4.34 8 6 8ZM6 10C3.99 10 0 11.01 0 13V14H12V13C12 11.01 8.01 10 6 10ZM13 8V5H11V8H8V10H11V13H13V10H16V8H13Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Identity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Page -->
|
||||
<section id="page-dashboard" class="page hidden">
|
||||
<div class="dashboard-container">
|
||||
<!-- Environments -->
|
||||
<div class="dashboard-section">
|
||||
<h2 class="section-title">Environments</h2>
|
||||
<div id="env-list" class="card-list">
|
||||
<div class="empty-state">No active environments</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sessions -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Sessions</h2>
|
||||
<button id="new-session-btn" class="btn-primary btn-sm">+ New Session</button>
|
||||
</div>
|
||||
<div id="session-list" class="card-list">
|
||||
<div class="empty-state">No sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Session Dialog -->
|
||||
<div id="new-session-dialog" class="dialog-overlay hidden">
|
||||
<div class="dialog-card">
|
||||
<h3>New Session</h3>
|
||||
<label for="ns-title">Title (optional)</label>
|
||||
<input type="text" id="ns-title" placeholder="My session" />
|
||||
<label for="ns-env">Environment</label>
|
||||
<select id="ns-env"></select>
|
||||
<div id="ns-error" class="error-msg hidden"></div>
|
||||
<div class="dialog-actions">
|
||||
<button id="ns-cancel" class="btn-outline">Cancel</button>
|
||||
<button id="ns-create" class="btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Session Detail Page -->
|
||||
<section id="page-session" class="page hidden">
|
||||
<div class="session-container">
|
||||
<!-- Header -->
|
||||
<div class="session-header">
|
||||
<a href="/code/" class="back-link">← Dashboard</a>
|
||||
<div class="session-meta">
|
||||
<h2 id="session-title" class="session-detail-title">Session</h2>
|
||||
<div class="session-meta-row">
|
||||
<span id="session-id" class="meta-item"></span>
|
||||
<span id="session-status" class="status-badge"></span>
|
||||
<span id="session-automation" class="automation-pill hidden" aria-live="polite"></span>
|
||||
<span id="session-env" class="meta-item"></span>
|
||||
<span id="session-time" class="meta-item"></span>
|
||||
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">
|
||||
Tasks <span id="task-badge" class="task-count-badge hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Event Stream -->
|
||||
<div id="event-stream" class="event-stream"></div>
|
||||
<!-- Permission Prompt Area -->
|
||||
<div id="permission-area" class="hidden"></div>
|
||||
<!-- Control Bar -->
|
||||
<div class="control-bar">
|
||||
<input type="text" id="msg-input" placeholder="Type a message..." autocomplete="off" />
|
||||
<button id="action-btn" class="action-btn" aria-label="Send">
|
||||
<svg id="action-icon-send" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M3 10L17 3L10 17L9 11L3 10Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg id="action-icon-stop" class="hidden" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="3" y="3" width="12" height="12" rx="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task Panel -->
|
||||
<div id="task-panel" class="task-panel hidden"></div>
|
||||
|
||||
<!-- Identity Panel (QR display + scan) -->
|
||||
<div id="identity-panel" class="identity-panel hidden">
|
||||
<div class="identity-panel-inner">
|
||||
<div class="identity-panel-header">
|
||||
<h3>Identity</h3>
|
||||
<button class="panel-close">×</button>
|
||||
</div>
|
||||
<div class="identity-panel-body">
|
||||
<div class="identity-section">
|
||||
<label>Your UUID</label>
|
||||
<div class="uuid-row">
|
||||
<code id="uuid-display" class="uuid-text"></code>
|
||||
<button id="uuid-copy-btn" class="btn-outline btn-sm">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="identity-section">
|
||||
<label>Scan on another device</label>
|
||||
<div id="qr-display" class="qr-container"></div>
|
||||
</div>
|
||||
<div class="identity-section">
|
||||
<label>Import identity from QR</label>
|
||||
<button id="qr-scan-btn" class="btn-outline">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:-2px;margin-right:4px;">
|
||||
<path d="M1 1H5V3H3V5H1V1ZM11 1H15V5H13V3H11V1ZM1 11H3V13H5V15H1V11ZM13 11H15V15H11V13H13V11ZM6 6H10V10H6V6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Upload QR Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Libraries -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||
<script type="module" src="/code/app.js"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,692 +0,0 @@
|
||||
/* === Event Stream === */
|
||||
.event-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* === Message Bubbles — Anthropic / Claude === */
|
||||
.msg-row {
|
||||
display: flex;
|
||||
max-width: 82%;
|
||||
animation: msgIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes msgIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg-row.user { align-self: flex-end; }
|
||||
.msg-row.assistant { align-self: flex-start; }
|
||||
.msg-row.tool { align-self: flex-start; max-width: 95%; }
|
||||
.msg-row.tool-trace-row { align-self: flex-start; max-width: 92%; }
|
||||
.msg-row.system { align-self: center; }
|
||||
.msg-row.result { align-self: center; }
|
||||
|
||||
.msg-bubble {
|
||||
padding: 12px 18px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-row.user .msg-bubble {
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
border-bottom-right-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.2);
|
||||
}
|
||||
|
||||
.msg-row.assistant .msg-bubble {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-bottom-left-radius: 6px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-turn-orphan {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.assistant-trace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.assistant-trace.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.16);
|
||||
background: rgba(245, 243, 239, 0.78);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(217, 119, 87, 0.28);
|
||||
background: rgba(250, 247, 242, 0.98);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.has-error {
|
||||
color: var(--red);
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
background: rgba(252, 238, 238, 0.88);
|
||||
}
|
||||
|
||||
.assistant-trace-glyph {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span:nth-child(1) { height: 7px; }
|
||||
.assistant-trace-glyph span:nth-child(2) { height: 10px; }
|
||||
.assistant-trace-glyph span:nth-child(3) { height: 5px; }
|
||||
|
||||
.assistant-trace-count {
|
||||
min-width: 1ch;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.assistant-trace-chevron {
|
||||
font-size: 0.9rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.is-open .assistant-trace-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assistant-trace-panel {
|
||||
width: min(100%, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(250, 247, 242, 0.98), rgba(245, 243, 239, 0.92)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-trace-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card:hover {
|
||||
border-color: rgba(217, 119, 87, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error {
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error:hover {
|
||||
border-color: rgba(196, 64, 64, 0.4);
|
||||
}
|
||||
|
||||
.msg-row.system .msg-bubble {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.msg-row.result .msg-bubble {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 6px 16px;
|
||||
border-radius: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* === Tool Cards — Anthropic === */
|
||||
.tool-card {
|
||||
background: var(--bg-tool-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.tool-card:hover { border-color: var(--accent); }
|
||||
|
||||
.tool-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.tool-card-header:hover { color: var(--text-primary); }
|
||||
|
||||
.tool-card-header .tool-icon {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.tool-card-header.is-open .tool-icon,
|
||||
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
|
||||
|
||||
.tool-card-body {
|
||||
margin-top: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 12px 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.tool-card-body.collapsed { display: none; }
|
||||
|
||||
/* === Permission Prompt — Anthropic === */
|
||||
.permission-prompt {
|
||||
background: var(--bg-permission);
|
||||
border: 1px solid #F0D9A8;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.permission-prompt .perm-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--orange);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.permission-prompt .perm-tool {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
background: var(--bg-card);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-xs);
|
||||
margin-bottom: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.permission-prompt .perm-actions { display: flex; gap: 10px; }
|
||||
.permission-prompt .perm-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.permission-prompt .perm-tool-name {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* === AskUserQuestion Panel === */
|
||||
.ask-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 2px 12px rgba(217, 119, 87, 0.15);
|
||||
}
|
||||
.ask-panel .ask-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ask-question {
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.ask-question:last-of-type { border-bottom: none; margin-bottom: 12px; }
|
||||
.ask-question-text {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ask-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ask-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ask-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ask-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(217, 119, 87, 0.04);
|
||||
}
|
||||
.ask-option.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(217, 119, 87, 0.1);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.ask-option-label { font-weight: 500; }
|
||||
.ask-option-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ask-other-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ask-other-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.ask-other-input:focus { border-color: var(--accent); }
|
||||
.ask-other-btn {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ask-other-btn:hover { border-color: var(--accent); }
|
||||
.ask-actions { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.ask-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ask-tab {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1.5px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ask-tab:hover { color: var(--text-primary); }
|
||||
.ask-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.ask-tab-page { display: none; }
|
||||
.ask-tab-page.active { display: block; }
|
||||
.ask-tab-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.ask-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* === ExitPlanMode Panel === */
|
||||
.plan-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px solid #7C6FA0;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-top: 8px;
|
||||
max-width: 95%;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 2px 12px rgba(124, 111, 160, 0.18);
|
||||
}
|
||||
.plan-panel .plan-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
color: #7C6FA0;
|
||||
}
|
||||
.plan-panel .plan-content {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.plan-panel .plan-content > :first-child { margin-top: 0; }
|
||||
.plan-panel .plan-content > :last-child { margin-bottom: 0; }
|
||||
.plan-panel .plan-content h1,
|
||||
.plan-panel .plan-content h2,
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-panel .plan-content h1 { font-size: 1.15rem; }
|
||||
.plan-panel .plan-content h2 { font-size: 1.05rem; }
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 { font-size: 0.95rem; }
|
||||
.plan-panel .plan-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.plan-panel .plan-content ul,
|
||||
.plan-panel .plan-content ol {
|
||||
margin: 0 0 12px 1.35em;
|
||||
padding: 0;
|
||||
}
|
||||
.plan-panel .plan-content li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.plan-panel .plan-content pre {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.plan-panel .plan-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.plan-panel .plan-content code {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.plan-panel .plan-content strong { font-weight: 600; }
|
||||
.plan-options { display: flex; flex-direction: column; gap: 6px; }
|
||||
.plan-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
gap: 10px;
|
||||
}
|
||||
.plan-option:hover {
|
||||
border-color: #7C6FA0;
|
||||
background: rgba(124, 111, 160, 0.04);
|
||||
}
|
||||
.plan-option.selected {
|
||||
border-color: #7C6FA0;
|
||||
background: rgba(124, 111, 160, 0.1);
|
||||
box-shadow: 0 0 0 1px #7C6FA0;
|
||||
}
|
||||
.plan-option-label { font-weight: 500; }
|
||||
.plan-option-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.plan-feedback-area {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
.plan-feedback-area.visible { display: block; }
|
||||
.plan-feedback-input {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.plan-feedback-input:focus { border-color: #7C6FA0; }
|
||||
.plan-actions { display: flex; gap: 10px; margin-top: 12px; }
|
||||
.plan-actions .btn-plan-submit {
|
||||
background: #7C6FA0;
|
||||
color: var(--text-light);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.plan-actions .btn-plan-submit:hover { background: #6B5E90; }
|
||||
.plan-actions .btn-plan-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* === Timestamps === */
|
||||
.event-time { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* === Loading Indicator — TUI star spinner === */
|
||||
.msg-row.loading-row {
|
||||
align-self: flex-start;
|
||||
max-width: 82%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 4px;
|
||||
animation: msgIn 0.3s ease-out;
|
||||
}
|
||||
.tui-spinner {
|
||||
font-size: 1.2rem;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
min-width: 1.2em;
|
||||
transition: color 2s ease;
|
||||
}
|
||||
.stalled .tui-spinner { color: var(--red); }
|
||||
.tui-verb {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition: color 2s ease;
|
||||
}
|
||||
.stalled .tui-verb { color: var(--red); }
|
||||
|
||||
/* Glimmer — reverse sweep highlight (same visual as TUI) */
|
||||
.glimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--text-secondary) 0%,
|
||||
var(--text-secondary) 40%,
|
||||
var(--accent) 50%,
|
||||
var(--text-secondary) 60%,
|
||||
var(--text-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: glimmerSweep 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glimmerSweep {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
.stalled .glimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--red) 0%,
|
||||
var(--red) 40%,
|
||||
#E06060 50%,
|
||||
var(--red) 60%,
|
||||
var(--red) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.tui-timer {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
.automation-activity-row {
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
}
|
||||
.automation-activity-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(217, 119, 87, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.automation-activity-standby .automation-activity-card {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.automation-activity-sleeping .automation-activity-card {
|
||||
color: var(--green);
|
||||
border-color: rgba(59, 138, 106, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
}
|
||||
.automation-activity-icon {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
}
|
||||
.automation-activity-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.automation-activity-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.automation-activity-countdown {
|
||||
margin-left: auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
/* === Pages === */
|
||||
.page {
|
||||
min-height: calc(100vh - 56px);
|
||||
animation: pageIn var(--transition-slow) ease-out;
|
||||
}
|
||||
.page.no-nav { min-height: 100vh; }
|
||||
|
||||
@keyframes pageIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* === Login — Anthropic === */
|
||||
#page-login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(217, 119, 87, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(59, 138, 106, 0.04) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--border-light);
|
||||
animation: cardIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.login-header { text-align: center; margin-bottom: 36px; }
|
||||
.login-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#login-form label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#login-form input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
#login-form input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input-focus);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--red);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--red-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
#login-btn { margin-top: 20px; width: 100%; padding: 13px; font-size: 0.95rem; }
|
||||
#login-form { margin-top: 24px; }
|
||||
|
||||
/* === Dashboard — Anthropic === */
|
||||
.dashboard-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
.dashboard-section { margin-bottom: 40px; }
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-header .section-title { margin-bottom: 0; }
|
||||
|
||||
.card-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Environment Card */
|
||||
.env-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.env-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.env-card .env-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.env-card .env-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.env-card .env-branch {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Session Card */
|
||||
.session-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.session-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.session-card:active { transform: translateY(0); }
|
||||
.session-card .session-title-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.session-card .session-id-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === Session Detail — Anthropic === */
|
||||
.session-container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#permission-area { flex-shrink: 0; }
|
||||
|
||||
.back-link {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.back-link:hover { color: var(--accent); text-decoration: none; }
|
||||
|
||||
.session-header { margin-bottom: 24px; flex-shrink: 0; }
|
||||
|
||||
.session-detail-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.session-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.automation-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
.automation-pill-icon { width: 24px; height: 18px; flex-shrink: 0; }
|
||||
.automation-pill-label { line-height: 1; }
|
||||
.automation-pill-proactive {
|
||||
color: var(--accent-hover);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.12), rgba(217, 119, 87, 0.06)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(217, 119, 87, 0.18);
|
||||
}
|
||||
.automation-pill-sleeping {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.14), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill-auto-run {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.12), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill.is-pulsing {
|
||||
animation: automationPillPulse 1.2s ease-out;
|
||||
}
|
||||
.automation-pill.is-pulsing .clawd-icon {
|
||||
animation: automationDotPulse 1.2s ease-out;
|
||||
}
|
||||
@keyframes automationPillPulse {
|
||||
0% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
35% { transform: translateY(-1px) scale(1.02); box-shadow: var(--shadow-md); }
|
||||
100% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
}
|
||||
@keyframes automationDotPulse {
|
||||
0% { transform: scale(1); opacity: 0.9; }
|
||||
35% { transform: scale(1.5); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 0.92; }
|
||||
}
|
||||
.clawd-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 30px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.clawd-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.clawd-shell,
|
||||
.clawd-foot { fill: currentColor; }
|
||||
.clawd-shell { opacity: 0.9; }
|
||||
.clawd-arm { fill: currentColor; opacity: 0.74; }
|
||||
.clawd-eye {
|
||||
fill: var(--text-primary);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.clawd-eye-line {
|
||||
display: none;
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.clawd-z {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.clawd-z-2 {
|
||||
top: -9px;
|
||||
right: 4px;
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
.clawd-icon-standby svg {
|
||||
animation: clawdStandbyBob 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-left {
|
||||
animation: clawdLookLeft 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-right {
|
||||
animation: clawdLookRight 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping svg {
|
||||
animation: clawdSleepFloat 3.2s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye {
|
||||
display: none;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye-line {
|
||||
display: block;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-1 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite 0.45s;
|
||||
}
|
||||
@keyframes clawdStandbyBob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1px); }
|
||||
}
|
||||
@keyframes clawdLookLeft {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.8px); }
|
||||
55% { transform: translateX(0.6px); }
|
||||
}
|
||||
@keyframes clawdLookRight {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.6px); }
|
||||
55% { transform: translateX(0.8px); }
|
||||
}
|
||||
@keyframes clawdSleepFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(1px); }
|
||||
}
|
||||
@keyframes clawdSleepZ {
|
||||
0% { transform: translate(0, 0) scale(0.94); opacity: 0; }
|
||||
20% { opacity: 0.88; }
|
||||
100% { transform: translate(4px, -8px) scale(1.04); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.automation-pill.is-pulsing,
|
||||
.automation-pill.is-pulsing .clawd-icon,
|
||||
.clawd-icon-standby svg,
|
||||
.clawd-icon-standby .clawd-eye-left,
|
||||
.clawd-icon-standby .clawd-eye-right,
|
||||
.clawd-icon-sleeping svg,
|
||||
.clawd-icon-sleeping .clawd-z-1,
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.meta-item {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* === Control Bar — Claude-style === */
|
||||
.control-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#msg-input {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--bg-card);
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
#msg-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1), var(--shadow);
|
||||
}
|
||||
#msg-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Circular action button */
|
||||
.action-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 2px 8px rgba(217, 119, 87, 0.25);
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 3px 12px rgba(217, 119, 87, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
.action-btn.loading {
|
||||
background: var(--red);
|
||||
box-shadow: 0 2px 8px rgba(200, 60, 60, 0.25);
|
||||
}
|
||||
.action-btn.loading:hover {
|
||||
background: #B33838;
|
||||
box-shadow: 0 3px 12px rgba(200, 60, 60, 0.35);
|
||||
}
|
||||
.action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.action-btn svg { display: block; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.login-card { margin: 16px; padding: 32px 24px; }
|
||||
.dashboard-container, .session-container { padding: 20px 16px; }
|
||||
.session-card { grid-template-columns: 1fr; gap: 6px; }
|
||||
.env-card { grid-template-columns: 1fr; }
|
||||
.msg-row { max-width: 95%; }
|
||||
.session-meta-row { flex-direction: column; gap: 4px; align-items: flex-start; }
|
||||
.control-bar { flex-wrap: nowrap; }
|
||||
#msg-input { min-width: 0; }
|
||||
.identity-panel-inner { width: 100%; max-width: 100%; }
|
||||
}
|
||||
|
||||
/* === Identity Panel (QR code + scan) === */
|
||||
.identity-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn var(--transition-fast) ease-out;
|
||||
}
|
||||
.identity-panel.hidden { display: none; }
|
||||
|
||||
.identity-panel-inner {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg, 20px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: cardIn var(--transition-slow) ease-out;
|
||||
}
|
||||
|
||||
.identity-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.identity-panel-header h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.panel-close:hover { color: var(--text-primary); }
|
||||
|
||||
.identity-panel-body {
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
.identity-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.identity-section:last-child { margin-bottom: 0; }
|
||||
.identity-section label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.uuid-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.uuid-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
padding: 8px 12px;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.qr-container canvas,
|
||||
.qr-container img {
|
||||
display: block !important;
|
||||
border-radius: var(--radius-sm, 8px);
|
||||
}
|
||||
|
||||
#qr-scan-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
formatCountdownRemaining,
|
||||
resolveActivityMode,
|
||||
shouldRenderTranscriptActivity,
|
||||
} from "./render.js";
|
||||
|
||||
describe("render activity helpers", () => {
|
||||
test("authoritative standby and sleeping states override stale working spinners", () => {
|
||||
expect(resolveActivityMode(true, { mode: "standby" })).toBe("standby");
|
||||
expect(resolveActivityMode(true, { mode: "sleeping" })).toBe("sleeping");
|
||||
expect(resolveActivityMode(true, null)).toBe("working");
|
||||
expect(resolveActivityMode(false, null)).toBe("idle");
|
||||
});
|
||||
|
||||
test("formats countdowns compactly", () => {
|
||||
expect(formatCountdownRemaining(35_000, 0)).toBe("35s");
|
||||
expect(formatCountdownRemaining(185_000, 0)).toBe("3m 5s");
|
||||
expect(formatCountdownRemaining(3_900_000, 0)).toBe("1h 5m");
|
||||
expect(formatCountdownRemaining(null, 0)).toBe("");
|
||||
});
|
||||
|
||||
test("renders transcript activity only for active work", () => {
|
||||
expect(shouldRenderTranscriptActivity("working")).toBe(true);
|
||||
expect(shouldRenderTranscriptActivity("standby")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("sleeping")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("idle")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { formatPlanContent } from "./render.js";
|
||||
|
||||
describe("formatPlanContent", () => {
|
||||
test("renders headings, paragraphs, and lists for plan panels", () => {
|
||||
const html = formatPlanContent(`## Summary
|
||||
Line one
|
||||
Line two
|
||||
|
||||
- First item
|
||||
- Second item
|
||||
|
||||
1. Step one
|
||||
2. Step two`);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<p>Line one<br>Line two</p>");
|
||||
expect(html).toContain("<ul><li>First item</li><li>Second item</li></ul>");
|
||||
expect(html).toContain("<ol><li>Step one</li><li>Step two</li></ol>");
|
||||
});
|
||||
|
||||
test("escapes unsafe markup and preserves inline formatting plus code blocks", () => {
|
||||
const html = formatPlanContent(`**Bold** with \`inline\` and <script>alert(1)</script>
|
||||
|
||||
\`\`\`js
|
||||
const markup = "<div>";
|
||||
\`\`\``);
|
||||
|
||||
expect(html).toContain("<strong>Bold</strong>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("inline</code>");
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
expect(html).toContain("<pre><code>const markup = "<div>";</code></pre>");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { isConversationClearedStatus } from "./render.js";
|
||||
|
||||
describe("status helpers", () => {
|
||||
test("detects direct conversation reset markers", () => {
|
||||
expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true);
|
||||
});
|
||||
|
||||
test("detects nested raw conversation reset markers", () => {
|
||||
expect(
|
||||
isConversationClearedStatus({
|
||||
status: "",
|
||||
raw: { status: "conversation_cleared" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores unrelated status payloads", () => {
|
||||
expect(isConversationClearedStatus({ status: "running" })).toBe(false);
|
||||
expect(isConversationClearedStatus({})).toBe(false);
|
||||
expect(isConversationClearedStatus(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
addAssistantToolTraceHost,
|
||||
addToolTraceEntry,
|
||||
clearActiveToolTraceHost,
|
||||
createToolTraceState,
|
||||
} from "./render.js";
|
||||
|
||||
describe("tool trace grouping state", () => {
|
||||
test("keeps tool entries attached to the current assistant turn", () => {
|
||||
let state = createToolTraceState();
|
||||
|
||||
const assistant = addAssistantToolTraceHost(state, "Checking the repo");
|
||||
state = assistant.state;
|
||||
|
||||
const toolUse = addToolTraceEntry(state, "use");
|
||||
state = toolUse.state;
|
||||
|
||||
const toolResult = addToolTraceEntry(state, "result");
|
||||
state = toolResult.state;
|
||||
|
||||
expect(assistant.host).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: [],
|
||||
});
|
||||
expect(toolUse.createdHost).toBeNull();
|
||||
expect(toolResult.createdHost).toBeNull();
|
||||
expect(state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: ["use", "result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates an orphan trace host when tool activity has no assistant turn", () => {
|
||||
const result = addToolTraceEntry(createToolTraceState(), "use");
|
||||
|
||||
expect(result.createdHost).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
});
|
||||
expect(result.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("starts a new orphan host after a visible user turn clears the active assistant host", () => {
|
||||
let state = createToolTraceState();
|
||||
state = addAssistantToolTraceHost(state, "Running tools").state;
|
||||
state = addToolTraceEntry(state, "use").state;
|
||||
|
||||
state = clearActiveToolTraceHost(state);
|
||||
|
||||
const nextResult = addToolTraceEntry(state, "result");
|
||||
|
||||
expect(nextResult.createdHost).toEqual({
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
});
|
||||
expect(nextResult.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Running tools",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
{
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user