feat: 支持 acp-link 包进行 acp 通用的 remote-control (#292)

* fix: 修复超时问题

* feat: 添加 acp-link 代码

* refactor: 样式重构完成

* feat: RCS 添加 ACP 后端支持

- 新增 ACP WebSocket handler (agent 注册、EventBus 订阅)
- 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发)
- 新增 SSE event stream 供外部消费者订阅 channel group 事件
- ACP REST 接口无鉴权 (agents、channel-groups)
- WebSocket 端点保留 token 鉴权
- SPA 路由 /acp/ 指向 acp.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加 ACP 专属前端界面

- 新增 /acp/ SPA 页面 (agent 列表 + 实时交互)
- Agent 列表按 channel group 分组,显示在线状态
- 通过 RCS WebSocket relay 与 agent 通信
- Vite multi-page 构建 (index.html + acp.html)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 支持 RCS relay 双向通信

- rcs-upstream 新增 messageHandler 转发非控制消息
- server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息
- newSession/loadSession 补充 mcpServers 参数
- 连接成功后显示 ACP Dashboard URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 移除 FileExplorer 及文件操作相关代码

- 删除 FileExplorer 组件
- ACPMain 移除 Files tab,仅保留 Chat 和 History
- client.ts 移除 listDir/readFile/onFileChanges 等方法
- types.ts 移除 FileItem/FileContent/FileChange 等类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复类型问题

* feat: RCS 后端统一 ACP/Bridge 注册逻辑

- store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑
- store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim
- environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id
- session: resolveOwnedWebSessionId 支持无 owner session 自动绑定
- acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册
- acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证
- event-bus: 增加 error 类型 payload 日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: acp-link 改 REST 注册 + WS identify 两步流程

- rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册
- rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId
- rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定
- server: 修复心跳跳过 relay 虚拟连接的 bug
- server: maxSessions 配置传入 RCS upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 前端统一 Chat 组件 + ACP 聊天界面重构

- 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu
- ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission)
- rcs-chat-adapter: 统一 bridge session SSE 适配器
- ACPClient: 增强 session 管理、permission 流程、streaming 支持
- index.css: 新增 chat 相关样式、动画、布局
- useCommands: 新增快捷命令 hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId

- 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录
- SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件
- App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID
- Dashboard: 统一 session 列表导航,ACP 显示紫色标签
- relay-client: 改用 UUID 认证替代 API token
- EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code)
- index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* build: 更新构建及测试修复

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-18 17:59:29 +08:00
committed by GitHub
parent 29cc74a170
commit 34154ee3f5
142 changed files with 17847 additions and 5577 deletions

View File

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

View File

@@ -0,0 +1,241 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import type { ACPClient } from "../src/acp/client";
import type { AgentSessionInfo } from "../src/acp/types";
import { ChatInterface } from "./ChatInterface";
import { cn } from "../src/lib/utils";
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
interface ACPMainProps {
client: ACPClient;
}
/**
* Main container — Anthropic sidebar + chat layout.
* Sidebar: sectioned by recency, orange active state, warm raised bg.
*/
export function ACPMain({ client }: ACPMainProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Handle session selection
const handleSelectSession = useCallback(async (session: AgentSessionInfo) => {
try {
if (client.supportsLoadSession) {
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
} else if (client.supportsResumeSession) {
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
} else {
throw new Error("Loading or resuming sessions is not supported by this agent.");
}
} catch (error) {
console.error("Failed to load/resume session:", error);
}
}, [client]);
return (
<div className="flex h-full w-full">
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
<div
className={cn(
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200 flex-shrink-0",
sidebarCollapsed ? "w-12" : "w-64",
)}
>
{/* 头部 */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
{!sidebarCollapsed && (
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider px-1"></span>
)}
<div className="flex items-center gap-0.5">
{!sidebarCollapsed && (
<button
type="button"
onClick={() => {
// ChatInterface handles new session internally
}}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-brand hover:bg-brand/10 transition-colors"
title="新会话"
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
>
{sidebarCollapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* 会话列表 */}
{!sidebarCollapsed && (
<div className="flex-1 overflow-y-auto">
<SidebarSessionList client={client} onSelectSession={handleSelectSession} />
</div>
)}
</div>
{/* 聊天区域 */}
<div className="flex-1 flex flex-col min-w-0">
<ChatInterface client={client} />
</div>
</div>
);
}
// =============================================================================
// 侧边栏会话列表 — Anthropic 分段式(今天/昨天/更早)
// =============================================================================
function SidebarSessionList({
client,
onSelectSession,
}: {
client: ACPClient;
onSelectSession: (session: AgentSessionInfo) => void;
}) {
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [activeId, setActiveId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
if (!client.supportsSessionList) {
setLoading(false);
return;
}
setLoading(true);
try {
const response = await client.listSessions();
setSessions(response.sessions);
} catch (err) {
console.warn("[SidebarSessionList] Failed to load:", err);
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => {
if (client.getState() === "connected" && client.supportsSessionList) {
loadSessions();
}
}, [client, loadSessions]);
useEffect(() => {
const handler = (state: string) => {
if (state === "connected") {
setTimeout(loadSessions, 200);
}
};
client.setConnectionStateHandler(handler);
return () => client.removeConnectionStateHandler(handler);
}, [client, loadSessions]);
useEffect(() => {
const interval = setInterval(loadSessions, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
const sorted = useMemo(
() =>
[...sessions].sort((a, b) => {
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return dateB - dateA;
}),
[sessions],
);
if (loading && sessions.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-muted font-display">...</span>
</div>
);
}
if (sessions.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<span className="text-xs text-text-muted font-display"></span>
</div>
);
}
// 按日期分组
const groups = groupByRecency(sorted);
return (
<nav className="py-2" aria-label="历史会话">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5">
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
{group.label}
</span>
</div>
{group.sessions.map((session) => (
<button
key={session.sessionId}
type="button"
onClick={() => {
setActiveId(session.sessionId);
onSelectSession(session);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
session.sessionId === activeId
? "bg-brand/10 text-text-primary border-l-2 border-l-brand"
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-2 border-l-transparent",
)}
title={session.title || session.sessionId}
>
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-text-muted" />
<span className="text-sm font-display truncate">
{session.title && session.title.trim() ? session.title : "新会话"}
</span>
</button>
))}
</div>
))}
</nav>
);
}
// =============================================================================
// 按日期分组:今天 / 昨天 / 更早
// =============================================================================
interface SessionGroup {
label: string;
sessions: AgentSessionInfo[];
}
function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const groups: SessionGroup[] = [
{ label: "今天", sessions: [] },
{ label: "昨天", sessions: [] },
{ label: "更早", sessions: [] },
];
for (const session of sessions) {
const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0);
if (date >= today) {
groups[0].sessions.push(session);
} else if (date >= yesterday) {
groups[1].sessions.push(session);
} else {
groups[2].sessions.push(session);
}
}
return groups.filter((g) => g.sessions.length > 0);
}

