mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
* 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>
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
import { useState } from "react";
|
|
import type { Question } from "../types";
|
|
import { esc, cn, truncate } from "../lib/utils";
|
|
import { TriangleAlert, Check } from "lucide-react";
|
|
|
|
// ============================================================
|
|
// PermissionPromptView — simple approve/reject for tool use
|
|
// ============================================================
|
|
|
|
export function PermissionPromptView({
|
|
requestId,
|
|
toolName,
|
|
toolInput,
|
|
description,
|
|
onApprove,
|
|
onReject,
|
|
}: {
|
|
requestId: string;
|
|
toolName: string;
|
|
toolInput: unknown;
|
|
description: string;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
}) {
|
|
const inputStr = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-warning-border/30 bg-surface-1 p-4">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-warning-border/15 text-warning-text">
|
|
<TriangleAlert className="h-3 w-3" />
|
|
</span>
|
|
<span className="text-sm font-semibold text-warning-text">Permission Request</span>
|
|
</div>
|
|
{description && <div className="mb-2 text-sm text-text-secondary">{esc(description)}</div>}
|
|
<div className="mb-2 font-mono text-xs font-bold text-text-primary">{esc(toolName)}</div>
|
|
{toolName !== "AskUserQuestion" && (
|
|
<pre className="mb-3 max-h-40 overflow-auto rounded-lg bg-surface-1 p-2 text-xs text-text-secondary font-mono">
|
|
{truncate(inputStr, 500)}
|
|
</pre>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onApprove}
|
|
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light transition-colors"
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
onClick={onReject}
|
|
className="rounded-lg bg-status-error/20 px-4 py-2 text-sm font-medium text-status-error hover:bg-status-error/30 transition-colors"
|
|
>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// AskUserPanelView — multi-question interactive panel
|
|
// ============================================================
|
|
|
|
export function AskUserPanelView({
|
|
questions,
|
|
description,
|
|
onSubmit,
|
|
onSkip,
|
|
}: {
|
|
requestId: string;
|
|
questions: Question[];
|
|
description: string;
|
|
onSubmit: (answers: Record<string, unknown>) => void;
|
|
onSkip: () => void;
|
|
}) {
|
|
const [answers, setAnswers] = useState<Record<string, unknown>>({});
|
|
const [otherTexts, setOtherTexts] = useState<Record<string, string>>({});
|
|
const [activeTab, setActiveTab] = useState(0);
|
|
|
|
const handleSelect = (qIdx: number, oIdx: number, multiSelect: boolean) => {
|
|
if (multiSelect) {
|
|
const current = (answers[qIdx] as number[]) || [];
|
|
const next = current.includes(oIdx) ? current.filter((i) => i !== oIdx) : [...current, oIdx];
|
|
setAnswers({ ...answers, [qIdx]: next });
|
|
} else {
|
|
setAnswers({ ...answers, [qIdx]: oIdx });
|
|
}
|
|
};
|
|
|
|
const handleOtherSubmit = (qIdx: number) => {
|
|
const text = otherTexts[qIdx]?.trim();
|
|
if (!text) return;
|
|
setAnswers({ ...answers, [qIdx]: text });
|
|
setOtherTexts({ ...otherTexts, [qIdx]: "" });
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const mapped: Record<string, unknown> = {};
|
|
for (const [qIdx, val] of Object.entries(answers)) {
|
|
const q = questions[parseInt(qIdx)];
|
|
if (!q) continue;
|
|
if (typeof val === "number") mapped[qIdx] = q.options?.[val]?.label || String(val);
|
|
else if (Array.isArray(val)) mapped[qIdx] = val.map((i) => q.options?.[i]?.label || String(i));
|
|
else mapped[qIdx] = val;
|
|
}
|
|
onSubmit(mapped);
|
|
};
|
|
|
|
// Single question — simple layout
|
|
if (questions.length <= 1) {
|
|
const q = questions[0] || { question: description, options: [], multiSelect: false };
|
|
const multiSelect = q.multiSelect || false;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-brand/30 bg-surface-1 p-4">
|
|
<div className="mb-3 text-sm font-semibold text-text-primary">
|
|
{esc(description || q.question || "Question")}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{(q.options || []).map((opt, j) => {
|
|
const isSelected = multiSelect
|
|
? ((answers[0] as number[]) || []).includes(j)
|
|
: answers[0] === j;
|
|
return (
|
|
<button
|
|
key={j}
|
|
onClick={() => handleSelect(0, j, multiSelect)}
|
|
className={cn(
|
|
"w-full rounded-lg border px-4 py-2.5 text-left text-sm transition-colors",
|
|
isSelected
|
|
? "border-brand bg-brand/10 text-text-primary"
|
|
: "border-border bg-surface-2 text-text-secondary hover:border-border-light",
|
|
)}
|
|
>
|
|
<div className="font-medium">{esc(opt.label)}</div>
|
|
{opt.description && <div className="mt-0.5 text-xs text-text-muted">{esc(opt.description)}</div>}
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={otherTexts[0] || ""}
|
|
onChange={(e) => setOtherTexts({ ...otherTexts, [0]: e.target.value })}
|
|
placeholder="Other..."
|
|
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
|
|
onKeyDown={(e) => e.key === "Enter" && handleOtherSubmit(0)}
|
|
/>
|
|
<button onClick={() => handleOtherSubmit(0)} className="rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors">
|
|
Send
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex gap-2">
|
|
<button onClick={handleSubmit} className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light transition-colors">Submit</button>
|
|
<button onClick={onSkip} className="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors">Skip</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Multiple questions — tab layout
|
|
const currentQ = questions[activeTab];
|
|
|
|
return (
|
|
<div className="rounded-xl border border-brand/30 bg-surface-1 p-4">
|
|
<div className="mb-3 text-sm font-semibold text-text-primary">{esc(description || "Questions")}</div>
|
|
<div className="mb-3 flex gap-1 overflow-x-auto">
|
|
{questions.map((q, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => setActiveTab(i)}
|
|
className={cn(
|
|
"rounded-md px-3 py-1.5 text-xs whitespace-nowrap transition-colors",
|
|
activeTab === i ? "bg-brand/20 text-brand" : "text-text-muted hover:bg-surface-2",
|
|
)}
|
|
>
|
|
{q.header || `Q${i + 1}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{currentQ && (
|
|
<QuestionTab
|
|
question={currentQ}
|
|
qIdx={activeTab}
|
|
answers={answers}
|
|
otherTexts={otherTexts}
|
|
onSelect={handleSelect}
|
|
onOtherTextChange={(qIdx, text) => setOtherTexts({ ...otherTexts, [qIdx]: text })}
|
|
onOtherSubmit={handleOtherSubmit}
|
|
/>
|
|
)}
|
|
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<span className="text-xs text-text-muted">{activeTab + 1} / {questions.length}</span>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleSubmit} className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light transition-colors">Submit All</button>
|
|
<button onClick={onSkip} className="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors">Skip</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QuestionTab({
|
|
question, qIdx, answers, otherTexts, onSelect, onOtherTextChange, onOtherSubmit,
|
|
}: {
|
|
question: Question;
|
|
qIdx: number;
|
|
answers: Record<string, unknown>;
|
|
otherTexts: Record<string, string>;
|
|
onSelect: (qIdx: number, oIdx: number, multiSelect: boolean) => void;
|
|
onOtherTextChange: (qIdx: number, text: string) => void;
|
|
onOtherSubmit: (qIdx: number) => void;
|
|
}) {
|
|
const multiSelect = question.multiSelect || false;
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-2 text-sm text-text-secondary">{esc(question.question)}</div>
|
|
<div className="space-y-2">
|
|
{(question.options || []).map((opt, j) => {
|
|
const isSelected = multiSelect
|
|
? ((answers[qIdx] as number[]) || []).includes(j)
|
|
: answers[qIdx] === j;
|
|
return (
|
|
<button
|
|
key={j}
|
|
onClick={() => onSelect(qIdx, j, multiSelect)}
|
|
className={cn(
|
|
"w-full rounded-lg border px-4 py-2.5 text-left text-sm transition-colors",
|
|
isSelected
|
|
? "border-brand bg-brand/10 text-text-primary"
|
|
: "border-border bg-surface-2 text-text-secondary hover:border-border-light",
|
|
)}
|
|
>
|
|
<div className="font-medium">{esc(opt.label)}</div>
|
|
{opt.description && <div className="mt-0.5 text-xs text-text-muted">{esc(opt.description)}</div>}
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={otherTexts[qIdx] || ""}
|
|
onChange={(e) => onOtherTextChange(qIdx, e.target.value)}
|
|
placeholder="Other..."
|
|
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
|
|
onKeyDown={(e) => e.key === "Enter" && onOtherSubmit(qIdx)}
|
|
/>
|
|
<button onClick={() => onOtherSubmit(qIdx)} className="rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// PlanPanelView — plan approval with feedback
|
|
// ============================================================
|
|
|
|
export function PlanPanelView({
|
|
planContent,
|
|
description,
|
|
onSubmit,
|
|
}: {
|
|
requestId: string;
|
|
planContent: string;
|
|
description: string;
|
|
onSubmit: (value: string, feedback?: string) => void;
|
|
}) {
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
const [feedback, setFeedback] = useState("");
|
|
const isEmpty = !planContent || !planContent.trim();
|
|
|
|
const handleSubmit = () => {
|
|
if (!selected) return;
|
|
onSubmit(selected, selected === "no" ? feedback : undefined);
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-xl border border-brand/30 bg-surface-1 p-4">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand/15 text-brand">
|
|
<Check className="h-3 w-3" strokeWidth={2.5} />
|
|
</span>
|
|
<span className="text-sm font-semibold text-text-primary">
|
|
{isEmpty ? "Exit plan mode?" : "Ready to code?"}
|
|
</span>
|
|
</div>
|
|
{!isEmpty && (
|
|
<div
|
|
className="mb-4 max-h-64 overflow-auto rounded-lg bg-tool-card p-4 text-sm text-text-secondary"
|
|
dangerouslySetInnerHTML={{ __html: formatPlanContent(planContent) }}
|
|
/>
|
|
)}
|
|
<div className="space-y-2">
|
|
{isEmpty ? (
|
|
<>
|
|
<PlanOption selected={selected === "yes-default"} onClick={() => setSelected("yes-default")} label="Yes" />
|
|
<PlanOption selected={selected === "no"} onClick={() => setSelected("no")} label="No" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlanOption selected={selected === "yes-accept-edits"} onClick={() => setSelected("yes-accept-edits")} label="Yes, auto-accept edits" desc="Approve plan and auto-accept file edits" />
|
|
<PlanOption selected={selected === "yes-default"} onClick={() => setSelected("yes-default")} label="Yes, manually approve edits" desc="Approve plan but confirm each edit" />
|
|
<PlanOption selected={selected === "no"} onClick={() => setSelected("no")} label="No, keep planning" desc="Provide feedback to refine the plan" />
|
|
</>
|
|
)}
|
|
</div>
|
|
{selected === "no" && (
|
|
<textarea
|
|
value={feedback}
|
|
onChange={(e) => setFeedback(e.target.value)}
|
|
placeholder="Tell Claude what to change..."
|
|
className="mt-3 w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
|
|
rows={3}
|
|
/>
|
|
)}
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!selected}
|
|
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
|
|
>
|
|
Submit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlanOption({ selected, onClick, label, desc }: { selected: boolean; onClick: () => void; label: string; desc?: string }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"w-full rounded-lg border px-4 py-2.5 text-left text-sm transition-colors",
|
|
selected ? "border-brand bg-brand/10 text-text-primary" : "border-border bg-surface-2 text-text-secondary hover:border-border-light",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
"flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors",
|
|
selected ? "border-brand bg-brand text-white" : "border-border",
|
|
)}>
|
|
{selected && "\u2713"}
|
|
</span>
|
|
<span className="font-medium">{label}</span>
|
|
</div>
|
|
{desc && <div className="mt-0.5 pl-6 text-xs text-text-muted">{desc}</div>}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function formatPlanContent(content: string): string {
|
|
let html = esc(content);
|
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, _l, code) =>
|
|
`<pre class="my-2 overflow-x-auto rounded-lg bg-tool-card p-3 font-mono text-xs">${code.trim()}</pre>`
|
|
);
|
|
html = html.replace(/`([^`]+)`/g, '<code class="rounded bg-tool-card px-1.5 py-0.5 font-mono text-xs">$1</code>');
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
return html;
|
|
}
|