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:
claude-code-best
2026-04-18 17:59:29 +08:00
committed by GitHub
parent 29cc74a170
commit 34154ee3f5
142 changed files with 17847 additions and 5577 deletions

910
bun.lock

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -0,0 +1,39 @@
{
"name": "acp-link",
"version": "1.0.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"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
});

View File

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

View File

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

View File

@@ -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

View File

@@ -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 升级:

View File

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

View File

@@ -4,24 +4,60 @@
"type": "module", "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"
} }
} }

View File

@@ -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 {

View File

@@ -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);
} }

View File

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

View File

@@ -1,6 +1,6 @@
import { log, error as logError } from "../../logger"; import { 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 */

View File

@@ -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" });

View File

@@ -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) {

View File

@@ -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;
} }

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

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

View File

@@ -1,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();
});

View File

@@ -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>
`;
}

View File

@@ -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();
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
"use client";
import { Button } from "../ui/button";
import { cn } from "../../src/lib/utils";
import { ArrowDownIcon, UserIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("mx-auto flex max-w-3xl flex-col gap-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>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
"use client";
import { cn } from "../../src/lib/utils";
import { Button } from "../ui/button";
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
import type { PermissionOption } from "../../src/acp/types";
// Get button variant based on option kind
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
switch (kind) {
case "allow_once":
case "allow_always":
return "default";
case "reject_once":
case "reject_always":
return "destructive";
default:
return "outline";
}
}
// Get button icon based on option kind
function getButtonIcon(kind: PermissionOption["kind"]) {
switch (kind) {
case "allow_once":
case "allow_always":
return <CheckIcon className="size-4" />;
case "reject_once":
case "reject_always":
return <XIcon className="size-4" />;
default:
return null;
}
}
// Permission buttons component - used inside Tool component
export interface ToolPermissionButtonsProps {
requestId: string;
options: PermissionOption[];
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
className?: string;
}
export function ToolPermissionButtons({ requestId, options, onRespond, className }: ToolPermissionButtonsProps) {
const handleOptionClick = (option: PermissionOption) => {
onRespond(requestId, option.optionId, option.kind);
};
return (
<div className={cn("p-3 border-t border-warning-border/30 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

View File

@@ -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";

View File

@@ -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);

View File

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

View File

@@ -0,0 +1,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;
}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,76 @@
import type { PendingPermission } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { ShieldAlert, Check, X } from "lucide-react";
// =============================================================================
// 权限请求面板 — 固定在输入框上方Anthropic warm token style
// =============================================================================
interface PermissionPanelProps {
requests: PendingPermission[];
onRespond?: (requestId: string, approved: boolean) => void;
className?: string;
}
export function PermissionPanel({ requests, onRespond, className }: PermissionPanelProps) {
if (requests.length === 0) return null;
return (
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
<div className="space-y-2">
{requests.map((req) => (
<PermissionCard
key={req.requestId}
request={req}
onRespond={onRespond}
/>
))}
</div>
</div>
);
}
// =============================================================================
// 单个权限卡片 — warm warning tokens + left-border accent
// =============================================================================
interface PermissionCardProps {
request: PendingPermission;
onRespond?: (requestId: string, approved: boolean) => void;
}
function PermissionCard({ request, onRespond }: PermissionCardProps) {
return (
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 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>
);
}

View File

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

View File

@@ -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]">&#9654;</span>;
case "complete":
return <span className="text-status-active text-[10px]">&#10003;</span>;
case "error":
return <span className="text-status-error text-[10px]">&#10005;</span>;
case "waiting_for_confirmation":
return <span className="text-brand text-[10px]">&#9083;</span>;
case "canceled":
return <span className="text-text-muted text-[10px]">&#8212;</span>;
case "rejected":
return <span className="text-status-error text-[10px]">&#10005;</span>;
default:
return null;
}
})();
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
return (
<div className={cn("px-3 py-2", compact && "py-1.5")}>
{/* 标题行 — 单行紧凑 */}
<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;
}

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View 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,
}

View File

@@ -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,
}

View File

@@ -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 }

View 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"

View 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,
}

View 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 }

View 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 };

View 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 }

View File

@@ -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 }

View File

@@ -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 }

View 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,
}

View File

@@ -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 }

View 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 }

View File

@@ -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 }

View File

@@ -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>
);
}

View 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 }

View File

@@ -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">&larr; 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">&times;</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>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).toContain("<pre><code>const markup = &quot;&lt;div&gt;&quot;;</code></pre>");
});
});

View File

@@ -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);
});
});

View File

@@ -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

View 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