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:
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal file
466
packages/remote-control-server/web/components/ACPConnect.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./ui/button";
|
||||
import { StatusDot } from "./ui/connection-status";
|
||||
import { ThemeToggle } from "./ui/theme-toggle";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "./ui/input-group";
|
||||
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp";
|
||||
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp";
|
||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
||||
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
||||
|
||||
// Get token from URL query param (for pre-filled URLs from server)
|
||||
function getTokenFromUrl(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("token") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Infer WebSocket URL from current page URL (for pre-filled links from server)
|
||||
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
|
||||
function inferProxyUrlFromPage(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
// Only infer if we have a token param (indicates user came from server-printed URL)
|
||||
if (!url.searchParams.has("token")) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${url.host}/ws`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial settings from defaults, with optional URL overrides
|
||||
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
||||
const settings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
// Override from URL if enabled (for pre-filled links from server)
|
||||
if (inferFromUrl) {
|
||||
const urlToken = getTokenFromUrl();
|
||||
const inferredUrl = inferProxyUrlFromPage();
|
||||
|
||||
if (urlToken) {
|
||||
settings.token = urlToken;
|
||||
}
|
||||
if (inferredUrl) {
|
||||
settings.proxyUrl = inferredUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export interface ACPConnectProps {
|
||||
onClientReady?: (client: ACPClient | null) => void;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
/** Handler for browser tool calls (only Chrome extension can execute these) */
|
||||
browserToolHandler?: (params: BrowserToolParams) => Promise<BrowserToolResult>;
|
||||
/** Show token input field (for remote access) */
|
||||
showTokenInput?: boolean;
|
||||
/** Infer proxy URL and token from page URL (for PWA) */
|
||||
inferFromUrl?: boolean;
|
||||
/** Placeholder for proxy URL input */
|
||||
placeholder?: string;
|
||||
/** Show QR code scan button (for mobile) */
|
||||
showScanButton?: boolean;
|
||||
}
|
||||
|
||||
export function ACPConnect({
|
||||
onClientReady,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
browserToolHandler,
|
||||
showTokenInput = false,
|
||||
inferFromUrl = false,
|
||||
placeholder = "Proxy server URL",
|
||||
showScanButton = false,
|
||||
}: ACPConnectProps) {
|
||||
const [settings, setSettings] = useState<ACPSettings>(() => getInitialSettings(inferFromUrl));
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
const [client, setClient] = useState<ACPClient | null>(null);
|
||||
const [maxHeight, setMaxHeight] = useState<number>(200);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const hasAutoCollapsedRef = useRef(false);
|
||||
const pendingAutoConnectRef = useRef(false);
|
||||
// Store initial settings in a ref to avoid eslint warning about empty deps
|
||||
const initialSettingsRef = useRef<ACPSettings>(settings);
|
||||
|
||||
// QR Scanner hook
|
||||
const handleQRScan = useCallback((data: QRCodeData) => {
|
||||
// Mark for auto-connect (will be triggered by settings useEffect)
|
||||
pendingAutoConnectRef.current = true;
|
||||
// Update settings - this will trigger auto-connect via useEffect
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
proxyUrl: data.url,
|
||||
token: data.token,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleQRError = useCallback((errorMsg: string) => {
|
||||
setError(errorMsg);
|
||||
}, []);
|
||||
|
||||
const { isScanning, videoRef, startScanning, stopScanning, scanFromFile } = useQRScanner({
|
||||
onScan: handleQRScan,
|
||||
onError: handleQRError,
|
||||
});
|
||||
|
||||
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
||||
useLayoutEffect(() => {
|
||||
if (expanded && contentRef.current) {
|
||||
setMaxHeight(contentRef.current.scrollHeight);
|
||||
}
|
||||
}, [expanded, isScanning]);
|
||||
|
||||
// File input ref for album scanning
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle file selection from album
|
||||
const handleFileSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await scanFromFile(file);
|
||||
stopScanning(); // Close the scanner overlay after album scan
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
e.target.value = "";
|
||||
},
|
||||
[scanFromFile, stopScanning]
|
||||
);
|
||||
|
||||
// Open file picker
|
||||
const handleSelectFromAlbum = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Initialize client once on mount using initial settings from ref
|
||||
useEffect(() => {
|
||||
const acpClient = new ACPClient(initialSettingsRef.current);
|
||||
acpClient.setConnectionStateHandler((state, err) => {
|
||||
setConnectionState(state);
|
||||
setError(err || null);
|
||||
});
|
||||
|
||||
setClient(acpClient);
|
||||
|
||||
return () => {
|
||||
acpClient.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Register browser tool handler when it changes
|
||||
useEffect(() => {
|
||||
if (client && browserToolHandler) {
|
||||
client.setBrowserToolCallHandler(browserToolHandler);
|
||||
}
|
||||
}, [client, browserToolHandler]);
|
||||
|
||||
// Update client settings when settings change, and auto-connect if pending
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
client.updateSettings(settings);
|
||||
|
||||
// Auto-connect after QR scan (when pendingAutoConnectRef is set)
|
||||
if (pendingAutoConnectRef.current) {
|
||||
pendingAutoConnectRef.current = false;
|
||||
client.connect().catch((e) => {
|
||||
// Ignore disconnect requested - user cancelled intentionally
|
||||
if (e instanceof DisconnectRequestedError) {
|
||||
return;
|
||||
}
|
||||
setError((e as Error).message);
|
||||
setIsShaking(true);
|
||||
setTimeout(() => setIsShaking(false), 500);
|
||||
onExpandedChange(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [settings, client, onExpandedChange]);
|
||||
|
||||
// Notify parent when client is ready and auto-collapse on connect
|
||||
useEffect(() => {
|
||||
const isConnected = connectionState === "connected";
|
||||
onClientReady?.(isConnected ? client : null);
|
||||
|
||||
// Auto-collapse when connected for the first time
|
||||
if (isConnected && !hasAutoCollapsedRef.current) {
|
||||
hasAutoCollapsedRef.current = true;
|
||||
onExpandedChange(false);
|
||||
}
|
||||
|
||||
// Reset auto-collapse flag when disconnected
|
||||
if (connectionState === "disconnected") {
|
||||
hasAutoCollapsedRef.current = false;
|
||||
}
|
||||
}, [connectionState, client, onClientReady, onExpandedChange]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
// Prevent duplicate connect calls if already connecting or connected
|
||||
if (!client || connectionState === "connecting" || connectionState === "connected") {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsShaking(false);
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (e) {
|
||||
// Ignore disconnect requested - user cancelled intentionally
|
||||
if (e instanceof DisconnectRequestedError) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = (e as Error).message;
|
||||
setError(errorMessage);
|
||||
// Trigger shake animation
|
||||
setIsShaking(true);
|
||||
setTimeout(() => setIsShaking(false), 500);
|
||||
// Ensure panel is expanded to show error
|
||||
onExpandedChange(true);
|
||||
}
|
||||
}, [client, connectionState, onExpandedChange]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
client?.disconnect();
|
||||
}, [client]);
|
||||
|
||||
const updateSetting = <K extends keyof ACPSettings>(key: K, value: ACPSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Clear error when starting to scan
|
||||
const handleStartScanning = useCallback(() => {
|
||||
setError(null);
|
||||
startScanning();
|
||||
}, [startScanning]);
|
||||
|
||||
const isConnected = connectionState === "connected";
|
||||
const isConnecting = connectionState === "connecting";
|
||||
|
||||
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isConnected && !isConnecting) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
}, [isConnected, isConnecting, handleConnect]);
|
||||
|
||||
// Format URL for display
|
||||
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, "");
|
||||
|
||||
// Get status label
|
||||
const statusLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background/80 backdrop-blur-sm">
|
||||
<div className="max-w-md mx-auto border-b">
|
||||
{/* Status Bar - Always visible */}
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={connectionState} />
|
||||
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
|
||||
{isConnected && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground">• {displayUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
|
||||
expanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable Settings Panel */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
maxHeight: expanded ? maxHeight : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? "animate-shake" : ""}`}>
|
||||
{/* Hidden file input for album scanning */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
|
||||
{isScanning && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="flex-1 w-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
onClick={stopScanning}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSelectFromAlbum}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<Image className="h-4 w-4 mr-2" />
|
||||
Select from Album
|
||||
</Button>
|
||||
<span className="text-sm text-white/80">
|
||||
or point camera at QR code
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
|
||||
<div className={`space-y-3 ${isScanning ? "invisible" : ""}`}>
|
||||
{/* Server URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-url">Server</Label>
|
||||
<div className="flex gap-2">
|
||||
{showScanButton && !isConnected && !isConnecting && (
|
||||
<Button
|
||||
onClick={handleStartScanning}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
title="Scan QR code"
|
||||
type="button"
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<InputGroup className="flex-1" data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<Globe />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="proxy-url"
|
||||
value={settings.proxyUrl}
|
||||
onChange={(e) => updateSetting("proxyUrl", e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isConnected || isConnecting}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</InputGroup>
|
||||
{!isConnected ? (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
type="button"
|
||||
>
|
||||
{isConnecting ? "..." : "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
type="button"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Token - only shown if enabled */}
|
||||
{showTokenInput && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auth-token">
|
||||
Auth Token
|
||||
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
||||
</Label>
|
||||
<InputGroup data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<KeyRound />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="auth-token"
|
||||
value={settings.token || ""}
|
||||
onChange={(e) => updateSetting("token", e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="For remote access"
|
||||
disabled={isConnected || isConnecting}
|
||||
type="password"
|
||||
aria-invalid={!!error}
|
||||
className="font-mono"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Working Directory */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="working-dir">
|
||||
Working Directory
|
||||
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
||||
</Label>
|
||||
<InputGroup data-disabled={isConnected || isConnecting}>
|
||||
<InputGroupAddon>
|
||||
<FolderOpen />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="working-dir"
|
||||
value={settings.cwd || ""}
|
||||
onChange={(e) => updateSetting("cwd", e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="/path/to/project"
|
||||
disabled={isConnected || isConnecting}
|
||||
aria-invalid={!!error}
|
||||
className="font-mono"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
241
packages/remote-control-server/web/components/ACPMain.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
|
||||
interface ACPMainProps {
|
||||
client: ACPClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main container — Anthropic sidebar + chat layout.
|
||||
* Sidebar: sectioned by recency, orange active state, warm raised bg.
|
||||
*/
|
||||
export function ACPMain({ client }: ACPMainProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Handle session selection
|
||||
const handleSelectSession = useCallback(async (session: AgentSessionInfo) => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else if (client.supportsResumeSession) {
|
||||
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else {
|
||||
throw new Error("Loading or resuming sessions is not supported by this agent.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load/resume session:", error);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200 flex-shrink-0",
|
||||
sidebarCollapsed ? "w-12" : "w-64",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider px-1">会话</span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// ChatInterface handles new session internally
|
||||
}}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
|
||||
title="新会话"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SidebarSessionList client={client} onSelectSession={handleSelectSession} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 聊天区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatInterface client={client} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 侧边栏会话列表 — Anthropic 分段式(今天/昨天/更早)
|
||||
// =============================================================================
|
||||
|
||||
function SidebarSessionList({
|
||||
client,
|
||||
onSelectSession,
|
||||
}: {
|
||||
client: ACPClient;
|
||||
onSelectSession: (session: AgentSessionInfo) => void;
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!client.supportsSessionList) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await client.listSessions();
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
console.warn("[SidebarSessionList] Failed to load:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client.getState() === "connected" && client.supportsSessionList) {
|
||||
loadSessions();
|
||||
}
|
||||
}, [client, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
if (state === "connected") {
|
||||
setTimeout(loadSessions, 200);
|
||||
}
|
||||
};
|
||||
client.setConnectionStateHandler(handler);
|
||||
return () => client.removeConnectionStateHandler(handler);
|
||||
}, [client, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadSessions, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">暂无会话</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const groups = groupByRecency(sorted);
|
||||
|
||||
return (
|
||||
<nav className="py-2" aria-label="历史会话">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(session.sessionId);
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
session.sessionId === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-2 border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-2 border-l-transparent",
|
||||
)}
|
||||
title={session.title || session.sessionId}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
{session.title && session.title.trim() ? session.title : "新会话"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 按日期分组:今天 / 昨天 / 更早
|
||||
// =============================================================================
|
||||
|
||||
interface SessionGroup {
|
||||
label: string;
|
||||
sessions: AgentSessionInfo[];
|
||||
}
|
||||
|
||||
function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
if (date >= today) {
|
||||
groups[0].sessions.push(session);
|
||||
} else if (date >= yesterday) {
|
||||
groups[1].sessions.push(session);
|
||||
} else {
|
||||
groups[2].sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
}
|
||||
717
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
717
packages/remote-control-server/web/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import imageCompression from "browser-image-compression";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
|
||||
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission } from "../src/lib/types";
|
||||
import { ChatView } from "./chat/ChatView";
|
||||
import { ChatInput } from "./chat/ChatInput";
|
||||
import { PermissionPanel } from "./chat/PermissionPanel";
|
||||
import { ModelSelectorPopover } from "./model-selector";
|
||||
import { useCommands } from "../src/hooks/useCommands";
|
||||
|
||||
// Image compression options
|
||||
// Claude API has a 5MB limit, so we target 2MB to be safe
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2, // Max output size in MB
|
||||
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
|
||||
useWebWorker: true, // Non-blocking compression
|
||||
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
|
||||
};
|
||||
|
||||
// Convert data URL to Blob without using fetch()
|
||||
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
// Parse the data URL: data:[<mediatype>][;base64],<data>
|
||||
const commaIndex = dataUrl.indexOf(",");
|
||||
if (commaIndex === -1) {
|
||||
throw new Error("Invalid data URL: missing comma separator");
|
||||
}
|
||||
|
||||
const header = dataUrl.slice(0, commaIndex);
|
||||
const base64Data = dataUrl.slice(commaIndex + 1);
|
||||
|
||||
// Extract MIME type from header (e.g., "data:image/png;base64")
|
||||
const mimeMatch = header.match(/^data:([^;,]+)/);
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
|
||||
|
||||
// Decode base64 to binary
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions - imported from shared types module
|
||||
// =============================================================================
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
client: ACPClient;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
// Map ACP status string to our status type
|
||||
function mapToolStatus(status: string): ToolCallStatus {
|
||||
if (status === "completed") return "complete";
|
||||
if (status === "failed") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
// Find tool call index in entries (search from end, like Zed)
|
||||
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ChatInterface Component
|
||||
// =============================================================================
|
||||
|
||||
export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
|
||||
const [entries, setEntries] = useState<ThreadEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sessionReady, setSessionReady] = useState(false);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Reference: Zed's supports_images() checks prompt_capabilities.image
|
||||
const [supportsImages, setSupportsImages] = useState(false);
|
||||
const { commands: availableCommands } = useCommands(client);
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
const resetThreadState = useCallback(() => {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
setSessionReady(false);
|
||||
}, []);
|
||||
|
||||
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
||||
}, [client]);
|
||||
|
||||
// =============================================================================
|
||||
// Permission Request Handler
|
||||
// =============================================================================
|
||||
const handlePermissionRequest = useCallback((request: PermissionRequestPayload) => {
|
||||
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
console.log("[ChatInterface] Permission request:", request);
|
||||
|
||||
setEntries((prev) => {
|
||||
// Find matching tool call (search from end)
|
||||
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
|
||||
|
||||
if (toolCallIndex >= 0) {
|
||||
// Update existing tool call's status
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== toolCallIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.status !== "running") return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "waiting_for_confirmation" as const,
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// No matching tool call - create standalone permission request as new entry
|
||||
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
|
||||
|
||||
const permissionToolCall: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
id: request.toolCall.toolCallId,
|
||||
title: request.toolCall.title || "Permission Request",
|
||||
status: "waiting_for_confirmation",
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
},
|
||||
isStandalonePermission: true,
|
||||
},
|
||||
};
|
||||
|
||||
return [...prev, permissionToolCall];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
// Session Update Handler (Zed-style: check last entry type)
|
||||
// =============================================================================
|
||||
const handleSessionUpdate = useCallback((sessionId: string, update: SessionUpdate) => {
|
||||
if (activeSessionIdRef.current && sessionId !== activeSessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent message chunk
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (message), append text
|
||||
if (lastChunk?.type === "message") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "message", text: lastChunk.text + text },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Otherwise add new message chunk
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "message", text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "message", text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle agent thought chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "agent_thought_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (thought), append text
|
||||
if (lastChunk?.type === "thought") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "thought", text: lastChunk.text + text },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Otherwise add new thought chunk
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "thought", text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry with thought
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "thought", text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle user message chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "user_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is UserMessage, append to it
|
||||
if (lastEntry?.type === "user_message") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
content: lastEntry.content + text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new UserMessage entry
|
||||
const newEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle tool call (UPSERT - update if exists, create if not)
|
||||
else if (update.sessionUpdate === "tool_call") {
|
||||
const toolCallData: ToolCallData = {
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
status: mapToolStatus(update.status),
|
||||
content: update.content,
|
||||
rawInput: update.rawInput,
|
||||
rawOutput: update.rawOutput,
|
||||
};
|
||||
|
||||
setEntries((prev) => {
|
||||
// UPSERT: Check if tool call already exists
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// UPDATE existing tool call
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
...toolCallData,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// CREATE new tool call entry
|
||||
const newEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: toolCallData,
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle tool call update (partial update)
|
||||
else if (update.sessionUpdate === "tool_call_update") {
|
||||
setEntries((prev) => {
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
// Tool call not found - create a failed tool call entry (like Zed)
|
||||
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
|
||||
const failedEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
id: update.toolCallId,
|
||||
title: update.title || "Tool call not found",
|
||||
status: "error",
|
||||
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
|
||||
},
|
||||
};
|
||||
return [...prev, failedEntry];
|
||||
}
|
||||
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
|
||||
const mergedContent = update.content
|
||||
? [...(entry.toolCall.content || []), ...update.content]
|
||||
: entry.toolCall.content;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
...(update.title && { title: update.title }),
|
||||
content: mergedContent,
|
||||
...(update.rawInput && { rawInput: update.rawInput }),
|
||||
...(update.rawOutput && { rawOutput: update.rawOutput }),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
// Setup Effect
|
||||
// =============================================================================
|
||||
useEffect(() => {
|
||||
client.setSessionCreatedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session created:", sessionId);
|
||||
activateSession(sessionId);
|
||||
});
|
||||
|
||||
client.setSessionLoadedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
|
||||
activateSession(sessionId, { resetEntries: false });
|
||||
});
|
||||
|
||||
client.setSessionSwitchingHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Switching to session:", sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
resetThreadState();
|
||||
});
|
||||
|
||||
client.setSessionUpdateHandler((sessionId: string, update: SessionUpdate) => {
|
||||
handleSessionUpdate(sessionId, update);
|
||||
});
|
||||
|
||||
client.setPromptCompleteHandler((stopReason) => {
|
||||
console.log("[ChatInterface] Prompt complete:", stopReason);
|
||||
// Always set isLoading=false when prompt completes
|
||||
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
|
||||
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
client.setPermissionRequestHandler(handlePermissionRequest);
|
||||
|
||||
client.setErrorMessageHandler((msg) => {
|
||||
console.error("[ChatInterface] Agent error:", msg);
|
||||
setErrorMessage(msg);
|
||||
// Clear any existing timer
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
// Auto-clear after 5 seconds
|
||||
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
|
||||
});
|
||||
|
||||
// Create session
|
||||
client.createSession();
|
||||
return () => {
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
client.setSessionCreatedHandler(() => {});
|
||||
client.setSessionLoadedHandler(() => {});
|
||||
client.setSessionSwitchingHandler(null);
|
||||
client.setSessionUpdateHandler(() => {});
|
||||
client.setPromptCompleteHandler(() => {});
|
||||
client.setPermissionRequestHandler(() => {});
|
||||
client.setErrorMessageHandler(() => {});
|
||||
};
|
||||
}, [activateSession, client, handlePermissionRequest, handleSessionUpdate, resetThreadState]);
|
||||
|
||||
// =============================================================================
|
||||
// User Actions
|
||||
// =============================================================================
|
||||
|
||||
// Reference: Zed's ConnectionView.reset() + set_server_state() + _external_thread()
|
||||
// Creates a new session by clearing current state and calling new_session
|
||||
// This is the core of Zed's NewThread action
|
||||
const handleNewSession = useCallback(() => {
|
||||
console.log("[ChatInterface] Creating new session...");
|
||||
|
||||
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
|
||||
// Cancel any ongoing request before creating new session
|
||||
if (isLoading) {
|
||||
client.cancel();
|
||||
}
|
||||
|
||||
// 1. Clear all entries (like Zed's set_server_state which creates new view)
|
||||
resetThreadState();
|
||||
setActiveSessionId(null);
|
||||
|
||||
// 3. Create new session (like Zed's initial_state -> connection.new_session())
|
||||
// The session_created handler will set sessionReady=true when ready
|
||||
client.createSession();
|
||||
}, [client, isLoading, resetThreadState]);
|
||||
|
||||
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
|
||||
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
|
||||
// 2. Send cancel notification to agent
|
||||
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
|
||||
const handleCancel = () => {
|
||||
console.log("[ChatInterface] Cancel requested");
|
||||
|
||||
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
|
||||
// Check if status should be canceled (matches Zed's logic)
|
||||
const shouldCancel =
|
||||
entry.toolCall.status === "running" ||
|
||||
entry.toolCall.status === "waiting_for_confirmation";
|
||||
|
||||
if (!shouldCancel) return entry;
|
||||
|
||||
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "canceled" as ToolCallStatus,
|
||||
permissionRequest: undefined, // Clear any pending permission request
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Send cancel notification to server (which forwards to agent)
|
||||
client.cancel();
|
||||
// Note: Do NOT set isLoading=false here!
|
||||
// Wait for prompt_complete with stopReason="cancelled" from the agent
|
||||
};
|
||||
|
||||
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
|
||||
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
|
||||
client.respondToPermission(requestId, optionId);
|
||||
|
||||
// Determine new status based on option kind
|
||||
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
|
||||
|
||||
// Update the tool call status in entries
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
|
||||
|
||||
// For standalone permission requests, mark as complete immediately when approved
|
||||
// For regular tool calls, mark as running (agent will update to complete later)
|
||||
let newStatus: ToolCallStatus;
|
||||
if (isRejected) {
|
||||
newStatus = "rejected";
|
||||
} else if (entry.toolCall.isStandalonePermission) {
|
||||
newStatus = "complete";
|
||||
} else {
|
||||
newStatus = "running";
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
permissionRequest: undefined,
|
||||
isStandalonePermission: undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [client]);
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
|
||||
// Collect pending permissions from tool call entries
|
||||
const pendingPermissions: PendingPermission[] = entries
|
||||
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
|
||||
.map((e) => ({
|
||||
requestId: e.toolCall.permissionRequest!.requestId,
|
||||
toolName: e.toolCall.title,
|
||||
toolInput: e.toolCall.rawInput || {},
|
||||
description: e.toolCall.title,
|
||||
options: e.toolCall.permissionRequest!.options,
|
||||
}));
|
||||
|
||||
// Handle permission respond for unified PermissionPanel
|
||||
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
|
||||
const kind = approved ? "accept_once" : "reject_once";
|
||||
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
|
||||
}, [handlePermissionResponse]);
|
||||
|
||||
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
|
||||
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
const images = message.images || [];
|
||||
|
||||
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
|
||||
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
|
||||
if (text) {
|
||||
contentBlocks.push({ type: "text", text });
|
||||
}
|
||||
|
||||
// Convert images to ContentBlock
|
||||
const userImages: UserMessageImage[] = [];
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
||||
let blob: Blob;
|
||||
if (dataUrl.startsWith("data:")) {
|
||||
blob = dataUrlToBlob(dataUrl);
|
||||
} else {
|
||||
const response = await fetch(dataUrl);
|
||||
blob = await response.blob();
|
||||
}
|
||||
|
||||
let finalBlob: Blob = blob;
|
||||
let finalMimeType = img.mimeType;
|
||||
|
||||
if (blob.size > 2 * 1024 * 1024) {
|
||||
const imageFile = new File([blob], "image.jpg", { type: blob.type });
|
||||
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
|
||||
finalMimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const base64Data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
|
||||
reader.readAsDataURL(finalBlob);
|
||||
});
|
||||
|
||||
const imageContent: ImageContent = {
|
||||
type: "image",
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
};
|
||||
contentBlocks.push(imageContent);
|
||||
|
||||
userImages.push({
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to process image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return;
|
||||
|
||||
// Add user message entry
|
||||
const userEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
images: userImages.length > 0 ? userImages : undefined,
|
||||
};
|
||||
setEntries((prev) => [...prev, userEntry]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await client.sendPrompt(contentBlocks);
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to send prompt:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, sessionReady, client]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Chat messages — unified ChatView */}
|
||||
<ChatView
|
||||
entries={entries}
|
||||
isLoading={isLoading && !sessionReady ? false : isLoading}
|
||||
onPermissionRespond={(requestId, optionId, optionKind) => {
|
||||
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
|
||||
}}
|
||||
emptyTitle={sessionReady ? "开始对话" : undefined}
|
||||
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
|
||||
/>
|
||||
|
||||
{/* Permission panel — fixed above input */}
|
||||
<PermissionPanel
|
||||
requests={pendingPermissions}
|
||||
onRespond={handlePermissionPanelRespond}
|
||||
/>
|
||||
|
||||
{/* Error banner */}
|
||||
{errorMessage && (
|
||||
<div className="mx-auto max-w-3xl w-full px-4 pb-1">
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setErrorMessage(null)}
|
||||
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
|
||||
>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selector + New thread + ChatInput */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 pb-1 flex items-center justify-between">
|
||||
<ModelSelectorPopover client={client} />
|
||||
{entries.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-text-muted hover:text-brand font-display gap-1"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
新会话
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Thread</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput
|
||||
onSubmit={handleChatInputSubmit}
|
||||
isLoading={isLoading}
|
||||
onInterrupt={handleCancel}
|
||||
disabled={!sessionReady}
|
||||
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
|
||||
supportsImages={supportsImages}
|
||||
commands={availableCommands.length > 0 ? availableCommands : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { User, Bot, Wrench, Loader2 } from "lucide-react";
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "running" | "complete" | "error";
|
||||
}
|
||||
|
||||
export interface ChatMessageData {
|
||||
id: string;
|
||||
role: "user" | "agent";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 p-4 rounded-lg",
|
||||
isUser ? "bg-muted/50" : "bg-background"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary"
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{isUser ? "You" : "Agent"}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-1.5 h-4 ml-0.5 bg-foreground animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
{message.toolCalls.map((tool) => (
|
||||
<ToolCallDisplay key={tool.id} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
|
||||
function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs px-2 py-1.5 rounded border",
|
||||
toolCall.status === "running" && "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
|
||||
toolCall.status === "complete" && "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
|
||||
toolCall.status === "error" && "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
|
||||
)}
|
||||
>
|
||||
{toolCall.status === "running" ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<Wrench className={cn(
|
||||
"w-3 h-3",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)} />
|
||||
)}
|
||||
<span className="truncate">{toolCall.title}</span>
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] uppercase font-medium",
|
||||
toolCall.status === "running" && "text-yellow-600 dark:text-yellow-400",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{toolCall.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal file
304
packages/remote-control-server/web/components/ThreadHistory.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Search, Clock, RefreshCw } from "lucide-react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../src/lib/utils";
|
||||
|
||||
// Reference: Zed's TimeBucket in thread_history.rs
|
||||
type TimeBucket = "today" | "yesterday" | "thisWeek" | "pastWeek" | "all";
|
||||
|
||||
// Reference: Zed's Display impl for TimeBucket
|
||||
const BUCKET_LABELS: Record<TimeBucket, string> = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
thisWeek: "This Week",
|
||||
pastWeek: "Past Week",
|
||||
all: "All", // Zed uses "All", not "Older"
|
||||
};
|
||||
|
||||
// Reference: Zed's TimeBucket::from_dates (line 1028-1051)
|
||||
// Rust's IsoWeek includes year, so we need to compare both year and week number
|
||||
function getTimeBucket(date: Date): TimeBucket {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const entryDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
if (entryDate.getTime() === today.getTime()) return "today";
|
||||
if (entryDate.getTime() === yesterday.getTime()) return "yesterday";
|
||||
|
||||
// This week: same ISO week AND year
|
||||
const todayIsoWeek = getISOWeekYear(today);
|
||||
const entryIsoWeek = getISOWeekYear(entryDate);
|
||||
if (todayIsoWeek.year === entryIsoWeek.year && todayIsoWeek.week === entryIsoWeek.week) {
|
||||
return "thisWeek";
|
||||
}
|
||||
|
||||
// Past week: (reference - 7days).iso_week()
|
||||
const lastWeekDate = new Date(today);
|
||||
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
|
||||
const lastWeekIsoWeek = getISOWeekYear(lastWeekDate);
|
||||
if (lastWeekIsoWeek.year === entryIsoWeek.year && lastWeekIsoWeek.week === entryIsoWeek.week) {
|
||||
return "pastWeek";
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Returns ISO week number AND ISO week year (important for year boundaries)
|
||||
function getISOWeekYear(date: Date): { week: number; year: number } {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year
|
||||
}
|
||||
|
||||
// Reference: Zed's formatted_time in HistoryEntryElement (line 904-921)
|
||||
// Exact format: Xd, Xh ago, Xm ago, Just now, Unknown
|
||||
function formatRelativeTime(date: Date | null): string {
|
||||
if (!date) return "Unknown"; // Zed uses "Unknown" for missing updatedAt
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m ago`;
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
interface ThreadHistoryProps {
|
||||
client: ACPClient;
|
||||
// Returns Promise to allow loading state tracking; resolves when session is loaded
|
||||
onSelectSession: (session: AgentSessionInfo) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface GroupedSessions {
|
||||
bucket: TimeBucket;
|
||||
sessions: AgentSessionInfo[];
|
||||
}
|
||||
|
||||
export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Start with isLoading=true to prevent flash of "no threads" message
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
// Track which session is currently being loaded to show loading state and prevent double-clicks
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
|
||||
// Check if session history is supported
|
||||
const supportsHistory = client.supportsSessionHistory;
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!client.supportsSessionList) {
|
||||
setError("Session list not supported by this agent");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.listSessions();
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (supportsHistory) {
|
||||
loadSessions();
|
||||
} else {
|
||||
// Not supported, clear loading state
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [supportsHistory, loadSessions]);
|
||||
|
||||
// Filter and group sessions
|
||||
// Reference: Zed's add_list_separators and filter_search_results
|
||||
const groupedSessions = useMemo((): GroupedSessions[] => {
|
||||
let filtered = sessions;
|
||||
|
||||
// Simple search filter (Zed uses fuzzy matching, we use substring for simplicity)
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = sessions.filter(
|
||||
(s) => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending (most recent first)
|
||||
// Zed expects the API to return sorted data, but we ensure it client-side
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA; // Descending
|
||||
});
|
||||
|
||||
// Group by time bucket (preserving sort order within each bucket)
|
||||
const groups = new Map<TimeBucket, AgentSessionInfo[]>();
|
||||
for (const session of sorted) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
const bucket = getTimeBucket(date);
|
||||
if (!groups.has(bucket)) groups.set(bucket, []);
|
||||
groups.get(bucket)!.push(session);
|
||||
}
|
||||
|
||||
// Return in chronological bucket order
|
||||
const bucketOrder: TimeBucket[] = ["today", "yesterday", "thisWeek", "pastWeek", "all"];
|
||||
return bucketOrder
|
||||
.filter((b) => groups.has(b))
|
||||
.map((bucket) => ({ bucket, sessions: groups.get(bucket)! }));
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
async (session: AgentSessionInfo) => {
|
||||
// Prevent double-clicks while loading
|
||||
if (loadingSessionId) return;
|
||||
|
||||
setLoadingSessionId(session.sessionId);
|
||||
try {
|
||||
await onSelectSession(session);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[onSelectSession, loadingSessionId]
|
||||
);
|
||||
|
||||
if (!supportsHistory) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">Session history is not supported by this agent.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flatItems = groupedSessions.flatMap((g) => g.sessions);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search header - Reference: Zed's search_editor */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-border">
|
||||
<Search className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Search threads..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 border-0 focus-visible:ring-0 shadow-none"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadSessions}
|
||||
disabled={isLoading}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{error && (
|
||||
<div className="p-4 text-center text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<RefreshCw className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
<p className="text-muted-foreground text-sm">Loading threads...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && !isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You don't have any past threads yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && sessions.length > 0 && groupedSessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No threads match your search.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* p-2 ensures rounded corners of buttons are not clipped */}
|
||||
<div className="p-2">
|
||||
{groupedSessions.map((group, groupIndex) => (
|
||||
<div key={group.bucket}>
|
||||
{/* Bucket separator - Reference: Zed's BucketSeparator */}
|
||||
<div className={cn("px-2 pb-1", groupIndex > 0 && "pt-3")}>
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{BUCKET_LABELS[group.bucket]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session entries */}
|
||||
{group.sessions.map((session) => {
|
||||
const globalIdx = flatItems.indexOf(session);
|
||||
const isSelected = globalIdx === selectedIndex;
|
||||
const isLoadingThis = loadingSessionId === session.sessionId;
|
||||
const isAnyLoading = loadingSessionId !== null;
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
disabled={isAnyLoading}
|
||||
onClick={() => {
|
||||
setSelectedIndex(globalIdx);
|
||||
handleSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
// min-w-0 is required for truncate to work in flex containers
|
||||
"w-full min-w-0 flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
isAnyLoading && !isLoadingThis && "opacity-50 cursor-not-allowed",
|
||||
isLoadingThis && "bg-accent"
|
||||
)}
|
||||
>
|
||||
{/* min-w-0 + truncate ensures long titles are clipped with ellipsis */}
|
||||
<span className="text-sm truncate flex-1 min-w-0">
|
||||
{session.title && session.title.trim() ? session.title : "New Thread"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
|
||||
{isLoadingThis ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
formatRelativeTime(date)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language?: string;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const lines = code.split("\n");
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="border-0">
|
||||
{showLineNumbers && (
|
||||
<td className="w-10 select-none pr-4 text-right align-top text-muted-foreground text-xs">
|
||||
{i + 1}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-0">
|
||||
<pre className="m-0 p-0 text-sm whitespace-pre font-mono">
|
||||
<code className="text-sm">{line || "\u00A0"}</code>
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ArrowDownIcon, UserIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("mx-auto flex max-w-3xl flex-col gap-4 p-4 min-w-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
/**
|
||||
* Button to scroll to the bottom of the conversation.
|
||||
* Can be used standalone or within ConversationScrollButtons container.
|
||||
* When used standalone, it handles its own visibility based on isAtBottom.
|
||||
* When used in ConversationScrollButtons, the container manages visibility.
|
||||
*/
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
title="Scroll to bottom"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Data attribute used to mark the last user message element.
|
||||
* ChatInterface adds this attribute to the last user message for scroll targeting.
|
||||
*/
|
||||
export const LAST_USER_MESSAGE_ATTR = "data-last-user-message";
|
||||
|
||||
export type ConversationScrollToLastUserMessageButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
/**
|
||||
* Button to scroll to the last user message in the conversation.
|
||||
* Reference: Issue #3 - Provide a feature to locate the last human message
|
||||
*/
|
||||
export const ConversationScrollToLastUserMessageButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollToLastUserMessageButtonProps) => {
|
||||
const handleScrollToLastUserMessage = useCallback(() => {
|
||||
// Find the last user message element by data attribute
|
||||
const lastUserMessage = document.querySelector(`[${LAST_USER_MESSAGE_ATTR}="true"]`);
|
||||
if (lastUserMessage) {
|
||||
lastUserMessage.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToLastUserMessage}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
title="Scroll to last user message"
|
||||
{...props}
|
||||
>
|
||||
<UserIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConversationScrollButtonsProps = ComponentProps<"div"> & {
|
||||
/** Whether there are user messages to scroll to */
|
||||
hasUserMessages?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Container for scroll navigation buttons.
|
||||
* Renders scroll-to-last-user-message and scroll-to-bottom buttons side by side.
|
||||
* Reference: Issue #3 - Provide a feature to locate the last human message
|
||||
*/
|
||||
export const ConversationScrollButtons = ({
|
||||
className,
|
||||
hasUserMessages = false,
|
||||
...props
|
||||
}: ConversationScrollButtonsProps) => {
|
||||
const { isAtBottom } = useStickToBottomContext();
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasUserMessages && <ConversationScrollToLastUserMessageButton />}
|
||||
<ConversationScrollButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./code-block";
|
||||
export * from "./conversation";
|
||||
export * from "./message";
|
||||
export * from "./permission-request";
|
||||
export * from "./prompt-input";
|
||||
export * from "./reasoning";
|
||||
export * from "./shimmer";
|
||||
export * from "./tool";
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "../ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, lazy, memo, Suspense, useContext, useEffect, useState } from "react";
|
||||
|
||||
const LazyStreamdown = lazy(() => import("streamdown").then((m) => ({ default: m.Streamdown })));
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[85%] min-w-0 flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full flex-col gap-2 overflow-hidden text-sm break-words",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = {
|
||||
children?: string;
|
||||
className?: string;
|
||||
mode?: "static" | "streaming";
|
||||
};
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, children, ...props }: MessageResponseProps) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={cn("whitespace-pre-wrap break-words", className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyStreamdown
|
||||
className={cn(
|
||||
"size-full break-words [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</LazyStreamdown>
|
||||
</Suspense>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import type { PermissionOption } from "../../src/acp/types";
|
||||
|
||||
// Get button variant based on option kind
|
||||
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
return "default";
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
// Get button icon based on option kind
|
||||
function getButtonIcon(kind: PermissionOption["kind"]) {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
return <CheckIcon className="size-4" />;
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return <XIcon className="size-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Permission buttons component - used inside Tool component
|
||||
export interface ToolPermissionButtonsProps {
|
||||
requestId: string;
|
||||
options: PermissionOption[];
|
||||
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolPermissionButtons({ requestId, options, onRespond, className }: ToolPermissionButtonsProps) {
|
||||
const handleOptionClick = (option: PermissionOption) => {
|
||||
onRespond(requestId, option.optionId, option.kind);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("p-3 border-t border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50", className)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldAlertIcon className="size-4 text-warning-text" />
|
||||
<span className="text-xs font-medium text-warning-text">
|
||||
Permission Required
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
variant={getButtonVariant(option.kind)}
|
||||
size="sm"
|
||||
onClick={() => handleOptionClick(option)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{getButtonIcon(option.kind)}
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full max-w-full overflow-hidden rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Extended state type to include our custom states
|
||||
export type ExtendedToolState = ToolUIPart["state"] | "waiting-for-confirmation" | "rejected";
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ExtendedToolState;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ExtendedToolState) => {
|
||||
const labels: Record<ExtendedToolState, string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
"waiting-for-confirmation": "Awaiting Approval",
|
||||
"rejected": "Rejected",
|
||||
};
|
||||
|
||||
const icons: Record<ExtendedToolState, ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"waiting-for-confirmation": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"rejected": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4 max-w-full", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50 overflow-hidden">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4 max-w-full overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div className="p-2">{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
334
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
334
packages/remote-control-server/web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Send, Square, Paperclip, Slash } from "lucide-react";
|
||||
import type { ChatInputMessage, UserMessageImage } from "../../src/lib/types";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
import { CommandMenu } from "./CommandMenu";
|
||||
import imageCompression from "browser-image-compression";
|
||||
|
||||
// 图片压缩配置
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2,
|
||||
maxWidthOrHeight: 2048,
|
||||
useWebWorker: true,
|
||||
fileType: "image/jpeg" as const,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Anthropic 风格聊天输入框 — 底部居中浮动卡片,橙色焦点环
|
||||
// =============================================================================
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit: (message: ChatInputMessage) => void;
|
||||
isLoading?: boolean;
|
||||
onInterrupt?: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** 是否支持图片上传 */
|
||||
supportsImages?: boolean;
|
||||
/** Agent 提供的可用 slash 命令 */
|
||||
commands?: AvailableCommand[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
onInterrupt,
|
||||
disabled = false,
|
||||
placeholder = "给 Claude 发送消息…",
|
||||
supportsImages = false,
|
||||
commands,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImages] = useState<UserMessageImage[]>([]);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandFilter, setCommandFilter] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || disabled) return;
|
||||
|
||||
onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined });
|
||||
setText("");
|
||||
setImages([]);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
// 重置 textarea 高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [text, images, disabled, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCommandMenu) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
// Let cmdk handle arrow keys and Enter for selection
|
||||
// Tab also closes the menu
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
onInterrupt?.();
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSubmit, isLoading, onInterrupt, showCommandMenu],
|
||||
);
|
||||
|
||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setText(value);
|
||||
|
||||
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
|
||||
if (value.startsWith("/") && commands && commands.length > 0) {
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(value.slice(1).split(/\s/)[0] || "");
|
||||
} else if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
}, [commands, showCommandMenu]);
|
||||
|
||||
// 粘贴图片
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent) => {
|
||||
if (!supportsImages) return;
|
||||
const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
const newImages = await processImageFiles(files);
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, [supportsImages]);
|
||||
|
||||
// 选择文件
|
||||
const handleFileSelect = useCallback(async () => {
|
||||
if (!fileInputRef.current) return;
|
||||
const files = fileInputRef.current.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages = await processImageFiles(Array.from(files));
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
// 清空 input 以便重复选择
|
||||
fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleCommandSelect = useCallback((command: AvailableCommand) => {
|
||||
setText(`/${command.name} `);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleCommandMenu = useCallback(() => {
|
||||
if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
} else {
|
||||
if (!text.startsWith("/")) {
|
||||
setText("/" + text);
|
||||
}
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(text.startsWith("/") ? text.slice(1).split(/\s/)[0] || "" : "");
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [showCommandMenu, text]);
|
||||
|
||||
const canSend = (text.trim() || images.length > 0) && !disabled;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-3 sm:px-4 pb-4 pt-2", className)}>
|
||||
<div className="relative">
|
||||
{/* Slash command menu — floating above input */}
|
||||
{showCommandMenu && commands && commands.length > 0 && (
|
||||
<CommandMenu
|
||||
commands={commands}
|
||||
filter={commandFilter}
|
||||
onSelect={handleCommandSelect}
|
||||
onClose={() => {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
}}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50"
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 overflow-hidden",
|
||||
"focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all",
|
||||
)}>
|
||||
{/* 图片预览 */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pt-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt="附件"
|
||||
className="h-14 w-14 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 — Anthropic 单行紧凑布局 */}
|
||||
<div className="flex items-end gap-2 px-3 py-2.5">
|
||||
{/* 左侧附件按钮 */}
|
||||
{supportsImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Slash 命令按钮 */}
|
||||
{commands && commands.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCommandMenu}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors",
|
||||
showCommandMenu
|
||||
? "bg-brand/15 text-brand"
|
||||
: "text-text-muted hover:text-text-secondary hover:bg-surface-1/50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
title="命令列表"
|
||||
>
|
||||
<Slash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Textarea — Poppins font */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"flex-1 resize-none border-none bg-transparent outline-none",
|
||||
"text-sm text-text-primary placeholder:text-text-muted font-display",
|
||||
"max-h-[200px] min-h-[24px] leading-normal",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 右侧发送/取消按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isLoading ? onInterrupt : handleSubmit}
|
||||
disabled={!isLoading && !canSend}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all",
|
||||
isLoading
|
||||
? "bg-text-primary text-surface-2 hover:bg-text-secondary"
|
||||
: canSend
|
||||
? "bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]"
|
||||
: "bg-surface-1 text-text-muted",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Square className="h-3.5 w-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end relative */}
|
||||
|
||||
{/* 提示文本 */}
|
||||
<div className="text-center mt-1.5">
|
||||
<span className="text-[11px] text-text-muted font-display">
|
||||
Enter 发送,Shift+Enter 换行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 图片处理工具
|
||||
// =============================================================================
|
||||
|
||||
async function processImageFiles(files: File[]): Promise<UserMessageImage[]> {
|
||||
const results: UserMessageImage[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
let blob: Blob = file;
|
||||
let mimeType = file.type;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
const compressed = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS);
|
||||
blob = compressed;
|
||||
mimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIdx = result.indexOf(",");
|
||||
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
results.push({ mimeType, data: base64 });
|
||||
} catch (err) {
|
||||
console.error("Failed to process image:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
166
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
166
packages/remote-control-server/web/components/chat/ChatView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ThreadEntry, ToolCallEntry } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
import { ToolCallGroup } from "./ToolCallGroup";
|
||||
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
|
||||
|
||||
// =============================================================================
|
||||
// 统一聊天视图 — Anthropic 编辑式排版
|
||||
// 无气泡间距,用垂直 rhythm 区分消息块
|
||||
// =============================================================================
|
||||
|
||||
interface ChatViewProps {
|
||||
entries: ThreadEntry[];
|
||||
isLoading?: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
entries,
|
||||
isLoading = false,
|
||||
onPermissionRespond,
|
||||
emptyTitle = "开始对话",
|
||||
emptyDescription = "输入消息开始聊天",
|
||||
}: ChatViewProps) {
|
||||
// 将相邻的 ToolCallEntry 合并为一组
|
||||
const grouped = groupToolCalls(entries);
|
||||
const hasMessages = entries.length > 0;
|
||||
|
||||
// 检查是否正在加载(最后一个条目是用户消息)
|
||||
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === "user_message";
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent>
|
||||
{!hasMessages ? (
|
||||
<ConversationEmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{grouped.map((item, i) => {
|
||||
if (item.type === "single") {
|
||||
return (
|
||||
<div key={`entry-${i}`} className={cn(entrySpacing(entries, i))}>
|
||||
<EntryRenderer entry={item.entry} isLoading={isLoading} onPermissionRespond={onPermissionRespond} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 工具调用组 — 紧贴在助手消息下方
|
||||
return (
|
||||
<div key={`group-${i}`} className="-mt-2">
|
||||
<ToolCallGroup entries={item.entries} onPermissionRespond={onPermissionRespond} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 思考指示器 — Anthropic 打字动画 */}
|
||||
{showThinking && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<span className="chat-typing-indicator" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConversationScrollButtons hasUserMessages={entries.some((e) => e.type === "user_message")} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 间距逻辑 — 用户消息前后间距大,工具调用紧贴
|
||||
// =============================================================================
|
||||
|
||||
function entrySpacing(entries: ThreadEntry[], index: number): string {
|
||||
const entry = entries[index];
|
||||
// 用户消息前面多留白
|
||||
if (entry?.type === "user_message") {
|
||||
return "pt-6 pb-2";
|
||||
}
|
||||
// 助手消息后面多留白(除非紧跟工具调用)
|
||||
if (entry?.type === "assistant_message") {
|
||||
const next = entries[index + 1];
|
||||
if (next?.type === "tool_call") {
|
||||
return "pt-2 pb-1";
|
||||
}
|
||||
return "pt-2 pb-4";
|
||||
}
|
||||
return "py-1";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单条目渲染器
|
||||
// =============================================================================
|
||||
|
||||
function EntryRenderer({
|
||||
entry,
|
||||
isLoading,
|
||||
onPermissionRespond,
|
||||
}: {
|
||||
entry: ThreadEntry;
|
||||
isLoading: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user_message":
|
||||
return <UserBubble entry={entry} />;
|
||||
case "assistant_message":
|
||||
return <AssistantBubble entry={entry} isStreaming={isLoading} />;
|
||||
case "tool_call":
|
||||
return (
|
||||
<ToolCallGroup
|
||||
entries={[entry as ToolCallEntry]}
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工具调用分组逻辑
|
||||
// =============================================================================
|
||||
|
||||
type GroupedItem =
|
||||
| { type: "single"; entry: ThreadEntry }
|
||||
| { type: "tool_group"; entries: ToolCallEntry[] };
|
||||
|
||||
function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
|
||||
const result: GroupedItem[] = [];
|
||||
let currentToolGroup: ToolCallEntry[] = [];
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup.length === 1) {
|
||||
result.push({ type: "single", entry: currentToolGroup[0] });
|
||||
} else if (currentToolGroup.length > 1) {
|
||||
result.push({ type: "tool_group", entries: currentToolGroup });
|
||||
}
|
||||
currentToolGroup = [];
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "tool_call") {
|
||||
currentToolGroup.push(entry);
|
||||
} else {
|
||||
flushToolGroup();
|
||||
result.push({ type: "single", entry });
|
||||
}
|
||||
}
|
||||
flushToolGroup();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
|
||||
// =============================================================================
|
||||
// Slash command picker — floating above ChatInput
|
||||
// =============================================================================
|
||||
|
||||
interface CommandMenuProps {
|
||||
commands: AvailableCommand[];
|
||||
/** Text after "/" used for filtering */
|
||||
filter: string;
|
||||
onSelect: (command: AvailableCommand) => void;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match — checks if all query chars appear in order in the text.
|
||||
* Same algorithm as ModelSelectorPicker.
|
||||
*/
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerText = text.toLowerCase();
|
||||
let queryIdx = 0;
|
||||
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||
queryIdx++;
|
||||
}
|
||||
}
|
||||
return queryIdx === lowerQuery.length;
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
commands,
|
||||
filter,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}: CommandMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => fuzzyMatch(filter, cmd.name) || fuzzyMatch(filter, cmd.description),
|
||||
);
|
||||
}, [commands, filter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList className="max-h-[320px]">
|
||||
<CommandEmpty className="text-xs text-text-muted font-display py-3">
|
||||
没有匹配的命令
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd.name}
|
||||
value={cmd.name}
|
||||
onSelect={() => onSelect(cmd)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 cursor-pointer",
|
||||
"rounded-lg mx-1",
|
||||
"data-[selected=true]:bg-brand/8 data-[selected=true]:text-text-primary",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-display font-medium text-brand">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.input?.hint && (
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{cmd.input.hint}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
|
||||
import { cn, esc } from "../../src/lib/utils";
|
||||
import { MessageResponse } from "../ai-elements/message";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
|
||||
|
||||
// =============================================================================
|
||||
// 用户消息 — 右对齐,深色反转背景,无气泡边框
|
||||
// Anthropic: right-aligned, inverted dark bg, rounded-xl with bottom-right notch
|
||||
// =============================================================================
|
||||
|
||||
interface UserBubbleProps {
|
||||
entry: UserMessageEntry;
|
||||
}
|
||||
|
||||
export function UserBubble({ entry }: UserBubbleProps) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] sm:max-w-[75%]">
|
||||
{/* 图片附件 */}
|
||||
{entry.images && entry.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2 justify-end">
|
||||
{entry.images.map((img, i) => (
|
||||
<ImageThumbnail key={i} image={img} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 文本内容 */}
|
||||
{entry.content && (
|
||||
<div className="rounded-2xl rounded-br-md bg-bg-inverted px-4 py-2.5 text-sm text-text-inverted whitespace-pre-wrap font-display leading-relaxed">
|
||||
{esc(entry.content)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 助手消息 — 左对齐,无背景卡片,编辑式排版
|
||||
// Anthropic: avatar + plain text, no bubble/card wrapper, serif body font
|
||||
// =============================================================================
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
entry: AssistantMessageEntry;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
return (
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Orange triangle avatar */}
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* 内容 — 无卡片背景,直接排版 */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
{/* Sender label */}
|
||||
<span className="text-sm font-medium text-text-primary font-display">Claude</span>
|
||||
{entry.chunks.map((chunk, i) => {
|
||||
if (chunk.type === "thought") {
|
||||
const isLastChunk = i === entry.chunks.length - 1;
|
||||
const isThoughtStreaming = isStreaming && isLastChunk;
|
||||
return (
|
||||
<Reasoning key={i} isStreaming={isThoughtStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<div className="text-sm text-text-secondary">
|
||||
{chunk.text}
|
||||
</div>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
// 普通消息块 — 直接输出,无包裹卡片
|
||||
return (
|
||||
<div key={i} className="message-content text-text-primary leading-loose">
|
||||
<MessageResponse>{chunk.text}</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 图片缩略图 — 点击放大
|
||||
// =============================================================================
|
||||
|
||||
function ImageThumbnail({ image }: { image: UserMessageImage }) {
|
||||
const dataUrl = `data:${image.mimeType};base64,${image.data}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
// 简单的点击放大 — 在新标签页打开图片
|
||||
const w = window.open("");
|
||||
if (w) {
|
||||
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="用户上传的图片"
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PendingPermission } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ShieldAlert, Check, X } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// 权限请求面板 — 固定在输入框上方(Anthropic warm token style)
|
||||
// =============================================================================
|
||||
|
||||
interface PermissionPanelProps {
|
||||
requests: PendingPermission[];
|
||||
onRespond?: (requestId: string, approved: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PermissionPanel({ requests, onRespond, className }: PermissionPanelProps) {
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
|
||||
<div className="space-y-2">
|
||||
{requests.map((req) => (
|
||||
<PermissionCard
|
||||
key={req.requestId}
|
||||
request={req}
|
||||
onRespond={onRespond}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单个权限卡片 — warm warning tokens + left-border accent
|
||||
// =============================================================================
|
||||
|
||||
interface PermissionCardProps {
|
||||
request: PendingPermission;
|
||||
onRespond?: (requestId: string, approved: boolean) => void;
|
||||
}
|
||||
|
||||
function PermissionCard({ request, onRespond }: PermissionCardProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50 px-4 py-3">
|
||||
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-warning-text">
|
||||
{request.toolName}
|
||||
</div>
|
||||
{request.description && (
|
||||
<div className="text-xs text-warning-text/80 mt-0.5 truncate">
|
||||
{request.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRespond?.(request.requestId, true)}
|
||||
className="h-8 px-3 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-light transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
允许
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRespond?.(request.requestId, false)}
|
||||
className="h-8 px-3 rounded-lg border border-warning-border/30 text-warning-text text-xs font-medium hover:bg-warning-bg transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Plus, MessageSquare, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { SessionListItem } from "../../src/lib/types";
|
||||
|
||||
// =============================================================================
|
||||
// 会话侧边栏 — Anthropic 分段式:今天/昨天/更早 + 橙色活跃态
|
||||
// =============================================================================
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionListItem[];
|
||||
activeId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
onNew?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeId,
|
||||
onSelect,
|
||||
onNew,
|
||||
className,
|
||||
}: SessionSidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 按日期分组
|
||||
const groups = groupByRecency(sessions);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200",
|
||||
collapsed ? "w-12" : "w-64",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider">会话</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{!collapsed && onNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 — 分段 */}
|
||||
{!collapsed && (
|
||||
<nav className="flex-1 overflow-y-auto py-2" aria-label="历史会话">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(session.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-l-2",
|
||||
session.id === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-transparent",
|
||||
)}
|
||||
title={session.title || session.id}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
{session.title || session.id.slice(0, 8)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-xs text-text-muted font-display">暂无会话</span>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 按日期分组
|
||||
// =============================================================================
|
||||
|
||||
interface SessionGroup {
|
||||
label: string;
|
||||
sessions: SessionListItem[];
|
||||
}
|
||||
|
||||
function groupByRecency(sessions: SessionListItem[]): SessionGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
|
||||
if (date >= today) {
|
||||
groups[0].sessions.push(session);
|
||||
} else if (date >= yesterday) {
|
||||
groups[1].sessions.push(session);
|
||||
} else {
|
||||
groups[2].sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolCallEntry, ToolCallData } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ToolPermissionButtons } from "../ai-elements/permission-request";
|
||||
|
||||
// =============================================================================
|
||||
// 工具调用折叠组 — Anthropic: subtle card, left-border accent, compact layout
|
||||
// =============================================================================
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
entries: ToolCallEntry[];
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}
|
||||
|
||||
export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// 单个工具调用
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<SingleToolCard
|
||||
tool={entries[0].toolCall}
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 多个工具调用 — 折叠组
|
||||
const summary = buildSummary(entries);
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-lg border border-border border-l-3 border-l-brand/50 bg-surface-2/50 overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted", expanded && "rotate-90")}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
<span className="text-xs text-text-muted font-display">{summary}</span>
|
||||
</button>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<SingleToolCard
|
||||
key={entry.toolCall.id || i}
|
||||
tool={entry.toolCall}
|
||||
compact
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单个工具卡片 — compact, left-accent, inline status
|
||||
// =============================================================================
|
||||
|
||||
interface SingleToolCardProps {
|
||||
tool: ToolCallData;
|
||||
compact?: boolean;
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}
|
||||
|
||||
function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardProps) {
|
||||
const [expanded, setExpanded] = useState(!compact);
|
||||
|
||||
const statusIcon = (() => {
|
||||
switch (tool.status) {
|
||||
case "running":
|
||||
return <span className="text-status-running text-[10px]">▶</span>;
|
||||
case "complete":
|
||||
return <span className="text-status-active text-[10px]">✓</span>;
|
||||
case "error":
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
case "waiting_for_confirmation":
|
||||
return <span className="text-brand text-[10px]">⍻</span>;
|
||||
case "canceled":
|
||||
return <span className="text-text-muted text-[10px]">—</span>;
|
||||
case "rejected":
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
|
||||
|
||||
return (
|
||||
<div className={cn("px-3 py-2", compact && "py-1.5")}>
|
||||
{/* 标题行 — 单行紧凑 */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer group"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{statusIcon}
|
||||
<span className="text-xs font-display font-medium text-text-secondary group-hover:text-text-primary transition-colors truncate">
|
||||
{tool.title}
|
||||
</span>
|
||||
{tool.status === "running" && (
|
||||
<span className="text-[10px] text-status-running animate-pulse">running</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 权限请求按钮 */}
|
||||
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
|
||||
<div className="mt-1.5 ml-4">
|
||||
<ToolPermissionButtons
|
||||
requestId={tool.permissionRequest.requestId}
|
||||
options={tool.permissionRequest.options}
|
||||
onRespond={onPermissionRespond || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expanded && (
|
||||
<div className="mt-1.5 ml-4 space-y-1.5">
|
||||
{tool.rawInput && Object.keys(tool.rawInput).length > 0 && (
|
||||
<div>
|
||||
<pre className="text-[11px] bg-surface-1 rounded-md p-2 overflow-x-auto font-mono max-h-36 text-text-secondary">
|
||||
{truncate(JSON.stringify(tool.rawInput, null, 2), 2000)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div>
|
||||
<pre className={cn(
|
||||
"text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36",
|
||||
tool.status === "error" ? "bg-status-error/10 text-status-error" : "bg-surface-1 text-text-secondary",
|
||||
)}>
|
||||
{formatOutput(tool)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工具函数
|
||||
// =============================================================================
|
||||
|
||||
/** 构建统计摘要 */
|
||||
function buildSummary(entries: ToolCallEntry[]): string {
|
||||
const toolCounts = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
const name = simplifyToolName(entry.toolCall.title);
|
||||
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const [name, count] of toolCounts) {
|
||||
parts.push(count === 1 ? name : `${count} 次${name}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return `${entries.length} 个工具调用`;
|
||||
if (parts.length === 1) return parts[0];
|
||||
return `${entries.length} 个工具: ${parts.join("、")}`;
|
||||
}
|
||||
|
||||
/** 简化工具名称 */
|
||||
function simplifyToolName(title: string): string {
|
||||
const match = title.match(/^(\w+)/);
|
||||
return match ? match[1] : title;
|
||||
}
|
||||
|
||||
/** 格式化工具输出 */
|
||||
function formatOutput(tool: ToolCallData): string {
|
||||
if (tool.content && tool.content.length > 0) {
|
||||
const texts = tool.content
|
||||
.filter((c): c is Extract<typeof c, { type: "content" }> => c.type === "content")
|
||||
.filter((c) => c.content.type === "text" && "text" in c.content)
|
||||
.map((c) => (c.content as { text: string }).text);
|
||||
if (texts.length > 0) return truncate(texts.join("\n"), 2000);
|
||||
}
|
||||
if (tool.rawOutput && Object.keys(tool.rawOutput).length > 0) {
|
||||
return truncate(JSON.stringify(tool.rawOutput, null, 2), 2000);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { ChatView } from "./ChatView";
|
||||
export { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
export { ToolCallGroup } from "./ToolCallGroup";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { PermissionPanel } from "./PermissionPanel";
|
||||
export { SessionSidebar } from "./SessionSidebar";
|
||||
export { CommandMenu } from "./CommandMenu";
|
||||
6
packages/remote-control-server/web/components/index.ts
Normal file
6
packages/remote-control-server/web/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./ACPConnect";
|
||||
export * from "./ACPMain";
|
||||
export * from "./ChatInterface";
|
||||
export * from "./ChatMessage";
|
||||
export * from "./ThreadHistory";
|
||||
export * from "./model-selector";
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
interface ModelSelectorPickerProps {
|
||||
models: ModelInfo[];
|
||||
currentModelId: string | null;
|
||||
onSelect: (model: ModelInfo) => void;
|
||||
/** Whether to show the search input (default: true) */
|
||||
showSearch?: boolean;
|
||||
/** Whether we're on a mobile device (disables auto-selection) */
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy search implementation for model filtering.
|
||||
* Reference: Zed's fuzzy_search() in model_selector.rs
|
||||
*/
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Simple fuzzy match - check if all query chars appear in order
|
||||
let queryIdx = 0;
|
||||
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||
queryIdx++;
|
||||
}
|
||||
}
|
||||
return queryIdx === lowerQuery.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model picker using cmdk Command component.
|
||||
* Reference: Zed's AcpModelPickerDelegate with fuzzy search support.
|
||||
*/
|
||||
export function ModelSelectorPicker({
|
||||
models,
|
||||
currentModelId,
|
||||
onSelect,
|
||||
showSearch = true,
|
||||
isMobile = false,
|
||||
}: ModelSelectorPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
// On mobile, don't auto-select first item (no keyboard navigation needed)
|
||||
// Use a non-existent value to prevent any item from being selected
|
||||
const [selectedValue, setSelectedValue] = useState(isMobile ? "__none__" : undefined);
|
||||
|
||||
// Filter models using fuzzy search
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!search) return models;
|
||||
return models.filter((model) =>
|
||||
fuzzyMatch(search, model.name) ||
|
||||
fuzzyMatch(search, model.modelId)
|
||||
);
|
||||
}, [models, search]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} value={selectedValue} onValueChange={setSelectedValue}>
|
||||
{showSearch && (
|
||||
<CommandInput
|
||||
placeholder="Select a model…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
onSelect={() => onSelect(model)}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{model.name}</span>
|
||||
{model.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{model.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
currentModelId === model.modelId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
import type { ACPClient } from "../../src/acp/client";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { useModels } from "../../src/hooks/useModels";
|
||||
|
||||
interface ModelSelectorPopoverProps {
|
||||
/** ACPClient instance for model state management */
|
||||
client: ACPClient;
|
||||
/** Callback when a model is selected */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model selector popover component.
|
||||
* Reference: Zed's AcpModelSelectorPopover that shows current model and allows switching.
|
||||
*/
|
||||
export function ModelSelectorPopover({
|
||||
client,
|
||||
onModelSelect,
|
||||
}: ModelSelectorPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
supportsModelSelection,
|
||||
availableModels,
|
||||
currentModel,
|
||||
setModel,
|
||||
isLoading,
|
||||
} = useModels(client);
|
||||
|
||||
// Always show the button — disable dropdown when no models available
|
||||
const hasModels = supportsModelSelection && availableModels.length > 0;
|
||||
|
||||
// Check if we're on a mobile device (touch-only)
|
||||
const isMobile = typeof window !== "undefined" &&
|
||||
window.matchMedia("(hover: none) and (pointer: coarse)").matches;
|
||||
|
||||
const handleSelect = async (model: ModelInfo) => {
|
||||
try {
|
||||
await setModel(model.modelId);
|
||||
onModelSelect?.(model.modelId);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("[ModelSelector] Failed to set model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={hasModels ? setOpen : undefined}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
disabled={!hasModels || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
<span className="max-w-32 truncate">
|
||||
{currentModel?.name ?? "Select Model"}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="end">
|
||||
<ModelSelectorPicker
|
||||
models={availableModels}
|
||||
currentModelId={currentModel?.modelId ?? null}
|
||||
onSelect={handleSelect}
|
||||
showSearch={!isMobile}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ModelSelectorPopover } from "./ModelSelectorPopover";
|
||||
export { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
|
||||
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal file
47
packages/remote-control-server/web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Separator } from "./separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
|
||||
61
packages/remote-control-server/web/components/ui/button.tsx
Normal file
61
packages/remote-control-server/web/components/ui/button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
93
packages/remote-control-server/web/components/ui/card.tsx
Normal file
93
packages/remote-control-server/web/components/ui/card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
183
packages/remote-control-server/web/components/ui/command.tsx
Normal file
183
packages/remote-control-server/web/components/ui/command.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ConnectionState } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
// Shared styles for connection state dots
|
||||
const connectionDotStyles: Record<ConnectionState, string> = {
|
||||
disconnected: "bg-gray-400",
|
||||
connecting: "bg-yellow-400 animate-pulse",
|
||||
connected: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]",
|
||||
error: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]",
|
||||
};
|
||||
|
||||
// Shared labels for connection states
|
||||
const connectionStateLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display label for a connection state
|
||||
*/
|
||||
export function getConnectionStateLabel(state: ConnectionState): string {
|
||||
return connectionStateLabels[state];
|
||||
}
|
||||
|
||||
/**
|
||||
* A small dot indicator for connection state
|
||||
* Used in status bars and headers
|
||||
*/
|
||||
export function StatusDot({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn("w-2 h-2 rounded-full", connectionDotStyles[state], className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A status indicator with dot and label
|
||||
* Used in cards and detailed views
|
||||
*/
|
||||
export function StatusIndicator({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-2 text-sm font-normal", className)}>
|
||||
<StatusDot state={state} />
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete status bar section with dot, label, and optional URL
|
||||
*/
|
||||
export function ConnectionStatusBar({
|
||||
state,
|
||||
displayUrl,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
displayUrl?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<StatusDot state={state} />
|
||||
<span className="text-sm font-medium">
|
||||
{getConnectionStateLabel(state)}
|
||||
</span>
|
||||
{state === "connected" && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{displayUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
144
packages/remote-control-server/web/components/ui/dialog.tsx
Normal file
144
packages/remote-control-server/web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/index.ts
Normal file
22
packages/remote-control-server/web/components/ui/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export * from "./badge"
|
||||
export * from "./connection-status"
|
||||
export * from "./button-group"
|
||||
export * from "./button"
|
||||
export * from "./card"
|
||||
export * from "./collapsible"
|
||||
export * from "./command"
|
||||
export * from "./dialog"
|
||||
export * from "./dropdown-menu"
|
||||
export * from "./hover-card"
|
||||
export * from "./input-group"
|
||||
export * from "./input"
|
||||
export * from "./label"
|
||||
export * from "./resizable"
|
||||
export * from "./scroll-area"
|
||||
export * from "./select"
|
||||
export * from "./separator"
|
||||
export * from "./tabs"
|
||||
export * from "./textarea"
|
||||
export * from "./theme-toggle"
|
||||
export * from "./tooltip"
|
||||
export * from "./popover"
|
||||
171
packages/remote-control-server/web/components/ui/input-group.tsx
Normal file
171
packages/remote-control-server/web/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Button } from "./button"
|
||||
import { Input } from "./input"
|
||||
import { Textarea } from "./textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/input.tsx
Normal file
22
packages/remote-control-server/web/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
22
packages/remote-control-server/web/components/ui/label.tsx
Normal file
22
packages/remote-control-server/web/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
|
||||
47
packages/remote-control-server/web/components/ui/popover.tsx
Normal file
47
packages/remote-control-server/web/components/ui/popover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
{/*
|
||||
Workaround for Radix ScrollArea bug #926:
|
||||
The Viewport's inner div uses display:table which breaks text-overflow:ellipsis.
|
||||
We override it to display:block using the [style] selector.
|
||||
See: https://github.com/radix-ui/primitives/issues/926
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div[style]]:!block"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
||||
188
packages/remote-control-server/web/components/ui/select.tsx
Normal file
188
packages/remote-control-server/web/components/ui/select.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
98
packages/remote-control-server/web/components/ui/tabs.tsx
Normal file
98
packages/remote-control-server/web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
forceMount,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
|
||||
forceMount?: true;
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
forceMount={forceMount}
|
||||
className={cn(
|
||||
"flex-1 outline-none",
|
||||
// When forceMount is used, hide inactive tabs
|
||||
forceMount && "data-[state=inactive]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme, type Theme } from "../../src/lib/theme";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
const themeOptions: { value: Theme; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
{resolvedTheme === "dark" ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{themeOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={theme === option.value ? "bg-accent" : ""}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="ml-2">{option.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
60
packages/remote-control-server/web/components/ui/tooltip.tsx
Normal file
60
packages/remote-control-server/web/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
Reference in New Issue
Block a user