mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)
* fix: 修复超时问题 * feat: 添加 acp-link 代码 * refactor: 样式重构完成 * feat: RCS 添加 ACP 后端支持 - 新增 ACP WebSocket handler (agent 注册、EventBus 订阅) - 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发) - 新增 SSE event stream 供外部消费者订阅 channel group 事件 - ACP REST 接口无鉴权 (agents、channel-groups) - WebSocket 端点保留 token 鉴权 - SPA 路由 /acp/ 指向 acp.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 添加 ACP 专属前端界面 - 新增 /acp/ SPA 页面 (agent 列表 + 实时交互) - Agent 列表按 channel group 分组,显示在线状态 - 通过 RCS WebSocket relay 与 agent 通信 - Vite multi-page 构建 (index.html + acp.html) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 支持 RCS relay 双向通信 - rcs-upstream 新增 messageHandler 转发非控制消息 - server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息 - newSession/loadSession 补充 mcpServers 参数 - 连接成功后显示 ACP Dashboard URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 移除 FileExplorer 及文件操作相关代码 - 删除 FileExplorer 组件 - ACPMain 移除 Files tab,仅保留 Chat 和 History - client.ts 移除 listDir/readFile/onFileChanges 等方法 - types.ts 移除 FileItem/FileContent/FileChange 等类型 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复类型问题 * feat: RCS 后端统一 ACP/Bridge 注册逻辑 - store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑 - store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim - environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id - session: resolveOwnedWebSessionId 支持无 owner session 自动绑定 - acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册 - acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证 - event-bus: 增加 error 类型 payload 日志 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 改 REST 注册 + WS identify 两步流程 - rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册 - rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId - rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定 - server: 修复心跳跳过 relay 虚拟连接的 bug - server: maxSessions 配置传入 RCS upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 前端统一 Chat 组件 + ACP 聊天界面重构 - 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu - ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission) - rcs-chat-adapter: 统一 bridge session SSE 适配器 - ACPClient: 增强 session 管理、permission 流程、streaming 支持 - index.css: 新增 chat 相关样式、动画、布局 - useCommands: 新增快捷命令 hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId - 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录 - SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件 - App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID - Dashboard: 统一 session 列表导航,ACP 显示紫色标签 - relay-client: 改用 UUID 认证替代 API token - EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code) - index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * build: 更新构建及测试修复 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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
|
ARG VERSION=0.1.0
|
||||||
|
|
||||||
|
# Copy package files for install
|
||||||
COPY packages/remote-control-server/package.json ./package.json
|
COPY packages/remote-control-server/package.json ./package.json
|
||||||
|
|
||||||
|
# Install all dependencies (including devDeps for vite build)
|
||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
COPY packages/remote-control-server/src ./src
|
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 \
|
RUN bun build src/index.ts --outfile=dist/server.js --target=bun \
|
||||||
--define "process.env.RCS_VERSION=\"${VERSION}\""
|
--define "process.env.RCS_VERSION=\"${VERSION}\""
|
||||||
|
|
||||||
@@ -19,8 +30,9 @@ ENV RCS_VERSION=${VERSION}
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built artifacts
|
||||||
COPY --from=builder /app/dist/server.js ./dist/server.js
|
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
|
VOLUME /app/data
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ volumes:
|
|||||||
rcs-data:
|
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 升级:
|
使用 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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"dev:web": "cd web && bunx vite",
|
||||||
"start": "bun run src/index.ts",
|
"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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^3.0.170",
|
||||||
|
"ai": "^6.0.168",
|
||||||
"hono": "^4.7.0",
|
"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": {
|
"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",
|
"@types/uuid": "^10.0.0",
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"vite": "^6.0.0",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"tailwindcss": "^4.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"),
|
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
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;
|
} as const;
|
||||||
|
|
||||||
export function getBaseUrl(): string {
|
export function getBaseUrl(): string {
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import { logger } from "hono/logger";
|
|||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import { closeAllConnections } from "./transport/ws-handler";
|
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 { startDisconnectMonitor } from "./services/disconnect-monitor";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import acpRoutes from "./routes/acp";
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
import v1Environments from "./routes/v1/environments";
|
import v1Environments from "./routes/v1/environments";
|
||||||
import v1EnvironmentsWork from "./routes/v1/environments.work";
|
import v1EnvironmentsWork from "./routes/v1/environments.work";
|
||||||
import v1Sessions from "./routes/v1/sessions";
|
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 v2CodeSessions from "./routes/v2/code-sessions";
|
||||||
import v2Worker from "./routes/v2/worker";
|
import v2Worker from "./routes/v2/worker";
|
||||||
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
|
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
|
||||||
@@ -33,9 +38,11 @@ app.use("/web/*", cors());
|
|||||||
// Health check
|
// Health check
|
||||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
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 __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/, "");
|
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
|
||||||
|
|
||||||
@@ -70,6 +77,10 @@ app.route("/web", webSessions);
|
|||||||
app.route("/web", webControl);
|
app.route("/web", webControl);
|
||||||
app.route("/web", webEnvironments);
|
app.route("/web", webEnvironments);
|
||||||
|
|
||||||
|
// ACP protocol routes
|
||||||
|
console.log("[RCS] ACP support enabled");
|
||||||
|
app.route("/acp", acpRoutes);
|
||||||
|
|
||||||
const port = config.port;
|
const port = config.port;
|
||||||
const host = config.host;
|
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] API key configuration loaded");
|
||||||
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
||||||
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
|
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
|
// Start disconnect monitor
|
||||||
startDisconnectMonitor();
|
startDisconnectMonitor();
|
||||||
@@ -87,15 +100,17 @@ export default {
|
|||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
websocket: {
|
websocket: {
|
||||||
...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
|
// Graceful shutdown
|
||||||
async function gracefulShutdown(signal: string) {
|
async function gracefulShutdown(signal: string) {
|
||||||
console.log(`\n[RCS] Received ${signal}, shutting down...`);
|
console.log(`\n[RCS] Received ${signal}, shutting down...`);
|
||||||
closeAllConnections();
|
closeAllConnections();
|
||||||
|
closeAllAcpConnections();
|
||||||
|
closeAllRelayConnections();
|
||||||
process.exit(0);
|
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 { log, error as logError } from "../../logger";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createBunWebSocket } from "hono/bun";
|
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
||||||
import { validateApiKey } from "../../auth/api-key";
|
import { validateApiKey } from "../../auth/api-key";
|
||||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
} from "../../transport/ws-handler";
|
} from "../../transport/ws-handler";
|
||||||
import { getSession, resolveExistingSessionId } from "../../services/session";
|
import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||||
|
|
||||||
const { upgradeWebSocket, websocket } = createBunWebSocket();
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
/** Authenticate via API key or worker JWT in Authorization header or ?token= query param */
|
/** 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 { log, error as logError } from "../logger";
|
||||||
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
|
||||||
import { storeListSessions } from "../store";
|
import { storeListSessions } from "../store";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { updateSessionStatus } from "./session";
|
import { updateSessionStatus } from "./session";
|
||||||
@@ -10,6 +10,14 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
|||||||
// Check environment heartbeat timeout
|
// Check environment heartbeat timeout
|
||||||
const envs = storeListActiveEnvironments();
|
const envs = storeListActiveEnvironments();
|
||||||
for (const env of envs) {
|
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) {
|
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)`);
|
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import {
|
import {
|
||||||
storeCreateEnvironment,
|
storeCreateEnvironment,
|
||||||
|
storeCreateSession,
|
||||||
storeGetEnvironment,
|
storeGetEnvironment,
|
||||||
storeUpdateEnvironment,
|
storeUpdateEnvironment,
|
||||||
storeListActiveEnvironments,
|
storeListActiveEnvironments,
|
||||||
@@ -18,6 +19,8 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
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,
|
workerType,
|
||||||
bridgeId: req.bridge_id,
|
bridgeId: req.bridge_id,
|
||||||
username: req.username,
|
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) {
|
export function deregisterEnvironment(envId: string) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
storeCreateSession,
|
storeCreateSession,
|
||||||
storeGetSession,
|
storeGetSession,
|
||||||
storeIsSessionOwner,
|
storeIsSessionOwner,
|
||||||
|
storeGetSessionOwners,
|
||||||
|
storeBindSession,
|
||||||
storeUpdateSession,
|
storeUpdateSession,
|
||||||
storeListSessions,
|
storeListSessions,
|
||||||
storeListSessionsByUsername,
|
storeListSessionsByUsername,
|
||||||
@@ -106,6 +108,16 @@ export function resolveOwnedWebSessionId(sessionId: string, uuid: string): strin
|
|||||||
return compatibleCodeSessionId;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface EnvironmentRecord {
|
|||||||
maxSessions: number;
|
maxSessions: number;
|
||||||
workerType: string;
|
workerType: string;
|
||||||
bridgeId: string | null;
|
bridgeId: string | null;
|
||||||
|
capabilities: Record<string, unknown> | null;
|
||||||
status: string;
|
status: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
lastPollAt: Date | null;
|
lastPollAt: Date | null;
|
||||||
@@ -97,6 +98,21 @@ export function storeDeleteToken(token: string): boolean {
|
|||||||
|
|
||||||
// ---------- Environment ----------
|
// ---------- 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: {
|
export function storeCreateEnvironment(req: {
|
||||||
secret: string;
|
secret: string;
|
||||||
machineName?: string;
|
machineName?: string;
|
||||||
@@ -107,7 +123,25 @@ export function storeCreateEnvironment(req: {
|
|||||||
workerType?: string;
|
workerType?: string;
|
||||||
bridgeId?: string;
|
bridgeId?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
}): EnvironmentRecord {
|
}): 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 id = `env_${uuid().replace(/-/g, "")}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const record: EnvironmentRecord = {
|
const record: EnvironmentRecord = {
|
||||||
@@ -120,6 +154,7 @@ export function storeCreateEnvironment(req: {
|
|||||||
maxSessions: req.maxSessions ?? 1,
|
maxSessions: req.maxSessions ?? 1,
|
||||||
workerType: req.workerType ?? "claude_code",
|
workerType: req.workerType ?? "claude_code",
|
||||||
bridgeId: req.bridgeId ?? null,
|
bridgeId: req.bridgeId ?? null,
|
||||||
|
capabilities: req.capabilities ?? null,
|
||||||
status: "active",
|
status: "active",
|
||||||
username: req.username ?? null,
|
username: req.username ?? null,
|
||||||
lastPollAt: now,
|
lastPollAt: now,
|
||||||
@@ -134,7 +169,7 @@ export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
|
|||||||
return environments.get(id);
|
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);
|
const rec = environments.get(id);
|
||||||
if (!rec) return false;
|
if (!rec) return false;
|
||||||
Object.assign(rec, patch, { updatedAt: new Date() });
|
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;
|
return owners ? owners.has(uuid) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function storeGetSessionOwners(sessionId: string): Set<string> | undefined {
|
||||||
|
return sessionOwners.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||||
const result: SessionRecord[] = [];
|
const result: SessionRecord[] = [];
|
||||||
for (const [sessionId, owners] of sessionOwners) {
|
for (const [sessionId, owners] of sessionOwners) {
|
||||||
@@ -325,6 +364,43 @@ export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemReco
|
|||||||
return true;
|
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) ----------
|
// ---------- Reset (for tests) ----------
|
||||||
|
|
||||||
export function storeReset() {
|
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;
|
type Subscriber = (event: SessionEvent) => void;
|
||||||
|
|
||||||
|
const MAX_EVENTS_PER_BUS = 5000;
|
||||||
|
|
||||||
export class EventBus {
|
export class EventBus {
|
||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
private events: SessionEvent[] = [];
|
private events: SessionEvent[] = [];
|
||||||
@@ -35,7 +37,14 @@ export class EventBus {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
this.events.push(full);
|
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) {
|
for (const cb of this.subscribers) {
|
||||||
try {
|
try {
|
||||||
cb(full);
|
cb(full);
|
||||||
@@ -85,3 +94,23 @@ export function removeEventBus(sessionId: string) {
|
|||||||
export function getAllEventBuses(): Map<string, EventBus> {
|
export function getAllEventBuses(): Map<string, EventBus> {
|
||||||
return buses;
|
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 { publishSessionEvent } from "../services/transport";
|
||||||
import { log, error as logError } from "../logger";
|
import { log, error as logError } from "../logger";
|
||||||
import { toClientPayload } from "./client-payload";
|
import { toClientPayload } from "./client-payload";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||||
interface CleanupEntry {
|
interface CleanupEntry {
|
||||||
@@ -11,15 +12,20 @@ interface CleanupEntry {
|
|||||||
keepalive: ReturnType<typeof setInterval>;
|
keepalive: ReturnType<typeof setInterval>;
|
||||||
ws: WSContext;
|
ws: WSContext;
|
||||||
openTime: number;
|
openTime: number;
|
||||||
|
lastClientActivity: number;
|
||||||
}
|
}
|
||||||
const cleanupBySession = new Map<string, CleanupEntry>();
|
const cleanupBySession = new Map<string, CleanupEntry>();
|
||||||
|
|
||||||
// Track all active WS connections for graceful shutdown
|
// Track all active WS connections for graceful shutdown
|
||||||
const activeConnections = new Set<WSContext>();
|
const activeConnections = new Set<WSContext>();
|
||||||
|
|
||||||
// Bridge sends keep_alive data frames every 120s. Send server-side keep_alive
|
// Server-side keepalive interval (configurable via RCS_WS_KEEPALIVE_INTERVAL).
|
||||||
// every 60s to ensure the connection stays alive even without user messages.
|
// Sends data frames to keep reverse proxies from closing idle connections.
|
||||||
const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
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.
|
* 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 */
|
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||||
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||||
const openTime = Date.now();
|
const openTime = Date.now();
|
||||||
|
const lastClientActivity = Date.now();
|
||||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||||
activeConnections.add(ws);
|
activeConnections.add(ws);
|
||||||
|
|
||||||
@@ -79,6 +86,17 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
|||||||
clearInterval(keepalive);
|
clearInterval(keepalive);
|
||||||
return;
|
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 {
|
try {
|
||||||
ws.send('{"type":"keep_alive"}\n');
|
ws.send('{"type":"keep_alive"}\n');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -86,13 +104,18 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
|||||||
}
|
}
|
||||||
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
}, 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.
|
* Called from onMessage — bridge sends newline-delimited JSON.
|
||||||
*/
|
*/
|
||||||
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
|
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());
|
const lines = data.split("\n").filter((l) => l.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { upgradeWebSocket, websocket } from "hono/bun";
|
||||||
@@ -19,6 +19,7 @@ export interface RegisterEnvironmentRequest {
|
|||||||
max_sessions?: number;
|
max_sessions?: number;
|
||||||
worker_type?: string;
|
worker_type?: string;
|
||||||
bridge_id?: string;
|
bridge_id?: string;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterEnvironmentResponse {
|
export interface RegisterEnvironmentResponse {
|
||||||
@@ -105,6 +106,8 @@ export interface EnvironmentResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
last_poll_at: number | null;
|
last_poll_at: number | null;
|
||||||
|
worker_type?: string;
|
||||||
|
capabilities?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionSummaryResponse {
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Remote Control — Claude Code</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Nav Bar -->
|
<div id="root"></div>
|
||||||
<nav id="navbar">
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
86
packages/remote-control-server/web/src/App.tsx
Normal file
86
packages/remote-control-server/web/src/App.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Navbar } from "./components/Navbar";
|
||||||
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { SessionDetail } from "./pages/SessionDetail";
|
||||||
|
import { IdentityPanel } from "./components/IdentityPanel";
|
||||||
|
import { ThemeProvider } from "./lib/theme";
|
||||||
|
import { getUuid, setUuid, apiBind } from "./api/client";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
const [identityOpen, setIdentityOpen] = useState(false);
|
||||||
|
|
||||||
|
// Simple hash-based router
|
||||||
|
const parseRoute = useCallback(() => {
|
||||||
|
// Ensure UUID exists
|
||||||
|
getUuid();
|
||||||
|
|
||||||
|
const path = window.location.pathname;
|
||||||
|
|
||||||
|
// Check for UUID import from QR scan (?uuid=xxx)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const importUuid = params.get("uuid");
|
||||||
|
if (importUuid) {
|
||||||
|
setUuid(importUuid);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("uuid");
|
||||||
|
window.history.replaceState(null, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CLI session bind (?sid=xxx) — bind session to current UUID
|
||||||
|
const sid = params.get("sid");
|
||||||
|
if (sid) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("sid");
|
||||||
|
window.history.replaceState(null, "", `/code/${sid}`);
|
||||||
|
setCurrentSessionId(sid);
|
||||||
|
// Bind this session to the current user's UUID for ownership
|
||||||
|
apiBind(sid).catch((err: unknown) => {
|
||||||
|
console.warn("Failed to bind session:", err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-based routing: /code/session_xxx → session detail
|
||||||
|
const match = path.match(/^\/code\/([^/]+)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
setCurrentSessionId(match[1]);
|
||||||
|
} else {
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
parseRoute();
|
||||||
|
window.addEventListener("popstate", parseRoute);
|
||||||
|
return () => window.removeEventListener("popstate", parseRoute);
|
||||||
|
}, [parseRoute]);
|
||||||
|
|
||||||
|
const navigateToSession = useCallback((sessionId: string) => {
|
||||||
|
window.history.pushState(null, "", `/code/${sessionId}`);
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateToDashboard = useCallback(() => {
|
||||||
|
window.history.pushState(null, "", "/code/");
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider defaultTheme="light">
|
||||||
|
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
||||||
|
<Navbar onIdentityClick={() => setIdentityOpen(true)} />
|
||||||
|
|
||||||
|
{currentSessionId ? (
|
||||||
|
<SessionDetail key={currentSessionId} sessionId={currentSessionId} />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<Dashboard onNavigateSession={navigateToSession} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user