View File

@@ -0,0 +1,717 @@
import { useState, useEffect, useCallback, useRef } from "react";
import imageCompression from "browser-image-compression";
import type { ACPClient } from "../src/acp/client";
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission } from "../src/lib/types";
import { ChatView } from "./chat/ChatView";
import { ChatInput } from "./chat/ChatInput";
import { PermissionPanel } from "./chat/PermissionPanel";
import { ModelSelectorPopover } from "./model-selector";
import { useCommands } from "../src/hooks/useCommands";
// Image compression options
// Claude API has a 5MB limit, so we target 2MB to be safe
const IMAGE_COMPRESSION_OPTIONS = {
maxSizeMB: 2, // Max output size in MB
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
useWebWorker: true, // Non-blocking compression
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
};
// Convert data URL to Blob without using fetch()
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
function dataUrlToBlob(dataUrl: string): Blob {
// Parse the data URL: data:[<mediatype>][;base64],<data>
const commaIndex = dataUrl.indexOf(",");
if (commaIndex === -1) {
throw new Error("Invalid data URL: missing comma separator");
}
const header = dataUrl.slice(0, commaIndex);
const base64Data = dataUrl.slice(commaIndex + 1);
// Extract MIME type from header (e.g., "data:image/png;base64")
const mimeMatch = header.match(/^data:([^;,]+)/);
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
// Decode base64 to binary
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
}
import { Plus } from "lucide-react";
import { Button } from "./ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./ui/tooltip";
// =============================================================================
// Type Definitions - imported from shared types module
// =============================================================================
interface ChatInterfaceProps {
client: ACPClient;
}
// =============================================================================
// Helper Functions
// =============================================================================
// Map ACP status string to our status type
function mapToolStatus(status: string): ToolCallStatus {
if (status === "completed") return "complete";
if (status === "failed") return "error";
return "running";
}
// Find tool call index in entries (search from end, like Zed)
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
return i;
}
}
return -1;
}
// =============================================================================
// ChatInterface Component
// =============================================================================
export function ChatInterface({ client }: ChatInterfaceProps) {
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
const [entries, setEntries] = useState<ThreadEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [sessionReady, setSessionReady] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Reference: Zed's supports_images() checks prompt_capabilities.image
const [supportsImages, setSupportsImages] = useState(false);
const { commands: availableCommands } = useCommands(client);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const resetThreadState = useCallback(() => {
setEntries([]);
setIsLoading(false);
setSessionReady(false);
}, []);
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
const shouldResetEntries = options?.resetEntries ?? true;
if (shouldResetEntries) {
setEntries([]);
setIsLoading(false);
}
setActiveSessionId(sessionId);
setSessionReady(true);
setSupportsImages(client.supportsImages);
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
}, [client]);
// =============================================================================
// Permission Request Handler
// =============================================================================
const handlePermissionRequest = useCallback((request: PermissionRequestPayload) => {
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
return;
}
console.log("[ChatInterface] Permission request:", request);
setEntries((prev) => {
// Find matching tool call (search from end)
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
if (toolCallIndex >= 0) {
// Update existing tool call's status
return prev.map((entry, index) => {
if (index !== toolCallIndex) return entry;
if (entry.type !== "tool_call") return entry;
if (entry.toolCall.status !== "running") return entry;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: "waiting_for_confirmation" as const,
permissionRequest: {
requestId: request.requestId,
options: request.options,
},
},
};
});
} else {
// No matching tool call - create standalone permission request as new entry
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
const permissionToolCall: ToolCallEntry = {
type: "tool_call",
toolCall: {
id: request.toolCall.toolCallId,
title: request.toolCall.title || "Permission Request",
status: "waiting_for_confirmation",
permissionRequest: {
requestId: request.requestId,
options: request.options,
},
isStandalonePermission: true,
},
};
return [...prev, permissionToolCall];
}
});
}, []);
// =============================================================================
// Session Update Handler (Zed-style: check last entry type)
// =============================================================================
const handleSessionUpdate = useCallback((sessionId: string, update: SessionUpdate) => {
if (activeSessionIdRef.current && sessionId !== activeSessionIdRef.current) {
return;
}
// Handle agent message chunk
if (update.sessionUpdate === "agent_message_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is AssistantMessage, append to it
if (lastEntry?.type === "assistant_message") {
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
// If last chunk is same type (message), append text
if (lastChunk?.type === "message") {
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [
...lastEntry.chunks.slice(0, -1),
{ type: "message", text: lastChunk.text + text },
],
},
];
}
// Otherwise add new message chunk
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [...lastEntry.chunks, { type: "message", text }],
},
];
}
// Create new AssistantMessage entry
const newEntry: AssistantMessageEntry = {
type: "assistant_message",
id: `assistant-${Date.now()}`,
chunks: [{ type: "message", text }],
};
return [...prev, newEntry];
});
}
// Handle agent thought chunk (NEW - was missing before)
else if (update.sessionUpdate === "agent_thought_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is AssistantMessage, append to it
if (lastEntry?.type === "assistant_message") {
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
// If last chunk is same type (thought), append text
if (lastChunk?.type === "thought") {
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [
...lastEntry.chunks.slice(0, -1),
{ type: "thought", text: lastChunk.text + text },
],
},
];
}
// Otherwise add new thought chunk
return [
...prev.slice(0, -1),
{
...lastEntry,
chunks: [...lastEntry.chunks, { type: "thought", text }],
},
];
}
// Create new AssistantMessage entry with thought
const newEntry: AssistantMessageEntry = {
type: "assistant_message",
id: `assistant-${Date.now()}`,
chunks: [{ type: "thought", text }],
};
return [...prev, newEntry];
});
}
// Handle user message chunk (NEW - was missing before)
else if (update.sessionUpdate === "user_message_chunk") {
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
if (!text) return;
setEntries((prev) => {
const lastEntry = prev[prev.length - 1];
// If last entry is UserMessage, append to it
if (lastEntry?.type === "user_message") {
return [
...prev.slice(0, -1),
{
...lastEntry,
content: lastEntry.content + text,
},
];
}
// Create new UserMessage entry
const newEntry: UserMessageEntry = {
type: "user_message",
id: `user-${Date.now()}`,
content: text,
};
return [...prev, newEntry];
});
}
// Handle tool call (UPSERT - update if exists, create if not)
else if (update.sessionUpdate === "tool_call") {
const toolCallData: ToolCallData = {
id: update.toolCallId,
title: update.title,
status: mapToolStatus(update.status),
content: update.content,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
};
setEntries((prev) => {
// UPSERT: Check if tool call already exists
const existingIndex = findToolCallIndex(prev, update.toolCallId);
if (existingIndex >= 0) {
// UPDATE existing tool call
return prev.map((entry, index) => {
if (index !== existingIndex) return entry;
if (entry.type !== "tool_call") return entry;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
...toolCallData,
},
};
});
}
// CREATE new tool call entry
const newEntry: ToolCallEntry = {
type: "tool_call",
toolCall: toolCallData,
};
return [...prev, newEntry];
});
}
// Handle tool call update (partial update)
else if (update.sessionUpdate === "tool_call_update") {
setEntries((prev) => {
const existingIndex = findToolCallIndex(prev, update.toolCallId);
if (existingIndex < 0) {
// Tool call not found - create a failed tool call entry (like Zed)
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
const failedEntry: ToolCallEntry = {
type: "tool_call",
toolCall: {
id: update.toolCallId,
title: update.title || "Tool call not found",
status: "error",
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
},
};
return [...prev, failedEntry];
}
return prev.map((entry, index) => {
if (index !== existingIndex) return entry;
if (entry.type !== "tool_call") return entry;
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
const mergedContent = update.content
? [...(entry.toolCall.content || []), ...update.content]
: entry.toolCall.content;
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: newStatus,
...(update.title && { title: update.title }),
content: mergedContent,
...(update.rawInput && { rawInput: update.rawInput }),
...(update.rawOutput && { rawOutput: update.rawOutput }),
},
};
});
});
}
}, []);
// =============================================================================
// Setup Effect
// =============================================================================
useEffect(() => {
client.setSessionCreatedHandler((sessionId) => {
console.log("[ChatInterface] Session created:", sessionId);
activateSession(sessionId);
});
client.setSessionLoadedHandler((sessionId) => {
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
activateSession(sessionId, { resetEntries: false });
});
client.setSessionSwitchingHandler((sessionId) => {
console.log("[ChatInterface] Switching to session:", sessionId);
setActiveSessionId(sessionId);
resetThreadState();
});
client.setSessionUpdateHandler((sessionId: string, update: SessionUpdate) => {
handleSessionUpdate(sessionId, update);
});
client.setPromptCompleteHandler((stopReason) => {
console.log("[ChatInterface] Prompt complete:", stopReason);
// Always set isLoading=false when prompt completes
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
setIsLoading(false);
});
client.setPermissionRequestHandler(handlePermissionRequest);
client.setErrorMessageHandler((msg) => {
console.error("[ChatInterface] Agent error:", msg);
setErrorMessage(msg);
// Clear any existing timer
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
// Auto-clear after 5 seconds
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
});
// Create session
client.createSession();
return () => {
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
client.setSessionCreatedHandler(() => {});
client.setSessionLoadedHandler(() => {});
client.setSessionSwitchingHandler(null);
client.setSessionUpdateHandler(() => {});
client.setPromptCompleteHandler(() => {});
client.setPermissionRequestHandler(() => {});
client.setErrorMessageHandler(() => {});
};
}, [activateSession, client, handlePermissionRequest, handleSessionUpdate, resetThreadState]);
// =============================================================================
// User Actions
// =============================================================================
// Reference: Zed's ConnectionView.reset() + set_server_state() + _external_thread()
// Creates a new session by clearing current state and calling new_session
// This is the core of Zed's NewThread action
const handleNewSession = useCallback(() => {
console.log("[ChatInterface] Creating new session...");
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
// Cancel any ongoing request before creating new session
if (isLoading) {
client.cancel();
}
// 1. Clear all entries (like Zed's set_server_state which creates new view)
resetThreadState();
setActiveSessionId(null);
// 3. Create new session (like Zed's initial_state -> connection.new_session())
// The session_created handler will set sessionReady=true when ready
client.createSession();
}, [client, isLoading, resetThreadState]);
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
// 2. Send cancel notification to agent
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
const handleCancel = () => {
console.log("[ChatInterface] Cancel requested");
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
setEntries((prev) =>
prev.map((entry) => {
if (entry.type !== "tool_call") return entry;
// Check if status should be canceled (matches Zed's logic)
const shouldCancel =
entry.toolCall.status === "running" ||
entry.toolCall.status === "waiting_for_confirmation";
if (!shouldCancel) return entry;
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: "canceled" as ToolCallStatus,
permissionRequest: undefined, // Clear any pending permission request
},
};
}),
);
// Send cancel notification to server (which forwards to agent)
client.cancel();
// Note: Do NOT set isLoading=false here!
// Wait for prompt_complete with stopReason="cancelled" from the agent
};
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
client.respondToPermission(requestId, optionId);
// Determine new status based on option kind
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
// Update the tool call status in entries
setEntries((prev) =>
prev.map((entry) => {
if (entry.type !== "tool_call") return entry;
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
// For standalone permission requests, mark as complete immediately when approved
// For regular tool calls, mark as running (agent will update to complete later)
let newStatus: ToolCallStatus;
if (isRejected) {
newStatus = "rejected";
} else if (entry.toolCall.isStandalonePermission) {
newStatus = "complete";
} else {
newStatus = "running";
}
return {
type: "tool_call",
toolCall: {
...entry.toolCall,
status: newStatus,
permissionRequest: undefined,
isStandalonePermission: undefined,
},
};
}),
);
}, [client]);
// =============================================================================
// Render
// =============================================================================
// Collect pending permissions from tool call entries
const pendingPermissions: PendingPermission[] = entries
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
.map((e) => ({
requestId: e.toolCall.permissionRequest!.requestId,
toolName: e.toolCall.title,
toolInput: e.toolCall.rawInput || {},
description: e.toolCall.title,
options: e.toolCall.permissionRequest!.options,
}));
// Handle permission respond for unified PermissionPanel
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
const kind = approved ? "accept_once" : "reject_once";
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
}, [handlePermissionResponse]);
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
const text = message.text.trim();
const images = message.images || [];
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
const contentBlocks: ContentBlock[] = [];
if (text) {
contentBlocks.push({ type: "text", text });
}
// Convert images to ContentBlock
const userImages: UserMessageImage[] = [];
for (const img of images) {
try {
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
let blob: Blob;
if (dataUrl.startsWith("data:")) {
blob = dataUrlToBlob(dataUrl);
} else {
const response = await fetch(dataUrl);
blob = await response.blob();
}
let finalBlob: Blob = blob;
let finalMimeType = img.mimeType;
if (blob.size > 2 * 1024 * 1024) {
const imageFile = new File([blob], "image.jpg", { type: blob.type });
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
finalMimeType = "image/jpeg";
}
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const commaIndex = result.indexOf(",");
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
};
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
reader.readAsDataURL(finalBlob);
});
const imageContent: ImageContent = {
type: "image",
mimeType: finalMimeType,
data: base64Data,
};
contentBlocks.push(imageContent);
userImages.push({
mimeType: finalMimeType,
data: base64Data,
});
} catch (error) {
console.error("[ChatInterface] Failed to process image:", error);
}
}
if (contentBlocks.length === 0) return;
// Add user message entry
const userEntry: UserMessageEntry = {
type: "user_message",
id: `user-${Date.now()}`,
content: text,
images: userImages.length > 0 ? userImages : undefined,
};
setEntries((prev) => [...prev, userEntry]);
setIsLoading(true);
try {
await client.sendPrompt(contentBlocks);
} catch (error) {
console.error("[ChatInterface] Failed to send prompt:", error);
setIsLoading(false);
}
}, [isLoading, sessionReady, client]);
return (
<div className="flex flex-col h-full">
{/* Chat messages — unified ChatView */}
<ChatView
entries={entries}
isLoading={isLoading && !sessionReady ? false : isLoading}
onPermissionRespond={(requestId, optionId, optionKind) => {
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
}}
emptyTitle={sessionReady ? "开始对话" : undefined}
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
/>
{/* Permission panel — fixed above input */}
<PermissionPanel
requests={pendingPermissions}
onRespond={handlePermissionPanelRespond}
/>
{/* Error banner */}
{errorMessage && (
<div className="mx-auto max-w-3xl w-full px-4 pb-1">
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
<span>{errorMessage}</span>
<button
type="button"
onClick={() => setErrorMessage(null)}
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
>
{"\u00D7"}
</button>
</div>
</div>
)}
{/* Model selector + New thread + ChatInput */}
<div className="flex-shrink-0">
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 pb-1 flex items-center justify-between">
<ModelSelectorPopover client={client} />
{entries.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-text-muted hover:text-brand font-display gap-1"
onClick={handleNewSession}
>
<Plus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>New Thread</TooltipContent>
</Tooltip>
)}
</div>
<ChatInput
onSubmit={handleChatInputSubmit}
isLoading={isLoading}
onInterrupt={handleCancel}
disabled={!sessionReady}
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
supportsImages={supportsImages}
commands={availableCommands.length > 0 ? availableCommands : undefined}
/>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
"use client";
import { Button } from "../ui/button";
import { cn } from "../../src/lib/utils";
import { ArrowDownIcon, UserIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("mx-auto flex max-w-3xl flex-col gap-4 p-4 min-w-0", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
/**
* Button to scroll to the bottom of the conversation.
* Can be used standalone or within ConversationScrollButtons container.
* When used standalone, it handles its own visibility based on isAtBottom.
* When used in ConversationScrollButtons, the container manages visibility.
*/
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
<Button
className={cn(
"rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
title="Scroll to bottom"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
);
};
/**
* Data attribute used to mark the last user message element.
* ChatInterface adds this attribute to the last user message for scroll targeting.
*/
export const LAST_USER_MESSAGE_ATTR = "data-last-user-message";
export type ConversationScrollToLastUserMessageButtonProps = ComponentProps<typeof Button>;
/**
* Button to scroll to the last user message in the conversation.
* Reference: Issue #3 - Provide a feature to locate the last human message
*/
export const ConversationScrollToLastUserMessageButton = ({
className,
...props
}: ConversationScrollToLastUserMessageButtonProps) => {
const handleScrollToLastUserMessage = useCallback(() => {
// Find the last user message element by data attribute
const lastUserMessage = document.querySelector(`[${LAST_USER_MESSAGE_ATTR}="true"]`);
if (lastUserMessage) {
lastUserMessage.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<Button
className={cn(
"rounded-full",
className
)}
onClick={handleScrollToLastUserMessage}
size="icon"
type="button"
variant="outline"
title="Scroll to last user message"
{...props}
>
<UserIcon className="size-4" />
</Button>
);
};
export type ConversationScrollButtonsProps = ComponentProps<"div"> & {
/** Whether there are user messages to scroll to */
hasUserMessages?: boolean;
};
/**
* Container for scroll navigation buttons.
* Renders scroll-to-last-user-message and scroll-to-bottom buttons side by side.
* Reference: Issue #3 - Provide a feature to locate the last human message
*/
export const ConversationScrollButtons = ({
className,
hasUserMessages = false,
...props
}: ConversationScrollButtonsProps) => {
const { isAtBottom } = useStickToBottomContext();
if (isAtBottom) return null;
return (
<div
className={cn(
"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2",
className
)}
{...props}
>
{hasUserMessages && <ConversationScrollToLastUserMessageButton />}
<ConversationScrollButton />
</div>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
"use client";
import { cn } from "../../src/lib/utils";
import { Button } from "../ui/button";
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
import type { PermissionOption } from "../../src/acp/types";
// Get button variant based on option kind
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
switch (kind) {
case "allow_once":
case "allow_always":
return "default";
case "reject_once":
case "reject_always":
return "destructive";
default:
return "outline";
}
}
// Get button icon based on option kind
function getButtonIcon(kind: PermissionOption["kind"]) {
switch (kind) {
case "allow_once":
case "allow_always":
return <CheckIcon className="size-4" />;
case "reject_once":
case "reject_always":
return <XIcon className="size-4" />;
default:
return null;
}
}
// Permission buttons component - used inside Tool component
export interface ToolPermissionButtonsProps {
requestId: string;
options: PermissionOption[];
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
className?: string;
}
export function ToolPermissionButtons({ requestId, options, onRespond, className }: ToolPermissionButtonsProps) {
const handleOptionClick = (option: PermissionOption) => {
onRespond(requestId, option.optionId, option.kind);
};
return (
<div className={cn("p-3 border-t border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50", className)}>
<div className="flex items-center gap-2 mb-2">
<ShieldAlertIcon className="size-4 text-warning-text" />
<span className="text-xs font-medium text-warning-text">
Permission Required
</span>
</div>
<div className="flex flex-wrap gap-2">
{options.map((option) => (
<Button
key={option.optionId}
variant={getButtonVariant(option.kind)}
size="sm"
onClick={() => handleOptionClick(option)}
className="gap-1.5"
>
{getButtonIcon(option.kind)}
{option.name}
</Button>
))}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import { cn } from "../../src/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: ReactNode;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "../../src/lib/utils";
import { motion } from "motion/react";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View File

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

View File

@@ -0,0 +1,334 @@
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from "react";
import { cn } from "../../src/lib/utils";
import { Send, Square, Paperclip, Slash } from "lucide-react";
import type { ChatInputMessage, UserMessageImage } from "../../src/lib/types";
import type { AvailableCommand } from "../../src/acp/types";
import { CommandMenu } from "./CommandMenu";
import imageCompression from "browser-image-compression";
// 图片压缩配置
const IMAGE_COMPRESSION_OPTIONS = {
maxSizeMB: 2,
maxWidthOrHeight: 2048,
useWebWorker: true,
fileType: "image/jpeg" as const,
};
// =============================================================================
// Anthropic 风格聊天输入框 — 底部居中浮动卡片,橙色焦点环
// =============================================================================
interface ChatInputProps {
onSubmit: (message: ChatInputMessage) => void;
isLoading?: boolean;
onInterrupt?: () => void;
disabled?: boolean;
placeholder?: string;
/** 是否支持图片上传 */
supportsImages?: boolean;
/** Agent 提供的可用 slash 命令 */
commands?: AvailableCommand[];
className?: string;
}
export function ChatInput({
onSubmit,
isLoading = false,
onInterrupt,
disabled = false,
placeholder = "给 Claude 发送消息…",
supportsImages = false,
commands,
className,
}: ChatInputProps) {
const [text, setText] = useState("");
const [images, setImages] = useState<UserMessageImage[]>([]);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [commandFilter, setCommandFilter] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = useCallback(() => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || disabled) return;
onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined });
setText("");
setImages([]);
setShowCommandMenu(false);
setCommandFilter("");
// 重置 textarea 高度
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [text, images, disabled, onSubmit]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showCommandMenu) {
if (e.key === "Escape") {
e.preventDefault();
setShowCommandMenu(false);
return;
}
// Let cmdk handle arrow keys and Enter for selection
// Tab also closes the menu
if (e.key === "Tab") {
e.preventDefault();
setShowCommandMenu(false);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (isLoading) {
onInterrupt?.();
} else {
handleSubmit();
}
}
},
[handleSubmit, isLoading, onInterrupt, showCommandMenu],
);
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setText(value);
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
if (value.startsWith("/") && commands && commands.length > 0) {
setShowCommandMenu(true);
setCommandFilter(value.slice(1).split(/\s/)[0] || "");
} else if (showCommandMenu) {
setShowCommandMenu(false);
setCommandFilter("");
}
// 自动调整高度
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [commands, showCommandMenu]);
// 粘贴图片
const handlePaste = useCallback(async (e: ClipboardEvent) => {
if (!supportsImages) return;
const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/"));
if (files.length === 0) return;
e.preventDefault();
const newImages = await processImageFiles(files);
setImages((prev) => [...prev, ...newImages]);
}, [supportsImages]);
// 选择文件
const handleFileSelect = useCallback(async () => {
if (!fileInputRef.current) return;
const files = fileInputRef.current.files;
if (!files || files.length === 0) return;
const newImages = await processImageFiles(Array.from(files));
setImages((prev) => [...prev, ...newImages]);
// 清空 input 以便重复选择
fileInputRef.current.value = "";
}, []);
const removeImage = useCallback((index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleCommandSelect = useCallback((command: AvailableCommand) => {
setText(`/${command.name} `);
setShowCommandMenu(false);
setCommandFilter("");
textareaRef.current?.focus();
}, []);
const toggleCommandMenu = useCallback(() => {
if (showCommandMenu) {
setShowCommandMenu(false);
setCommandFilter("");
} else {
if (!text.startsWith("/")) {
setText("/" + text);
}
setShowCommandMenu(true);
setCommandFilter(text.startsWith("/") ? text.slice(1).split(/\s/)[0] || "" : "");
textareaRef.current?.focus();
}
}, [showCommandMenu, text]);
const canSend = (text.trim() || images.length > 0) && !disabled;
return (
<div className={cn("w-full max-w-3xl mx-auto px-3 sm:px-4 pb-4 pt-2", className)}>
<div className="relative">
{/* Slash command menu — floating above input */}
{showCommandMenu && commands && commands.length > 0 && (
<CommandMenu
commands={commands}
filter={commandFilter}
onSelect={handleCommandSelect}
onClose={() => {
setShowCommandMenu(false);
setCommandFilter("");
}}
className="absolute bottom-full left-0 right-0 mb-1 z-50"
/>
)}
<div className={cn(
"rounded-xl border border-border bg-surface-2 overflow-hidden",
"focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all",
)}>
{/* 图片预览 */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{images.map((img, i) => (
<div key={i} className="relative group">
<img
src={`data:${img.mimeType};base64,${img.data}`}
alt="附件"
className="h-14 w-14 object-cover rounded-lg border border-border"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
{"\u00D7"}
</button>
</div>
))}
</div>
)}
{/* 输入区域 — Anthropic 单行紧凑布局 */}
<div className="flex items-end gap-2 px-3 py-2.5">
{/* 左侧附件按钮 */}
{supportsImages && (
<>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
disabled={disabled}
>
<Paperclip className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
</>
)}
{/* Slash 命令按钮 */}
{commands && commands.length > 0 && (
<button
type="button"
onClick={toggleCommandMenu}
className={cn(
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors",
showCommandMenu
? "bg-brand/15 text-brand"
: "text-text-muted hover:text-text-secondary hover:bg-surface-1/50",
)}
disabled={disabled}
title="命令列表"
>
<Slash className="h-4 w-4" />
</button>
)}
{/* Textarea — Poppins font */}
<textarea
ref={textareaRef}
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none border-none bg-transparent outline-none",
"text-sm text-text-primary placeholder:text-text-muted font-display",
"max-h-[200px] min-h-[24px] leading-normal",
)}
/>
{/* 右侧发送/取消按钮 */}
<button
type="button"
onClick={isLoading ? onInterrupt : handleSubmit}
disabled={!isLoading && !canSend}
className={cn(
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all",
isLoading
? "bg-text-primary text-surface-2 hover:bg-text-secondary"
: canSend
? "bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]"
: "bg-surface-1 text-text-muted",
)}
>
{isLoading ? (
<Square className="h-3.5 w-3.5" fill="currentColor" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>{/* end relative */}
{/* 提示文本 */}
<div className="text-center mt-1.5">
<span className="text-[11px] text-text-muted font-display">
Enter Shift+Enter
</span>
</div>
</div>
);
}
// =============================================================================
// 图片处理工具
// =============================================================================
async function processImageFiles(files: File[]): Promise<UserMessageImage[]> {
const results: UserMessageImage[] = [];
for (const file of files) {
try {
let blob: Blob = file;
let mimeType = file.type;
if (file.size > 2 * 1024 * 1024) {
const compressed = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS);
blob = compressed;
mimeType = "image/jpeg";
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(",");
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
};
reader.onerror = () => reject(new Error("FileReader error"));
reader.readAsDataURL(blob);
});
results.push({ mimeType, data: base64 });
} catch (err) {
console.error("Failed to process image:", err);
}
}
return results;
}

