From 797870dda56df9b90c3257d3bc86839661aa6e4b Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 18:09:32 +0800 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20usage=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=BC=BA=E5=A4=B1=E6=97=B6=E7=9A=84=E9=98=B2?= =?UTF-8?q?=E5=BE=A1=E6=80=A7=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三方 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 --- src/cost-tracker.ts | 4 ++-- src/services/api/claude.ts | 1 + src/tasks/LocalAgentTask/LocalAgentTask.tsx | 5 ++++- src/utils/tokens.ts | 7 +++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cost-tracker.ts b/src/cost-tracker.ts index b03184c6a1..c50a0591a8 100644 --- a/src/cost-tracker.ts +++ b/src/cost-tracker.ts @@ -263,8 +263,8 @@ function addToTotalModelUsage( maxOutputTokens: 0, } - modelUsage.inputTokens += usage.input_tokens - modelUsage.outputTokens += usage.output_tokens + modelUsage.inputTokens += usage.input_tokens ?? 0 + modelUsage.outputTokens += usage.output_tokens ?? 0 modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0 modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0 modelUsage.webSearchRequests += diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 9d017206d9..0643b8ea6e 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -2238,6 +2238,7 @@ async function* queryModel( const m: AssistantMessage = { message: { ...partialMessage, + usage: partialMessage.usage ?? { ...EMPTY_USAGE }, content: normalizeContentFromAPI( [contentBlock] as BetaContentBlock[], tools, diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 3b65cfb82e..a4975913a3 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -106,7 +106,10 @@ export function updateProgressFromMessage( if (message.type !== 'assistant') { return } - const usage = message.message!.usage as BetaUsage + const usage = message.message!.usage as BetaUsage | undefined + if (!usage) { + return + } // Keep latest input (it's cumulative in the API), sum outputs tracker.latestInputTokens = (usage.input_tokens as number) + diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index e326d350d4..8c156ab0f8 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -46,11 +46,14 @@ function getAssistantMessageId(message: Message): string | undefined { * Use tokenCountWithEstimation() when you need context size from messages. */ export function getTokenCountFromUsage(usage: Usage): number { + if (!usage) { + return 0 + } return ( - usage.input_tokens + + (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + - usage.output_tokens + (usage.output_tokens ?? 0) ) } From c3aca08562af080a3163a631f4df7406ceec82cc Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 18:11:13 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20ACP=20Plan=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20=E2=80=94=20=E6=94=AF=E6=8C=81=20session/update=20plan=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=9A=84=E5=8F=AF=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件 渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。 Co-Authored-By: Claude Opus 4.6 --- .../web/components/ChatInterface.tsx | 34 +++- .../web/components/chat/ChatView.tsx | 9 +- .../web/components/chat/PlanView.tsx | 156 ++++++++++++++++++ .../web/components/chat/index.ts | 1 + .../web/src/acp/types.ts | 12 ++ .../web/src/lib/types.ts | 12 +- 6 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 packages/remote-control-server/web/components/chat/PlanView.tsx diff --git a/packages/remote-control-server/web/components/ChatInterface.tsx b/packages/remote-control-server/web/components/ChatInterface.tsx index 645d4d477a..d9d3c6e6df 100644 --- a/packages/remote-control-server/web/components/ChatInterface.tsx +++ b/packages/remote-control-server/web/components/ChatInterface.tsx @@ -2,7 +2,7 @@ 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 type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types"; import { ChatView } from "./chat/ChatView"; import { ChatInput } from "./chat/ChatInput"; import { PermissionPanel } from "./chat/PermissionPanel"; @@ -384,6 +384,38 @@ export function ChatInterface({ client }: ChatInterfaceProps) { }); }); } + // Handle plan update (replace entire plan) + else if (update.sessionUpdate === "plan") { + setEntries((prev) => { + // Empty entries → remove existing plan + if (update.entries.length === 0) { + return prev.filter((e) => e.type !== "plan"); + } + + // Find last plan entry + const lastPlanIndex = prev.reduce( + (acc, entry, i) => (entry.type === "plan" ? i : acc), + -1, + ); + + if (lastPlanIndex >= 0) { + // Update existing plan in place + return prev.map((entry, index) => + index === lastPlanIndex + ? { ...entry, entries: update.entries } + : entry, + ); + } + + // Create new plan entry + const newPlanEntry: PlanDisplayEntry = { + type: "plan", + id: `plan-${Date.now()}`, + entries: update.entries, + }; + return [...prev, newPlanEntry]; + }); + } }, []); // ============================================================================= diff --git a/packages/remote-control-server/web/components/chat/ChatView.tsx b/packages/remote-control-server/web/components/chat/ChatView.tsx index b928b802a3..ed72074a18 100644 --- a/packages/remote-control-server/web/components/chat/ChatView.tsx +++ b/packages/remote-control-server/web/components/chat/ChatView.tsx @@ -1,7 +1,8 @@ -import type { ThreadEntry, ToolCallEntry } from "../../src/lib/types"; +import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from "../../src/lib/types"; import { cn } from "../../src/lib/utils"; import { UserBubble, AssistantBubble } from "./MessageBubble"; import { ToolCallGroup } from "./ToolCallGroup"; +import { PlanDisplay } from "./PlanView"; import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation"; // ============================================================================= @@ -98,6 +99,10 @@ function entrySpacing(entries: ThreadEntry[], index: number): string { } return "pt-2 pb-4"; } + // Plan 条目适当间距 + if (entry?.type === "plan") { + return "pt-2 pb-2"; + } return "py-1"; } @@ -126,6 +131,8 @@ function EntryRenderer({ onPermissionRespond={onPermissionRespond} /> ); + case "plan": + return ; default: return null; } diff --git a/packages/remote-control-server/web/components/chat/PlanView.tsx b/packages/remote-control-server/web/components/chat/PlanView.tsx new file mode 100644 index 0000000000..c03717a1d4 --- /dev/null +++ b/packages/remote-control-server/web/components/chat/PlanView.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import type { PlanDisplayEntry } from "../../src/lib/types"; +import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from "../../src/acp/types"; +import { cn } from "../../src/lib/utils"; + +// ============================================================================= +// Plan 展示组件 — 执行计划可视化 +// ============================================================================= + +interface PlanDisplayProps { + entry: PlanDisplayEntry; +} + +export function PlanDisplay({ entry }: PlanDisplayProps) { + const [collapsed, setCollapsed] = useState(false); + const { entries } = entry; + + if (entries.length === 0) return null; + + const completed = entries.filter((e) => e.status === "completed").length; + const total = entries.length; + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+
+ {/* Header */} + + + {/* Entry list */} + {!collapsed && ( +
5 && "max-h-64 overflow-y-auto", + )}> + {entries.map((planEntry, i) => ( + + ))} +
+ )} +
+
+ ); +} + +// ============================================================================= +// 单条 Plan 条目 +// ============================================================================= + +function PlanEntryRow({ entry }: { entry: PlanEntry }) { + return ( +
+ + + + + {entry.content} + + +
+ ); +} + +// ============================================================================= +// 状态图标 +// ============================================================================= + +function StatusIcon({ status }: { status: PlanEntryStatus }) { + switch (status) { + case "completed": + return ( + + + + + ); + case "in_progress": + return ( + + + + + ); + case "pending": + return ( + + + + ); + } +} + +// ============================================================================= +// 优先级标签 +// ============================================================================= + +function PriorityBadge({ priority }: { priority: PlanEntryPriority }) { + const styles: Record = { + high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", + medium: "bg-brand/10 text-brand dark:bg-brand/20", + low: "bg-surface-1 text-text-muted", + }; + + const labels: Record = { + high: "高", + medium: "中", + low: "低", + }; + + return ( + + {labels[priority]} + + ); +} diff --git a/packages/remote-control-server/web/components/chat/index.ts b/packages/remote-control-server/web/components/chat/index.ts index d79cc68efa..750ce89e61 100644 --- a/packages/remote-control-server/web/components/chat/index.ts +++ b/packages/remote-control-server/web/components/chat/index.ts @@ -1,6 +1,7 @@ export { ChatView } from "./ChatView"; export { UserBubble, AssistantBubble } from "./MessageBubble"; export { ToolCallGroup } from "./ToolCallGroup"; +export { PlanDisplay } from "./PlanView"; export { ChatInput } from "./ChatInput"; export { PermissionPanel } from "./PermissionPanel"; export { SessionSidebar } from "./SessionSidebar"; diff --git a/packages/remote-control-server/web/src/acp/types.ts b/packages/remote-control-server/web/src/acp/types.ts index 779378d9bc..09bc76c8ab 100644 --- a/packages/remote-control-server/web/src/acp/types.ts +++ b/packages/remote-control-server/web/src/acp/types.ts @@ -295,8 +295,20 @@ export interface AgentThoughtChunkUpdate { content: ContentBlock; } +export type PlanEntryPriority = "high" | "medium" | "low"; +export type PlanEntryStatus = "pending" | "in_progress" | "completed"; + +export interface PlanEntry { + _meta?: Record | null; + content: string; + priority: PlanEntryPriority; + status: PlanEntryStatus; +} + export interface PlanUpdate { sessionUpdate: "plan"; + _meta?: Record | null; + entries: PlanEntry[]; } export interface UserMessageChunkUpdate { diff --git a/packages/remote-control-server/web/src/lib/types.ts b/packages/remote-control-server/web/src/lib/types.ts index 2e32702fea..9ba1ff0322 100644 --- a/packages/remote-control-server/web/src/lib/types.ts +++ b/packages/remote-control-server/web/src/lib/types.ts @@ -2,7 +2,7 @@ // Unified Chat Data Model — shared between ACP and RCS chat interfaces // ============================================================================= -import type { ToolCallContent, PermissionOption } from "../acp/types"; +import type { ToolCallContent, PermissionOption, PlanEntry } from "../acp/types"; // 工具调用状态 export type ToolCallStatus = @@ -62,11 +62,19 @@ export interface ToolCallEntry { toolCall: ToolCallData; } +// Plan 展示条目(Agent 执行计划) +export interface PlanDisplayEntry { + type: "plan"; + id: string; + entries: PlanEntry[]; +} + // 统一聊天条目类型 export type ThreadEntry = | UserMessageEntry | AssistantMessageEntry - | ToolCallEntry; + | ToolCallEntry + | PlanDisplayEntry; // ============================================================================= // Chat 组件 Props 类型 From 6cb215e20359ec519b14d674c7a9051395bb71e3 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 18:13:23 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=E7=A9=B7=E9=AC=BC=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=B8=8B=E8=B7=B3=E8=BF=87=20verification=20agent=20?= =?UTF-8?q?=E4=BB=A5=E8=8A=82=E7=9C=81=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/constants/prompts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index 1145207c48..a043e9df54 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -7,6 +7,7 @@ import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { getCurrentWorktreeSession } from '../utils/worktree.js' import { getSessionStartDate } from './common.js' import { getInitialSettings } from '../utils/settings/settings.js' +import { isPoorModeActive } from '../commands/poor/poorMode.js' import { AGENT_TOOL_NAME, VERIFICATION_AGENT_TYPE, @@ -391,7 +392,9 @@ function getSessionSpecificGuidanceSection( hasAgentTool && feature('VERIFICATION_AGENT') && // 3P default: false — verification agent is ant-only A/B - getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) + getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) && + // Poor mode: skip verification agent to save tokens + !isPoorModeActive() ? `The contract: when non-trivial implementation happens on your turn, independent adversarial verification must happen before you report completion \u2014 regardless of who did the implementing (you directly, a fork you spawned, or a subagent). You are the one reporting to the user; you own the gate. Non-trivial means: 3+ file edits, backend/API changes, or infrastructure changes. Spawn the ${AGENT_TOOL_NAME} tool with subagent_type="${VERIFICATION_AGENT_TYPE}". Your own checks, caveats, and a fork's self-checks do NOT substitute \u2014 only the verifier assigns a verdict; you cannot self-assign PARTIAL. Pass the original user request, all files changed (by anyone), the approach, and the plan file path if applicable. Flag concerns if you have them but do NOT share test results or claim things work. On FAIL: fix, resume the verifier with its findings plus your fix, repeat until PASS. On PASS: spot-check it \u2014 re-run 2-3 commands from its report, confirm every PASS has a Command run block with output that matches your re-run. If any PASS lacks a command block or diverges, resume the verifier with the specifics. On PARTIAL (from the verifier): report what passed and what could not be verified.` : null, ].filter(item => item !== null) From 5ccc34cdade61de951f08a9c8d7c5afdd5b0acf5 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 19:00:16 +0800 Subject: [PATCH 04/11] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20RCS=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20+=20=E5=89=8D=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20(+116=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增 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 --- .../src/__tests__/automationState.test.ts | 182 ++++++++++++ .../src/__tests__/client-payload.test.ts | 258 ++++++++++++++++++ .../src/__tests__/transport-normalize.test.ts | 188 +++++++++++++ .../web/src/__tests__/api-client.test.ts | 143 ++++++++++ .../web/src/__tests__/utils.test.ts | 221 +++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 packages/remote-control-server/src/__tests__/automationState.test.ts create mode 100644 packages/remote-control-server/src/__tests__/client-payload.test.ts create mode 100644 packages/remote-control-server/src/__tests__/transport-normalize.test.ts create mode 100644 packages/remote-control-server/web/src/__tests__/api-client.test.ts create mode 100644 packages/remote-control-server/web/src/__tests__/utils.test.ts diff --git a/packages/remote-control-server/src/__tests__/automationState.test.ts b/packages/remote-control-server/src/__tests__/automationState.test.ts new file mode 100644 index 0000000000..cb322d2e74 --- /dev/null +++ b/packages/remote-control-server/src/__tests__/automationState.test.ts @@ -0,0 +1,182 @@ +import { describe, test, expect } from "bun:test"; +import { + getAutomationStateSnapshot, + getAutomationStateEventPayload, + automationStatesEqual, +} from "../services/automationState"; +import type { AutomationStateResponse } from "../types/api"; + +// ============================================================================= +// normalizeAutomationState (via getAutomationStateSnapshot) +// ============================================================================= + +describe("normalizeAutomationState", () => { + test("returns undefined when metadata has no automation_state key", () => { + expect(getAutomationStateSnapshot({})).toBeUndefined(); + expect(getAutomationStateSnapshot({ other: true })).toBeUndefined(); + expect(getAutomationStateSnapshot(null)).toBeUndefined(); + expect(getAutomationStateSnapshot(undefined)).toBeUndefined(); + }); + + test("returns disabled state for null automation_state", () => { + const result = getAutomationStateSnapshot({ automation_state: null }); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns disabled state for non-object automation_state", () => { + for (const val of ["string", 123, true, []]) { + const result = getAutomationStateSnapshot({ automation_state: val }); + expect(result?.enabled).toBe(false); + } + }); + + test("normalizes enabled: true correctly", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true } }); + expect(result?.enabled).toBe(true); + }); + + test("normalizes enabled to false for non-true values", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } }); + expect(result?.enabled).toBe(false); + }); + + test("accepts phase: standby", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } }); + expect(result?.phase).toBe("standby"); + }); + + test("accepts phase: sleeping", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } }); + expect(result?.phase).toBe("sleeping"); + }); + + test("rejects invalid phase values", () => { + for (const phase of ["running", "idle", "active", "", null]) { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } }); + expect(result?.phase).toBeNull(); + } + }); + + test("normalizes next_tick_at as number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } }); + expect(result?.next_tick_at).toBe(12345); + }); + + test("normalizes next_tick_at as null for non-number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } }); + expect(result?.next_tick_at).toBeNull(); + }); + + test("normalizes sleep_until as number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } }); + expect(result?.sleep_until).toBe(99999); + }); + + test("normalizes sleep_until as null for non-number", () => { + const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } }); + expect(result?.sleep_until).toBeNull(); + }); + + test("fully normalizes a complete valid state", () => { + const result = getAutomationStateSnapshot({ + automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 }, + }); + expect(result).toEqual({ + enabled: true, + phase: "sleeping", + next_tick_at: 100, + sleep_until: 200, + }); + }); +}); + +// ============================================================================= +// getAutomationStateEventPayload +// ============================================================================= + +describe("getAutomationStateEventPayload", () => { + test("returns disabled default when no automation_state in metadata", () => { + const result = getAutomationStateEventPayload({}); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns disabled default for null metadata", () => { + const result = getAutomationStateEventPayload(null); + expect(result).toEqual({ + enabled: false, + phase: null, + next_tick_at: null, + sleep_until: null, + }); + }); + + test("returns normalized state when automation_state present", () => { + const result = getAutomationStateEventPayload({ + automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 }, + }); + expect(result).toEqual({ + enabled: true, + phase: "standby", + next_tick_at: 50, + sleep_until: 60, + }); + }); + + test("returns a new object each call (not frozen reference)", () => { + const a = getAutomationStateEventPayload({}); + const b = getAutomationStateEventPayload({}); + expect(a).toEqual(b); + expect(a).not.toBe(b); + }); +}); + +// ============================================================================= +// automationStatesEqual +// ============================================================================= + +describe("automationStatesEqual", () => { + const base: AutomationStateResponse = { + enabled: true, + phase: "standby", + next_tick_at: 100, + sleep_until: 200, + }; + + test("returns true for identical states", () => { + expect(automationStatesEqual(base, { ...base })).toBe(true); + }); + + test("returns false when enabled differs", () => { + expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false); + }); + + test("returns false when phase differs", () => { + expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false); + expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false); + }); + + test("returns false when next_tick_at differs", () => { + expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false); + expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false); + }); + + test("returns false when sleep_until differs", () => { + expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false); + expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false); + }); + + test("returns true when both are disabled defaults", () => { + const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null }; + expect(automationStatesEqual(disabled, { ...disabled })).toBe(true); + }); +}); diff --git a/packages/remote-control-server/src/__tests__/client-payload.test.ts b/packages/remote-control-server/src/__tests__/client-payload.test.ts new file mode 100644 index 0000000000..9a47996a59 --- /dev/null +++ b/packages/remote-control-server/src/__tests__/client-payload.test.ts @@ -0,0 +1,258 @@ +import { describe, test, expect } from "bun:test"; +import { toClientPayload } from "../transport/client-payload"; +import type { SessionEvent } from "../transport/event-bus"; + +function makeEvent(overrides: Partial & Pick): SessionEvent { + return { + id: "evt-1", + sessionId: overrides.sessionId, + type: overrides.type, + payload: null, + direction: "inbound", + seqNum: 1, + createdAt: Date.now(), + ...overrides, + }; +} + +// ============================================================================= +// user / user_message +// ============================================================================= + +describe("toClientPayload — user message", () => { + test("maps user type with content", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-1", + payload: { content: "hello" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("user"); + expect(result.session_id).toBe("sess-1"); + expect((result as any).message.role).toBe("user"); + expect((result as any).message.content).toBe("hello"); + }); + + test("maps user_message type same as user", () => { + const event = makeEvent({ + type: "user_message", + sessionId: "sess-2", + payload: { content: "world" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("user"); + expect(result.session_id).toBe("sess-2"); + }); + + test("falls back to message field when content is missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-3", + payload: { message: "fallback msg" }, + }); + const result = toClientPayload(event); + expect((result as any).message.content).toBe("fallback msg"); + }); + + test("falls back to empty string when both content and message missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-4", + payload: {}, + }); + const result = toClientPayload(event); + expect((result as any).message.content).toBe(""); + }); + + test("includes isSynthetic when true", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-5", + payload: { content: "auto", isSynthetic: true }, + }); + const result = toClientPayload(event); + expect((result as any).isSynthetic).toBe(true); + }); + + test("does not include isSynthetic when false", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-6", + payload: { content: "manual", isSynthetic: false }, + }); + const result = toClientPayload(event); + expect((result as any).isSynthetic).toBeUndefined(); + }); + + test("uses payload.uuid when present", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-7", + payload: { content: "hi", uuid: "custom-uuid" }, + }); + const result = toClientPayload(event); + expect(result.uuid).toBe("custom-uuid"); + }); + + test("falls back to event.id when payload.uuid is missing", () => { + const event = makeEvent({ + type: "user", + sessionId: "sess-8", + payload: { content: "hi" }, + }); + const result = toClientPayload(event); + expect(result.uuid).toBe("evt-1"); + }); +}); + +// ============================================================================= +// permission_response / control_response +// ============================================================================= + +describe("toClientPayload — permission response", () => { + test("approved=true maps to allow behavior", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-1", + payload: { approved: true, request_id: "req-1" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + const resp = (result as any).response; + expect(resp.subtype).toBe("success"); + expect(resp.request_id).toBe("req-1"); + expect(resp.response.behavior).toBe("allow"); + }); + + test("approved=false maps to deny behavior with error", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-2", + payload: { approved: false, request_id: "req-2" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + const resp = (result as any).response; + expect(resp.subtype).toBe("error"); + expect(resp.error).toBe("Permission denied by user"); + expect(resp.response.behavior).toBe("deny"); + }); + + test("approved=false includes feedback message when provided", () => { + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-3", + payload: { approved: false, request_id: "req-3", message: "please revise" }, + }); + const result = toClientPayload(event); + expect((result as any).response.message).toBe("please revise"); + }); + + test("passes through existingResponse directly", () => { + const existingResponse = { subtype: "success", custom: true }; + const event = makeEvent({ + type: "control_response", + sessionId: "sess-4", + payload: { approved: true, response: existingResponse }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_response"); + expect((result as any).response).toBe(existingResponse); + }); + + test("includes updatedInput when approved with updated_input", () => { + const updatedInput = { file_path: "/new/path" }; + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-5", + payload: { approved: true, request_id: "req-5", updated_input: updatedInput }, + }); + const result = toClientPayload(event); + expect((result as any).response.response.updatedInput).toEqual(updatedInput); + }); + + test("includes updatedPermissions when approved with updated_permissions", () => { + const perms = [{ type: "allow", tool: "bash" }]; + const event = makeEvent({ + type: "permission_response", + sessionId: "sess-6", + payload: { approved: true, request_id: "req-6", updated_permissions: perms }, + }); + const result = toClientPayload(event); + expect((result as any).response.response.updatedPermissions).toEqual(perms); + }); +}); + +// ============================================================================= +// interrupt +// ============================================================================= + +describe("toClientPayload — interrupt", () => { + test("maps interrupt to control_request with subtype interrupt", () => { + const event = makeEvent({ + type: "interrupt", + sessionId: "sess-1", + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_request"); + expect((result as any).request_id).toBe("evt-1"); + expect((result as any).request.subtype).toBe("interrupt"); + }); +}); + +// ============================================================================= +// control_request +// ============================================================================= + +describe("toClientPayload — control_request", () => { + test("passes through request_id and request from payload", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-1", + payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("control_request"); + expect((result as any).request_id).toBe("req-99"); + expect((result as any).request.subtype).toBe("permission"); + }); + + test("falls back request to payload when no request field", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-2", + payload: { request_id: "req-10", custom: "data" }, + }); + const result = toClientPayload(event); + expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" }); + }); + + test("falls back request_id to event.id when missing", () => { + const event = makeEvent({ + type: "control_request", + sessionId: "sess-3", + payload: { request: { subtype: "test" } }, + }); + const result = toClientPayload(event); + expect((result as any).request_id).toBe("evt-1"); + }); +}); + +// ============================================================================= +// default fallback +// ============================================================================= + +describe("toClientPayload — default types", () => { + test("passes through unknown type with type/uuid/session_id/message", () => { + const event = makeEvent({ + type: "assistant", + sessionId: "sess-1", + payload: { uuid: "u-1", content: "response text" }, + }); + const result = toClientPayload(event); + expect(result.type).toBe("assistant"); + expect(result.uuid).toBe("u-1"); + expect(result.session_id).toBe("sess-1"); + expect(result.message).toEqual({ uuid: "u-1", content: "response text" }); + }); +}); diff --git a/packages/remote-control-server/src/__tests__/transport-normalize.test.ts b/packages/remote-control-server/src/__tests__/transport-normalize.test.ts new file mode 100644 index 0000000000..5ef361875e --- /dev/null +++ b/packages/remote-control-server/src/__tests__/transport-normalize.test.ts @@ -0,0 +1,188 @@ +import { describe, test, expect } from "bun:test"; + +const { normalizePayload } = await import("../services/transport"); + +// extractContent is not exported; we test it via normalizePayload's content field + +// ============================================================================= +// extractContent (via normalizePayload content field) +// ============================================================================= + +describe("extractContent", () => { + test("returns empty string for null payload", () => { + const result = normalizePayload("assistant", null); + expect(result.content).toBe(""); + }); + + test("returns empty string for undefined payload", () => { + const result = normalizePayload("assistant", undefined); + expect(result.content).toBe(""); + }); + + test("returns the string for string payload", () => { + const result = normalizePayload("assistant", "hello world"); + expect(result.content).toBe("hello world"); + }); + + test("extracts content field from object payload", () => { + const result = normalizePayload("assistant", { content: "direct content" }); + expect(result.content).toBe("direct content"); + }); + + test("extracts message.content string from object payload", () => { + const result = normalizePayload("assistant", { message: { content: "msg content" } }); + expect(result.content).toBe("msg content"); + }); + + test("extracts text blocks from message.content array", () => { + const payload = { + message: { + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "World" }, + ], + }, + }; + const result = normalizePayload("assistant", payload); + expect(result.content).toBe("Hello World"); + }); + + test("ignores non-text blocks in message.content array", () => { + const payload = { + message: { + content: [ + { type: "image", url: "http://example.com/img.png" }, + { type: "text", text: "only this" }, + ], + }, + }; + const result = normalizePayload("assistant", payload); + expect(result.content).toBe("only this"); + }); + + test("returns empty string when no extractable content", () => { + const result = normalizePayload("assistant", { foo: "bar" }); + expect(result.content).toBe(""); + }); + + test("prefers direct content over message.content", () => { + const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } }); + expect(result.content).toBe("direct"); + }); +}); + +// ============================================================================= +// normalizePayload — field preservation +// ============================================================================= + +describe("normalizePayload — field preservation", () => { + test("preserves raw payload", () => { + const payload = { content: "test", extra: true }; + const result = normalizePayload("assistant", payload); + expect(result.raw).toBe(payload); + }); + + test("preserves uuid field", () => { + const result = normalizePayload("assistant", { uuid: "u-123" }); + expect(result.uuid).toBe("u-123"); + }); + + test("does not preserve uuid when empty string", () => { + const result = normalizePayload("assistant", { uuid: "" }); + expect(result.uuid).toBeUndefined(); + }); + + test("preserves isSynthetic boolean", () => { + const result = normalizePayload("assistant", { isSynthetic: true }); + expect(result.isSynthetic).toBe(true); + }); + + test("preserves status string", () => { + const result = normalizePayload("assistant", { status: "running" }); + expect(result.status).toBe("running"); + }); + + test("preserves subtype string", () => { + const result = normalizePayload("assistant", { subtype: "progress" }); + expect(result.subtype).toBe("progress"); + }); + + test("preserves tool_name from tool_name field", () => { + const result = normalizePayload("tool", { tool_name: "bash" }); + expect(result.tool_name).toBe("bash"); + }); + + test("preserves tool_name from name field", () => { + const result = normalizePayload("tool", { name: "read" }); + expect(result.tool_name).toBe("read"); + }); + + test("preserves tool_input from tool_input field", () => { + const input = { command: "ls" }; + const result = normalizePayload("tool", { tool_input: input }); + expect(result.tool_input).toEqual(input); + }); + + test("preserves tool_input from input field", () => { + const input = { path: "/tmp" }; + const result = normalizePayload("tool", { input }); + expect(result.tool_input).toEqual(input); + }); + + test("preserves request_id", () => { + const result = normalizePayload("permission", { request_id: "req-1" }); + expect(result.request_id).toBe("req-1"); + }); + + test("preserves request object", () => { + const req = { subtype: "permission" }; + const result = normalizePayload("permission", { request: req }); + expect(result.request).toEqual(req); + }); + + test("preserves approved field", () => { + const result = normalizePayload("permission", { approved: true }); + expect(result.approved).toBe(true); + }); + + test("preserves updated_input", () => { + const input = { command: "rm -rf" }; + const result = normalizePayload("permission", { updated_input: input }); + expect(result.updated_input).toEqual(input); + }); + + test("preserves message field for backward compat", () => { + const msg = { role: "user", content: "hi" }; + const result = normalizePayload("assistant", { message: msg }); + expect(result.message).toEqual(msg); + }); +}); + +// ============================================================================= +// normalizePayload — task_state special handling +// ============================================================================= + +describe("normalizePayload — task_state type", () => { + test("preserves task_list_id (snake_case)", () => { + const result = normalizePayload("task_state", { task_list_id: "tl-1" }); + expect(result.task_list_id).toBe("tl-1"); + }); + + test("preserves taskListId (camelCase)", () => { + const result = normalizePayload("task_state", { taskListId: "tl-2" }); + expect(result.taskListId).toBe("tl-2"); + }); + + test("preserves tasks array", () => { + const tasks = [{ id: "t1", title: "Task 1" }]; + const result = normalizePayload("task_state", { tasks }); + expect(result.tasks).toEqual(tasks); + }); + + test("does not preserve task fields for non-task_state type", () => { + const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] }); + expect(result.task_list_id).toBeUndefined(); + expect(result.taskListId).toBeUndefined(); + expect(result.tasks).toBeUndefined(); + }); +}); diff --git a/packages/remote-control-server/web/src/__tests__/api-client.test.ts b/packages/remote-control-server/web/src/__tests__/api-client.test.ts new file mode 100644 index 0000000000..9a7848d07a --- /dev/null +++ b/packages/remote-control-server/web/src/__tests__/api-client.test.ts @@ -0,0 +1,143 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// In-memory localStorage mock +let store: Record = {}; + +beforeEach(() => { + store = {}; + (globalThis as any).localStorage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v; }, + removeItem: (k: string) => { delete store[k]; }, + clear: () => { store = {}; }, + get length() { return Object.keys(store).length; }, + key: () => null, + }; +}); + +// Mock fetch +const fetchMock = { + lastUrl: "", + lastOpts: {} as RequestInit, + response: { ok: true, status: 200, statusText: "OK" }, + responseData: {} as any, +}; + +beforeEach(() => { + fetchMock.lastUrl = ""; + fetchMock.lastOpts = {}; + fetchMock.response = { ok: true, status: 200, statusText: "OK" }; + fetchMock.responseData = {}; +}); + +(globalThis as any).fetch = async (url: string, opts: RequestInit) => { + fetchMock.lastUrl = url; + fetchMock.lastOpts = opts; + return { + ok: fetchMock.response.ok, + status: fetchMock.response.status, + statusText: fetchMock.response.statusText, + json: async () => fetchMock.responseData, + } as Response; +}; + +// Mock crypto.randomUUID +(globalThis as any).crypto = { + randomUUID: () => "test-uuid-12345678", +}; + +const { getUuid, setUuid } = await import("../api/client"); + +// Import api* functions - they depend on getUuid and fetch +const client = await import("../api/client"); + +// ============================================================================= +// getUuid() +// ============================================================================= + +describe("getUuid", () => { + test("returns existing UUID from localStorage", () => { + store["rcs_uuid"] = "existing-uuid"; + expect(getUuid()).toBe("existing-uuid"); + }); + + test("generates and stores new UUID when none exists", () => { + const uuid = getUuid(); + expect(uuid).toBe("test-uuid-12345678"); + expect(store["rcs_uuid"]).toBe("test-uuid-12345678"); + }); + + test("returns same UUID on subsequent calls", () => { + const a = getUuid(); + const b = getUuid(); + expect(a).toBe(b); + }); +}); + +// ============================================================================= +// setUuid() +// ============================================================================= + +describe("setUuid", () => { + test("writes UUID to localStorage", () => { + setUuid("custom-uuid-999"); + expect(store["rcs_uuid"]).toBe("custom-uuid-999"); + }); + + test("getUuid returns the set UUID", () => { + setUuid("my-uuid"); + expect(getUuid()).toBe("my-uuid"); + }); +}); + +// ============================================================================= +// api() — tested via apiFetchSession (GET) and apiBind (POST) +// ============================================================================= + +describe("api functions", () => { + test("GET request appends uuid to URL", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.responseData = []; + await client.apiFetchSessions(); + expect(fetchMock.lastUrl).toContain("uuid=test-uuid"); + expect(fetchMock.lastOpts.method).toBe("GET"); + }); + + test("GET request uses ? for URL without existing query params", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.responseData = []; + await client.apiFetchSessions(); + expect(fetchMock.lastUrl).toContain("?uuid="); + }); + + test("GET request uses & for URL with existing query params", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.responseData = []; + await client.apiFetchAllSessions(); + // apiFetchAllSessions calls GET /web/sessions/all + expect(fetchMock.lastUrl).toContain("?uuid="); + }); + + test("POST request includes JSON body", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.responseData = {}; + await client.apiBind("sess-1"); + expect(fetchMock.lastOpts.method).toBe("POST"); + expect(fetchMock.lastOpts.body).toBe(JSON.stringify({ sessionId: "sess-1" })); + expect(fetchMock.lastOpts.headers).toEqual({ "Content-Type": "application/json" }); + }); + + test("throws error on non-ok response", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" }; + fetchMock.responseData = { error: { type: "auth", message: "Invalid UUID" } }; + await expect(client.apiFetchSessions()).rejects.toThrow("Invalid UUID"); + }); + + test("throws with statusText when error message is missing", async () => { + store["rcs_uuid"] = "test-uuid"; + fetchMock.response = { ok: false, status: 500, statusText: "Internal Server Error" }; + fetchMock.responseData = {}; + await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error"); + }); +}); diff --git a/packages/remote-control-server/web/src/__tests__/utils.test.ts b/packages/remote-control-server/web/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..79a83e4e29 --- /dev/null +++ b/packages/remote-control-server/web/src/__tests__/utils.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect } from "bun:test"; + +const { + formatTime, + statusClass, + isClosedSessionStatus, + truncate, + generateMessageUuid, + extractEventText, + isConversationClearedStatus, +} = await import("../lib/utils"); + +// ============================================================================= +// formatTime() +// ============================================================================= + +describe("formatTime", () => { + test("returns empty string for null", () => { + expect(formatTime(null)).toBe(""); + }); + + test("returns empty string for undefined", () => { + expect(formatTime(undefined)).toBe(""); + }); + + test("returns empty string for 0", () => { + expect(formatTime(0)).toBe(""); + }); + + test("formats valid unix timestamp", () => { + const result = formatTime(1700000000); + expect(result).toContain("2023"); + }); +}); + +// ============================================================================= +// statusClass() +// ============================================================================= + +describe("statusClass", () => { + test("maps known statuses correctly", () => { + expect(statusClass("active")).toBe("active"); + expect(statusClass("running")).toBe("running"); + expect(statusClass("idle")).toBe("idle"); + expect(statusClass("inactive")).toBe("inactive"); + expect(statusClass("requires_action")).toBe("requires_action"); + expect(statusClass("archived")).toBe("archived"); + expect(statusClass("error")).toBe("error"); + }); + + test("returns default for unknown status", () => { + expect(statusClass("unknown")).toBe("default"); + }); + + test("returns default for null", () => { + expect(statusClass(null)).toBe("default"); + }); + + test("returns default for undefined", () => { + expect(statusClass(undefined)).toBe("default"); + }); + + test("returns default for empty string", () => { + expect(statusClass("")).toBe("default"); + }); +}); + +// ============================================================================= +// isClosedSessionStatus() +// ============================================================================= + +describe("isClosedSessionStatus", () => { + test("returns true for archived", () => { + expect(isClosedSessionStatus("archived")).toBe(true); + }); + + test("returns true for inactive", () => { + expect(isClosedSessionStatus("inactive")).toBe(true); + }); + + test("returns false for active", () => { + expect(isClosedSessionStatus("active")).toBe(false); + }); + + test("returns false for null", () => { + expect(isClosedSessionStatus(null)).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isClosedSessionStatus(undefined)).toBe(false); + }); +}); + +// ============================================================================= +// truncate() +// ============================================================================= + +describe("truncate", () => { + test("returns empty string for null", () => { + expect(truncate(null, 10)).toBe(""); + }); + + test("returns empty string for undefined", () => { + expect(truncate(undefined, 10)).toBe(""); + }); + + test("returns original string when shorter than max", () => { + expect(truncate("hello", 10)).toBe("hello"); + }); + + test("returns original string when exactly max length", () => { + expect(truncate("12345", 5)).toBe("12345"); + }); + + test("truncates and appends ... when longer than max", () => { + expect(truncate("hello world", 5)).toBe("hello..."); + }); +}); + +// ============================================================================= +// generateMessageUuid() +// ============================================================================= + +describe("generateMessageUuid", () => { + test("returns a non-empty string", () => { + const uuid = generateMessageUuid(); + expect(typeof uuid).toBe("string"); + expect(uuid.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================= +// extractEventText() +// ============================================================================= + +describe("extractEventText", () => { + test("returns empty string for null", () => { + expect(extractEventText(null)).toBe(""); + }); + + test("returns empty string for undefined", () => { + expect(extractEventText(undefined)).toBe(""); + }); + + test("returns empty string for non-object", () => { + expect(extractEventText("string" as any)).toBe(""); + }); + + test("extracts payload.content string", () => { + expect(extractEventText({ content: "hello" })).toBe("hello"); + }); + + test("extracts from message.content text blocks array", () => { + const payload = { + message: { + content: [ + { type: "text", text: "line 1" }, + { type: "text", text: "line 2" }, + ], + }, + }; + expect(extractEventText(payload)).toBe("line 1\nline 2"); + }); + + test("ignores non-text blocks", () => { + const payload = { + message: { + content: [ + { type: "image", data: "base64..." }, + { type: "text", text: "only text" }, + ], + }, + }; + expect(extractEventText(payload)).toBe("only text"); + }); + + test("returns empty string when message.content has no text blocks", () => { + const payload = { + message: { content: [{ type: "image", data: "base64" }] }, + }; + expect(extractEventText(payload)).toBe(""); + }); + + test("returns empty string for empty object", () => { + expect(extractEventText({})).toBe(""); + }); +}); + +// ============================================================================= +// isConversationClearedStatus() +// ============================================================================= + +describe("isConversationClearedStatus", () => { + test("returns true when payload.status is conversation_cleared", () => { + expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true); + }); + + test("returns true when payload.raw.status is conversation_cleared", () => { + expect(isConversationClearedStatus({ raw: { status: "conversation_cleared" } })).toBe(true); + }); + + test("returns false for null", () => { + expect(isConversationClearedStatus(null)).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isConversationClearedStatus(undefined)).toBe(false); + }); + + test("returns false for other status", () => { + expect(isConversationClearedStatus({ status: "active" })).toBe(false); + }); + + test("returns false when raw has different status", () => { + expect(isConversationClearedStatus({ raw: { status: "running" } })).toBe(false); + }); + + test("returns false for empty object", () => { + expect(isConversationClearedStatus({})).toBe(false); + }); +}); From 5cb4e3c6c45092bbfbebdf07fc1adc82365707c2 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 19:40:45 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20RCS=20ACP=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9D=83=E9=99=90=E6=A8=A1=E5=BC=8F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=20+=20=E6=9D=83=E9=99=90=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断) - 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递 - 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug - 权限模式和模型选择持久化到 localStorage - acp-link 直接连接路径同步支持 permissionMode 透传 Co-Authored-By: Claude Opus 4.6 --- .impeccable.md | 78 ++++++++++++ CLAUDE.md | 45 ++++++- packages/acp-link/src/server.ts | 7 +- .../web/components/ChatInterface.tsx | 113 ++++++++++++++++-- .../web/src/acp/client.ts | 4 +- .../web/src/acp/types.ts | 2 +- .../web/src/hooks/useModels.ts | 8 ++ src/services/acp/agent.ts | 7 +- src/services/acp/permissions.ts | 4 +- 9 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 .impeccable.md diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000000..bf6a05200a --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,78 @@ +# Impeccable Design Context + +## Users + +**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows. +- DevOps engineers managing remote agents via RCS dashboard +- Development teams collaborating through shared sessions +- Individual developers using terminal CLI daily + +**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality. + +**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth. + +## Brand Personality + +**3 words**: Warm, Considered, Human + +**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围). + +**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference). + +**References**: +- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward. +- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility). + +**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee." + +## Aesthetic Direction + +**Theme**: Light + Dark dual mode (user/system preference switch) + +**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world. + +**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality. + +**Color**: +- Primary: Claude orange family (`#D77757` / terra cotta) +- Accent: Warm neutrals tinted toward orange +- Semantic: Success/Error/Warning following Anthropic's established palette +- Dark mode: Warm dark surfaces (not cold blue-black) + +**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor. + +**Scope**: All Web UI — RCS control panel, documentation site, landing pages. + +## Design Principles + +1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship. +2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color. +3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place. +4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest. +5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch. + +## Existing Design Assets + +### Brand Colors (from theme system) +- Claude Orange: `rgb(215,119,87)` / `#D77757` +- Claude Blue: `rgb(87,105,247)` / `#5769F7` +- Permission Blue: `rgb(87,105,247)` +- Auto Accept Violet: `rgb(135,0,255)` +- Plan Mode Teal: `rgb(0,102,102)` +- Success: `rgb(78,186,101)` +- Error: `rgb(255,107,128)` +- Warning: `rgb(255,193,7)` + +### Logo +- CCB text + orange play button icon +- Dark/Light SVG variants in `docs/logo/` +- Favicon: Orange circle `#D97706` with white play triangle + +### Mascot +- "Clawd" — terminal-art character with multiple poses +- Theme-aware coloring + +### Theme System +- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto +- 89+ semantic color tokens +- Full documentation in `packages/@ant/ink/docs/04-theme-system.md` diff --git a/CLAUDE.md b/CLAUDE.md index a985a5cadc..aa35cf025f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ bun run build bun run build:vite # Test -bun test # run all tests (3066 tests / 205 files / 0 fail) +bun test # run all tests (3175 tests / 207 files / 0 fail) bun test src/utils/__tests__/hash.test.ts # run single file bun test --coverage # with coverage report @@ -157,6 +157,7 @@ bun run docs:dev | `packages/@ant/model-provider/` | Model provider 抽象层 | | `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) | | `packages/agent-tools/` | Agent 工具集 | +| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) | | `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) | | `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) | | `packages/mcp-client/` | MCP 客户端库 | @@ -173,10 +174,16 @@ bun run docs:dev ### Bridge / Remote Control - **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 -- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。 +- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React + Vite)。通过 `bun run rcs` 启动。 - CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。 - 详见 `docs/features/remote-control-self-hosting.md`。 +### ACP Protocol (Agent Client Protocol) + +- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。 +- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理。 +- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示。 + ### Daemon Mode - **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 @@ -209,6 +216,12 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。 +### 穷鬼模式(Budget Mode) + +- 通过 `/poor` 命令切换,持久化到 `settings.json`。 +- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。 +- 实现在 `src/commands/poor/poorMode.ts`。 + ### Stubbed/Deleted Modules | Module | Status | @@ -233,7 +246,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 ## Testing - **框架**: `bun:test`(内置断言 + mock) -- **当前状态**: 3066 tests / 205 files / 0 fail +- **当前状态**: 3175 tests / 207 files / 0 fail - **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` - **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) - **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) @@ -278,3 +291,29 @@ bun run typecheck # equivalent to bun run typecheck - **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 - **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 + +## Design Context + +Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。 + +### 核心设计原则 + +1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流 +2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖 +3. **Density with clarity** — 技术用户需要信息密度,但不能混乱 +4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队 +5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温 + +### 品牌色 + +- 主色:Claude Orange `#D77757`(terra cotta) +- 辅色:Claude Blue `#5769F7` +- 暗色模式使用温暖的深色表面(非冷蓝黑色) + +### 目标用户 + +技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。 + +### 视觉参考 + +Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。 diff --git a/packages/acp-link/src/server.ts b/packages/acp-link/src/server.ts index 5b4d229ce0..6a6f95a271 100644 --- a/packages/acp-link/src/server.ts +++ b/packages/acp-link/src/server.ts @@ -318,7 +318,7 @@ async function handleConnect(ws: WSContext): Promise { async function handleNewSession( ws: WSContext, - params: { cwd?: string }, + params: { cwd?: string; permissionMode?: string }, ): Promise { const state = clients.get(ws); if (!state?.connection) { @@ -332,6 +332,7 @@ async function handleNewSession( const result = await state.connection.newSession({ cwd: sessionCwd, mcpServers: [], + ...(params.permissionMode ? { _meta: { permissionMode: params.permissionMode } } : {}), }); state.sessionId = result.sessionId; @@ -634,7 +635,7 @@ export async function startServer(config: ServerConfig): Promise { handleDisconnect(relayWs); break; case "new_session": - await handleNewSession(relayWs, (msg.payload as { cwd?: string }) || {}); + await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {}); break; case "prompt": await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] }); @@ -734,7 +735,7 @@ export async function startServer(config: ServerConfig): Promise { handleDisconnect(ws); break; case "new_session": - await handleNewSession(ws, (data.payload as { cwd?: string }) || {}); + await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {}); break; case "prompt": await handlePrompt(ws, data.payload as { content: ContentBlock[] }); diff --git a/packages/remote-control-server/web/components/ChatInterface.tsx b/packages/remote-control-server/web/components/ChatInterface.tsx index d9d3c6e6df..8acc93166e 100644 --- a/packages/remote-control-server/web/components/ChatInterface.tsx +++ b/packages/remote-control-server/web/components/ChatInterface.tsx @@ -44,13 +44,14 @@ function dataUrlToBlob(dataUrl: string): Blob { return new Blob([bytes], { type: mimeType }); } -import { Plus } from "lucide-react"; +import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react"; import { Button } from "./ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "./ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; // ============================================================================= // Type Definitions - imported from shared types module @@ -60,6 +61,71 @@ interface ChatInterfaceProps { client: ACPClient; } +// ============================================================================= +// Permission Mode Selector +// ============================================================================= + +const PERMISSION_MODES = [ + { value: "default", label: "默认", description: "手动审批权限请求" }, + { value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" }, + { value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" }, + { value: "plan", label: "规划模式", description: "仅规划,不执行工具" }, + { value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" }, + { value: "auto", label: "自动判断", description: "AI 自动判断是否批准" }, +] as const; + +function PermissionModeSelector({ + mode, + onModeChange, +}: { + mode: string; + onModeChange: (mode: string) => void; +}) { + const [open, setOpen] = useState(false); + const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0]; + + return ( + + + + + + {PERMISSION_MODES.map((m) => ( + + ))} + + + ); +} + // ============================================================================= // Helper Functions // ============================================================================= @@ -95,6 +161,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) { const activeSessionIdRef = useRef(null); const [errorMessage, setErrorMessage] = useState(null); const errorTimerRef = useRef | null>(null); + const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default"); // Reference: Zed's supports_images() checks prompt_capabilities.image const [supportsImages, setSupportsImages] = useState(false); const { commands: availableCommands } = useCommands(client); @@ -462,7 +529,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) { }); // Create session - client.createSession(); + client.createSession(undefined, permissionMode); return () => { if (errorTimerRef.current) clearTimeout(errorTimerRef.current); client.setSessionCreatedHandler(() => {}); @@ -497,8 +564,8 @@ export function ChatInterface({ client }: ChatInterfaceProps) { // 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]); + client.createSession(undefined, permissionMode); + }, [client, isLoading, resetThreadState, permissionMode]); // Cancel handler - matches Zed's cancel() logic in acp_thread.rs // 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled @@ -591,9 +658,36 @@ export function ChatInterface({ client }: ChatInterfaceProps) { // 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]); + // Find the matching permission request to get the real optionId + const perm = pendingPermissions.find((p) => p.requestId === requestId); + let optionId: string | null = null; + let optionKind: PermissionOption["kind"] | null = null; + + if (perm?.options && perm.options.length > 0) { + if (approved) { + // Pick the first allow option (prefer allow_once, then allow_always) + const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always"); + if (allowOpt) { + optionId = allowOpt.optionId; + optionKind = allowOpt.kind; + } + } else { + // Pick the first reject option + const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always"); + if (rejectOpt) { + optionId = rejectOpt.optionId; + optionKind = rejectOpt.kind; + } + } + } + + // Fallback: if no matching option found, use null (cancelled) + if (!optionId) { + optionKind = approved ? "allow_once" : "reject_once"; + } + + handlePermissionResponse(requestId, optionId, optionKind); + }, [handlePermissionResponse, pendingPermissions]); // Handle ChatInput submit — convert ChatInputMessage to ContentBlock[] const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => { @@ -716,7 +810,10 @@ export function ChatInterface({ client }: ChatInterfaceProps) { {/* Model selector + New thread + ChatInput */}
- +
+ { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} /> + +
{entries.length > 0 && ( diff --git a/packages/remote-control-server/web/src/acp/client.ts b/packages/remote-control-server/web/src/acp/client.ts index 7da0fc3638..81af976d84 100644 --- a/packages/remote-control-server/web/src/acp/client.ts +++ b/packages/remote-control-server/web/src/acp/client.ts @@ -568,10 +568,10 @@ export class ACPClient { this.ws.send(JSON.stringify(message)); } - async createSession(cwd?: string): Promise { + async createSession(cwd?: string, permissionMode?: string): Promise { // Use provided cwd, or fall back to settings.cwd const sessionCwd = cwd ?? this.settings.cwd; - this.send({ type: "new_session", payload: { cwd: sessionCwd } }); + this.send({ type: "new_session", payload: { cwd: sessionCwd, permissionMode } }); } // Reference: Zed's MessageEditor.contents() builds Vec diff --git a/packages/remote-control-server/web/src/acp/types.ts b/packages/remote-control-server/web/src/acp/types.ts index 09bc76c8ab..1a57c6e743 100644 --- a/packages/remote-control-server/web/src/acp/types.ts +++ b/packages/remote-control-server/web/src/acp/types.ts @@ -88,7 +88,7 @@ export type BrowserToolResult = export type ProxyMessage = | { type: "connect" } | { type: "disconnect" } - | { type: "new_session"; payload?: { cwd?: string } } + | { type: "new_session"; payload?: { cwd?: string; permissionMode?: string } } | { type: "prompt"; payload: { content: ContentBlock[] } } // Changed from { text: string } to match Zed | { type: "cancel" } | { type: "permission_response"; payload: PermissionResponsePayload } diff --git a/packages/remote-control-server/web/src/hooks/useModels.ts b/packages/remote-control-server/web/src/hooks/useModels.ts index 312c0e7386..f10b2ee019 100644 --- a/packages/remote-control-server/web/src/hooks/useModels.ts +++ b/packages/remote-control-server/web/src/hooks/useModels.ts @@ -37,6 +37,13 @@ export function useModels(client: ACPClient): UseModelsResult { // Handler for when model state changes (session created or disconnected) const handleModelStateChanged = (state: SessionModelState | null) => { setModelState(state); + // Auto-restore previously selected model when a new session is created + if (state && state.availableModels.length > 0) { + const saved = localStorage.getItem("acp_model_id"); + if (saved && saved !== state.currentModelId && state.availableModels.some((m) => m.modelId === saved)) { + client.setSessionModel(saved).catch(() => {}); + } + } }; // Handler for when current model changes within a session @@ -83,6 +90,7 @@ export function useModels(client: ACPClient): UseModelsResult { setIsLoading(true); try { await client.setSessionModel(modelId); + localStorage.setItem("acp_model_id", modelId); // The model_changed event will update the state } catch (error) { setIsLoading(false); diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index 092adfa09d..7be0d799f1 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -459,10 +459,13 @@ export class AcpAgent implements Agent { const permissionContext = getEmptyToolPermissionContext() const tools: Tools = getTools(permissionContext) - // Parse permission mode from settings + // Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings + const metaPermissionMode = (params._meta as Record | null | undefined)?.permissionMode as string | undefined + console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode) const permissionMode = resolvePermissionMode( - this.getSetting('permissions.defaultMode'), + metaPermissionMode ?? this.getSetting('permissions.defaultMode'), ) + console.log('[ACP Agent] Resolved permissionMode:', permissionMode) // Create the permission bridge canUseTool function const canUseTool = createAcpCanUseTool( diff --git a/src/services/acp/permissions.ts b/src/services/acp/permissions.ts index 782346f21c..854fe1c3e7 100644 --- a/src/services/acp/permissions.ts +++ b/src/services/acp/permissions.ts @@ -59,7 +59,9 @@ export function createAcpCanUseTool( } // ── bypassPermissions mode ─────────────────────────────────── - if (getCurrentMode() === 'bypassPermissions') { + const currentMode = getCurrentMode() + console.log('[ACP Permissions] currentMode:', currentMode, 'tool:', tool.name) + if (currentMode === 'bypassPermissions') { return { behavior: 'allow', updatedInput: input, From 9bf844da79f87ec34807eb665775b4879284c40c Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 18 Apr 2026 21:17:46 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20RCS=20Web=20UI=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20+=20QR=20=E4=BF=AE=E5=A4=8D=20+=20ACP=20=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../web/components/ACPMain.tsx | 32 ++-- .../web/components/ChatInterface.tsx | 37 +++- .../components/ai-elements/conversation.tsx | 12 +- .../ai-elements/permission-request.tsx | 2 +- .../web/components/ai-elements/reasoning.tsx | 10 +- .../web/components/ai-elements/shimmer.tsx | 23 +-- .../web/components/chat/ChatInput.tsx | 16 +- .../web/components/chat/ChatView.tsx | 26 +-- .../web/components/chat/CommandMenu.tsx | 140 ++++++++------ .../web/components/chat/MessageBubble.tsx | 72 +++++-- .../web/components/chat/PermissionPanel.tsx | 2 +- .../web/components/chat/PlanView.tsx | 23 +-- .../web/components/chat/SessionSidebar.tsx | 6 +- .../web/components/chat/ToolCallGroup.tsx | 12 +- packages/remote-control-server/web/index.html | 2 + .../remote-control-server/web/src/App.tsx | 58 ++++-- .../web/src/components/ACPDirectView.tsx | 86 +++++++++ .../web/src/components/ControlBar.tsx | 9 +- .../web/src/components/EnvironmentList.tsx | 10 +- .../web/src/components/IdentityPanel.tsx | 65 ++++--- .../web/src/components/Navbar.tsx | 72 ++++--- .../web/src/components/NewSessionDialog.tsx | 56 +++--- .../web/src/components/PermissionViews.tsx | 11 +- .../web/src/components/SessionList.tsx | 9 +- .../web/src/components/TaskPanel.tsx | 31 +-- .../remote-control-server/web/src/index.css | 177 +++++++++--------- .../web/src/pages/Dashboard.tsx | 10 +- .../web/src/pages/SessionDetail.tsx | 25 +-- 28 files changed, 624 insertions(+), 410 deletions(-) create mode 100644 packages/remote-control-server/web/src/components/ACPDirectView.tsx diff --git a/packages/remote-control-server/web/components/ACPMain.tsx b/packages/remote-control-server/web/components/ACPMain.tsx index 1551a5e40d..216ff8e8ed 100644 --- a/packages/remote-control-server/web/components/ACPMain.tsx +++ b/packages/remote-control-server/web/components/ACPMain.tsx @@ -7,13 +7,14 @@ import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react"; interface ACPMainProps { client: ACPClient; + agentId?: string; } /** * Main container — Anthropic sidebar + chat layout. * Sidebar: sectioned by recency, orange active state, warm raised bg. */ -export function ACPMain({ client }: ACPMainProps) { +export function ACPMain({ client, agentId }: ACPMainProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // Handle session selection @@ -36,16 +37,16 @@ export function ACPMain({ client }: ACPMainProps) { {/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
{/* 头部 */} -
+
{!sidebarCollapsed && ( - 会话 + 会话 )} -
+
{!sidebarCollapsed && (
); @@ -170,11 +171,12 @@ function SidebarSessionList({ const groups = groupByRecency(sorted); return ( -