From 6048b2fae035c39d10630198e7a32489e2751fba Mon Sep 17 00:00:00 2001 From: "yuang.peng" Date: Mon, 20 Apr 2026 12:10:05 +0800 Subject: [PATCH 1/2] feat: introduce MessageGroup model replacing flat Message[] for grouped assistant rendering --- docs/design/unified-tool-design.md | 214 ++++++--- .../com/github/codeplangui/ChatService.kt | 58 +-- src/main/resources/webview/index.html | 120 ++--- webview/package.json | 2 +- webview/src/App.tsx | 51 +- webview/src/components/AssistantGroup.tsx | 81 ++++ webview/src/components/AssistantMarkdown.tsx | 83 ++++ webview/src/groupReducer.ts | 255 ++++++++++ webview/test/groupReducer.test.mjs | 436 ++++++++++++++++++ webview/tsconfig.test.json | 3 +- 10 files changed, 1118 insertions(+), 185 deletions(-) create mode 100644 webview/src/components/AssistantGroup.tsx create mode 100644 webview/src/components/AssistantMarkdown.tsx create mode 100644 webview/src/groupReducer.ts create mode 100644 webview/test/groupReducer.test.mjs diff --git a/docs/design/unified-tool-design.md b/docs/design/unified-tool-design.md index 5bff61e..732b6e3 100644 --- a/docs/design/unified-tool-design.md +++ b/docs/design/unified-tool-design.md @@ -1,10 +1,11 @@ # CodePlanGUI 统一工具协议系统 — 设计文档 -> **版本**: v2.0 -> **日期**: 2026-04-17 +> **版本**: v3.0 +> **日期**: 2026-04-20 > **状态**: Draft +> **前置依赖**: Phase 1(统一事件通道 `onEvent`)已完成;Phase 2(消息分组 `MessageGroup`)已完成 > **参考**: MiniCode PRD_UNIFIED_TOOL_PROTOCOL.md、claw-code 权限模型调研 -> **变更**: v1.0 → v2.0 合并 protocol-design + architecture,优化 7 项设计问题 +> **变更**: v2.0 → v3.0 适配统一事件通道(`onEvent`)和消息分组(`MessageGroup`)架构 --- @@ -558,7 +559,7 @@ private suspend fun awaitApproval(requestId: String): Boolean { } } -// Vue 回调时恢复协程 +// 前端通过 window.__bridge.approvalResponse() 回调时恢复协程 fun onApprovalResponse(requestId: String, decision: String) { val cont = pendingApprovals.remove(requestId) ?: return val approved = decision == "allow" @@ -590,10 +591,10 @@ EditFileExecutor / WriteFileExecutor 修改已有文件 │ 计算增删行数 │ ├─ 通过 Bridge 发送 diff 审批请求 - │ 事件: file_change_request - │ Payload: { requestId, path, diff, stats: { added, removed } } + │ 调用: notifyFileChangeRequest() → buildEventJS("file_change_request", ...) + │ → onEvent("file_change_request", payload) → groupReducer │ - ├─ 协程挂起 awaitApproval(),等待 Vue 回调 + ├─ 协程挂起 awaitApproval(),等待前端 window.__bridge.fileChangeResponse() 回调 │ ├─ 用户拒绝 → ToolResult(ok=false, "User rejected") │ @@ -610,7 +611,8 @@ WriteFileExecutor 创建新文件 ├─ 文件不存在 → 新建文件确认流程 │ ├─ 通过 Bridge 发送新建文件确认请求 - │ 事件: file_create_request + │ 调用: notifyFileCreateRequest() → buildEventJS("file_create_request", ...) + │ → onEvent("file_create_request", payload) → groupReducer │ Payload: { │ requestId, │ path, @@ -619,7 +621,7 @@ WriteFileExecutor 创建新文件 │ language: 根据扩展名推断的语言标识 │ } │ - ├─ 协程挂起 awaitApproval(),等待 Vue 回调 + ├─ 协程挂起 awaitApproval(),等待前端 window.__bridge 回调 │ ├─ 用户拒绝 → ToolResult(ok=false, "User rejected") │ @@ -628,19 +630,19 @@ WriteFileExecutor 创建新文件 ### 8.4 Bridge 事件定义 -#### Kotlin → Vue +#### Kotlin → 前端(走 `onEvent` 统一通道) -| 事件 | 字段 | 说明 | -|------|------|------| +| 事件 type | Payload 字段 | 说明 | +|-----------|-------------|------| | `file_change_request` | requestId, path, diff, stats | 修改已有文件的 diff 审批 | | `file_create_request` | requestId, path, size, preview, language | 新建文件的确认 | -#### Vue → Kotlin +#### 前端 → Kotlin(走 `window.__bridge` 动作方法) -| 事件 | 字段 | 说明 | -|------|------|------| -| `file_change_response` | requestId, decision | `"allow"` 或 `"deny"` | -| `file_create_response` | requestId, decision | `"allow"` 或 `"deny"` | +| 动作方法 | 参数 | 说明 | +|----------|------|------| +| `fileChangeResponse` | requestId, decision | `"allow"` 或 `"deny"` | +| `fileCreateResponse` | requestId, decision | `"allow"` 或 `"deny"` | ### 8.5 前端组件 @@ -660,56 +662,118 @@ WriteFileExecutor 创建新文件 ## 9. Bridge 事件体系扩展 -### 9.1 现有事件(保留) +> **架构前提**:Phase 1 已将 15 个独立回调统一为单一 `onEvent(type, payload)` 通道(`buildEventJS()` → `onEvent`)。Phase 2 引入 `MessageGroup[]` 替代扁平 `Message[]`,`eventReducer` 升级为 `groupReducer`。本节在此基础上扩展。 -| 方向 | 事件 | 说明 | -|------|------|------| -| IDE → Vue | `onApprovalRequest` | 命令审批请求 | -| IDE → Vue | `onExecutionStatus` | 执行状态更新 | -| Vue → IDE | `approvalResponse` | 用户审批决定 | +### 9.1 现有事件(保留,走 `onEvent` 通道) -### 9.2 新增事件 +Phase 1 已将以下事件统一到 `onEvent(type, payload)` 通道: -| 方向 | 事件 | 说明 | -|------|------|------| -| IDE → Vue | `onFileChangeRequest` | 文件修改 diff 审批请求 | -| Vue → IDE | `fileChangeResponse` | diff 审批决定 | -| IDE → Vue | `onFileCreateRequest` | 新建文件确认请求 | -| Vue → IDE | `fileCreateResponse` | 新建文件确认决定 | +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `approval_request` | `{requestId, command, description}` | 命令审批请求 | +| `execution_card` | `{requestId, command, description}` | 执行卡片创建 | +| `execution_status` | `{requestId, status, result}` | 执行状态更新 | +| `log` | `{requestId, line, type}` | 执行日志 | +| `round_end` | `{msgId}` | 工具调用轮次结束(Phase 2 激活) | + +### 9.2 新增事件(新增 type,复用 `onEvent` 通道) + +新增 2 个 IDE → 前端事件类型,直接在 `groupReducer` 中新增 case 处理: + +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `file_change_request` | `{requestId, path, diff, stats: {added, removed}}` | 文件修改 diff 审批请求 | +| `file_create_request` | `{requestId, path, size, preview, language}` | 新建文件确认请求 | + +新增 2 个前端 → IDE 动作方法(挂载到 `window.__bridge` 动作接口): + +| 动作方法 | 参数 | 说明 | +|----------|------|------| +| `fileChangeResponse` | `(requestId, decision)` | `"allow"` 或 `"deny"` | +| `fileCreateResponse` | `(requestId, decision)` | `"allow"` 或 `"deny"` | ### 9.3 现有事件扩展 -**onApprovalRequest** 增加 `toolName` 参数: +**`approval_request`** 增加 `toolName` 和 `toolInput` 字段: -| 参数 | 现有 | 新增 | +| 字段 | 现有 | 变更 | |------|------|------| -| `requestId` | ✓ | ✓ | -| `command` | ✓ | → 改名 `toolInput`(通用化) | -| `description` | ✓ | ✓ | -| `toolName` | — | ✓(如 `run_command`、`mcp__xxx`) | +| `requestId` | ✓ | 不变 | +| `command` | ✓ | → 改名 `toolInput`(通用化,兼容旧值) | +| `description` | ✓ | 不变 | +| `toolName` | — | **新增**(如 `run_command`、`mcp__xxx`) | 前端根据 `toolName` 渲染不同审批 UI: - `run_command` → 展示命令 + 描述(现有样式) +- `edit_file` / `write_file` → 跳过审批弹框(由 `file_change_request` / `file_create_request` 独立处理) - 其他工具 → 展示工具名 + 参数摘要 ### 9.4 BridgeHandler.kt 变更 +所有新事件均通过 `buildEventJS()` 生成,走统一 `onEvent` 通道: + | 方法 | 变更 | |------|------| -| `notifyApprovalRequest` | 参数增加 `toolName` | -| `notifyFileChangeRequest` | **新增** | -| `notifyFileCreateRequest` | **新增** | -| Bridge JS 模板 | 新增 `onFileChangeRequest` / `fileChangeResponse` / `onFileCreateRequest` / `fileCreateResponse` | +| `notifyApprovalRequest` | payload 增加 `toolName`、`command` 改名 `toolInput`;内部改为 `flushAndPush(buildEventJS("approval_request", ...))` | +| `notifyFileChangeRequest` | **新增** — `flushAndPush(buildEventJS("file_change_request", ...))` | +| `notifyFileCreateRequest` | **新增** — `flushAndPush(buildEventJS("file_create_request", ...))` | + +调度策略: +- `approval_request` / `file_change_request` / `file_create_request` → `flushAndPush`(结构性事件,需先刷空待处理 token) +- 与 Phase 1 中 `execution_card`、`approval_request` 的调度策略一致 ### 9.5 bridge.d.ts 变更 -| 接口方法 | 变更 | -|----------|------| -| `onApprovalRequest` | 增加 `toolName` 参数 | -| `onFileChangeRequest` | **新增** | -| `fileChangeResponse` | **新增** | -| `onFileCreateRequest` | **新增** | -| `fileCreateResponse` | **新增** | +Bridge 接口保持 `onEvent` + 动作方法结构不变,仅扩展动作方法: + +```typescript +interface Bridge { + // 统一事件通道(Kotlin → JS)— 不变 + onEvent: (type: string, payloadJson: string) => void + + // 动作方法(JS → Kotlin) + sendMessage: (text: string, includeContext: boolean) => void + approvalResponse: (requestId: string, action: string, addToWhitelist?: boolean) => void + fileChangeResponse: (requestId: string, decision: string) => void // 新增 + fileCreateResponse: (requestId: string, decision: string) => void // 新增 + cancelStream: () => void + newChat: () => void + openSettings: () => void + debugLog: (message: string) => void + frontendReady: () => void +} +``` + +### 9.6 前端 groupReducer 扩展 + +新增事件类型在 `groupReducer`(Phase 2 引入,替代 `eventReducer`)中新增 case: + +```typescript +case "file_change_request": + return { + ...state, + fileChangeReview: { + requestId: payload.requestId, + path: payload.path, + diff: payload.diff, + stats: payload.stats, + }, + } + +case "file_create_request": + return { + ...state, + fileCreateReview: { + requestId: payload.requestId, + path: payload.path, + size: payload.size, + preview: payload.preview, + language: payload.language, + }, + } +``` + +`approval_request` 的 case 扩展为根据 `toolName` 区分渲染逻辑(现有命令审批 + 通用工具审批)。 --- @@ -814,6 +878,8 @@ MCP Server Trust (Phase 4) ### 12.1 删除的代码(约 120 行) +> **注意**:Phase 2 已删除 `bridgeNotifiedStart` 集合、延迟 `notifyStart` 逻辑和 `notifyRemoveMessage` hack。以下仅列出统一工具设计额外删除的代码。 + | 方法/字段 | 原因 | |-----------|------| | `runCommandToolDefinition()` | 移到 ToolSpecs.kt 常量 | @@ -876,15 +942,21 @@ MCP Server Trust (Phase 4) | 组件 | 变更 | |------|------| | `ChatService.kt` | 删除 ~120 行硬编码工具逻辑,替换为 ~15 行 Dispatcher 调用 | -| `BridgeHandler.kt` | approval_request 增加 toolName;新增 file_change_request / file_create_request 事件 | -| `bridge.d.ts` | 更新审批类型;新增文件变更和新建文件事件类型 | +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 增加 `toolName`;新增 `notifyFileChangeRequest` / `notifyFileCreateRequest`,均走 `buildEventJS()` 统一通道 | +| `bridge.d.ts` | 新增 `fileChangeResponse` / `fileCreateResponse` 动作方法 | +| `groupReducer.ts` | 新增 `file_change_request` / `file_create_request` case;`approval_request` case 扩展 `toolName` 分支 | | `ApprovalDialog.tsx` | 扩展支持多种工具类型的审批展示 | +| `FileChangeDialog.tsx` | **新增** — unified diff 审批组件 | +| `FileCreateConfirmDialog.tsx` | **新增** — 新建文件确认组件 | +| `App.tsx` | 新增 `FileChangeDialog` / `FileCreateConfirmDialog` 渲染 + `window.__bridge` 动作方法调用 | --- ## 14. 文件变更清单 -### 14.1 新建文件(16 个) +### 14.1 新建文件(19 个) + +**Kotlin 后端(16 个):** | 文件 | 说明 | |------|------| @@ -905,15 +977,25 @@ MCP Server Trust (Phase 4) | `execution/executors/EditFileExecutor.kt` | 精确替换 + diff 审批 | | `execution/executors/WriteFileExecutor.kt` | 整文件写入 + 确认/diff 审批 | +**React 前端(3 个):** + +| 文件 | 说明 | +|------|------| +| `components/FileChangeDialog.tsx` | 文件修改 diff 审批组件 | +| `components/FileCreateConfirmDialog.tsx` | 新建文件确认组件 | +| `types/tool-review.d.ts` | 文件审批相关类型定义 | + ### 14.2 修改文件 | 文件 | 变更 | |------|------| | `ChatService.kt` | 删除硬编码工具逻辑,改用 ToolCallDispatcher | | `ExecutionResult.kt` | 新增 `toToolResult()` 转换方法 | -| `BridgeHandler.kt` | 审批请求增加 toolName;新增 4 个 Bridge 事件 | -| `bridge.d.ts` | 更新审批类型;新增文件变更/新建文件类型 | -| `ApprovalDialog.tsx` | 扩展支持多工具类型审批 | +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 扩展;新增 `notifyFileChangeRequest` / `notifyFileCreateRequest`(走 `buildEventJS` 统一通道);新增 `fileChangeResponse` / `fileCreateResponse` 动作注入 | +| `bridge.d.ts` | 新增 `fileChangeResponse` / `fileCreateResponse` 动作方法;更新 `approval_request` payload 类型 | +| `groupReducer.ts` | 新增 `file_change_request` / `file_create_request` case;`approval_request` case 扩展 `toolName` 分支 | +| `ApprovalDialog.tsx` | 扩展支持多工具类型审批(根据 `toolName` 切换 UI) | +| `App.tsx` | 新增 `FileChangeDialog` / `FileCreateConfirmDialog` 渲染;注入 `fileChangeResponse` / `fileCreateResponse` 回调 | | `SettingsState` + Settings UI | 新增 permissionMode 字段和 UI | ### 14.3 不动文件 @@ -922,12 +1004,17 @@ MCP Server Trust (Phase 4) |------|------| | `CommandExecutionService.kt` | BashExecutor 和 GrepExecutor 包装它,不改内部 | | `ToolCallAccumulator.kt` | SSE 解析层不变 | -| `ExecutionCard.tsx` | 展示逻辑通用,无需修改 | +| `ExecutionCard.tsx` | 展示逻辑通用,无需修改(Phase 2 已将其归入 `AssistantGroup`) | +| `useBridge.ts` | Phase 1 已统一为 `onEvent`,无需改动 | +| `AssistantGroup.tsx` | Phase 2 新增组件,无需改动 | +| `AssistantMarkdown.tsx` | Phase 2 新增组件,无需改动 | --- ## 15. 迁移步骤 +> **前置条件**:Phase 1(统一事件通道)和 Phase 2(消息分组)已完成。 + | 步骤 | 内容 | 依赖 | 涉及文件 | |------|------|------|----------| | **Step 1** | 核心类型(纯新增) | 无 | ToolResult、ToolContext、ToolSpec、PermissionMode、ToolExecutor | @@ -936,8 +1023,9 @@ MCP Server Trust (Phase 4) | **Step 4** | 写入类执行器 + 审批机制 | Step 2 | EditFileExecutor、WriteFileExecutor、FileChangeReview、FileWriteLock | | **Step 5** | ToolCallDispatcher | Step 3, 4 | ToolCallDispatcher | | **Step 6** | ChatService 重构 | Step 5 | ChatService.kt(重构) | -| **Step 7** | Bridge + Settings + 前端扩展 | Step 5 | BridgeHandler、bridge.d.ts、SettingsState、ApprovalDialog、FileChangeDialog、FileCreateConfirmDialog | -| **Step 8** | 测试 & 清理 | Step 7 | 测试文件、删除废弃代码 | +| **Step 7** | Bridge 事件扩展(`buildEventJS` + 动作注入) | Step 5 | BridgeHandler、bridge.d.ts | +| **Step 8** | 前端扩展(groupReducer + 审批组件 + Settings) | Step 7 | groupReducer、ApprovalDialog、FileChangeDialog、FileCreateConfirmDialog、App.tsx、SettingsState | +| **Step 9** | 测试 & 清理 | Step 8 | 测试文件、删除废弃代码 | 每个步骤完成后可独立验证。 @@ -983,7 +1071,19 @@ MCP Server Trust (Phase 4) --- -## 附录 A:v1.0 → v2.0 变更摘要 +## 附录 A:变更摘要 + +### v2.0 → v3.0(适配统一事件通道 + 消息分组) + +| # | 变更 | 原因 | +|---|------|------| +| 1 | Bridge 事件体系(第 9 节)完全重写 | Phase 1 已将 15 个独立回调统一为 `onEvent(type, payload)` 通道,新增事件走 `buildEventJS()` 生成,不再注册独立回调 | +| 2 | `bridge.d.ts` 变更从"新增回调接口"改为"新增动作方法" | Bridge 接口只有 `onEvent` + 动作方法,新事件仅新增 payload 类型定义,新审批响通过新增动作方法 | +| 3 | 新增第 9.6 节"前端 groupReducer 扩展" | Phase 2 用 `groupReducer` 替代 `eventReducer`,新事件需在 reducer 中新增 case | +| 4 | 文件变更清单(第 14 节)增加前端组件和 reducer | Phase 2 引入 `AssistantGroup`、`groupReducer` 等新文件,需在清单中反映 | +| 5 | 迁移步骤(第 15 节)拆分 Bridge 扩展和前端扩展为独立步骤 | Bridge 事件扩展(`buildEventJS`)和前端组件开发(groupReducer + 审批弹框)可独立验证 | + +### v1.0 → v2.0(合并文档 + 优化设计) | # | 变更 | 原因 | |---|------|------| diff --git a/src/main/kotlin/com/github/codeplangui/ChatService.kt b/src/main/kotlin/com/github/codeplangui/ChatService.kt index 1e5ee28..034f772 100644 --- a/src/main/kotlin/com/github/codeplangui/ChatService.kt +++ b/src/main/kotlin/com/github/codeplangui/ChatService.kt @@ -72,10 +72,9 @@ class ChatService(private val project: Project) : Disposable { private val pendingApprovals = ConcurrentHashMap>() private val pendingApprovalCommands = ConcurrentHashMap() - // Tracks which msgIds have had notifyStart sent to the frontend - // When tools are enabled, notifyStart is deferred until the final response round - // so ExecutionCards appear before the assistant bubble - private val bridgeNotifiedStart = mutableSetOf() + // NOTE: bridgeNotifiedStart removed in Phase 2 — notifyStart is now sent + // unconditionally at the start of each streaming round, and round_end replaces + // the old remove_message hack for discarding intermediate tokens. fun attachBridge(handler: BridgeHandler) { bridgeHandler = handler @@ -178,12 +177,7 @@ class ChatService(private val project: Project) : Disposable { tools = if (commandExecutionEnabled) listOf(runCommandToolDefinition()) else null ) - // When tools are enabled, defer notifyStart so ExecutionCards appear before the assistant bubble. - // The bubble is created only on the final response round (no tool calls). - if (!commandExecutionEnabled) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - } + // notifyStart is now sent unconditionally in startStreamingRound() (Phase 2). startStreamingRound(msgId, request, toolsEnabled = commandExecutionEnabled) } @@ -194,7 +188,6 @@ class ChatService(private val project: Project) : Disposable { val msgId = activeMessageId activeMessageId = null if (wasStreaming && msgId != null) { - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyEnd(msgId) } @@ -206,7 +199,6 @@ class ChatService(private val project: Project) : Disposable { activeMessageId = null truncationHandler.reset() resetToolCallState() - bridgeNotifiedStart.clear() session = ChatSession() pendingApprovals.values.forEach { it.complete(false) } pendingApprovals.clear() @@ -324,10 +316,9 @@ class ChatService(private val project: Project) : Disposable { resetToolCallState() responseBuffer.clear() - // Do NOT create the assistant bubble here — the next round might produce - // more tool calls. The bubble is created lazily in onToken and removed - // in onFinishReason("tool_calls") if tool calls follow, ensuring all - // execution cards appear before the final streaming bubble. + // The next round's startStreamingRound() will send notifyStart which + // the frontend's groupReducer handles by reusing the existing assistant + // group (still streaming). Intermediate tokens are discarded via round_end. sendMessageInternal(msgId) } @@ -399,7 +390,6 @@ $selection activeStream?.cancel() activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) resetToolCallState() publishStatus() bridgeHandler?.notifyStructuredError(BridgeErrorPayload( @@ -409,6 +399,10 @@ $selection } private fun startStreamingRound(msgId: String, request: okhttp3.Request, toolsEnabled: Boolean) { + // Phase 2: notifyStart sent unconditionally — the frontend's groupReducer + // handles continuation rounds by reusing the existing assistant group. + bridgeHandler?.notifyStart(msgId) + val responseBuffer = StringBuilder() scope.launch { val stream = client.streamChat( @@ -416,27 +410,11 @@ $selection onToken = { token -> if (activeMessageId == msgId) { responseBuffer.append(token) - // Lazily create the assistant bubble on first token. - // If this round ends up producing tool_calls, onFinishReason will - // remove the bubble so execution cards stay in front. - if (msgId !in bridgeNotifiedStart) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - } bridgeHandler?.notifyToken(token) } }, onEnd = { if (activeMessageId == msgId && !truncationHandler.isPendingContinuation) { - // If the bubble hasn't been started yet (no tool calls in this round), - // start it now and flush the buffered content. - if (msgId !in bridgeNotifiedStart) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - if (responseBuffer.isNotEmpty()) { - bridgeHandler?.notifyToken(responseBuffer.toString()) - } - } logger.info("[CodePlanGUI Approval] model round completed msgId=$msgId") session.add(Message( role = MessageRole.ASSISTANT, @@ -447,7 +425,6 @@ $selection persistSession() activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyEnd(msgId) } @@ -457,7 +434,6 @@ $selection logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message") activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyStructuredError(classifyStreamError(message)) } @@ -469,14 +445,9 @@ $selection }, onFinishReason = { reason -> if (toolsEnabled && reason == "tool_calls" && activeMessageId == msgId) { - // Remove the assistant bubble if it was already created by the - // lazy init in onToken. This guarantees that execution cards - // (pushed during handleToolCallComplete) always appear before - // the final assistant bubble. - if (msgId in bridgeNotifiedStart) { - bridgeHandler?.notifyRemoveMessage(msgId) - bridgeNotifiedStart.remove(msgId) - } + // Phase 2: tell the frontend to discard intermediate tokens from + // this round, keeping only execution cards. + bridgeHandler?.notifyRoundEnd(msgId) val capturedBuffer = responseBuffer scope.launch { handleToolCallComplete(msgId, capturedBuffer) } } @@ -725,7 +696,6 @@ $selection pendingApprovals.values.forEach { it.complete(false) } pendingApprovals.clear() pendingApprovalCommands.clear() - bridgeNotifiedStart.clear() scope.cancel() } diff --git a/src/main/resources/webview/index.html b/src/main/resources/webview/index.html index e187645..fab51ca 100644 --- a/src/main/resources/webview/index.html +++ b/src/main/resources/webview/index.html @@ -4,7 +4,7 @@ CodePlanGUI - +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Wa(e,!0)}`}br(e){return"
"}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const a=this.parser.parseInline(n),r=WA(e);if(r===null)return a;e=r;let i='",i}image({href:e,title:t,text:n,tokens:a}){a&&(n=this.parser.parseInline(a,this.parser.textRenderer));const r=WA(e);if(r===null)return Wa(n);e=r;let i=`${n}{const o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{const a={...n};if(a.async=this.defaults.async||a.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){const i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let s=r.renderer.apply(this,o);return s===!1&&(s=i.apply(this,o)),s}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),a.extensions=t),n.renderer){const r=this.defaults.renderer||new Id(this.defaults);for(const i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const o=i,s=n.renderer[o],l=r[o];r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u||""}}a.renderer=r}if(n.tokenizer){const r=this.defaults.tokenizer||new Ad(this.defaults);for(const i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const o=i,s=n.tokenizer[o],l=r[o];r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u}}a.tokenizer=r}if(n.hooks){const r=this.defaults.hooks||new Uu;for(const i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const o=i,s=n.hooks[o],l=r[o];Uu.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async)return Promise.resolve(s.call(r,c)).then(d=>l.call(r,d));const u=s.call(r,c);return l.call(r,u)}:r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u}}a.hooks=r}if(n.walkTokens){const r=this.defaults.walkTokens,i=n.walkTokens;a.walkTokens=function(o){let s=[];return s.push(i.call(this,o)),r&&(s=s.concat(r.call(this,o))),s}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return br.lex(e,t??this.defaults)}parser(e,t){return vr.parse(e,t??this.defaults)}parseMarkdown(e){return(n,a)=>{const r={...a},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const s=i.hooks?i.hooks.provideLexer():e?br.lex:br.lexInline,l=i.hooks?i.hooks.provideParser():e?vr.parse:vr.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then(c=>s(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>l(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let c=s(n,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let u=l(c,i);return i.hooks&&(u=i.hooks.postprocess(u)),u}catch(c){return o(c)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){const a="

An error occurred:

"+Wa(n.message+"",!0)+"
";return t?Promise.resolve(a):a}if(t)return Promise.reject(n);throw n}}},Qi=new vL;function gt(e,t){return Qi.parse(e,t)}gt.options=gt.setOptions=function(e){return Qi.setOptions(e),gt.defaults=Qi.defaults,cL(gt.defaults),gt};gt.getDefaults=bh;gt.defaults=io;gt.use=function(...e){return Qi.use(...e),gt.defaults=Qi.defaults,cL(gt.defaults),gt};gt.walkTokens=function(e,t){return Qi.walkTokens(e,t)};gt.parseInline=Qi.parseInline;gt.Parser=vr;gt.parser=vr.parse;gt.Renderer=Id;gt.TextRenderer=Nh;gt.Lexer=br;gt.lexer=br.lex;gt.Tokenizer=Ad;gt.Hooks=Uu;gt.parse=gt;gt.options;gt.setOptions;gt.use;gt.walkTokens;gt.parseInline;vr.parse;br.lex;function IK(e){if(typeof e=="function"&&(e={highlight:e}),!e||typeof e.highlight!="function")throw new Error("Must provide highlight function");return typeof e.langPrefix!="string"&&(e.langPrefix="language-"),typeof e.emptyLangClass!="string"&&(e.emptyLangClass=""),{async:!!e.async,walkTokens(t){if(t.type!=="code")return;const n=jA(t.lang);if(e.async)return Promise.resolve(e.highlight(t.text,n,t.lang||"")).then(XA(t));const a=e.highlight(t.text,n,t.lang||"");if(a instanceof Promise)throw new Error("markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.");XA(t)(a)},useNewRenderer:!0,renderer:{code(t,n,a){typeof t=="object"&&(a=t.escaped,n=t.lang,t=t.text);const r=jA(n),i=r?e.langPrefix+JA(r):e.emptyLangClass,o=i?` class="${i}"`:"";return t=t.replace(/\n$/,""),`
${a?t:JA(t,!0)}
+
`}}}}function jA(e){return(e||"").match(/\S*/)[0]}function XA(e){return t=>{typeof t=="string"&&t!==e.text&&(e.escaped=!0,e.text=t)}}const hL=/[&<>"']/,DK=new RegExp(hL.source,"g"),TL=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,xK=new RegExp(TL.source,"g"),MK={"&":"&","<":"<",">":">",'"':""","'":"'"},ZA=e=>MK[e];function JA(e,t){if(t){if(hL.test(e))return e.replace(DK,ZA)}else if(TL.test(e))return e.replace(xK,ZA);return e}const wK=new vL(IK({langPrefix:"hljs language-",highlight(e,t){const n=HA.getLanguage(t)?t:"plaintext";return HA.highlight(e,{language:n}).value}}));async function LK(e){if(navigator.clipboard?.writeText)try{return await navigator.clipboard.writeText(e),!0}catch{}const t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();try{return document.execCommand("copy")}finally{document.body.removeChild(t)}}function PK({content:e}){const t=m.useRef(null);return m.useEffect(()=>{if(!t.current)return;const n=wK.parse(e);t.current.innerHTML=C3.sanitize(n),t.current.querySelectorAll("pre").forEach(a=>{const r=a.querySelector("code");if(!r||a.querySelector("button"))return;a.classList.add("assistant-code-block");const i=document.createElement("div");i.className="bubble-copy-anchor",a.appendChild(i);const o=document.createElement("div");i.appendChild(o);const s=document.createElement("button");s.className="bubble-copy-fallback",s.textContent="Copy",s.onclick=async()=>{await LK(r.textContent||"")&&(s.textContent="Copied",window.setTimeout(()=>{s.textContent="Copy"},2e3))},o.appendChild(s)})},[e]),K.jsx("div",{ref:t,className:"assistant-markdown"})}function kK({logs:e,isStreaming:t}){const[n,a]=m.useState(!1),r=m.useRef(null),i=m.useRef(t);return m.useEffect(()=>{t?i.current=!0:i.current&&(i.current=!1,a(!0))},[t]),m.useEffect(()=>{r.current&&!n&&(r.current.scrollTop=r.current.scrollHeight)},[e.length,n]),e.length===0?null:K.jsxs("div",{className:"exec-log-panel",children:[K.jsxs("div",{className:"exec-log-header",onClick:()=>a(!n),children:[n?K.jsx(YU,{}):K.jsx(bU,{}),K.jsxs("span",{className:"exec-log-title",children:["Output (",e.length,")"]})]}),!n&&K.jsxs("div",{ref:r,className:"exec-log-body",children:[e.map((o,s)=>K.jsx("div",{className:`exec-log-line exec-log-${o.type}`,children:o.text},s)),t&&K.jsx("span",{className:"stream-cursor"})]})]})}function UK({data:e}){const{command:t,status:n,result:a,logs:r}=e,i=n==="running",o=r&&r.length>0,s=()=>{switch(n){case"waiting":return K.jsxs(K.Fragment,{children:[K.jsx(UU,{style:{marginRight:6}}),"等待审批"]});case"running":return K.jsxs(K.Fragment,{children:[K.jsx(Jd,{style:{marginRight:6}}),"执行中"]});case"blocked":return K.jsxs(K.Fragment,{children:[K.jsx(mC,{style:{marginRight:6,color:"#ff4d4f"}}),"已拦截 · ",a?.reason]});case"denied":return K.jsxs(K.Fragment,{children:[K.jsx(mC,{style:{marginRight:6,color:"#ff4d4f"}}),"用户拒绝"]});case"timeout":return K.jsxs(K.Fragment,{children:[K.jsx(sU,{style:{marginRight:6,color:"#faad14"}}),"超时 · ",a?.timeout_seconds,"s"]});case"done":{if(!a)return null;const l=a.status==="ok",c=a.duration_ms?`${(a.duration_ms/1e3).toFixed(1)}s`:"";return l?K.jsxs(K.Fragment,{children:[K.jsx(nU,{style:{marginRight:6,color:"#52c41a"}}),"完成 · exit ",a.exit_code," · ",c]}):K.jsxs(K.Fragment,{children:[K.jsx(_U,{style:{marginRight:6,color:"#ff4d4f"}}),"失败 · exit ",a.exit_code," · ",c]})}}};return K.jsxs("div",{className:"exec-card",children:[K.jsx("div",{className:"exec-card-header",children:s()}),K.jsxs(In.Text,{code:!0,className:"exec-card-command",children:["$ ",t]}),o&&K.jsx(kK,{logs:r,isStreaming:i}),!o&&a?.stdout&&K.jsx(eI,{text:a.stdout,label:"stdout"}),!o&&a?.stderr&&K.jsx(eI,{text:a.stderr,label:"stderr"}),a?.truncated&&K.jsx(In.Text,{type:"secondary",style:{fontSize:11},children:"[output truncated]"})]})}const BE=5;function eI({text:e,label:t}){const n=e.split(` +`),[a,r]=m.useState(n.length<=BE),i=a?n:n.slice(0,BE);return K.jsxs("div",{style:{marginTop:8},children:[t&&K.jsx(In.Text,{type:"secondary",style:{fontSize:11},children:t}),K.jsx("pre",{style:{margin:"4px 0",fontSize:12,overflowX:"auto",background:"rgba(0,0,0,0.04)",padding:"6px 10px",borderRadius:4},children:i.join(` +`)}),!a&&K.jsxs(In.Link,{style:{fontSize:12},onClick:()=>r(!0),children:["▼ show ",n.length-BE," more lines"]})]})}async function BK(e){if(navigator.clipboard?.writeText)try{return await navigator.clipboard.writeText(e),!0}catch{}const t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();try{return document.execCommand("copy")}finally{document.body.removeChild(t)}}function FK({text:e}){const[t,n]=m.useState(!1),a=async()=>{await BK(e)&&(n(!0),setTimeout(()=>n(!1),2e3))};return K.jsx(Vn,{type:"text",size:"small",icon:t?K.jsx(bM,{}):K.jsx(vM,{}),onClick:a,className:"bubble-copy-button"})}const GK=m.memo(function({group:t}){return K.jsx("div",{className:"assistant-group",children:t.children.map(n=>n.kind==="execution"?K.jsx(UK,{data:n.data},n.data.requestId):K.jsx("div",{className:"message-row message-row-assistant",children:K.jsxs("div",{className:"message-bubble message-bubble-assistant",children:[K.jsxs("div",{className:"assistant-bubble-header",children:[K.jsx("span",{className:"assistant-bubble-label",children:"assistant"}),K.jsx(FK,{text:n.content})]}),K.jsx(PK,{content:n.content}),n.isStreaming&&K.jsx("span",{className:"stream-cursor"})]})},n.id))})});function $K(e){const n=e.trimStart().split(/\s+|[|;><&]/)[0]?.trim()??"";return n.substring(n.lastIndexOf("/")+1)}function zK({open:e,command:t,description:n,onAllow:a,onDeny:r}){const[i,o]=m.useState(!1),s=()=>{a(i),o(!1)},l=()=>{r(),o(!1)},c=$K(t),u=c?`允许所有 ${c} 命令自动执行`:"记住此命令,以后自动执行";return K.jsxs(Ya,{open:e,getContainer:!1,title:K.jsxs("span",{children:[K.jsx(eB,{style:{color:"#faad14",marginRight:8}}),"AI 请求执行命令"]}),footer:[K.jsx(Vn,{onClick:l,children:"拒绝"},"deny"),K.jsx(Vn,{type:"primary",danger:!0,onClick:s,children:"允许执行"},"allow")],closable:!1,maskClosable:!1,children:[K.jsx("div",{style:{marginBottom:12},children:K.jsxs(In.Text,{code:!0,style:{fontSize:13,display:"block",padding:"8px 12px",background:"rgba(0,0,0,0.06)",borderRadius:6},children:["$ ",t]})}),n&&K.jsx(In.Text,{type:"secondary",children:n}),K.jsx("div",{style:{marginTop:12},children:K.jsx(_h,{checked:i,onChange:d=>o(d.target.checked),children:u})})]})}function YK({error:e,onClose:t}){const n=e.type==="config"?"warning":"error",a=e.action==="openSettings"?K.jsx(Vn,{size:"small",type:"link",onClick:()=>window.__bridge?.openSettings(),children:"打开设置"}):e.action==="retry"?K.jsx(Vn,{size:"small",type:"link",onClick:t,children:"关闭"}):void 0;return K.jsx(P0,{message:e.message,type:n,closable:!0,onClose:t,className:"error-banner",action:a?K.jsx(mh,{children:a}):void 0})}const HK={unconfigured:"Provider not configured",ready:"Ready",streaming:"Streaming response",error:"API key missing"};function qK(e){return HK[e]}function VK({inputText:e,isLoading:t,isBridgeReady:n,connectionState:a}){const r=e.trim();return n?a==="unconfigured"?{canSend:!1,reason:"请先在 Settings 中配置 Provider",text:r}:a==="error"?{canSend:!1,reason:"API Key 未设置或未保存,请在 Settings 中重新配置并应用",text:r}:t?{canSend:!1,reason:"请等待当前响应完成",text:r}:r?{canSend:!0,reason:null,text:r}:{canSend:!1,reason:"请输入问题",text:r}:{canSend:!1,reason:"IDE bridge 正在连接,请稍后",text:r}}function WK({onNewChat:e,onOpenSettings:t,status:n,bridgeReady:a}){const r=n.providerName||"CodePlanGUI",i=n.model||(a?"Select a provider in Settings":"Connecting bridge");return K.jsxs("div",{className:"provider-bar",children:[K.jsxs("div",{children:[K.jsx(In.Title,{level:5,className:"provider-title",children:r}),K.jsxs(In.Text,{className:"provider-meta",children:[i," · ",qK(n.connectionState)]}),n.contextFile&&K.jsxs(In.Text,{className:"provider-context",title:n.contextFile,children:[K.jsx(DU,{})," ",n.contextFile]})]}),K.jsxs("div",{className:"provider-actions",children:[K.jsx(Vn,{type:"text",size:"small",icon:K.jsx(QU,{}),onClick:t,title:"Open Settings",className:"provider-action"}),K.jsx(Vn,{type:"text",size:"small",icon:K.jsx(GU,{}),onClick:e,title:"New Chat",className:"provider-action"})]})]})}function KK(e,t){return e?t?{label:"context on",title:t}:{label:"no open file",title:"当前没有可附加的文件上下文"}:{label:"context off",title:"不附加文件上下文"}}function CL(e){if(typeof e=="string")return e;if(e==null)return"{}";try{return JSON.stringify(e)}catch{return"{}"}}function QK(e){const t=CL(e);try{return JSON.parse(t)}catch{return}}function jK(e,t){return{...e,...t,contextFile:t.contextFile??e.contextFile??""}}function XK(e,t){return{...e,contextFile:t}}function ZK(e){for(let t=e.length-1;t>=0;t--)if(e[t].type==="assistant")return t;return-1}function Co(e,t){const n=ZK(e.groups);if(n===-1)return e;const a=[...e.groups];return a[n]=t(a[n]),{...e,groups:a}}function JK(e){const t=[];let n=null;for(const a of e)a.role!=="user"&&a.role!=="assistant"||a.role==="assistant"&&a.content.trim().length===0||(a.role==="user"?(n&&(t.push(n),n=null),t.push({type:"human",id:a.id,message:{id:a.id,content:a.content}})):(n||(n={type:"assistant",id:a.id,children:[],isStreaming:!1}),n.children.push({kind:"text",id:`text-${a.id}`,content:a.content,isStreaming:!1})));return n&&t.push(n),t}function e9(e,t,n){switch(t){case"start":{const a=e.groups[e.groups.length-1];return a?.type==="assistant"&&a.isStreaming?{...e,isLoading:!0,error:null,currentRoundTextIndex:null}:{...e,isLoading:!0,error:null,currentRoundTextIndex:null,groups:[...e.groups,{type:"assistant",id:n.msgId,children:[],isStreaming:!0}]}}case"token":{const a=e.groups[e.groups.length-1];if(a?.type!=="assistant")return e;const r=a;let i=e.currentRoundTextIndex;if(i!==null&&r.children[i]?.kind==="text"){const l=r.children[i],c=[...r.children];c[i]={kind:"text",id:l.id,content:l.content+n.text,isStreaming:l.isStreaming};const u=[...e.groups];return u[u.length-1]={...r,children:c},{...e,groups:u}}const o={kind:"text",id:`text-${Date.now()}`,content:n.text,isStreaming:!0};i=r.children.length;const s=[...e.groups];return s[s.length-1]={...r,children:[...r.children,o]},{...e,groups:s,currentRoundTextIndex:i}}case"execution_card":return Co(e,a=>({...a,children:[...a.children,{kind:"execution",data:{requestId:n.requestId,command:n.command,status:"running"}}]}));case"log":return Co(e,a=>({...a,children:a.children.map(r=>r.kind==="execution"&&r.data.requestId===n.requestId?{...r,data:{...r.data,logs:[...r.data.logs||[],{text:n.line,type:n.type}]}}:r)}));case"execution_status":{const a=QK(n.result);return Co(e,r=>({...r,children:r.children.map(i=>i.kind==="execution"&&i.data.requestId===n.requestId?{...i,data:{...i.data,status:n.status,result:a}}:i)}))}case"approval_request":return{...Co(e,a=>({...a,children:a.children.map(r=>r.kind==="execution"&&r.data.requestId===n.requestId?{...r,data:{...r.data,status:"waiting"}}:r)})),approvalRequestId:n.requestId,approvalCommand:n.command,approvalDescription:n.description,approvalOpen:!0};case"round_end":return e.currentRoundTextIndex!==null?Co({...e,currentRoundTextIndex:null},a=>({...a,children:a.children.filter((r,i)=>i!==e.currentRoundTextIndex)})):e;case"end":return Co({...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null},a=>({...a,isStreaming:!1,children:a.children.map(r=>r.kind==="text"?{...r,isStreaming:!1}:r)}));case"error":{const a=e.groups.map(r=>r.type==="assistant"&&r.isStreaming?{...r,isStreaming:!1,children:r.children.map(i=>i.kind==="text"?{...i,isStreaming:!1}:i)}:r);return{...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null,groups:a,error:{type:"runtime",message:n.message}}}case"structured_error":{const a=e.groups.map(r=>r.type==="assistant"&&r.isStreaming?{...r,isStreaming:!1,children:r.children.map(i=>i.kind==="text"?{...i,isStreaming:!1}:i)}:r);return{...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null,groups:a,error:{type:n.type,message:n.message,action:n.action}}}case"continuation":return{...e,continuationInfo:{current:n.current,max:n.max}};case"restore_messages":return{...e,groups:JK(JSON.parse(n.messages))};case"status":return{...e,status:jK(e.status,n)};case"context_file":return{...e,status:XK(e.status,n.fileName)};case"theme":return{...e,themeMode:n.mode};default:return e}}function t9(e){const[t,n]=m.useState(()=>window.__bridge?.isReady===!0),a=m.useRef(!1),r=m.useRef(e);return r.current=e,m.useEffect(()=>{const i=()=>{window.__bridge||(window.__bridge={isReady:!1,sendMessage:()=>{},newChat:()=>{},openSettings:()=>{},cancelStream:()=>{},frontendReady:()=>{},debugLog:()=>{},onEvent:(s,l)=>{},approvalResponse:()=>{}}),window.__bridge.onEvent=(s,l)=>{try{const c=JSON.parse(l);r.current(s,c)}catch(c){console.warn(`[CodePlanGUI] Failed to parse event payload: type=${s}`,c)}};const o=window.__bridge.isReady===!0;n(o),o&&!a.current&&(a.current=!0,window.__bridge.frontendReady())};return i(),document.addEventListener("bridge_ready",i),()=>document.removeEventListener("bridge_ready",i)},[]),t}function n9(e,t,n){const a=e.trim();return!n||t||!a?null:{text:a}}const a9={groups:[],isLoading:!1,error:null,status:{providerName:"",model:"",connectionState:"unconfigured",contextFile:""},themeMode:"dark",approvalOpen:!1,approvalRequestId:"",approvalCommand:"",approvalDescription:"",continuationInfo:null,currentRoundTextIndex:null};function r9(){const[e,t]=m.useState(a9),[n,a]=m.useState(""),r=m.useRef(!1),[i,o]=m.useState(!0),s=m.useRef(null),{groups:l,isLoading:c,error:u,status:d,themeMode:_,approvalOpen:p,approvalRequestId:f,approvalCommand:v,approvalDescription:b,continuationInfo:E}=e;m.useEffect(()=>{document.documentElement.classList.remove("theme-dark","theme-light"),document.documentElement.classList.add(`theme-${_}`)},[_]);const g=()=>{s.current?.scrollIntoView({behavior:"smooth"})};m.useEffect(()=>{g()},[l]);const S=m.useCallback(R=>{window.__bridge?.debugLog(R)},[]),h=m.useCallback((R,A)=>{if(R==="execution_card")S(`[approval-ui] received execution card requestId=${A.requestId} command=${A.command} description=${A.description}`);else if(R==="approval_request")S(`[approval-ui] received approval request requestId=${A.requestId} command=${A.command} description=${A.description}`);else if(R==="execution_status"){const D=CL(A.result);S(`[approval-ui] received execution status requestId=${A.requestId} status=${A.status} result=${D.slice(0,240)}`)}t(D=>e9(D,R,A))},[S]),C=m.useCallback(R=>{S(`[approval-ui] modal allow clicked requestId=${f} addToWhitelist=${R}`),t(A=>({...A,approvalOpen:!1})),window.__bridge?.approvalResponse(f,"allow",R)},[f,S]),y=m.useCallback(()=>{S(`[approval-ui] modal deny clicked requestId=${f}`),t(R=>({...R,approvalOpen:!1})),window.__bridge?.approvalResponse(f,"deny")},[f,S]),T=_==="dark"?Vy.darkAlgorithm:Vy.defaultAlgorithm,O=t9(h);m.useEffect(()=>{O&&t(R=>({...R,error:null}))},[O]);const N=VK({inputText:n,isLoading:c,isBridgeReady:O,connectionState:d.connectionState}),I=KK(i,d.contextFile||""),x=()=>{if(!N.canSend){N.reason&&N.text&&t(D=>({...D,error:{type:"runtime",message:N.reason}}));return}const R=n9(N.text,c,O);if(!R)return;const A=t3();t(D=>({...D,groups:[...D.groups,{type:"human",id:A,message:{id:A,content:R.text}}]})),a(""),window.__bridge?.sendMessage(R.text,i)},w=R=>{R.key==="Enter"&&!R.shiftKey&&!r.current&&(R.preventDefault(),x())},U=()=>{t(R=>({...R,groups:[],error:null,isLoading:!1,currentRoundTextIndex:null})),window.__bridge?.newChat()},L=m.useCallback(()=>{c&&window.__bridge?.cancelStream()},[c]);m.useEffect(()=>{const R=A=>{A.key==="Escape"&&c&&(A.preventDefault(),window.__bridge?.cancelStream())};return document.addEventListener("keydown",R),()=>document.removeEventListener("keydown",R)},[c]);const P=R=>{R.style.height="auto",R.style.height=`${Math.min(R.scrollHeight,120)}px`};return K.jsx(Pr,{theme:{algorithm:T,token:{colorPrimary:"#d2a15e",colorInfo:"#d2a15e",borderRadius:16,fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif"}},children:K.jsxs("div",{className:"app-shell",children:[K.jsx(zK,{open:p,command:v,description:b,onAllow:C,onDeny:y}),K.jsx(WK,{onNewChat:U,onOpenSettings:()=>window.__bridge?.openSettings(),status:d,bridgeReady:O}),u&&K.jsx(YK,{error:u,onClose:()=>t(R=>({...R,error:null}))}),K.jsxs("div",{className:"messages-area",children:[l.length===0&&K.jsx("div",{className:"empty-state",children:K.jsxs("div",{className:"empty-card",children:[K.jsx("div",{className:"empty-icon",children:"✦"}),K.jsx("div",{className:"empty-kicker",children:"Ready for context"}),K.jsx(In.Title,{level:3,className:"empty-title",children:"向 AI 提问,或选中代码后右键 Ask AI"}),K.jsxs("div",{className:"empty-copy",children:["当前会话支持流式输出、上下文注入和 Markdown 代码块复制。输入区支持",K.jsx("strong",{children:" Enter 发送"}),",",K.jsx("strong",{children:"Shift+Enter 换行"}),"。"]}),d.connectionState==="unconfigured"&&K.jsx(Vn,{type:"link",onClick:()=>window.__bridge?.openSettings(),children:"打开 Settings 配置 Provider"})]})}),l.map(R=>R.type==="human"?K.jsx("div",{className:"message-row message-row-user",children:K.jsx("div",{className:"message-bubble message-bubble-user",children:K.jsx(In.Text,{children:R.message.content})})},R.id):K.jsx(GK,{group:R},R.id)),c&&!l.some(R=>R.type==="assistant"&&R.children.some(A=>A.kind==="text"&&A.isStreaming))&&K.jsxs("div",{className:"continuation-indicator",children:[K.jsx("span",{className:"continuation-spinner"}),E&&K.jsxs("span",{className:"continuation-text",children:["续写中 ",E.current,"/",E.max]})]}),K.jsx("div",{ref:s})]}),K.jsxs("div",{className:"input-area",children:[K.jsx("div",{className:"input-meta",children:K.jsxs("div",{className:"context-toggle",children:[K.jsx(Cc,{title:I.title,children:K.jsx(Hw,{size:"small",checked:i,onChange:o})}),K.jsx("span",{className:"context-caption context-file-label",title:I.title,children:I.label})]})}),K.jsxs("div",{className:"composer-row",children:[K.jsx("textarea",{value:n,onChange:R=>{a(R.target.value),P(R.target)},onCompositionStart:()=>{r.current=!0},onCompositionEnd:R=>{r.current=!1,a(R.target.value)},onKeyDown:w,placeholder:"输入问题... (Enter 发送,Shift+Enter 换行)",disabled:c,rows:1,className:"composer-input"}),K.jsx(Vn,{type:"primary",icon:c?K.jsx(Xk,{}):K.jsx(VU,{}),onClick:c?L:x,disabled:!c&&!N.canSend,title:c?"停止生成 (Esc)":N.reason??"Send",size:"small",className:"send-button"})]})]})]})})}Tk.createRoot(document.getElementById("root")).render(K.jsx(m.StrictMode,{children:K.jsx(r9,{})}));