Files
claude-code/packages/remote-control-server/web/components/chat/ChatInput.tsx
claude-code-best 2e9aaf4993 feat: ACP 协议版本 remote control (#293)
* fix: 添加 usage 字段缺失时的防御性防护

第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段,
导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。

- claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE
- LocalAgentTask.tsx: usage 为 undefined 时提前返回
- tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0
- cost-tracker.ts: input_tokens/output_tokens 加 ?? 0

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

* feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化

补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件
渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。

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

* feat: 穷鬼模式下跳过 verification agent 以节省 token

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

* test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests)

后端新增 3 个测试文件 (70 tests):
- automationState: normalize/snapshot/equals 纯函数
- client-payload: toClientPayload 协议转换
- transport-normalize: normalizePayload + extractContent

前端新增 2 个测试文件 (46 tests):
- utils: formatTime/statusClass/truncate/extractEventText 等
- api-client: getUuid/setUuid/api GET/POST 错误处理

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

* feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复

- 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
- 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递
- 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug
- 权限模式和模型选择持久化到 localStorage
- acp-link 直接连接路径同步支持 permissionMode 透传

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

* feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转

- RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading,
  主题系统改进, 组件样式优化
- IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制
  解决 Radix Dialog Portal 挂载时序问题
- ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token }
  后存储 sessionStorage 并跳转 /code/?acp=1
- 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并
  渲染 ACPMain 聊天界面

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

* feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线

- agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode
- agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境)
- permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool
  统一权限流水线, 替代原来分散的处理逻辑
- permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步

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

* fix: acp-link 支持 permissionMode 默认值传递给 agent

客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode,
导致 agent 收到 _meta: undefined, permission 回退到 default。

修复: handleNewSession 使用 fallback 链:
  客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量

使用: ACP_PERMISSION_MODE=auto acp-link claude

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

* docs: 更新文档及说明

* fix: 修复类型错误

* chore: 提交脚本

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 21:54:22 +08:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
// Arrow keys and Enter are handled by CommandMenu via document-level listener
// Don't submit or move cursor when menu is open
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
return;
}
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-4 sm:px-8 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={`Attached image ${i + 1}`}
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 min-h-[32px] min-w-[32px] 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"
aria-label={`Remove image ${i + 1}`}
>
{"\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" />
<span className="sr-only">Attach file</span>
</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;
}