mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)
* fix: 修复超时问题 * feat: 添加 acp-link 代码 * refactor: 样式重构完成 * feat: RCS 添加 ACP 后端支持 - 新增 ACP WebSocket handler (agent 注册、EventBus 订阅) - 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发) - 新增 SSE event stream 供外部消费者订阅 channel group 事件 - ACP REST 接口无鉴权 (agents、channel-groups) - WebSocket 端点保留 token 鉴权 - SPA 路由 /acp/ 指向 acp.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 添加 ACP 专属前端界面 - 新增 /acp/ SPA 页面 (agent 列表 + 实时交互) - Agent 列表按 channel group 分组,显示在线状态 - 通过 RCS WebSocket relay 与 agent 通信 - Vite multi-page 构建 (index.html + acp.html) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 支持 RCS relay 双向通信 - rcs-upstream 新增 messageHandler 转发非控制消息 - server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息 - newSession/loadSession 补充 mcpServers 参数 - 连接成功后显示 ACP Dashboard URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 移除 FileExplorer 及文件操作相关代码 - 删除 FileExplorer 组件 - ACPMain 移除 Files tab,仅保留 Chat 和 History - client.ts 移除 listDir/readFile/onFileChanges 等方法 - types.ts 移除 FileItem/FileContent/FileChange 等类型 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复类型问题 * feat: RCS 后端统一 ACP/Bridge 注册逻辑 - store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑 - store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim - environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id - session: resolveOwnedWebSessionId 支持无 owner session 自动绑定 - acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册 - acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证 - event-bus: 增加 error 类型 payload 日志 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 改 REST 注册 + WS identify 两步流程 - rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册 - rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId - rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定 - server: 修复心跳跳过 relay 虚拟连接的 bug - server: maxSessions 配置传入 RCS upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 前端统一 Chat 组件 + ACP 聊天界面重构 - 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu - ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission) - rcs-chat-adapter: 统一 bridge session SSE 适配器 - ACPClient: 增强 session 管理、permission 流程、streaming 支持 - index.css: 新增 chat 相关样式、动画、布局 - useCommands: 新增快捷命令 hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId - 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录 - SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件 - App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID - Dashboard: 统一 session 列表导航,ACP 显示紫色标签 - relay-client: 改用 UUID 认证替代 API token - EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code) - index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * build: 更新构建及测试修复 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
82
packages/remote-control-server/web/src/pages/Dashboard.tsx
Normal file
82
packages/remote-control-server/web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { apiFetchAllSessions, apiFetchEnvironments } from "../api/client";
|
||||
import type { Session, Environment } from "../types";
|
||||
import { EnvironmentList } from "../components/EnvironmentList";
|
||||
import { SessionList } from "../components/SessionList";
|
||||
import { NewSessionDialog } from "../components/NewSessionDialog";
|
||||
|
||||
interface DashboardProps {
|
||||
onNavigateSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ onNavigateSession }: DashboardProps) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [environments, setEnvironments] = useState<Environment[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const [sess, envs] = await Promise.all([apiFetchAllSessions(), apiFetchEnvironments()]);
|
||||
setSessions(sess || []);
|
||||
setEnvironments(envs || []);
|
||||
} catch (err) {
|
||||
console.error("Dashboard render error:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
const interval = setInterval(loadDashboard, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDashboard]);
|
||||
|
||||
const handleSessionCreated = (session: Session) => {
|
||||
setDialogOpen(false);
|
||||
onNavigateSession(session.id);
|
||||
};
|
||||
|
||||
const handleSelectEnvironment = useCallback((env: Environment) => {
|
||||
if (env.worker_type === "acp") {
|
||||
// Navigate to ACP agent detail page (same origin, shares UUID auth)
|
||||
window.history.pushState(null, "", `/acp/agent/${env.id}`);
|
||||
// Force page reload to load ACP app
|
||||
window.location.href = `/acp/agent/${env.id}`;
|
||||
}
|
||||
// Bridge environments: no direct navigation (sessions are listed below)
|
||||
}, []);
|
||||
|
||||
const handleSelectSession = useCallback((sessionId: string) => {
|
||||
onNavigateSession(sessionId);
|
||||
}, [onNavigateSession]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
{/* Environments */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-4 font-display text-lg font-semibold text-text-primary">Environments</h2>
|
||||
<EnvironmentList environments={environments} onSelectEnvironment={handleSelectEnvironment} />
|
||||
</section>
|
||||
|
||||
{/* Sessions */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary">Sessions</h2>
|
||||
<button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-light transition-colors"
|
||||
>
|
||||
+ New Session
|
||||
</button>
|
||||
</div>
|
||||
<SessionList sessions={sessions} onSelect={handleSelectSession} />
|
||||
</section>
|
||||
|
||||
<NewSessionDialog
|
||||
open={dialogOpen}
|
||||
environments={environments}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onCreated={handleSessionCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
packages/remote-control-server/web/src/pages/SessionDetail.tsx
Normal file
487
packages/remote-control-server/web/src/pages/SessionDetail.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
apiFetchSession,
|
||||
apiSendControl,
|
||||
apiInterrupt,
|
||||
} from "../api/client";
|
||||
import type { Session, SessionEvent } from "../types";
|
||||
import { isClosedSessionStatus, formatTime, cn } from "../lib/utils";
|
||||
import { RCSChatAdapter } from "../lib/rcs-chat-adapter";
|
||||
import type { ThreadEntry, PendingPermission } from "../lib/types";
|
||||
import { StatusBadge } from "../components/Navbar";
|
||||
import { TaskPanel } from "../components/TaskPanel";
|
||||
import {
|
||||
PermissionPromptView,
|
||||
AskUserPanelView,
|
||||
PlanPanelView,
|
||||
} from "../components/PermissionViews";
|
||||
|
||||
// Unified chat components
|
||||
import { ChatView } from "../../components/chat/ChatView";
|
||||
import { ChatInput } from "../../components/chat/ChatInput";
|
||||
import { TooltipProvider } from "../../components/ui/tooltip";
|
||||
|
||||
// ACP chat components
|
||||
import { ACPClient, DisconnectRequestedError } from "../acp/client";
|
||||
import { createRelayClient } from "../acp/relay-client";
|
||||
import { ACPMain } from "../../components/ACPMain";
|
||||
import { StatusDot } from "../../components/ui/connection-status";
|
||||
|
||||
interface SessionDetailProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function SessionDetail({ sessionId }: SessionDetailProps) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [sessionStatus, setSessionStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [taskPanelOpen, setTaskPanelOpen] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
const [entries, setEntries] = useState<ThreadEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingPermissions, setPendingPermissions] = useState<PendingPermission[]>([]);
|
||||
const adapterRef = useRef<RCSChatAdapter | null>(null);
|
||||
|
||||
// Create RCSChatAdapter
|
||||
const adapter = useMemo(
|
||||
() =>
|
||||
new RCSChatAdapter(sessionId, setEntries, {
|
||||
onStatusChange: (status) => {
|
||||
setSessionStatus(status);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("[RCSChatAdapter] error:", err);
|
||||
},
|
||||
onPermissionRequest: (permission) => {
|
||||
setPendingPermissions((prev) => {
|
||||
if (prev.some((p) => p.requestId === permission.requestId)) return prev;
|
||||
return [...prev, permission];
|
||||
});
|
||||
},
|
||||
}),
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
adapterRef.current = adapter;
|
||||
return () => {
|
||||
adapter.disconnect();
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
// Load session data and initialize adapter
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const sess = await apiFetchSession(sessionId);
|
||||
if (cancelled) return;
|
||||
setSession(sess);
|
||||
setSessionStatus(sess.status);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load session");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.init();
|
||||
} catch (err) {
|
||||
console.warn("Failed to init adapter:", err);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, adapter]);
|
||||
|
||||
const closed = isClosedSessionStatus(sessionStatus);
|
||||
|
||||
// Send message via ChatInput
|
||||
const handleSubmit = useCallback(
|
||||
async (message: import("../../src/lib/types").ChatInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
if (!text || closed) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await adapter.sendMessage(text, message.images);
|
||||
} catch (err) {
|
||||
console.error("Send failed:", err);
|
||||
}
|
||||
},
|
||||
[adapter, closed],
|
||||
);
|
||||
|
||||
// Interrupt
|
||||
const handleInterrupt = useCallback(async () => {
|
||||
try {
|
||||
await adapter.interrupt();
|
||||
} catch (err) {
|
||||
console.error("Interrupt failed:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [adapter]);
|
||||
|
||||
// Mark loading done when last assistant message stops streaming
|
||||
useEffect(() => {
|
||||
if (entries.length === 0) return;
|
||||
const last = entries[entries.length - 1];
|
||||
if (last?.type === "assistant_message" || last?.type === "tool_call") {
|
||||
// If the last entry is no longer a streaming tool, consider loading done
|
||||
if (last.type === "tool_call" && last.toolCall.status === "running") return;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
// Permission actions
|
||||
const handleApprovePermission = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await adapter.respondPermission(requestId, true);
|
||||
} catch (err) {
|
||||
console.error("Failed to approve:", err);
|
||||
}
|
||||
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const handleRejectPermission = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await adapter.respondPermission(requestId, false);
|
||||
} catch (err) {
|
||||
console.error("Failed to reject:", err);
|
||||
}
|
||||
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
const handleSubmitAnswers = useCallback(
|
||||
async (
|
||||
requestId: string,
|
||||
answers: Record<string, unknown>,
|
||||
questions: import("../types").Question[],
|
||||
) => {
|
||||
try {
|
||||
await apiSendControl(sessionId, {
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: requestId,
|
||||
updated_input: { questions, answers },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to submit answers:", err);
|
||||
}
|
||||
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const handleSubmitPlanResponse = useCallback(
|
||||
async (requestId: string, value: string, feedback?: string) => {
|
||||
try {
|
||||
if (value === "no") {
|
||||
await apiSendControl(sessionId, {
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: requestId,
|
||||
...(feedback ? { message: feedback } : {}),
|
||||
});
|
||||
} else {
|
||||
const modeMap: Record<string, string> = {
|
||||
"yes-accept-edits": "acceptEdits",
|
||||
"yes-default": "default",
|
||||
};
|
||||
await apiSendControl(sessionId, {
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: requestId,
|
||||
updated_permissions: [
|
||||
{ type: "setMode", mode: modeMap[value] || "default", destination: "session" },
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to submit plan response:", err);
|
||||
}
|
||||
setPendingPermissions((prev) => prev.filter((p) => p.requestId !== requestId));
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-status-error">{error}</p>
|
||||
<a href="/code/" className="mt-4 inline-block text-brand hover:underline">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-text-muted">Loading session...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ACP session — render ACP relay chat
|
||||
if (session.source === "acp" && session.environment_id) {
|
||||
return <ACPSessionDetail sessionId={sessionId} agentId={session.environment_id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Session Header */}
|
||||
<div className="border-b bg-surface-1 px-4 py-3">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-1">
|
||||
<a
|
||||
href="/code/"
|
||||
className="text-sm text-text-muted hover:text-text-secondary transition-colors no-underline"
|
||||
>
|
||||
← Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary">
|
||||
{session.title || session.id}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{sessionStatus && <StatusBadge status={sessionStatus} />}
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatTime(session.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowMeta(!showMeta)}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text-secondary transition-colors"
|
||||
title="Session info"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTaskPanelOpen(!taskPanelOpen)}
|
||||
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showMeta && (
|
||||
<div className="mt-2 rounded-md bg-surface-2 px-3 py-2 text-xs text-text-muted space-y-1 font-mono">
|
||||
<div><span className="text-text-secondary font-sans font-medium">Session</span> {session.id}</div>
|
||||
{session.environment_id && (
|
||||
<div><span className="text-text-secondary font-sans font-medium">Environment</span> {session.environment_id}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat messages — unified ChatView */}
|
||||
<ChatView
|
||||
entries={entries}
|
||||
isLoading={isLoading}
|
||||
emptyTitle="开始对话"
|
||||
emptyDescription="输入消息开始聊天"
|
||||
/>
|
||||
|
||||
{/* Unified Permission Panel — above input */}
|
||||
{pendingPermissions.length > 0 && (
|
||||
<div className="border-t bg-surface-1 px-4 py-3">
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
{pendingPermissions.map((req) => (
|
||||
<PermissionEventView
|
||||
key={req.requestId}
|
||||
request={req}
|
||||
onApprove={() => handleApprovePermission(req.requestId)}
|
||||
onReject={() => handleRejectPermission(req.requestId)}
|
||||
onSubmitAnswers={handleSubmitAnswers}
|
||||
onSubmitPlan={handleSubmitPlanResponse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified ChatInput — claude.ai style */}
|
||||
<ChatInput
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onInterrupt={handleInterrupt}
|
||||
disabled={closed}
|
||||
placeholder={closed ? "会话已关闭" : "输入消息..."}
|
||||
/>
|
||||
|
||||
{/* Task Panel */}
|
||||
{taskPanelOpen && <TaskPanel onClose={() => setTaskPanelOpen(false)} />}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Permission Event View — routes to correct UI
|
||||
// ============================================================
|
||||
|
||||
function PermissionEventView({
|
||||
request,
|
||||
onApprove,
|
||||
onReject,
|
||||
onSubmitAnswers,
|
||||
onSubmitPlan,
|
||||
}: {
|
||||
request: PendingPermission;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onSubmitAnswers: (requestId: string, answers: Record<string, unknown>, questions: import("../types").Question[]) => void;
|
||||
onSubmitPlan: (requestId: string, value: string, feedback?: string) => void;
|
||||
}) {
|
||||
const toolName = request.toolName;
|
||||
const toolInput = request.toolInput;
|
||||
const description = request.description || "";
|
||||
|
||||
if (toolName === "AskUserQuestion") {
|
||||
const questions = (toolInput.questions as import("../types").Question[]) || [];
|
||||
return (
|
||||
<AskUserPanelView
|
||||
requestId={request.requestId}
|
||||
questions={questions}
|
||||
description={description}
|
||||
onSubmit={(answers) => onSubmitAnswers(request.requestId, answers, questions)}
|
||||
onSkip={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const planContent = (toolInput.plan as string) || "";
|
||||
return (
|
||||
<PlanPanelView
|
||||
requestId={request.requestId}
|
||||
planContent={planContent}
|
||||
description={description}
|
||||
onSubmit={(value, feedback) => onSubmitPlan(request.requestId, value, feedback)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PermissionPromptView
|
||||
requestId={request.requestId}
|
||||
toolName={toolName}
|
||||
toolInput={toolInput}
|
||||
description={description}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ACP Session Detail — renders ACP relay chat in session page
|
||||
// ============================================================
|
||||
|
||||
function ACPSessionDetail({ sessionId, agentId }: { sessionId: string; agentId: string }) {
|
||||
const [client, setClient] = useState<ACPClient | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<"disconnected" | "connecting" | "connected" | "error">("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const clientRef = useRef<ACPClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const relayClient = createRelayClient(agentId);
|
||||
|
||||
relayClient.setConnectionStateHandler((state, err) => {
|
||||
setConnectionState(state);
|
||||
setError(err || null);
|
||||
});
|
||||
|
||||
clientRef.current = relayClient;
|
||||
setClient(relayClient);
|
||||
|
||||
relayClient.connect().catch((e) => {
|
||||
if (e instanceof DisconnectRequestedError) return;
|
||||
setError((e as Error).message);
|
||||
setConnectionState("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
relayClient.disconnect();
|
||||
clientRef.current = null;
|
||||
setClient(null);
|
||||
setConnectionState("disconnected");
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-surface-1 px-4 py-3">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-1">
|
||||
<a href="/code/" className="text-sm text-text-muted hover:text-text-secondary transition-colors no-underline">
|
||||
← Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot state={connectionState} />
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary">
|
||||
{agentId}
|
||||
</h2>
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">ACP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && connectionState === "error" && (
|
||||
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm border-b">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionState === "connecting" && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-brand border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p className="text-text-muted text-sm">Connecting to agent...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionState === "error" && !client && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="font-medium mb-1">Connection Failed</p>
|
||||
<p className="text-text-muted text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client && connectionState === "connected" && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ACPMain client={client} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user