View File

@@ -0,0 +1,166 @@
import type { ThreadEntry, ToolCallEntry } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { UserBubble, AssistantBubble } from "./MessageBubble";
import { ToolCallGroup } from "./ToolCallGroup";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
// =============================================================================
// 统一聊天视图 — Anthropic 编辑式排版
// 无气泡间距,用垂直 rhythm 区分消息块
// =============================================================================
interface ChatViewProps {
entries: ThreadEntry[];
isLoading?: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
emptyTitle?: string;
emptyDescription?: string;
}
export function ChatView({
entries,
isLoading = false,
onPermissionRespond,
emptyTitle = "开始对话",
emptyDescription = "输入消息开始聊天",
}: ChatViewProps) {
// 将相邻的 ToolCallEntry 合并为一组
const grouped = groupToolCalls(entries);
const hasMessages = entries.length > 0;
// 检查是否正在加载(最后一个条目是用户消息)
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === "user_message";
return (
<Conversation className="flex-1">
<ConversationContent>
{!hasMessages ? (
<ConversationEmptyState
title={emptyTitle}
description={emptyDescription}
/>
) : (
<>
{grouped.map((item, i) => {
if (item.type === "single") {
return (
<div key={`entry-${i}`} className={cn(entrySpacing(entries, i))}>
<EntryRenderer entry={item.entry} isLoading={isLoading} onPermissionRespond={onPermissionRespond} />
</div>
);
}
// 工具调用组 — 紧贴在助手消息下方
return (
<div key={`group-${i}`} className="-mt-2">
<ToolCallGroup entries={item.entries} onPermissionRespond={onPermissionRespond} />
</div>
);
})}
{/* 思考指示器 — Anthropic 打字动画 */}
{showThinking && (
<div className="flex gap-3 items-start">
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
</svg>
</div>
<div className="flex items-center gap-1 pt-1">
<span className="chat-typing-indicator" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</div>
</div>
)}
</>
)}
<ConversationScrollButtons hasUserMessages={entries.some((e) => e.type === "user_message")} />
</ConversationContent>
</Conversation>
);
}
// =============================================================================
// 间距逻辑 — 用户消息前后间距大,工具调用紧贴
// =============================================================================
function entrySpacing(entries: ThreadEntry[], index: number): string {
const entry = entries[index];
// 用户消息前面多留白
if (entry?.type === "user_message") {
return "pt-6 pb-2";
}
// 助手消息后面多留白(除非紧跟工具调用)
if (entry?.type === "assistant_message") {
const next = entries[index + 1];
if (next?.type === "tool_call") {
return "pt-2 pb-1";
}
return "pt-2 pb-4";
}
return "py-1";
}
// =============================================================================
// 单条目渲染器
// =============================================================================
function EntryRenderer({
entry,
isLoading,
onPermissionRespond,
}: {
entry: ThreadEntry;
isLoading: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}) {
switch (entry.type) {
case "user_message":
return <UserBubble entry={entry} />;
case "assistant_message":
return <AssistantBubble entry={entry} isStreaming={isLoading} />;
case "tool_call":
return (
<ToolCallGroup
entries={[entry as ToolCallEntry]}
onPermissionRespond={onPermissionRespond}
/>
);
default:
return null;
}
}
// =============================================================================
// 工具调用分组逻辑
// =============================================================================
type GroupedItem =
| { type: "single"; entry: ThreadEntry }
| { type: "tool_group"; entries: ToolCallEntry[] };
function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
const result: GroupedItem[] = [];
let currentToolGroup: ToolCallEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length === 1) {
result.push({ type: "single", entry: currentToolGroup[0] });
} else if (currentToolGroup.length > 1) {
result.push({ type: "tool_group", entries: currentToolGroup });
}
currentToolGroup = [];
};
for (const entry of entries) {
if (entry.type === "tool_call") {
currentToolGroup.push(entry);
} else {
flushToolGroup();
result.push({ type: "single", entry });
}
}
flushToolGroup();
return result;
}

View File

@@ -0,0 +1,113 @@
import { useMemo, useRef, useEffect } from "react";
import {
Command,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "../ui/command";
import { cn } from "../../src/lib/utils";
import type { AvailableCommand } from "../../src/acp/types";
// =============================================================================
// Slash command picker — floating above ChatInput
// =============================================================================
interface CommandMenuProps {
commands: AvailableCommand[];
/** Text after "/" used for filtering */
filter: string;
onSelect: (command: AvailableCommand) => void;
onClose: () => void;
className?: string;
}
/**
* Fuzzy match — checks if all query chars appear in order in the text.
* Same algorithm as ModelSelectorPicker.
*/
function fuzzyMatch(query: string, text: string): boolean {
if (!query) return true;
const lowerQuery = query.toLowerCase();
const lowerText = text.toLowerCase();
let queryIdx = 0;
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
if (lowerText[i] === lowerQuery[queryIdx]) {
queryIdx++;
}
}
return queryIdx === lowerQuery.length;
}
export function CommandMenu({
commands,
filter,
onSelect,
onClose,
className,
}: CommandMenuProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
// Filter commands by current input
const filtered = useMemo(() => {
if (!filter) return commands;
return commands.filter(
(cmd) => fuzzyMatch(filter, cmd.name) || fuzzyMatch(filter, cmd.description),
);
}, [commands, filter]);
return (
<div
ref={containerRef}
className={cn(
"rounded-xl border border-border bg-surface-2 shadow-lg",
className,
)}
>
<Command shouldFilter={false}>
<CommandList className="max-h-[320px]">
<CommandEmpty className="text-xs text-text-muted font-display py-3">
</CommandEmpty>
<CommandGroup>
{filtered.map((cmd) => (
<CommandItem
key={cmd.name}
value={cmd.name}
onSelect={() => onSelect(cmd)}
className={cn(
"flex items-center gap-2 px-3 py-2 cursor-pointer",
"rounded-lg mx-1",
"data-[selected=true]:bg-brand/8 data-[selected=true]:text-text-primary",
)}
>
<span className="text-sm font-display font-medium text-brand">
/{cmd.name}
</span>
<span className="text-xs text-text-muted truncate flex-1">
{cmd.description}
</span>
{cmd.input?.hint && (
<span className="text-[10px] text-text-muted italic">
{cmd.input.hint}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
import { cn, esc } from "../../src/lib/utils";
import { MessageResponse } from "../ai-elements/message";
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
// =============================================================================
// 用户消息 — 右对齐,深色反转背景,无气泡边框
// Anthropic: right-aligned, inverted dark bg, rounded-xl with bottom-right notch
// =============================================================================
interface UserBubbleProps {
entry: UserMessageEntry;
}
export function UserBubble({ entry }: UserBubbleProps) {
return (
<div className="flex justify-end">
<div className="max-w-[85%] sm:max-w-[75%]">
{/* 图片附件 */}
{entry.images && entry.images.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2 justify-end">
{entry.images.map((img, i) => (
<ImageThumbnail key={i} image={img} />
))}
</div>
)}
{/* 文本内容 */}
{entry.content && (
<div className="rounded-2xl rounded-br-md bg-bg-inverted px-4 py-2.5 text-sm text-text-inverted whitespace-pre-wrap font-display leading-relaxed">
{esc(entry.content)}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 助手消息 — 左对齐,无背景卡片,编辑式排版
// Anthropic: avatar + plain text, no bubble/card wrapper, serif body font
// =============================================================================
interface AssistantBubbleProps {
entry: AssistantMessageEntry;
isStreaming?: boolean;
}
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
return (
<div className="flex gap-3 items-start">
{/* Orange triangle avatar */}
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
</svg>
</div>
{/* 内容 — 无卡片背景,直接排版 */}
<div className="flex-1 min-w-0 space-y-3">
{/* Sender label */}
<span className="text-sm font-medium text-text-primary font-display">Claude</span>
{entry.chunks.map((chunk, i) => {
if (chunk.type === "thought") {
const isLastChunk = i === entry.chunks.length - 1;
const isThoughtStreaming = isStreaming && isLastChunk;
return (
<Reasoning key={i} isStreaming={isThoughtStreaming}>
<ReasoningTrigger />
<ReasoningContent>
<div className="text-sm text-text-secondary">
{chunk.text}
</div>
</ReasoningContent>
</Reasoning>
);
}
// 普通消息块 — 直接输出,无包裹卡片
return (
<div key={i} className="message-content text-text-primary leading-loose">
<MessageResponse>{chunk.text}</MessageResponse>
</div>
);
})}
</div>
</div>
);
}
// =============================================================================
// 图片缩略图 — 点击放大
// =============================================================================
function ImageThumbnail({ image }: { image: UserMessageImage }) {
const dataUrl = `data:${image.mimeType};base64,${image.data}`;
return (
<button
type="button"
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
onClick={() => {
// 简单的点击放大 — 在新标签页打开图片
const w = window.open("");
if (w) {
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
}
}}
>
<img
src={dataUrl}
alt="用户上传的图片"
className="h-20 w-20 object-cover"
/>
</button>
);
}

View File

@@ -0,0 +1,76 @@
import type { PendingPermission } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { ShieldAlert, Check, X } from "lucide-react";
// =============================================================================
// 权限请求面板 — 固定在输入框上方Anthropic warm token style
// =============================================================================
interface PermissionPanelProps {
requests: PendingPermission[];
onRespond?: (requestId: string, approved: boolean) => void;
className?: string;
}
export function PermissionPanel({ requests, onRespond, className }: PermissionPanelProps) {
if (requests.length === 0) return null;
return (
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
<div className="space-y-2">
{requests.map((req) => (
<PermissionCard
key={req.requestId}
request={req}
onRespond={onRespond}
/>
))}
</div>
</div>
);
}
// =============================================================================
// 单个权限卡片 — warm warning tokens + left-border accent
// =============================================================================
interface PermissionCardProps {
request: PendingPermission;
onRespond?: (requestId: string, approved: boolean) => void;
}
function PermissionCard({ request, onRespond }: PermissionCardProps) {
return (
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50 px-4 py-3">
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-warning-text">
{request.toolName}
</div>
{request.description && (
<div className="text-xs text-warning-text/80 mt-0.5 truncate">
{request.description}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => onRespond?.(request.requestId, true)}
className="h-8 px-3 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-light transition-colors flex items-center gap-1.5"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onRespond?.(request.requestId, false)}
className="h-8 px-3 rounded-lg border border-warning-border/30 text-warning-text text-xs font-medium hover:bg-warning-bg transition-colors flex items-center gap-1.5"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,206 @@
import { useState } from "react";
import type { ToolCallEntry, ToolCallData } from "../../src/lib/types";
import { cn } from "../../src/lib/utils";
import { ToolPermissionButtons } from "../ai-elements/permission-request";
// =============================================================================
// 工具调用折叠组 — Anthropic: subtle card, left-border accent, compact layout
// =============================================================================
interface ToolCallGroupProps {
entries: ToolCallEntry[];
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}
export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupProps) {
const [expanded, setExpanded] = useState(false);
if (entries.length === 0) return null;
// 单个工具调用
if (entries.length === 1) {
return (
<div className="pl-10">
<SingleToolCard
tool={entries[0].toolCall}
onPermissionRespond={onPermissionRespond}
/>
</div>
);
}
// 多个工具调用 — 折叠组
const summary = buildSummary(entries);
return (
<div className="pl-10">
<div className="rounded-lg border border-border border-l-3 border-l-brand/50 bg-surface-2/50 overflow-hidden">
{/* 折叠头 */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-1/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={cn("transition-transform text-text-muted", expanded && "rotate-90")}
>
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
<span className="text-xs text-text-muted font-display">{summary}</span>
</button>
{/* 展开内容 */}
{expanded && (
<div className="border-t border-border divide-y divide-border">
{entries.map((entry, i) => (
<SingleToolCard
key={entry.toolCall.id || i}
tool={entry.toolCall}
compact
onPermissionRespond={onPermissionRespond}
/>
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 单个工具卡片 — compact, left-accent, inline status
// =============================================================================
interface SingleToolCardProps {
tool: ToolCallData;
compact?: boolean;
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
}
function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardProps) {
const [expanded, setExpanded] = useState(!compact);
const statusIcon = (() => {
switch (tool.status) {
case "running":
return <span className="text-status-running text-[10px]">&#9654;</span>;
case "complete":
return <span className="text-status-active text-[10px]">&#10003;</span>;
case "error":
return <span className="text-status-error text-[10px]">&#10005;</span>;
case "waiting_for_confirmation":
return <span className="text-brand text-[10px]">&#9083;</span>;
case "canceled":
return <span className="text-text-muted text-[10px]">&#8212;</span>;
case "rejected":
return <span className="text-status-error text-[10px]">&#10005;</span>;
default:
return null;
}
})();
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
return (
<div className={cn("px-3 py-2", compact && "py-1.5")}>
{/* 标题行 — 单行紧凑 */}
<div
className="flex items-center gap-1.5 cursor-pointer group"
onClick={() => setExpanded(!expanded)}
>
{statusIcon}
<span className="text-xs font-display font-medium text-text-secondary group-hover:text-text-primary transition-colors truncate">
{tool.title}
</span>
{tool.status === "running" && (
<span className="text-[10px] text-status-running animate-pulse">running</span>
)}
</div>
{/* 权限请求按钮 */}
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
<div className="mt-1.5 ml-4">
<ToolPermissionButtons
requestId={tool.permissionRequest.requestId}
options={tool.permissionRequest.options}
onRespond={onPermissionRespond || (() => {})}
/>
</div>
)}
{/* 展开详情 */}
{expanded && (
<div className="mt-1.5 ml-4 space-y-1.5">
{tool.rawInput && Object.keys(tool.rawInput).length > 0 && (
<div>
<pre className="text-[11px] bg-surface-1 rounded-md p-2 overflow-x-auto font-mono max-h-36 text-text-secondary">
{truncate(JSON.stringify(tool.rawInput, null, 2), 2000)}
</pre>
</div>
)}
{hasOutput && (
<div>
<pre className={cn(
"text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36",
tool.status === "error" ? "bg-status-error/10 text-status-error" : "bg-surface-1 text-text-secondary",
)}>
{formatOutput(tool)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
// =============================================================================
// 工具函数
// =============================================================================
/** 构建统计摘要 */
function buildSummary(entries: ToolCallEntry[]): string {
const toolCounts = new Map<string, number>();
for (const entry of entries) {
const name = simplifyToolName(entry.toolCall.title);
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
}
const parts: string[] = [];
for (const [name, count] of toolCounts) {
parts.push(count === 1 ? name : `${count}${name}`);
}
if (parts.length === 0) return `${entries.length} 个工具调用`;
if (parts.length === 1) return parts[0];
return `${entries.length} 个工具: ${parts.join("、")}`;
}
/** 简化工具名称 */
function simplifyToolName(title: string): string {
const match = title.match(/^(\w+)/);
return match ? match[1] : title;
}
/** 格式化工具输出 */
function formatOutput(tool: ToolCallData): string {
if (tool.content && tool.content.length > 0) {
const texts = tool.content
.filter((c): c is Extract<typeof c, { type: "content" }> => c.type === "content")
.filter((c) => c.content.type === "text" && "text" in c.content)
.map((c) => (c.content as { text: string }).text);
if (texts.length > 0) return truncate(texts.join("\n"), 2000);
}
if (tool.rawOutput && Object.keys(tool.rawOutput).length > 0) {
return truncate(JSON.stringify(tool.rawOutput, null, 2), 2000);
}
return "";
}
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + "..." : str;
}

View File

@@ -0,0 +1,7 @@
export { ChatView } from "./ChatView";
export { UserBubble, AssistantBubble } from "./MessageBubble";
export { ToolCallGroup } from "./ToolCallGroup";
export { ChatInput } from "./ChatInput";
export { PermissionPanel } from "./PermissionPanel";
export { SessionSidebar } from "./SessionSidebar";
export { CommandMenu } from "./CommandMenu";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import type { ConnectionState } from "../../src/acp/types";
import { cn } from "../../src/lib/utils";
// Shared styles for connection state dots
const connectionDotStyles: Record<ConnectionState, string> = {
disconnected: "bg-gray-400",
connecting: "bg-yellow-400 animate-pulse",
connected: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]",
error: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]",
};
// Shared labels for connection states
const connectionStateLabels: Record<ConnectionState, string> = {
disconnected: "Disconnected",
connecting: "Connecting...",
connected: "Connected",
error: "Error",
};
/**
* Get the display label for a connection state
*/
export function getConnectionStateLabel(state: ConnectionState): string {
return connectionStateLabels[state];
}
/**
* A small dot indicator for connection state
* Used in status bars and headers
*/
export function StatusDot({
state,
className,
}: {
state: ConnectionState;
className?: string;
}) {
return (
<span
className={cn("w-2 h-2 rounded-full", connectionDotStyles[state], className)}
/>
);
}
/**
* A status indicator with dot and label
* Used in cards and detailed views
*/
export function StatusIndicator({
state,
className,
}: {
state: ConnectionState;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-2 text-sm font-normal", className)}>
<StatusDot state={state} />
{state}
</span>
);
}
/**
* A complete status bar section with dot, label, and optional URL
*/
export function ConnectionStatusBar({
state,
displayUrl,
className,
}: {
state: ConnectionState;
displayUrl?: string;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<StatusDot state={state} />
<span className="text-sm font-medium">
{getConnectionStateLabel(state)}
</span>
{state === "connected" && displayUrl && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{displayUrl}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "../../src/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,256 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "../../src/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "../../src/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,22 @@
export * from "./badge"
export * from "./connection-status"
export * from "./button-group"
export * from "./button"
export * from "./card"
export * from "./collapsible"
export * from "./command"
export * from "./dialog"
export * from "./dropdown-menu"
export * from "./hover-card"
export * from "./input-group"
export * from "./input"
export * from "./label"
export * from "./resizable"
export * from "./scroll-area"
export * from "./select"
export * from "./separator"
export * from "./tabs"
export * from "./textarea"
export * from "./theme-toggle"
export * from "./tooltip"
export * from "./popover"

View File

@@ -0,0 +1,171 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../src/lib/utils"
import { Button } from "./button"
import { Input } from "./input"
import { Textarea } from "./textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "../../src/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "../../src/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "../../src/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,53 @@
"use client"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "../../src/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -0,0 +1,63 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "../../src/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
{/*
Workaround for Radix ScrollArea bug #926:
The Viewport's inner div uses display:table which breaks text-overflow:ellipsis.
We override it to display:block using the [style] selector.
See: https://github.com/radix-ui/primitives/issues/926
*/}
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div[style]]:!block"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,188 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "../../src/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "../../src/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,98 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "../../src/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
forceMount,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
forceMount?: true;
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
forceMount={forceMount}
className={cn(
"flex-1 outline-none",
// When forceMount is used, hide inactive tabs
forceMount && "data-[state=inactive]:hidden",
className
)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../src/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,47 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme, type Theme } from "../../src/lib/theme";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./dropdown-menu";
const themeOptions: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
];
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
{resolvedTheme === "dark" ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{themeOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => setTheme(option.value)}
className={theme === option.value ? "bg-accent" : ""}
>
{option.icon}
<span className="ml-2">{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "../../src/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }