From 41e485529d9778cdfcab8a0ee9cacfb5d57e26b7 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Thu, 22 Oct 2026 23:31:36 +0800 Subject: [PATCH 1/2] Improve browser sessions, sidebar operations and tool details --- CLAUDE.md | 28 +- cmake/acecode_source_paths.cmake | 14 +- docs/agent-browser-macos-handoff.md | 14 +- docs/agent-browser.md | 76 +- docs/daemon-api.md | 109 +- docs/help-source/group5.py | 11 +- docs/help-source/sources.json | 3 +- docs/help/agent-browser.html | 12 +- docs/help/assets/search-index.js | 16 +- docs/user-manual.md | 2 + .../design.md | 10 +- .../proposal.md | 10 +- .../specs/responsive-workbench-layout/spec.md | 46 +- .../specs/webui-side-panel/spec.md | 8 +- .../tasks.md | 9 + .../.openspec.yaml | 2 + .../add-agent-browser-cookie-modes/design.md | 261 ++++ .../proposal.md | 40 + .../agent-browser-cookie-storage/spec.md | 155 +++ .../add-agent-browser-cookie-modes/tasks.md | 110 ++ .../verification.md | 74 + .../.openspec.yaml | 2 + .../add-sidebar-session-multiselect/design.md | 31 + .../proposal.md | 26 + .../specs/sidebar-session-selection/spec.md | 47 + .../add-sidebar-session-multiselect/tasks.md | 18 + .../validation.md | 23 + .../.openspec.yaml | 2 + .../design.md | 29 + .../proposal.md | 26 + .../specs/web-chat-activity-collapse/spec.md | 37 + .../simplify-transcript-tool-details/tasks.md | 27 + .../sync-preview-file-to-list/.openspec.yaml | 2 + .../sync-preview-file-to-list/design.md | 26 + .../sync-preview-file-to-list/proposal.md | 24 + .../specs/webui-side-panel/spec.md | 38 + .../sync-preview-file-to-list/tasks.md | 21 + .../tool/agent_browser/cdp_client.cpp | 9 + .../tool/agent_browser/cdp_client.hpp | 5 + src/apps/daemon/worker.cpp | 3 + .../desktop/agent_browser_cookie_bridge.cpp | 78 ++ .../desktop/agent_browser_cookie_bridge.hpp | 7 + .../desktop/agent_browser_cookie_jars.cpp | 194 +++ .../desktop/agent_browser_cookie_jars.hpp | 128 ++ src/apps/desktop/agent_browser_host.cpp | 1232 ++++++++--------- src/apps/desktop/agent_browser_host.hpp | 29 +- .../desktop/agent_browser_host_common.cpp | 22 + .../desktop/agent_browser_host_common.hpp | 8 + src/apps/desktop/agent_browser_host_mac.mm | 1092 +++++++-------- .../desktop/agent_browser_macos_helpers.hpp | 63 + .../desktop/agent_browser_macos_helpers.mm | 577 ++++++++ .../desktop/agent_browser_windows_helpers.cpp | 759 ++++++++++ .../desktop/agent_browser_windows_helpers.hpp | 107 ++ src/apps/desktop/main.cpp | 3 +- src/apps/tui/app/tui_services.cpp | 2 + src/apps/tui/app/tui_services.hpp | 3 + .../web/handlers/agent_browser_top_sites.cpp | 358 +++++ .../web/handlers/agent_browser_top_sites.hpp | 74 + .../web/handlers/session_batch_handler.cpp | 172 +++ .../web/handlers/session_batch_handler.hpp | 69 + src/apps/web/routes/routes_agent_browser.cpp | 162 +++ src/apps/web/routes/routes_session_batch.cpp | 285 ++++ src/apps/web/server.cpp | 2 + src/apps/web/server_helpers.cpp | 125 +- src/apps/web/server_impl.hpp | 22 + src/apps/web/sidebar_sessions.cpp | 36 +- src/base/environment/data_dir_migration.cpp | 14 +- src/base/environment/data_dir_migration.hpp | 7 +- src/base/ipc/agent_browser_store_state.cpp | 224 +++ src/base/ipc/agent_browser_store_state.hpp | 77 ++ src/domain/session/session_fork_listeners.cpp | 58 + src/domain/session/session_fork_listeners.hpp | 26 + src/domain/session/session_manager.cpp | 4 + src/domain/session/sidebar_tree.cpp | 32 + src/domain/session/sidebar_tree.hpp | 4 + .../agent_browser_store_lifecycle.cpp | 125 ++ .../agent_browser_store_lifecycle.hpp | 63 + src/layers.tsv | 2 + tests/CMakeLists.txt | 20 + tests/cpp_source_paths.json | 7 +- .../agent_browser_cookie_jars_test.cpp | 253 ++++ .../helpers/agent_browser_cookie_smoke.cpp | 498 +++++++ tests/environment/data_dir_migration_test.cpp | 47 +- tests/ipc/agent_browser_store_state_test.cpp | 182 +++ tests/session/session_fork_listener_test.cpp | 143 ++ tests/session/sidebar_tree_test.cpp | 49 + .../agent_browser_store_lifecycle_test.cpp | 156 +++ tests/web/agent_browser_top_sites_test.cpp | 222 +++ tests/web/session_batch_handler_test.cpp | 113 ++ tests/web/sidebar_sessions_test.cpp | 22 + tests/web/web_server_smoke_test.cpp | 224 +++ web/scripts/i18n-en-overrides.mjs | 52 + web/scripts/test-preview-file-locate.mjs | 194 +++ web/scripts/test-responsive-sidebars.mjs | 182 +-- web/scripts/test-sidebar-session-tree.mjs | 204 ++- web/scripts/test-tool-transcript.mjs | 135 ++ web/src/App.jsx | 5 + web/src/components/ActivityLine.jsx | 20 +- web/src/components/AgentBrowserMoreMenu.jsx | 270 ++++ web/src/components/AgentBrowserNewTab.jsx | 291 ++++ web/src/components/AgentBrowserPanel.jsx | 165 ++- web/src/components/ChatView.jsx | 38 +- web/src/components/DesktopContextMenu.jsx | 4 + web/src/components/InputBar.jsx | 33 + web/src/components/PreviewDetailsPanel.jsx | 31 +- web/src/components/RichComposer.jsx | 49 + web/src/components/SettingsPage.jsx | 71 + web/src/components/ShellCommandCard.jsx | 12 +- web/src/components/SidePanel.jsx | 72 +- web/src/components/Sidebar.jsx | 467 ++++++- web/src/components/ToolBlock.jsx | 177 +-- web/src/components/ToolDetailMetadata.jsx | 17 + web/src/i18n/catalogs.js | 5 +- web/src/i18n/catalogs/toolTranscript.js | 121 ++ web/src/i18n/sourceCatalog.generated.js | 104 +- web/src/lib/agentBrowserArchitecture.test.js | 56 +- web/src/lib/agentBrowserCookieMode.js | 225 +++ web/src/lib/agentBrowserCookieMode.test.js | 189 +++ web/src/lib/agentBrowserRecentFiles.js | 144 ++ web/src/lib/agentBrowserRecentFiles.test.js | 158 +++ web/src/lib/agentBrowserTopSites.js | 192 +++ web/src/lib/agentBrowserTopSites.test.js | 197 +++ web/src/lib/api.js | 11 + web/src/lib/appShortcuts.js | 1 + web/src/lib/appShortcuts.test.js | 6 + web/src/lib/connection.js | 8 +- web/src/lib/desktopContextMenu.js | 13 + web/src/lib/desktopContextMenu.test.js | 8 +- web/src/lib/fileTreeLocate.js | 8 + web/src/lib/fileTreeLocate.test.js | 28 +- web/src/lib/questionFeedback.test.js | 4 +- web/src/lib/responsiveSidebars.js | 29 +- web/src/lib/responsiveSidebars.test.js | 67 +- web/src/lib/richComposerModel.js | 14 +- web/src/lib/runTests.js | 8 + web/src/lib/sessionReferenceDrop.js | 80 ++ web/src/lib/sessionReferenceDrop.test.js | 99 ++ web/src/lib/sessionTranscript.test.js | 3 +- web/src/lib/settingsSearch.js | 1 + web/src/lib/settingsSearch.test.js | 10 + web/src/lib/shortcutBindings.js | 1 + web/src/lib/sidebarSessionBatch.js | 82 ++ web/src/lib/sidebarSessionBatch.test.js | 74 + web/src/lib/sidebarSessionSelection.js | 42 + web/src/lib/sidebarSessionSelection.test.js | 28 + .../sidebarWorkspaceDragArchitecture.test.js | 2 +- web/src/lib/sidebarWorkspaceRefresh.test.js | 2 +- web/src/lib/submenuPosition.js | 33 + web/src/lib/submenuPosition.test.js | 72 + web/src/lib/toolPresentation.js | 186 +++ web/src/lib/toolPresentation.test.js | 151 ++ web/src/lib/transcriptProjection.js | 7 +- web/src/lib/transcriptProjection.test.js | 4 +- web/src/lib/useAgentBrowserCookieMode.js | 20 + web/src/lib/useSidebarSessionSelection.js | 59 + web/src/styles/globals.css | 359 ++++- web/src/styles/shell-command-card.css | 10 + web/src/styles/tool-details.css | 12 + 158 files changed, 13214 insertions(+), 1795 deletions(-) create mode 100644 openspec/changes/add-agent-browser-cookie-modes/.openspec.yaml create mode 100644 openspec/changes/add-agent-browser-cookie-modes/design.md create mode 100644 openspec/changes/add-agent-browser-cookie-modes/proposal.md create mode 100644 openspec/changes/add-agent-browser-cookie-modes/specs/agent-browser-cookie-storage/spec.md create mode 100644 openspec/changes/add-agent-browser-cookie-modes/tasks.md create mode 100644 openspec/changes/add-agent-browser-cookie-modes/verification.md create mode 100644 openspec/changes/add-sidebar-session-multiselect/.openspec.yaml create mode 100644 openspec/changes/add-sidebar-session-multiselect/design.md create mode 100644 openspec/changes/add-sidebar-session-multiselect/proposal.md create mode 100644 openspec/changes/add-sidebar-session-multiselect/specs/sidebar-session-selection/spec.md create mode 100644 openspec/changes/add-sidebar-session-multiselect/tasks.md create mode 100644 openspec/changes/add-sidebar-session-multiselect/validation.md create mode 100644 openspec/changes/simplify-transcript-tool-details/.openspec.yaml create mode 100644 openspec/changes/simplify-transcript-tool-details/design.md create mode 100644 openspec/changes/simplify-transcript-tool-details/proposal.md create mode 100644 openspec/changes/simplify-transcript-tool-details/specs/web-chat-activity-collapse/spec.md create mode 100644 openspec/changes/simplify-transcript-tool-details/tasks.md create mode 100644 openspec/changes/sync-preview-file-to-list/.openspec.yaml create mode 100644 openspec/changes/sync-preview-file-to-list/design.md create mode 100644 openspec/changes/sync-preview-file-to-list/proposal.md create mode 100644 openspec/changes/sync-preview-file-to-list/specs/webui-side-panel/spec.md create mode 100644 openspec/changes/sync-preview-file-to-list/tasks.md create mode 100644 src/apps/desktop/agent_browser_cookie_bridge.cpp create mode 100644 src/apps/desktop/agent_browser_cookie_bridge.hpp create mode 100644 src/apps/desktop/agent_browser_cookie_jars.cpp create mode 100644 src/apps/desktop/agent_browser_cookie_jars.hpp create mode 100644 src/apps/desktop/agent_browser_host_common.cpp create mode 100644 src/apps/desktop/agent_browser_host_common.hpp create mode 100644 src/apps/desktop/agent_browser_macos_helpers.hpp create mode 100644 src/apps/desktop/agent_browser_macos_helpers.mm create mode 100644 src/apps/desktop/agent_browser_windows_helpers.cpp create mode 100644 src/apps/desktop/agent_browser_windows_helpers.hpp create mode 100644 src/apps/web/handlers/agent_browser_top_sites.cpp create mode 100644 src/apps/web/handlers/agent_browser_top_sites.hpp create mode 100644 src/apps/web/handlers/session_batch_handler.cpp create mode 100644 src/apps/web/handlers/session_batch_handler.hpp create mode 100644 src/apps/web/routes/routes_agent_browser.cpp create mode 100644 src/apps/web/routes/routes_session_batch.cpp create mode 100644 src/base/ipc/agent_browser_store_state.cpp create mode 100644 src/base/ipc/agent_browser_store_state.hpp create mode 100644 src/domain/session/session_fork_listeners.cpp create mode 100644 src/domain/session/session_fork_listeners.hpp create mode 100644 src/host/session_host/agent_browser_store_lifecycle.cpp create mode 100644 src/host/session_host/agent_browser_store_lifecycle.hpp create mode 100644 tests/desktop/agent_browser_cookie_jars_test.cpp create mode 100644 tests/desktop/helpers/agent_browser_cookie_smoke.cpp create mode 100644 tests/ipc/agent_browser_store_state_test.cpp create mode 100644 tests/session/session_fork_listener_test.cpp create mode 100644 tests/session_host/agent_browser_store_lifecycle_test.cpp create mode 100644 tests/web/agent_browser_top_sites_test.cpp create mode 100644 tests/web/session_batch_handler_test.cpp create mode 100644 web/scripts/test-preview-file-locate.mjs create mode 100644 web/scripts/test-tool-transcript.mjs create mode 100644 web/src/components/AgentBrowserMoreMenu.jsx create mode 100644 web/src/components/AgentBrowserNewTab.jsx create mode 100644 web/src/components/ToolDetailMetadata.jsx create mode 100644 web/src/i18n/catalogs/toolTranscript.js create mode 100644 web/src/lib/agentBrowserCookieMode.js create mode 100644 web/src/lib/agentBrowserCookieMode.test.js create mode 100644 web/src/lib/agentBrowserRecentFiles.js create mode 100644 web/src/lib/agentBrowserRecentFiles.test.js create mode 100644 web/src/lib/agentBrowserTopSites.js create mode 100644 web/src/lib/agentBrowserTopSites.test.js create mode 100644 web/src/lib/sessionReferenceDrop.js create mode 100644 web/src/lib/sessionReferenceDrop.test.js create mode 100644 web/src/lib/sidebarSessionBatch.js create mode 100644 web/src/lib/sidebarSessionBatch.test.js create mode 100644 web/src/lib/sidebarSessionSelection.js create mode 100644 web/src/lib/sidebarSessionSelection.test.js create mode 100644 web/src/lib/submenuPosition.js create mode 100644 web/src/lib/submenuPosition.test.js create mode 100644 web/src/lib/toolPresentation.js create mode 100644 web/src/lib/toolPresentation.test.js create mode 100644 web/src/lib/useAgentBrowserCookieMode.js create mode 100644 web/src/lib/useSidebarSessionSelection.js create mode 100644 web/src/styles/tool-details.css diff --git a/CLAUDE.md b/CLAUDE.md index 589f1768b..80ba8a469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Backup deletion verifies the pointer and preserves it when cleaning the default **数据目录迁移的几条铁律(fix-feedback-0924 第 1 条,YTB 反馈:迁移出错后界面卡死 / 迁移必然失败)。** (1) OS 错误文本(`ec.message()` / `e.what()`)先单独经 `migration_os_error_text` / `ensure_utf8` 转 UTF-8 再拼接 —— 不要对拼好的整串转,整串里的 UTF-8 中文路径会被按 GBK 重解成乱码;`routes_environment.cpp::json_response` 的 `dump(..., error_handler_t::replace)` 是出口兜底,不能去掉(曾经 GBK 的「系统找不到指定的路径」让 /migration 每次轮询都 500,前端永远停在「迁移中」)。前端轮询由 `migrationPollOutcome` 给出动作、组件函数式 `setJob`,连续 3 次失败就停并标 unknown。构造非法 UTF-8 测试数据时注意 GBK「一」= `D2 BB` 恰好是合法 UTF-8。 (2) `to_extended_length_path`(`\\?\`)只用于迁移与清理的文件 IO,绝不进 JSON / 指针 / 日志;SQLite 打开 ≥240 字符才换扩展形式(同进程两种形式打开同一 WAL 库会分裂 winShmNode);staging 名是 `.acecode-mig-` + 8 hex。运行期仍不是 longPathAware。 -(3) `agent-browser/webview2`(Desktop 启动即创建、持续写)只能「尽力复制 + 不参与变更复检」,SQLite 仍优先 backup;`edge-app-profile/` 整个排除;已快照主库的 `-wal/-shm/-journal` 都不复制(热 journal 会把快照回滚坏)。排除 `cache/no-workspace//.acecode/tmp` 的代价:删旧目录后历史里对 scratch 的引用失效。`previous_size_bytes` 含未复制但留在旧目录的数据。 +(3) Agent Browser profile(新装 `agent_browser/webview2`,旧装机沿用 `agent-browser/webview2`,`migration_is_best_effort_root` 两种都认;运行数据改下划线命名时漏过这里,新装用户 Desktop 开着就迁移失败,以后再改运行数据名字要把 `data_dir_migration.cpp` 里按目录名判定的几处一起改)Desktop 启动即创建、持续写,只能「尽力复制 + 不参与变更复检」,SQLite 仍优先 backup;`edge-app-profile/` 整个排除;已快照主库的 `-wal/-shm/-journal` 都不复制(热 journal 会把快照回滚坏)。排除 `cache/no-workspace//.acecode/tmp` 的代价:删旧目录后历史里对 scratch 的引用失效。`previous_size_bytes` 含未复制但留在旧目录的数据。 (4) 「其他实例」判定只在已被证明 PID 复用时放行(`evaluate_daemon_pid_holder`,与 daemon_pool 同口径,身份未知一律拦);每个拒绝分支都记日志(SESSIONS_BUSY 带 `busy_session_ids()`)。redirect 解析告警发生在日志初始化之前,由 worker / TUI / headless / Windows 服务四处在 `init_with_rotation` 之后调 `log_deferred_data_dir_resolution_warning()` 补记。 The Settings shell uses open groups in `globals.css`; user-provided Claude-style @@ -627,6 +627,10 @@ SidePanel 折叠 UI:`ChatView` 把 `SidePanel` 包到 `
第一个文本输入框(表单型)> primary(确认型)> 第一个可聚焦元素;Enter 只在焦点**不在**会自己消费 Enter 的元素(button / a[href] / textarea / contenteditable / `
` 内控件——表单靠隐式提交)上时点 primary,子组件自己处理 Enter 必须 `preventDefault`(Tag 输入框那样),否则会双触发;按住不放的 auto-repeat Enter 一律 `preventDefault`(Chromium 的按钮激活发生在 keypress、且派发给当时聚焦的元素,「按住 Enter 打开确认框 → 焦点落到删除 → 连带确认」就是这么来的;keydown 被阻止就不再产生 keypress),textarea / contenteditable 内放行。输入法合成中的 Esc 不关对话框。焦点环只在 `:focus-visible`(`.ace-modal-dialog :is(button, [role="button"], a[href])`),鼠标点开时脚本聚焦的默认按钮不画环、按 Tab 后才出现,与系统对话框一致。**不要**再手写 `fixed inset-0` 遮罩当对话框(循环编辑表单与 opencode 导入框已收编;`agentBrowserArchitecture.test.js` 的 floatingSurfaceOwners 清单相应减少)。自动弹出的 `WorkspaceCleanupNotice` 故意不标 primary:无人触发的对话框不能带破坏性默认操作,默认落在「保留」。守卫:`lib/dialogKeyboard.test.js` + `lib/dialogKeyboardArchitecture.test.js`(后者还断言取消类文案永远不会被标成 primary)。 排队卡片栈(`redesign-webui-queue-cards`):busy 期间提交的待发送消息**不进 transcript**,改由 ``(在 `` 上方)渲染成卡片堆。状态机(`lib/chatInputQueue.js`)与 `enqueueQueuedInput` / `cancelQueuedInput` / `markQueuedInput*` / `nextQueuedInput` / `completeQueuedInputForMessage` 全部不变;只是渲染分支换地方。每张卡片左侧 3px `.ace-queue-card-indicator` 色条标注状态(QUEUED 灰 / FAILED 红),右侧恒挂"取消"(close 图标),FAILED 多一个"重试"。状态↔标签映射收敛在 `lib/queueCardItem.js::buildQueueCardItem`(纯函数,Node 单测覆盖);DOM 端只是把这份结构映射到 className。`Message.jsx::UserBubble` 已剥离 `queued`/`onCancelQueued`/`onRetryQueued` props——transcript 里出现的 user 气泡一定是后端真实落库的消息。 @@ -762,6 +766,28 @@ JS↔C++ bridge(同进程,webview `bind`,无 HTTP): - **前端页签只有一个来源**:`web/src/lib/agentBrowserPages.js`(App 级单写者登记表,`App.jsx` 挂载时 `installAgentBrowserPageListener()` + 全量 `reconcileAgentBrowserPageStore()`,ChatView 切会话再按会话对账)→ `previewTabs.js::syncBrowserTabsForSession` 派生页签;本 ChatView 首次见到的页面自动打开并激活(`revealedBrowserPagesRef` 按会话记)。**不要**再从 `agentBrowserActivityFromItems` 创建页签或调无参 `getAgentBrowserState()` 猜全局活动页,活动推断现在只决定彩虹边框与「Agent 切目标页时前置该页签」。旧 Desktop 不带 owner 的事件走 `claimUnownedAgentBrowserPage` 本地认领兜底。 - 回归测试:`tests/desktop/agent_browser_page_directory_test.cpp`、`tests/tool/agent_browser_tools_test.cpp` 的 owner 两条、Windows smoke 的 `SMOKE_OWNERSHIP_OK` 段、前端 `agentBrowserPages.test.js` + `previewTabs.test.js::syncBrowserTabsForSession` + `agentBrowserArchitecture.test.js` 的归属合同。设计全文见 [docs/agent-browser.md](docs/agent-browser.md)「页面归属」。 +### Agent Browser 新标签页(仿 Codex New Tab,不做「工具」区) + +空白页(`content_state=empty`)由 `AgentBrowserNewTab.jsx` 渲染「推荐 + 最近」,两块都空时退回原 Browser 提示。逆向依据:Codex 安装包 `app.asar` 的 `thread-side-panel-new-tab-*.js`(Tools / Recents)与 `thread-app-shell-chrome-*.js`(Suggested 读 Chromium `browsingHistory.subscribeTopSites`,前 4 个,可移除 + 撤销)。 + +- **推荐 = ACECode 自己记的访问**,WebView2 / WKWebView 没有历史接口。App 级 `installAgentBrowserVisitRecorder` 把 live 页面的访问报给 `POST /api/agent-browser/visits`,daemon 存 `/agent_browser_top_sites.json`(`handlers/agent_browser_top_sites.cpp` 纯逻辑 + `routes_agent_browser.cpp`,只给本机直连,迁移闸门期间 409)。**不要改存 localStorage**:Desktop 每次启动 daemon 端口都变,origin 跟着变。跟踪器规则:地址变化才计一次访问,刷新同址不计,标题 / 图标后到用 `new_visit:false` 补充;前端加载前已打开的页面用 `aceDesktop_agentBrowserListPages` 打底,否则每次刷新 / 切工作区都把已开页面多算一次。Desktop 的默认标题「新标签页」必须写成比较里的字面量才能过滤 —— i18n 编译插件会把模块级常量里的中文换成译文。 +- **最近 = 本会话改过的文件**(`agentBrowserRecentFiles.js`,与会话变更同源),点开用文件预览替换该浏览器页签(ChatView `replaceBrowserTabWithFile`)。持久化的工具消息没有 `timestamp`,历史工具项的 `ts` 是加载时刻兜底,不能直接显示:历史项(带 `messageId` 字段,实时事件建的工具项没有)取所在回合用户消息的时间。 +- 回归:`tests/web/agent_browser_top_sites_test.cpp`、`web_server_smoke_test.cpp::AgentBrowserTopSitesRoundTripAndStayLocalOnly`、前端 `agentBrowserTopSites.test.js` / `agentBrowserRecentFiles.test.js`。 + +### Agent Browser Cookie 存储(add-agent-browser-cookie-modes,复刻 Claude 的 Keep cookies) + +三档 `cookie_mode`:`shared`(默认,全部会话共用一份并落盘)/ `per_session`(每个**根会话**一份,子代理归根会话)/ `until_quit`(只在本次运行保留)。存储键由纯函数决定(`src/apps/desktop/agent_browser_cookie_jars.{hpp,cpp}`):只有 per_session 返回 `session:`,owner 为空(旧 daemon、未绑定页面)一律回 `shared`。工具栏「⋮」菜单(`AgentBrowserMoreMenu.jsx`,「将控制台日志添加到聊天」也搬进了这里)与「设置 > 工具」共用 `web/src/lib/agentBrowserCookieMode.js` 的单写者 store。用户文档:`docs/agent-browser.md`「Cookie 存储」与帮助页 agent-browser。改这块要守住的几条(多数是 2026-10-21 实测踩出来的,冒烟 `agent_browser_cookie_smoke` 逐条守着): + +- **Windows 一种键一个 WebView2 Profile,共用同一个 environment。** shared **不设 ProfileName**(沿用默认 Profile,升级后登录还在,别"统一"成命名 Profile);until_quit = `acecode-until-quit` + InPrivate;session = `s-` + SHA-256(id) 前 24 位(Profile 名限 64 字符、大小写不敏感,headless 自定义 id 可能超长)。命名 Profile 的磁盘目录是 `EBWebView/WV2Profile_<名>`,不是 `EBWebView/<名>` —— 一律经 `agent_browser_session_profile_directory_name` 派生。 +- **until_quit 必须挂一个隐藏的保活 controller**:InPrivate Profile 在最后一个页面关闭时就销毁,实测不保活时关掉所有页面再打开登录已丢。保活一直持有到切离该档或 Desktop 退出。macOS 对应宿主持有的单个 `nonPersistentDataStore`。 +- **切档不用 `Profile8::Delete`**:被 Delete 的 Profile 同一次运行内不能同名重建,而用户可能马上切回去。切到 until_quit 时 shared 立即 `ClearBrowsingDataAll`,各会话存储只在状态文件记 `pending_wipe`(下次启动在建 environment 之前删目录,或本次运行切回 per_session 时首次建页前先清)。`Delete` / 删目录只用于永久删除会话。以 until_quit 启动时,第一个页面之前也要清一次 shared(异常退出残留)。 +- **会话 cookie(无过期时间)重启必丢**,共享档也一样,是引擎行为(Claude 相同),帮助页让用户勾「记住我」。别为此去改持久化。 +- **跨进程状态在 `state.json` 的 `agent_browser` 段**(`src/base/ipc/agent_browser_store_state.hpp`,经 `update_state_json` 锁):`cookie_mode` / `session_stores` / `pending_wipe` 只由 Desktop 写;`pending_deletions` / `pending_seeds`(上限 500)由 daemon / TUI 的 `host/session_host/agent_browser_store_lifecycle` 追加。不放 config.json:会被 daemon 整份配置写回覆盖。 +- **删除与分叉**:永久删除会话(purge 监听)→ 记 `pending_deletions` 再在服务自己的线程上发无数据的代理操作 `drain_store_tasks` 提醒 Desktop(失败只记 debug,旧 Desktop 不认识也无害,协议版本不升);分叉(`session_fork_listeners`,`fork_session_to_new_id` 成功后触发)→ 记 `pending_seeds`,子会话**首次**建页时若父会话已有存储就复制父会话**此刻**的 cookie。监听器回调只写状态文件,不做网络 IO。 +- **切档重建页面**:`agent_browser_pages_to_rebuild` 挑出存储键变化的页,同一 page id / owner / 共享标记在新存储里重建并导航回原 URL,后退历史丢失;页面目录的显示页 / Agent 目标页不动。按会话存储准备(清空 / 播种)期间页面的地址栏导航与代理命令排队,就绪后按显式 page id 重放。 +- macOS 实现(`agent_browser_host_mac.mm`:固定 UUID / SHA-256 派生 UUID 的 `dataStoreForIdentifier:`、`removeDataStoreForIdentifier:`、`httpCookieStore` 逐条复制)**尚未在 Mac 上编译验证**,改动前先跑一次 mac 构建。 +- 回归:`tests/desktop/agent_browser_cookie_jars_test.cpp`、`tests/ipc/agent_browser_store_state_test.cpp`、`tests/session/session_fork_listener_test.cpp`、`tests/session_host/agent_browser_store_lifecycle_test.cpp`、Windows 冒烟 `agent_browser_cookie_smoke`(EXCLUDE_FROM_ALL,输出 `SMOKE_COOKIE_MODES_OK`),前端 `agentBrowserCookieMode.test.js`、`submenuPosition.test.js`、`agentBrowserArchitecture.test.js` 的菜单合同。 + ### iOS 模拟器(openspec add-ios-simulator,仅 macOS) 逆向 Claude Desktop 的同类功能后自研复刻:Claude 用 FBSimulatorControl 的 Swift helper 且要求 xcode-select 指向完整 Xcode;我们的 iOS 同事常用绿色安装的 Xcode,所以 Xcode 必须自动发现 + 可配置。用户文档 [docs/ios-simulator.md](docs/ios-simulator.md),接口 [docs/daemon-api.md](docs/daemon-api.md) 第 20 节。 diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index 508a2fbd7..a78c6c4f6 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -4,15 +4,19 @@ set(ACECODE_UPGRADE_MANIFEST_SOURCE "${CMAKE_SOURCE_DIR}/src/adapters/upgrade/ma set(ACECODE_CHANNEL_BRIDGE_SOURCE "${CMAKE_SOURCE_DIR}/src/host/channels/bridge.cpp") set(ACECODE_WINPTY_AGENT_LOCATION_SOURCE "${CMAKE_SOURCE_DIR}/src/base/pty/winpty_agent_location.cpp") set(ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/deepin_window_effects.cpp") -set(ACECODE_DESKTOP_MAIN_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/main.cpp") +set(ACECODE_DESKTOP_MAIN_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/main.cpp" + "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_cookie_bridge.cpp") set(ACECODE_MACOS_CLI_LAUNCHER_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/macos_cli_launcher.cpp") set(ACECODE_DESKTOP_SPLASH_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/splash_screen.cpp") set(ACECODE_DESKTOP_WEB_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/web_host.cpp") set(ACECODE_DESKTOP_PET_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet.cpp") set(ACECODE_DESKTOP_PET_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet_mac.mm") set(ACECODE_DESKTOP_LINUX_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/linux_desktop.cpp") -set(ACECODE_AGENT_BROWSER_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host.cpp") -set(ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host_mac.mm") +set(ACECODE_AGENT_BROWSER_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host.cpp" + "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_windows_helpers.cpp" + "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host_common.cpp") +set(ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host_mac.mm" + "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_macos_helpers.mm") set(ACECODE_IOS_SIMULATOR_VIEW_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/ios_simulator_view.cpp") set(ACECODE_IOS_SIMULATOR_VIEW_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/ios_simulator_view_mac.mm") set(ACECODE_IOS_SIMULATOR_VIEW_BRIDGE_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/ios_simulator_view_bridge.cpp") @@ -83,6 +87,10 @@ set(ACECODE_AGENT_BROWSER_HOST_SMOKE_SOURCES ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/agent_browser_host_smoke.cpp ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_BROWSER_CDP_SOURCE}) +set(ACECODE_AGENT_BROWSER_COOKIE_SMOKE_SOURCES + ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/agent_browser_cookie_smoke.cpp + ${ACECODE_AGENT_BROWSER_HOST_SOURCE} + ${ACECODE_BROWSER_CDP_SOURCE}) set(ACECODE_MICROPHONE_PERMISSION_SMOKE_SOURCES ${CMAKE_SOURCE_DIR}/tests/desktop/helpers/microphone_permission_smoke.cpp ${ACECODE_DESKTOP_WEB_HOST_SOURCE}) diff --git a/docs/agent-browser-macos-handoff.md b/docs/agent-browser-macos-handoff.md index 5a717c5e1..5f9ec51a7 100644 --- a/docs/agent-browser-macos-handoff.md +++ b/docs/agent-browser-macos-handoff.md @@ -76,7 +76,16 @@ bridge;`src/apps/desktop/main.cpp` 也已为 `__APPLE__` 开启相同 bindings target 做 availability 检查。Apple 将 identifier data store 定义为 profile 用的独立持久化 store: -3. 所有页面配置复用同一个隔离 persistent data store,但每页保留独立 history/DOM。 + **「保留 Cookie」三档**(openspec add-agent-browser-cookie-modes D5)在此基础上按档位 + 选 store:共享(默认)就是上面这个固定 UUID 的 store,升级不掉登录;退出即清用宿主 + 持有的 `nonPersistentDataStore`(持有到切离该档或退出,所以关掉所有页面也不丢); + 按会话用 `agent_browser_session_store_uuid(根会话 id)` 派生的 UUID 调 + `dataStoreForIdentifier:`。清空走 `removeDataOfTypes:allWebsiteDataTypes`,删除会话 + 存储先释放 store 再 `removeDataStoreForIdentifier:`(WebKit 仍占用时失败,记录留到 + 下次启动),分叉复制用 `httpCookieStore getAllCookies:` + `setCookie:`。切档时存储 + 变了的页面拆掉 WKWebView、用同一个 page id 在新 store 里重建并导航回原地址;按会话 + store 第一次被用到时先清空 / 复制,期间命令排队、页面不报 ready。 +3. 同一档位下各页面复用对应的 data store,但每页保留独立 history/DOM。 设置 `WKNavigationDelegate`/`WKUIDelegate`,把 URL、title、loading、历史能力、 新窗口和 JavaScript dialog 连同 page id 映射到现有 `AgentBrowserState`。 `target=_blank` 在发起请求的同一个 WKWebView 中加载。保留 WKWebView 的标准网页 @@ -191,6 +200,9 @@ Windows 的 Developer Tools 按钮使用 WebView2 `OpenDevToolsWindow`。WKWebVi composer 上方引用区;加载失败不出现红色条。 - [ ] `ToggleDevTools` 在没有公开 WebKit API 时返回明确 unsupported,不使用私有 API。 - [ ] Profile 重启后登录保留,Safari Profile 和 ACECode 主 UI storage 不被复用。 +- [ ] 「保留 Cookie」三档:按会话互不可见、子代理沿用根会话;退出即清关掉所有页面后仍 + 登录、重启后消失;切到退出即清立即清空共享与按会话存储;清除浏览数据只影响当前存储; + 永久删除会话后其 store 被删除;分叉会话第一次打开浏览器时带着父会话的登录。 - [ ] 任意页面中不存在 `aceDesktop_*`、daemon token 或任意文件读取能力。 - [ ] inactive tab、modal、窗口最小化/隐藏、resize、Retina/非 Retina 屏幕切换不遮挡 UI。 - [ ] `browser_open` 自动创建新页;其它 tool 锁定调用开始时的 page id。只有目标页显示 diff --git a/docs/agent-browser.md b/docs/agent-browser.md index dc8c9d61c..2f8f858d6 100644 --- a/docs/agent-browser.md +++ b/docs/agent-browser.md @@ -24,16 +24,31 @@ flowchart LR favicon 也只显示在对应 `page_id` 页签上,无图标或图标加载失败时回退到 Browser 地球图标。后台页面更新标题或图标不会抢占当前页签。每页有独立 URL、DOM、历史 记录和加载状态。 -- 新页内容区由 React 显示极简 Browser welcome,不再向 WebView2 导航一张伪造的 - 空白 HTML。输入地址后,原生 controller 在加载期间保持隐藏,只有成功加载的真实 - 网页才会覆盖到该内容区。 +- 新页内容区由 React 显示新标签页,不再向 WebView2 导航一张伪造的空白 HTML。 + 版式照 Codex 桌面版的 New Tab(不做它最上方的「工具」区): + - **推荐**:常访问网站,一行最多 4 个(图标 + 标题,无标题显示主机名),点击在当前 + 页打开;悬停出现 × 可移除,提示条里能「撤销」。数据来自 ACECode 自己记的访问: + App 级记录器(`lib/agentBrowserTopSites.js`)在页面加载完成(`content_state=live`) + 时把访问报给 daemon,地址变化才计一次访问,刷新同一地址不计,标题 / 图标后到只做 + 补充;前端加载前就已打开的页面先用 `aceDesktop_agentBrowserListPages` 打底, + 不会因刷新或切工作区重复计数。daemon 存在 `/agent_browser_top_sites.json` + (Desktop 每次启动端口会变,localStorage 跨启动保不住),按「访问次数 × 最近访问 + 时间分档权重」排序(Firefox frecency 的 4 / 14 / 31 / 90 天分档),接口只给本机直连。 + - **最近**:本会话改过的文件(与「会话变更」同一来源),每行「图标 + 文件名 + 类型 + + 时间」,最多显示 6 行,上下箭头整页滚动;点开后用文件预览替换这个空白页签。持久化的 + 工具消息没有时间戳,历史改动显示所在回合用户消息的时间,实时改动显示工具事件时间。 + - 两块都为空时退回原来的 Browser welcome 提示。 + 输入地址后,原生 controller 在加载期间保持隐藏,只有成功加载的真实网页才会覆盖到 + 该内容区。 - 地址栏支持 `http://`、`https://`、`about:blank`;裸域名自动补 `https://`;无 scheme 且包含空格的文本使用 Bing 搜索。 - 后退、前进、刷新和网页本身的导航只作用于当前页签绑定的 WebView2 页面。 - 地址栏右侧的“与智能体共享”按页控制 Agent 是否可以经 Desktop 代理访问该页。用户 手工创建的新页默认不共享;`browser_open` 创建的新页默认共享。关闭共享后,Agent 对该页的 claim、选择、关闭和 CDP 命令都会以 `page_not_shared_with_agent` 拒绝。 -- 地址栏后的操作区可以把网页元素或当前页控制台日志添加到聊天。元素选择使用网页内 +- 地址栏后的操作区可以把网页元素或当前页控制台日志添加到聊天(“将元素添加到聊天”是 + 独立按钮,控制台日志在最右侧「⋮」菜单里,同一菜单还有「保留 Cookie」与「清除浏览 + 数据」,见「Cookie 存储」)。元素选择使用网页内 高亮,单击选取元素,`Esc` 取消;Windows 还支持拖拽选取共同祖先,并返回 HTML path 与计算样式。macOS 首版返回 URL、标题、元素名/属性、outer HTML、尺寸和可见文本, 暂不做跨 closed shadow root 或拖拽共同祖先选择。控制台附件是点击当时的当前页快照。 @@ -66,9 +81,10 @@ flowchart LR 有历史时提供“返回上一页”;WebView2/Chromium 自带错误文档始终保持隐藏。 浏览器 Profile 与 ACECode 主 UI、Edge/Safari Profile 隔离并持久化:Windows 位于 -`/agent-browser/webview2`;macOS 把固定 data-store UUID 保存在 -`/agent-browser/macos-profile-id`,实际网页数据由系统 WebKit 对应的 -identifier data store 管理。在 Browser 页签中完成的登录会在 Desktop 重启后保留。 +`/agent_browser/webview2`;macOS 把固定 data-store UUID 保存在 +`/agent_browser/macos_profile_id`,实际网页数据由系统 WebKit 对应的 +identifier data store 管理。升级前已经存在 `agent-browser/` 目录的安装继续沿用连字符旧名 +(见 [运行数据命名](runtime_file_naming.md))。在 Browser 页签中完成的登录会在 Desktop 重启后保留。 ## Agent 工具 @@ -161,6 +177,47 @@ macOS 的交互工具(click/fill/type/press/hover/drag/scroll)额外暴露 - **已知限制**:非显示页面的 WebView2 处于隐藏状态,Chromium 会节流定时器;后台 会话里的 `browser_screenshot` 是否总能出帧需要按站点实测。 +## Cookie 存储 + +复刻 Claude 桌面版内置浏览器的「Keep cookies」(openspec +add-agent-browser-cookie-modes)。浏览器工具栏最右侧「⋮」菜单与「设置 > 工具 > +Agent 浏览器」都能切换三档,默认**共享**: + +| 档位 | Windows(WebView2) | macOS(WKWebView) | +|---|---|---| +| 退出时清除 | 命名 InPrivate Profile `acecode-until-quit`,外加一个隐藏保活页 | 宿主持有的 `nonPersistentDataStore` | +| 共享(默认) | 默认 Profile(无选项建 controller,升级前的登录数据就在这里) | 固定 UUID 的 identifier store | +| 按会话 | 命名 Profile `s-` | 由根会话 id 派生 UUID 的 identifier store | + +- 子代理用根会话(`owner.root_session_id`)那份;没有 owner 的页面一律用共享存储。 +- 档位存在 `state.json` 的 `agent_browser` 段(`src/base/ipc/agent_browser_store_state.hpp`), + 只由 Desktop 写;daemon / TUI 只往里追加「待删除」「待复制」记录。 +- **切档**:存储变了的已打开页面用原 page id、owner、共享标记在新存储里重建并导航回 + 原地址(后退历史会丢)。切到退出时清除会立即清空共享存储(完成前挂着 + `shared_wipe_pending`,异常退出后下次启动补清),所有按会话存储打「待清空」标记: + 下次启动在建 environment 之前删目录,本次运行里若又被用到就先清空再用。共享与按会话 + 互切不删任何数据。 +- **清除浏览数据**:清当前页所用的那份存储(`ClearBrowsingDataAll` / + `removeDataOfTypes`),完成后重新加载同一存储里的页面;同步 bridge 只返回「已开始」, + 结果经 `acecode:agent-browser-storage` 事件回报。 +- **会话删除与分叉**:永久删除会话(归档不算)后 daemon / TUI 记待删除并经 + `drain_store_tasks` 提醒 Desktop;Desktop 关掉该根会话的页面,本次运行加载过的存储用 + `ICoreWebView2Profile8::Delete`(macOS `removeDataStoreForIdentifier:`),没加载过的 + 直接删目录。分叉成功后记一条复制记录,子会话第一次打开浏览器时(按会话档)从父会话 + 存储复制当时的全部 cookie,之后两边互不影响。Desktop 没开时这些都在下次启动处理。 +- **实测的 WebView2 行为**(2026-10-21,`tests/desktop/helpers/agent_browser_cookie_smoke.cpp`): + 命名 Profile 的目录是 `EBWebView/WV2Profile_<名字>`;InPrivate Profile 在最后一个页面 + 关闭后就会丢数据,所以退出时清除档必须有保活页;没有过期时间的会话 cookie 在 Desktop + 重启后丢失(Claude 用的 Electron 同样 `persist_session_cookies = false`),网站登录时 + 要勾「记住我」才能跨重启保持登录。 +- 回归:`tests/desktop/agent_browser_cookie_jars_test.cpp`、 + `tests/ipc/agent_browser_store_state_test.cpp`、 + `tests/session_host/agent_browser_store_lifecycle_test.cpp`、 + `tests/session/session_fork_listener_test.cpp`、Windows 冒烟 + `agent_browser_cookie_smoke`(`SMOKE_COOKIE_MODES_OK`,需要能访问 example.com),前端 + `agentBrowserCookieMode.test.js`、`submenuPosition.test.js` 与 + `agentBrowserArchitecture.test.js` 的 cookie 一条。 + ## 运行时和安全边界 - Desktop 只创建本机 IPC;请求必须携带 manifest 中的随机令牌。Windows 使用随机命名 @@ -175,6 +232,11 @@ macOS 的交互工具(click/fill/type/press/hover/drag/scroll)额外暴露 Desktop 按 `page_id` 找到现有用户页面,再 dispatch 到 UI 线程;Windows 调用 `CallDevToolsProtocolMethod`,macOS 映射到公开 WKWebView API 或命名 content world。 一次工具调用首次选定页面后保持锁定,不会另建 target、误连其它浏览器或切页串页。 +- 代理操作:`cdp`(默认)、`create_page`、`claim_page`、`select_page`、`close_page`, + 以及 `drain_store_tasks`。最后一个不带页面与参数,只是提醒 Desktop 处理 `state.json` + 里 daemon / TUI 记下的 cookie 存储待办(永久删除会话后删除它的按会话存储,见下文 + 「Cookie 存储」);成功回 `{"ok":true,"result":{}}`。旧版 Desktop 回复「operation is + invalid」,daemon 只记 debug 日志,待办留到 Desktop 下次启动时处理。协议版本不变。 - Agent Browser 不注册 `aceDesktop_*` binding、host object 或 web-message handler。任意网页无法取得主 ACECode UI 的 daemon token、localStorage 或 native bridge。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index c5f67129e..36852889a 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -285,6 +285,7 @@ Development `--static-dir` resources continue to be read from disk on each reque | PUT | `/api/no-workspace/pinned-sessions` | set pinned no-workspace task ids | | GET | `/api/pinned-sessions/order` | read global cross-scope pin order | | PUT | `/api/pinned-sessions/order` | set global cross-scope pin order | +| POST | `/api/session-batch` | pin/unpin/archive/unarchive/read/unread many sessions at once | | GET | `/api/sessions` | compatibility session list | | GET | `/api/desktop-office?session=&workspace=` | recent five offices and selected root/agent runtime snapshot | | GET | `/api/session-search/sessions?q=...&limit=N&cursor=...&request_id=...` | incremental global session catalog page | @@ -418,6 +419,9 @@ Development `--static-dir` resources continue to be read from disk on each reque | POST | `/api/memory/reset` | clear one scope (entries, index, inbox, archive) | | GET | `/api/config/tool-rewrites` | read tool rewrite settings plus the built-in tool catalog | | PUT | `/api/config/tool-rewrites` | replace tool rewrite settings, persist `tool_rewrites.json`, apply live | +| GET | `/api/agent-browser/top-sites?limit=N` | Agent Browser New Tab「推荐」: most visited sites (local clients only) | +| POST | `/api/agent-browser/visits` | record one Agent Browser page visit or its later title/favicon (local clients only) | +| POST | `/api/agent-browser/top-sites/dismiss` / `restore` | hide / un-hide one suggested site (local clients only) | | GET | `/api/config/tool-preamble` | read the concrete progress text switch (work mode) | | PUT | `/api/config/tool-preamble` | set the concrete progress text switch in `config.json`, push to active sessions | | GET | `/api/config/sandbox` | read sandbox switches, filesystem lists, defaults and platform probe | @@ -853,6 +857,13 @@ The same query parameters apply. Body: {"source":"session:older-run","target":"loop:task-id","placement":"inside"} ``` +For a batch, replace `source` with `sources`, an ordered nonempty array of +distinct node keys. The entire batch is validated and saved atomically; a stale, +duplicate, selected destination or invalid member rejects the batch without +changing the layout. Relative source order is preserved for all placements. +An invalid `sources` type returns HTTP 409/`SIDEBAR_INVALID_SOURCES`. +The response reveals all moved nodes. The single-source body remains supported. + `placement` is `before`, `after`, or `inside`. Moves remain within the endpoint's scope. Only a scheduled root with `loop_execution.loop_id` (the alarm marker) can accept children, including an empty group or a detached scheduled execution. @@ -2460,6 +2471,74 @@ Body: Normalizes, prunes unavailable pinned items, persists, and echoes the same shape as `GET`. +### `POST /api/session-batch` + +Applies one sidebar action to many sessions in a single request. Each pin file, +the global pin order and each workspace attention file are written at most +once. Items are processed independently: a failing item does not stop the +others, and the response is `200` with per-item results. Only a malformed +request (unknown action, empty or oversized `items`, invalid id) returns `400`. + +Body: + +```json +{ + "action": "pin", + "items": [ + {"session_id":"sid-1","workspace_hash":"abc123"}, + {"session_id":"task-1","no_workspace":true}, + {"session_id":"sid-2","workspace_hash":"abc123","cursor":42} + ] +} +``` + +- `action`: `pin`, `unpin`, `archive`, `unarchive`, `read` or `unread`. +- `items`: 1–500 entries in visible sidebar order. `session_id` must match + `[A-Za-z0-9_-]{1,128}`. No-workspace tasks set `no_workspace:true` (the pin + scope `__no_workspace__` is also accepted as `workspace_hash`). Duplicate + scope + id pairs are ignored. `cursor` is used only by `read`, with the same + meaning as the WebSocket `mark_session_read` cursor. + +Semantics: + +- `pin` inserts sessions that are not yet pinned at the top in request order + (the first item ends up first) and leaves already pinned sessions where they + are, both in the scope pin list and in the global order. Sessions that cannot + be pinned (missing, archived, subagent) fail with `session cannot be pinned`. +- `unpin` removes the items from their scope lists and the global order. +- `archive` / `unarchive` follow the single-item archive routes, including the + subagent `409` rule (reported as a per-item error). Archived sessions are + removed from pin lists and the global order in the same request. +- `read` / `unread` follow the WebSocket `mark_session_read` / + `mark_session_unread` messages and still broadcast `session_status` for + sessions whose state changed. + +Response: + +```json +{ + "action": "pin", + "succeeded": 2, + "failed": 1, + "results": [ + {"session_id":"sid-1","workspace_hash":"abc123","no_workspace":false,"ok":true}, + {"session_id":"task-1","workspace_hash":"","no_workspace":true,"ok":true}, + {"session_id":"gone","workspace_hash":"abc123","no_workspace":false,"ok":false, + "error":"session cannot be pinned"} + ], + "pinned_scopes": [ + {"pin_scope":"abc123","session_ids":["sid-1","sid-0"]}, + {"pin_scope":"__no_workspace__","session_ids":["task-1"]} + ], + "pinned_order": {"items":[{"workspace_hash":"abc123","session_id":"sid-1"}]} +} +``` + +`pinned_scopes` and `pinned_order` are present when pins changed (`pin`, +`unpin`, `archive`). Archive results carry `session` (the updated session meta, +same shape as the single archive route); read/unread results carry `status` +(the `session_status` payload). + --- ## 7. Files @@ -4298,6 +4377,30 @@ writes the file atomically, publishes the mapping to the process so the next model request uses it, and returns the same shape as GET. Hook matchers accept the rewritten names as aliases of the native tool while a rewrite is active. +### Agent Browser New Tab suggestions (most visited sites) + +The Desktop Agent Browser's empty page lists suggested sites the way Codex +Desktop's New Tab does. WebView2 / WKWebView expose no browsing history, so the +WebUI reports visits and the daemon keeps them in +`/agent_browser_top_sites.json` (`{version, sites:[{url, title?, +favicon?, visit_count, last_visit_ms}], blocked:[url]}`, at most 200 sites and +200 dismissed URLs). Browsing history is private: all four routes return 403 +`{error:"LOCAL_ONLY"}` to anything but a direct loopback client (the remote Web +proxy's `127.0.0.2` source included); writes return 409 +`DATA_DIR_MIGRATION_ACTIVE` while a data directory migration runs. + +- `POST /api/agent-browser/visits` `{url, title?, favicon?, new_visit?=true}`. + Only `http(s)` URLs are accepted (400 `BAD_URL` otherwise); the `#fragment` is + dropped. `new_visit:true` counts a navigation; `false` only fills in the title + or favicon of an existing entry. Favicons are `http(s)` URLs or `data:image/` + URLs up to 64 KiB; anything else is ignored. Returns `{ok, changed}`. +- `GET /api/agent-browser/top-sites?limit=N` (default 4, max 12) returns + `{sites:[{url, title, favicon?}]}`, dismissed URLs excluded, ordered by + `visit_count × recency weight` (1.0 within 4 days, 0.7 within 14, 0.5 within + 31, 0.3 within 90, else 0.1). An empty title falls back to the host. +- `POST /api/agent-browser/top-sites/dismiss` / `restore` `{url}` hide or un-hide + one suggestion; later visits do not un-hide it. + ### Tool preamble / concrete progress text (`openspec add-tool-preamble`) Settings > General > Work mode: "适合日常工作" (daily) turns it on, "用于编程" @@ -5004,8 +5107,10 @@ removes only private staging, preserves the source and existing target files, and re-enables writes. A pointer-write failure retains the copied target for recovery. The staging directory is a sibling of the target named `.acecode-mig-<8 hex>`. -`agent-browser/webview2` (the Desktop Agent Browser profile, open and written for -the whole Desktop lifetime) is copied best-effort: it is enumerated separately and +`agent_browser/webview2` (the Desktop Agent Browser profile, open and written for +the whole Desktop lifetime; installations that already had the legacy +`agent-browser/webview2` keep that name, and both are recognized) is copied +best-effort: it is enumerated separately and tolerates entries vanishing mid-walk, skips rebuildable caches (`Cache`, `Code Cache`, `GPUCache`, `GrShaderCache`, `GraphiteDawnCache`, `DawnCache`, `DawnGraphiteCache`, `DawnWebGPUCache`, `ShaderCache`, `Crashpad`) and `lockfile` / diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index 29d003f05..a11825393 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -46,7 +46,7 @@ "agent-browser": page("在桌面端把需要处理的网页共享给智能体,让浏览、定位和操作发生在你能看到的页面中。", [ section("open", "打开浏览器面板", - '''

从桌面右侧打开工具 > 浏览器新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。

这里的页面属于 ACECode 内置浏览器。它使用自己的持久化浏览器配置,页面间可共享该配置中的登录信息;不会直接继承你日常 Edge 或 Safari 的全部登录状态。

''', + '''

从桌面右侧打开工具 > 浏览器新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。

这里的页面属于 ACECode 内置浏览器。它使用自己的浏览器配置,不会继承你日常 Edge 或 Safari 的登录状态;登录信息保存多久、各会话之间是否共用,见下文“保持登录与清除浏览数据”。

''', '''

也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。

''', figure("AD-07", "浏览器页签与地址栏", "展示打开工具菜单、浏览器页签、地址输入及加载状态,使用公开示例页面。")), section("share", "决定哪些页面交给智能体", @@ -54,8 +54,13 @@ figure("AD-08", "页面共享与可见操作", "展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。"), '''

隐藏面板会保留页面状态;关闭页签会释放该页面。遇到操作失败时先检查它是否仍存在、是否共享和是否加载完成,再让智能体继续。

'''), section("context", "引用页面元素与控制台信息", - '''

元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。

提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。

让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在设置 > 工具检查。

''') -], ["docs/agent-browser.md", "web/src/components/PreviewDetailsPanel.jsx", "web/src/components/SettingsPage.jsx"]), + '''

元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。控制台信息从工具栏最右侧的 ⋮ 菜单选择将控制台日志添加到聊天,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。

提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。

让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在设置 > 工具检查。

'''), + section("cookies", "保持登录与清除浏览数据", + '''

在浏览器里登录网站后,登录状态(Cookie)保存多久由保留 Cookie决定。打开工具栏最右侧的 ⋮ 菜单,在 Cookie 一组里选择;也可以在设置 > 工具的 Agent 浏览器下方修改,两处是同一个设置。

''', + table(["选项", "效果"], [["共享(默认)", "所有会话共用同一份 Cookie,退出 ACECode 后仍保留,下次打开网站通常不用重新输入账号密码。"], ["按会话", "每个会话各自保存,会话之间看不到对方的登录;子任务使用所属主会话的那份。从某个会话分叉出的新会话,第一次打开浏览器时会带上原会话当时的登录。"], ["退出时清除", "只在本次运行中保留,关掉页面再打开仍是登录状态,退出 ACECode 后清除。切换到这一项时,已经保存的 Cookie 会立即删除。"]]), + '''

切换选项后,已打开的页面会在新的存储中按原地址重新加载,后退历史不保留。清除浏览数据删除当前页面所用那份存储里的 Cookie、登录状态和网站数据,确认框会说明影响范围:共享时影响所有会话,按会话时只影响当前会话。永久删除某个会话时,它在“按会话”下保存的数据会一并删除;归档不会删除。

''', + note("勾选“记住我”", "有的网站只在本次打开期间保留登录(没有设置过期时间),这类登录退出 ACECode 后都会失效,与选项无关。登录时勾选网站提供的“记住我”或“保持登录”,下次打开才能保持登录。")) +], ["docs/agent-browser.md", "web/src/components/PreviewDetailsPanel.jsx", "web/src/components/SettingsPage.jsx", "web/src/components/AgentBrowserMoreMenu.jsx"]), "channels": page("在设置里连接 QQ、微信、飞书、钉钉、Telegram、Discord 或 LINE,管理机主与授权;也可使用已有渠道插件和手动 Webhook。", [ section("im", "连接 QQ、微信、飞书、钉钉、Telegram、Discord 与 LINE", diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json index 3b411db5f..0ee281611 100644 --- a/docs/help-source/sources.json +++ b/docs/help-source/sources.json @@ -359,7 +359,8 @@ "sources": [ "docs/agent-browser.md", "web/src/components/PreviewDetailsPanel.jsx", - "web/src/components/SettingsPage.jsx" + "web/src/components/SettingsPage.jsx", + "web/src/components/AgentBrowserMoreMenu.jsx" ] }, { diff --git a/docs/help/agent-browser.html b/docs/help/agent-browser.html index 692f3ced3..6591f95a9 100644 --- a/docs/help/agent-browser.html +++ b/docs/help/agent-browser.html @@ -35,20 +35,24 @@

Agent 浏览器

在桌面端把需要处理的网页共享给智能体,让浏览、定位和操作发生在你能看到的页面中。

-
本页内容
+
本页内容
-

打开浏览器面板

从桌面右侧打开工具 > 浏览器新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。

这里的页面属于 ACECode 内置浏览器。它使用自己的持久化浏览器配置,页面间可共享该配置中的登录信息;不会直接继承你日常 Edge 或 Safari 的全部登录状态。

+

打开浏览器面板

从桌面右侧打开工具 > 浏览器新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。

这里的页面属于 ACECode 内置浏览器。它使用自己的浏览器配置,不会继承你日常 Edge 或 Safari 的登录状态;登录信息保存多久、各会话之间是否共用,见下文“保持登录与清除浏览数据”。

也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。

详情页签的打开工具菜单:文件、浏览器与侧边聊天 ACECode 原生 Agent 浏览器实际打开 example.com,显示页签、地址栏和页面内容
在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。Windows 实际界面 · 点击图片查看原图

决定哪些页面交给智能体

你手动创建的页面默认不共享。需要让智能体读取或操作时,在目标页签开启与智能体共享。智能体通过浏览器工具创建的页面默认共享;每个页面的状态独立。

未共享的页面不会因其正在前台就自动开放给工具。开始任务前明确目标页签与操作范围,例如“检查这个页面的表单校验,提交前先让我确认”。登录过程和敏感表单也应在正确页面中处理。

展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。

隐藏面板会保留页面状态;关闭页签会释放该页面。遇到操作失败时先检查它是否仍存在、是否共享和是否加载完成,再让智能体继续。

-

引用页面元素与控制台信息

元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。

提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。

让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在设置 > 工具检查。

+

引用页面元素与控制台信息

元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。控制台信息从工具栏最右侧的 ⋮ 菜单选择将控制台日志添加到聊天,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。

提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。

让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在设置 > 工具检查。

+

保持登录与清除浏览数据

在浏览器里登录网站后,登录状态(Cookie)保存多久由保留 Cookie决定。打开工具栏最右侧的 ⋮ 菜单,在 Cookie 一组里选择;也可以在设置 > 工具的 Agent 浏览器下方修改,两处是同一个设置。

+
选项效果
共享(默认)所有会话共用同一份 Cookie,退出 ACECode 后仍保留,下次打开网站通常不用重新输入账号密码。
按会话每个会话各自保存,会话之间看不到对方的登录;子任务使用所属主会话的那份。从某个会话分叉出的新会话,第一次打开浏览器时会带上原会话当时的登录。
退出时清除只在本次运行中保留,关掉页面再打开仍是登录状态,退出 ACECode 后清除。切换到这一项时,已经保存的 Cookie 会立即删除。
+

切换选项后,已打开的页面会在新的存储中按原地址重新加载,后退历史不保留。清除浏览数据删除当前页面所用那份存储里的 Cookie、登录状态和网站数据,确认框会说明影响范围:共享时影响所有会话,按会话时只影响当前会话。永久删除某个会话时,它在“按会话”下保存的数据会一并删除;归档不会删除。

+
- +
diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index 0357babaa..4b6324754 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -1062,7 +1062,7 @@ window.ACECODE_HELP_INDEX = [ "title": "Agent 浏览器", "url": "agent-browser.html", "description": "在桌面端把需要处理的网页共享给智能体,让浏览、定位和操作发生在你能看到的页面中。", - "text": "打开浏览器面板 从桌面右侧 打开工具 > 浏览器 新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。 这里的页面属于 ACECode 内置浏览器。它使用自己的持久化浏览器配置,页面间可共享该配置中的登录信息;不会直接继承你日常 Edge 或 Safari 的全部登录状态。 也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。 在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。 Windows 实际界面 · 点击图片查看原图 决定哪些页面交给智能体 你手动创建的页面默认不共享。需要让智能体读取或操作时,在目标页签开启 与智能体共享 。智能体通过浏览器工具创建的页面默认共享;每个页面的状态独立。 未共享的页面不会因其正在前台就自动开放给工具。开始任务前明确目标页签与操作范围,例如“检查这个页面的表单校验,提交前先让我确认”。登录过程和敏感表单也应在正确页面中处理。 AD-08 图片占位 页面共享与可见操作 此处补充实际界面截图 16 : 9 展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。 隐藏面板会保留页面状态;关闭页签会释放该页面。遇到操作失败时先检查它是否仍存在、是否共享和是否加载完成,再让智能体继续。 引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。 让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在 设置 > 工具 检查。" + "text": "打开浏览器面板 从桌面右侧 打开工具 > 浏览器 新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。 这里的页面属于 ACECode 内置浏览器。它使用自己的浏览器配置,不会继承你日常 Edge 或 Safari 的登录状态;登录信息保存多久、各会话之间是否共用,见下文“保持登录与清除浏览数据”。 也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。 在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。 Windows 实际界面 · 点击图片查看原图 决定哪些页面交给智能体 你手动创建的页面默认不共享。需要让智能体读取或操作时,在目标页签开启 与智能体共享 。智能体通过浏览器工具创建的页面默认共享;每个页面的状态独立。 未共享的页面不会因其正在前台就自动开放给工具。开始任务前明确目标页签与操作范围,例如“检查这个页面的表单校验,提交前先让我确认”。登录过程和敏感表单也应在正确页面中处理。 AD-08 图片占位 页面共享与可见操作 此处补充实际界面截图 16 : 9 展示单个页签的共享开关、共享状态,以及智能体操作时的边框或光标反馈,遮挡任何账号信息。 隐藏面板会保留页面状态;关闭页签会释放该页面。遇到操作失败时先检查它是否仍存在、是否共享和是否加载完成,再让智能体继续。 引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。控制台信息从工具栏最右侧的 ⋮ 菜单选择 将控制台日志添加到聊天 ,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。 让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在 设置 > 工具 检查。 保持登录与清除浏览数据 在浏览器里登录网站后,登录状态(Cookie)保存多久由 保留 Cookie 决定。打开工具栏最右侧的 ⋮ 菜单,在 Cookie 一组里选择;也可以在 设置 > 工具 的 Agent 浏览器下方修改,两处是同一个设置。 选项 效果 共享(默认) 所有会话共用同一份 Cookie,退出 ACECode 后仍保留,下次打开网站通常不用重新输入账号密码。 按会话 每个会话各自保存,会话之间看不到对方的登录;子任务使用所属主会话的那份。从某个会话分叉出的新会话,第一次打开浏览器时会带上原会话当时的登录。 退出时清除 只在本次运行中保留,关掉页面再打开仍是登录状态,退出 ACECode 后清除。切换到这一项时,已经保存的 Cookie 会立即删除。 切换选项后,已打开的页面会在新的存储中按原地址重新加载,后退历史不保留。 清除浏览数据 删除当前页面所用那份存储里的 Cookie、登录状态和网站数据,确认框会说明影响范围:共享时影响所有会话,按会话时只影响当前会话。永久删除某个会话时,它在“按会话”下保存的数据会一并删除;归档不会删除。 勾选“记住我” 有的网站只在本次打开期间保留登录(没有设置过期时间),这类登录退出 ACECode 后都会失效,与选项无关。登录时勾选网站提供的“记住我”或“保持登录”,下次打开才能保持登录。" }, { "page": "Agent 浏览器", @@ -1070,7 +1070,7 @@ window.ACECODE_HELP_INDEX = [ "title": "打开浏览器面板", "url": "agent-browser.html#open", "description": "打开浏览器面板 从桌面右侧 打开工具 > 浏览器 新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux ", - "text": "打开浏览器面板 从桌面右侧 打开工具 > 浏览器 新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。 这里的页面属于 ACECode 内置浏览器。它使用自己的持久化浏览器配置,页面间可共享该配置中的登录信息;不会直接继承你日常 Edge 或 Safari 的全部登录状态。 也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。 在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。 Windows 实际界面 · 点击图片查看原图" + "text": "打开浏览器面板 从桌面右侧 打开工具 > 浏览器 新建浏览器页签,输入地址后导航。没有协议的主机名会按 HTTPS 处理;普通搜索词可发起搜索。当前原生 Agent 浏览器支持 Windows 和 macOS 14 及以上的相应桌面构建,Linux 与直接 Web 页面不具备同样的原生桥接能力。 这里的页面属于 ACECode 内置浏览器。它使用自己的浏览器配置,不会继承你日常 Edge 或 Safari 的登录状态;登录信息保存多久、各会话之间是否共用,见下文“保持登录与清除浏览数据”。 也可以从 HTML/HTM 文件预览中的浏览器图标打开本地页面:先处理未保存修改,再用 file:// 访问磁盘文件。该操作仍使用当前会话的原生浏览器;依赖后端接口、构建输出或开发服务器的应用,应按项目说明启动服务后访问对应地址。出现加载错误时源文件标签会保留。 在详情区域的打开工具菜单选择浏览器,可通过地址栏打开网页。下图为 ACECode Agent 浏览器实际加载的 example.com 页面。 Windows 实际界面 · 点击图片查看原图" }, { "page": "Agent 浏览器", @@ -1085,8 +1085,16 @@ window.ACECODE_HELP_INDEX = [ "group": "进阶使用", "title": "引用页面元素与控制台信息", "url": "agent-browser.html#context", - "description": "引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来", - "text": "引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。添加控制台信息时,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。 让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在 设置 > 工具 检查。" + "description": "引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。控制台信息从工具栏最右侧的 ⋮ 菜单选择 将控制台日志添加到聊天 ,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文", + "text": "引用页面元素与控制台信息 元素选择可以把目标位置作为上下文加入输入区,按 Esc 退出选择。控制台信息从工具栏最右侧的 ⋮ 菜单选择 将控制台日志添加到聊天 ,引用的是当时的快照,不会自动发送消息,也不会持续同步未来所有日志。 提交前检查附件或上下文项是否指向正确页面,可以移除无关项。动态页面、跨来源 iframe 和不同操作系统的原生实现存在能力差异;选不到时描述页面和元素位置,并以实际返回信息为准。 让智能体完成操作后,检查网页结果与必要的应用数据。浏览器动作成功只证明交互执行,不代表业务提交一定成功。连接能力状态可以在 设置 > 工具 检查。" + }, + { + "page": "Agent 浏览器", + "group": "进阶使用", + "title": "保持登录与清除浏览数据", + "url": "agent-browser.html#cookies", + "description": "保持登录与清除浏览数据 在浏览器里登录网站后,登录状态(Cookie)保存多久由 保留 Cookie 决定。打开工具栏最右侧的 ⋮ 菜单,在 Cookie 一组里选择;也可以在 设置 > 工具 的 Agent 浏览器下方修改,两处是同一个设置。 选项", + "text": "保持登录与清除浏览数据 在浏览器里登录网站后,登录状态(Cookie)保存多久由 保留 Cookie 决定。打开工具栏最右侧的 ⋮ 菜单,在 Cookie 一组里选择;也可以在 设置 > 工具 的 Agent 浏览器下方修改,两处是同一个设置。 选项 效果 共享(默认) 所有会话共用同一份 Cookie,退出 ACECode 后仍保留,下次打开网站通常不用重新输入账号密码。 按会话 每个会话各自保存,会话之间看不到对方的登录;子任务使用所属主会话的那份。从某个会话分叉出的新会话,第一次打开浏览器时会带上原会话当时的登录。 退出时清除 只在本次运行中保留,关掉页面再打开仍是登录状态,退出 ACECode 后清除。切换到这一项时,已经保存的 Cookie 会立即删除。 切换选项后,已打开的页面会在新的存储中按原地址重新加载,后退历史不保留。 清除浏览数据 删除当前页面所用那份存储里的 Cookie、登录状态和网站数据,确认框会说明影响范围:共享时影响所有会话,按会话时只影响当前会话。永久删除某个会话时,它在“按会话”下保存的数据会一并删除;归档不会删除。 勾选“记住我” 有的网站只在本次打开期间保留登录(没有设置过期时间),这类登录退出 ACECode 后都会失效,与选项无关。登录时勾选网站提供的“记住我”或“保持登录”,下次打开才能保持登录。" }, { "page": "消息渠道与远程控制", diff --git a/docs/user-manual.md b/docs/user-manual.md index 8aaa5f390..574928670 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -229,6 +229,8 @@ Desktop 文件树中也可以右键文件或文件夹并选择“添加到会话 ### Desktop 文件详情与标签页 +在文件详情或文件标签上右键选择“在文件列表展示”,或按 `Ctrl+[`,可在当前工作区的文件列表中定位文件:自动展开父目录、滚动选中,并短暂放大高亮后恢复。重复定位会再次高亮;开启系统“减少动态效果”时只高亮、不缩放。工作区外的文件不执行此操作。 + 在 Desktop 文件树打开工作区内的文本文件后,详情页直接就是编辑器,不需要再点击“编辑”:普通文本和代码使用保留语法显色的轻量源码编辑器,Markdown 使用轻量所见即所得编辑器。界面不显示额外的编辑、保存或取消按钮;两种编辑器都支持 `Ctrl+S` 保存,保存会保留原文件的编码、BOM 和换行风格。工作区外的绝对文件仍可预览,但保持只读。 尚未保存的标签页显示实心大点。草稿会跟随标签页保留,切换标签或用详情面板右上角叉号隐藏面板都不会丢失;再次打开详情会恢复原标签、草稿和浏览器页面。只有在标签页上执行“关闭”“关闭其他”“关闭右侧”或“全部关闭”时才会移除标签。涉及未保存草稿时会提供“保存并关闭”“不保存”“取消”:批量保存只有在全部成功后才关闭页签,任一文件失败都会保留所有目标页签和失败草稿。文件在编辑期间被外部程序或 Agent 改动时,保存会提示冲突并保留当前草稿,不会静默覆盖磁盘内容。 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/design.md b/openspec/changes/adapt-sidebars-to-transcript-width/design.md index f351317fc..5bb7e78ab 100644 --- a/openspec/changes/adapt-sidebars-to-transcript-width/design.md +++ b/openspec/changes/adapt-sidebars-to-transcript-width/design.md @@ -11,13 +11,13 @@ App 持有全局左栏开关、按工作台保存的右栏开关和全局栏宽 ## Decisions 1. 按用户调低折叠阈值的要求,所有字号的阅读下限统一为 350 CSS px。恢复门槛比折叠门槛高 64 px;使用壳体实际宽度、保存栏宽和预览意图预测展开后的 transcript 宽度,避免用收起后变宽的 transcript 直接触发反向展开。 -2. 独立纯函数决定左右自动折叠与可停靠能力;React hook 只负责测量、临时状态与外部点击。左栏优先保留,右侧预览和列表作为一组让出空间。子对话打开时,父 transcript 的预算额外考虑子对话宽度。 -3. 只由正常布局下的手动开关修改现有偏好。popup 互斥、切工作台清空,窄窗口刷新重新按偏好计算。宽度恢复后,原先开启的栏停靠,原先关闭的栏保持关闭。 -4. 左右 popup 保留原组件实例,不用改变 portal 目标的方式搬运内容。通过 React 事件边界识别栏内 portal 菜单,防止操作栏内菜单被误认作外部点击。Escape 尊重内层菜单/对话框的处理。 -5. 窄右侧 popup 同时有详情与列表时,以详情为主,列表可临时覆盖详情供选择;不能因两个最小宽度相加而把浮层撑出窗口。临时列表开关也不修改持久化偏好。 +2. 按 2026-10-20 用户要求,纯函数只决定左栏自动折叠与可停靠能力。预测左栏展开时调用共用布局求解器,扣除实际开启的右侧导航、详情及子对话占宽;右栏手动关闭后不再预留其空间。React hook 继续负责测量、临时状态与外部点击。 +3. 自动折叠和左栏 popup 不写入持久化偏好;切工作台清空 popup,窄窗口刷新重新按偏好计算。宽度恢复后,原先开启的左栏停靠,原先关闭的左栏保持关闭。右栏始终服从手动开关,展开不会触发响应式浮层。 +4. 左栏 popup 保留原组件实例,不用改变 portal 目标的方式搬运内容。通过 React 事件边界识别栏内 portal 菜单,防止操作栏内菜单被误认作外部点击。Escape 尊重内层菜单/对话框的处理。 +5. 保留现有组件接口和已保存的右栏浮层宽度字段,但布局决策固定返回右栏不自动折叠、允许正常展开,所有右栏响应式浮层入口因此不再触发。右侧详情与列表沿用原正常布局;左栏收起后空间仍不足时,右栏不会被强制隐藏,transcript 使用剩余空间。 6. 现有显式预览最大化/演示模式继续生效;自动折叠只保护常规对话布局,不能把用户主动最大化误当成挤压。 7. Desktop 的原生最小窗口宽度由旧的 1170 降为 480 逻辑像素,低于左栏自动折叠门槛。Windows 继续按 DPI 缩放并受工作区上限约束;macOS 拖动和程序化设置宽度共用此下限。初始窗口大小保持现有设置。原生下限与 transcript 的阅读下限分开,用户主动缩到更窄时由前端使用全部可用空间。 -8. 浮层也提供靠 transcript 一侧的拖宽手柄:左浮层拖右边,右浮层拖左边。使用实际渲染宽度作为拖动起点,范围由当前容器和 24 px 外部点击区域计算。浮层手动宽度保存在同一个全局栏宽偏好中的可选 `sidebarPopup` / `rightPanelPopup` 字段,不改正常占位宽度和开关;拖窄不会因此退出浮动或压缩 transcript。默认仍使用现有栏宽,窄视口临时夹取不能覆盖保存值。Pointer capture 保持跨边界拖动,取消、失焦、关闭或卸载均清理监听与拖动光标;键盘方向键沿用原手柄步长。 +8. 左栏浮层继续通过右边缘调宽。使用实际渲染宽度作为拖动起点,范围由当前容器和 24 px 外部点击区域计算。浮层手动宽度保存在同一个全局栏宽偏好的 `sidebarPopup` 字段,不改正常占位宽度和开关;拖窄不会因此退出浮动或压缩 transcript。默认仍使用现有栏宽,窄视口临时夹取不能覆盖保存值。Pointer capture 保持跨边界拖动,取消、失焦、关闭或卸载均清理监听与拖动光标;键盘方向键沿用原手柄步长。 ## Risks / Trade-offs diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md b/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md index a29edb847..bf62f9958 100644 --- a/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md +++ b/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md @@ -1,14 +1,14 @@ ## Why -窗口缩小时,左右边栏与预览会挤压 transcript,导致最重要的对话内容不可读。需要优先为 transcript 让出空间,并在窄窗口里保留通过 popup 使用边栏的能力。 +窗口缩小时,左右边栏与预览会挤压 transcript。按 2026-10-20 用户要求,自动折叠仅作用于左边栏,右侧文件列表与详情保持手动开关控制;窄窗口保留通过 popup 临时使用左栏的能力。 ## What Changes -- 以 transcript 的可用宽度为依据,先自动收起整个右侧工作区,再收起左栏。 +- 以包含右侧文件列表、详情与子对话占宽的 transcript 可用宽度为依据,仅自动收起左栏;右栏不因窗口缩放自动收起或变为浮层。 - 所有字号统一保留 350 CSS px 阅读宽度,恢复时增加 64 px 余量,避免临界抖动。 -- 窄窗口 expand 打开对应侧 popup,栏内正常操作,点击外部、再次点击按钮或 Escape 收起;两侧 popup 互斥。 +- 窄窗口左栏 expand 打开可调宽 popup,栏内正常操作,点击外部、再次点击按钮或 Escape 收起;右栏按钮始终按手动开关展开或收起。 - 自动折叠与 popup 不修改持久化开关和栏宽;仅恢复用户原先开启的栏。 -- 保留预览页签、编辑草稿和子对话状态;窗口极窄时优先让 transcript 使用可用空间。 +- 保留预览页签、编辑草稿和子对话状态;左栏收起后仍不足 350 px 时保持右栏手动选择,transcript 使用剩余空间。 ## Capabilities @@ -16,7 +16,7 @@ - `responsive-workbench-layout`: transcript 优先的空间分配、自动折叠、popup 与偏好恢复。 ### Modified Capabilities -- `webui-side-panel`: 单会话侧面板不再要求所有窗口宽度下固定占位,支持响应式隐藏与浮层。 +- `webui-side-panel`: 单会话右侧面板在所有窗口宽度下均由手动开关决定是否占位。 ## Impact diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md b/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md index f23916d43..f55ebf593 100644 --- a/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md +++ b/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md @@ -1,63 +1,63 @@ ## Purpose -保证 ACECode 单会话工作台在窗口缩放时优先显示可阅读的 transcript,同时保留左右辅助栏的临时访问能力、用户保存的开关选择和栏宽,避免窗口缩放意外改变用户长期布局。 +保证 ACECode 单会话工作台在窗口缩放时通过自动折叠左栏为 transcript 让出空间,同时保持右栏手动开关、左栏临时浮层及用户保存的布局偏好。 ## ADDED Requirements ### Requirement: Transcript-first responsive collapse -系统 SHALL 根据 transcript 可用宽度决定辅助栏是否占位。所有字号阅读下限 SHALL 统一为 350 CSS px;恢复展开 SHALL 额外保留 64 px。右侧导航与预览及打开的子对话占宽 MUST 参与判断。空间不足 SHALL 先收右侧工作区,再收左栏;整个窗口不足阅读下限时 transcript SHALL 使用可用空间而不是撑出视口。显式最大化预览和演示模式 SHALL 保留原行为。 +系统 SHALL 根据 transcript 可用宽度决定左栏是否占位。所有字号折叠阈值 SHALL 统一为 350 CSS px;恢复展开 SHALL 额外保留 64 px。实际开启的右侧导航、预览及子对话占宽 MUST 参与判断;手动关闭的右侧内容 MUST NOT 预留宽度。空间不足 SHALL 仅自动收起左栏,SHALL NOT 自动收起右栏或将其转为 popup。左栏收起后仍不足阅读下限时 SHALL 保持右栏手动选择,由 transcript 使用剩余空间。显式最大化预览和演示模式 SHALL 保留原行为。 #### Scenario: 连续缩小再拉大 - **WHEN** 用户从足够宽的窗口逐渐缩小并重新拉宽 -- **THEN** 右栏先收起、左栏后收起,恢复按左栏优先且 transcript 仍可读的条件进行,临界宽度的小幅抖动不反复切换 +- **THEN** 只有左栏按空间不足及恢复余量切换,临界宽度的小幅抖动不反复切换;右栏保持用户手动选择的显示状态 #### Scenario: 打开预览造成拥挤 - **WHEN** 窗口宽度不变但预览或子对话占用更多空间 -- **THEN** 系统重新判断 transcript 的空间并按相同顺序释放辅助栏占宽 +- **THEN** 系统重新判断 transcript 的空间并仅自动折叠左栏,右侧内容保持显示 #### Scenario: 统一 350 px 折叠边界 - **WHEN** 不同字号下,正常占位的辅助栏使 transcript 可用宽度逐渐缩至 350 CSS px,再继续变窄 -- **THEN** 恰好 350 CSS px 时保持占位,低于该值时按右栏优先的顺序折叠;已折叠栏需满足额外 64 px 余量才能恢复 +- **THEN** 恰好 350 CSS px 时左栏保持占位,低于该值时左栏自动折叠;已折叠左栏需满足额外 64 px 余量才能恢复 #### Scenario: 原生窗口允许拖动到单列布局 -- **WHEN** 用户拖动 Desktop 窗口边缘缩小宽度 -- **THEN** 原生窗口 SHALL 允许缩到 480 逻辑像素,能够触发左右栏自动折叠及单列 transcript,不能以旧多栏布局的最小宽度阻止缩小 +- **WHEN** 用户已手动关闭右栏,并拖动 Desktop 窗口边缘缩小宽度 +- **THEN** 原生窗口 SHALL 允许缩到 480 逻辑像素,能够触发左栏自动折叠及单列 transcript,不能以旧多栏布局的最小宽度阻止缩小 ### Requirement: Preserve persistent layout intent -系统 SHALL 独立保存用户左右栏开关及栏宽;自动折叠、浮层开关和恢复 SHALL NOT 修改这些偏好。宽度恢复 SHALL 仅恢复之前开启的栏。刷新、重启和切工作台 SHALL 使用既有持久化作用域中的偏好,不能继承其他工作台的临时 popup。 +系统 SHALL 独立保存用户左右栏开关及栏宽;左栏自动折叠、浮层开关和恢复 SHALL NOT 修改这些偏好。宽度恢复 SHALL 仅恢复之前开启的左栏。右栏手动开关 SHALL 始终更新既有偏好。刷新、重启和切工作台 SHALL 使用既有持久化作用域中的偏好,不能继承其他工作台的临时 popup。 #### Scenario: 原本关闭的栏临时查看 -- **WHEN** 原本关闭的栏在窄窗口中通过 popup 打开,随后窗口拉宽或刷新 +- **WHEN** 原本关闭的左栏在窄窗口中通过 popup 打开,随后窗口拉宽或刷新 - **THEN** 该栏仍然关闭,已保存开关与栏宽不变 #### Scenario: 原本开启的栏自动恢复 -- **WHEN** 原本开启的栏因空间不足自动收起,随后恢复门槛满足 +- **WHEN** 原本开启的左栏因空间不足自动收起,随后恢复门槛满足 - **THEN** 该栏恢复正常占位,预览页签与草稿仍然保留 ### Requirement: Temporary sidebar popup -在空间不足以正常展开时,expand SHALL 打开对应侧 popup,不改变 transcript 的布局宽度。popup SHALL 限于可用视口,左右互斥;栏内操作和其菜单 SHALL 保持可用;点击栏外、再次点击对应按钮或 Escape SHALL 收起。内层菜单或对话框处理 Escape 时 SHALL 优先关闭内层。 +在空间不足以正常展开左栏时,左侧 expand SHALL 打开 popup,不改变 transcript 的布局宽度。popup SHALL 限于可用视口;栏内操作和其菜单 SHALL 保持可用;点击栏外、再次点击左侧按钮或 Escape SHALL 收起。内层菜单或对话框处理 Escape 时 SHALL 优先关闭内层。右侧 expand SHALL 始终正常展开右栏而非触发 popup。 #### Scenario: 点击内部与外部 -- **WHEN** 用户在窄窗口打开一个边栏并点击其中的菜单、滚动或选择内容 +- **WHEN** 用户在窄窗口打开左栏 popup 并点击其中的菜单、滚动或选择内容 - **THEN** 栏正常响应且不会因栏内点击被收起;点击栏外时 popup 收起,持久化偏好不变 -#### Scenario: 切换两侧与键盘关闭 -- **WHEN** 一个 popup 打开时用户打开另一侧或按 Escape -- **THEN** 打开另一侧时关闭当前侧;Escape 关闭当前 popup 并将键盘焦点还给相应展开按钮 +#### Scenario: 右栏操作与键盘关闭 +- **WHEN** 左栏 popup 打开时用户切换右侧开关或按 Escape +- **THEN** 切换右侧开关时关闭左栏 popup 并执行手动开关;Escape 关闭左栏 popup 并将键盘焦点还给左侧展开按钮 -#### Scenario: 窄右栏中的详情和文件列表 -- **WHEN** 右侧 popup 不能并排容纳详情和导航 -- **THEN** 用户仍可在浮层内查看详情和打开导航选择文件,浮层不超出窗口 +#### Scenario: 窄窗口中的右侧详情和文件列表 +- **WHEN** 用户缩小窗口或在窄窗口手动展开详情和文件列表 +- **THEN** 右侧内容保持正常占位布局和手动开关语义,不自动隐藏或进入浮层 ### Requirement: Resizable sidebar popups -左右 popup SHALL 支持拖动靠 transcript 一侧的边缘调整宽度,并支持手柄上的键盘方向键。调整 SHALL 保持浮动,不改变 transcript 几何和正常占位栏宽或开关;手动浮层宽度 SHALL 独立持久化。浮层 SHALL 保留可点击的外部区域并遵守当前视口边界;窗口临时变窄 SHALL NOT 覆盖保存的浮层宽度。拖动取消、关闭、切会话或失焦后 SHALL 清理拖动状态。 +左栏 popup SHALL 支持拖动右边缘调整宽度,并支持手柄上的键盘方向键。调整 SHALL 保持浮动,不改变 transcript 几何和正常占位栏宽或开关;手动浮层宽度 SHALL 独立持久化。浮层 SHALL 保留可点击的外部区域并遵守当前视口边界;窗口临时变窄 SHALL NOT 覆盖保存的浮层宽度。拖动取消、关闭、切会话或失焦后 SHALL 清理拖动状态。 -#### Scenario: 拖宽详情浮层 -- **WHEN** 用户向左拖动右侧详情浮层的左边缘 -- **THEN** 浮层变宽,右边缘保持贴边,transcript 宽度和草稿不变;向右拖动则变窄,过程中不会自动收起或恢复占位 +#### Scenario: 拖宽左栏浮层 +- **WHEN** 用户向右拖动左栏浮层的右边缘 +- **THEN** 浮层变宽,左边缘保持贴边,transcript 宽度不变;向左拖动则变窄,过程中不会自动收起或恢复占位 #### Scenario: 保存浮层宽度并适配视口 -- **WHEN** 用户调整左栏或右栏浮层宽度后关闭、重开或刷新,再缩小和恢复窗口 +- **WHEN** 用户调整左栏浮层宽度后关闭、重开或刷新,再缩小和恢复窗口 - **THEN** 浮层恢复用户选择的宽度,空间不足时仅临时夹取到视口范围;正常栏宽与开关不变 #### Scenario: 跨边界拖动和取消 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md b/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md index 092da987f..21bce2fd4 100644 --- a/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md +++ b/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md @@ -2,12 +2,16 @@ ### Requirement: Side panel layout in single-session view -Web UI 单会话视图 (`view === 'single'`) SHALL 提供右侧导航面板及详情预览,默认导航宽度 280 CSS px,允许使用用户保存的栏宽。是否正常占位 SHALL 服从 transcript 优先的响应式布局与用户保存的开关;窄窗口展开 SHALL 使用 popup。Grid4 / Grid9 视图下 SHALL NOT 显示该面板。 +Web UI 单会话视图 (`view === 'single'`) SHALL 提供右侧导航面板及详情预览,默认导航宽度 280 CSS px,允许使用用户保存的栏宽。是否正常占位 SHALL 服从用户保存的手动开关,SHALL NOT 因窗口缩放自动收起或变为 popup。Grid4 / Grid9 视图下 SHALL NOT 显示该面板。 #### Scenario: 单会话视图渲染右侧面板 -- **WHEN** 用户进入单会话视图且已开启右侧面板、空间充足 +- **WHEN** 用户进入单会话视图且已开启右侧面板 - **THEN** ChatView 右侧按保存宽度显示导航和已打开的详情 +#### Scenario: 窄窗口手动开关 +- **WHEN** 用户缩小窗口,随后手动收起或展开右侧面板 +- **THEN** 缩放不改变其显示状态;手动操作更新既有偏好,展开后正常占位且不触发 popup + #### Scenario: 切到 4 宫格隐藏面板 - **WHEN** 用户从单会话视图切到 4 宫格视图 - **THEN** SidePanel 从单会话布局移除,grid 视图主区按既定布局填满 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md b/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md index 4f61bd8b6..45a37297a 100644 --- a/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md +++ b/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md @@ -26,8 +26,17 @@ - [x] 6.1 将所有字号的 transcript 折叠阈值统一降至 350 CSS px,保留 64 px 恢复缓冲;更新边界与真实页面验证,完成 Web 测试、构建及严格校验。 +## 7. 自动折叠仅作用于左栏 + +- [x] 7.1 按 2026-10-20 用户要求取消右栏自动折叠及响应式浮层触发,左栏按包含右侧占宽的 transcript 预算判断,保留 350 px 阈值、64 px 恢复缓冲及偏好记忆。 +- [x] 7.2 更新纯逻辑和真实 App 验证,覆盖右栏始终服从手动开关、详情/子对话占宽、左栏浮层调宽与刷新;完成 Web 测试、构建及严格校验。 + ## 验证记录 +以下记录按实施阶段保留;第 7 节覆盖此前关于右栏自动折叠及浮层的约定。 + +- 仅左栏自动折叠补验(2026-10-20):纯逻辑逐像素验证右栏不自动折叠,左栏计入实际显示的右侧导航、详情和子对话占宽,手动关闭右侧内容后释放预算。真实 App 验证右栏开启时左栏在窗口 906/905 px 保持/折叠,970 px 恢复;右栏关闭时边界为 626/625 px,690 px 恢复。右栏窄窗口手动展开正常占位,详情草稿在缩放中保留,手动关闭仍触发既有未保存确认。四种开关组合、刷新、工作台切换、左栏指针/键盘调宽和取消均通过;已检查详情正常占位和左栏浮层截图。完整 `pnpm test`、`pnpm build`、`node scripts/test-responsive-sidebars.mjs`、严格 OpenSpec 校验与 diff 检查通过。截图目录:系统临时目录下 `ace-responsive-sidebars-J7weXZ`。未重建或重启 Desktop。 + - 最终 `pnpm test`、`pnpm build` 均退出 0;构建扫描 4541 个正则,无旧 WebView 不兼容的 lookbehind。`openspec validate adapt-sidebars-to-transcript-width --strict` 与 `git diff --check` 通过。 - `node src/lib/responsiveSidebars.test.js`:通过。覆盖逐像素缩小、自定义左栏宽度、恢复余量、四种开关组合及预览/子对话占宽。 - `node scripts/test-responsive-sidebars.mjs`:通过。使用真实 App、TopBar、Sidebar、ChatView 和存储模块,仅隔离 API 与 WebSocket;覆盖外部点击、按钮切换、Escape、portal 菜单、列表自身收起、四组合刷新恢复、编辑器实例与未保存草稿、显式最大化、子对话占宽、切工作台清理 popup。 diff --git a/openspec/changes/add-agent-browser-cookie-modes/.openspec.yaml b/openspec/changes/add-agent-browser-cookie-modes/.openspec.yaml new file mode 100644 index 000000000..9076a6609 --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-20 diff --git a/openspec/changes/add-agent-browser-cookie-modes/design.md b/openspec/changes/add-agent-browser-cookie-modes/design.md new file mode 100644 index 000000000..8e31f7766 --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/design.md @@ -0,0 +1,261 @@ +# Design + +## Context + +动机见 proposal.md「Why」,行为要求见 `specs/agent-browser-cookie-storage/spec.md`。 + +**参照实现(Claude 桌面版 2.42304.2,逆向自 `app.asar` 主进程包与 `ion-dist` 界面包):** + +- 设置项 `launchPreviewStorage`,取值 `none | shared | session`。默认值写在偏好默认表里,是 `none`。界面文案分别是 Until quit / Shared / Per session。 +- 每档对应一个 Electron session 分区,由 `nN()` 生成分区名: + - `none`:不带 `persist:` 前缀的内存分区 `launch-preview-`。 + - `shared`:`persist:launch-preview-cowork-shared`。 + - `session`:`persist:launch-preview-session-<会话 id>`。 + - 本机磁盘上可以看到 `%APPDATA%\Claude\Partitions\launch-preview-cowork-shared\Network\Cookies`。 +- 已经落过盘的分区记在 `launchPreviewPersistedWorkspaces` 和 `launchPreviewSessionScopedSessions` 两个清单里,供清理时枚举。 +- 清理函数 `OM()` 依次调用 `session.clearData()` 和 `clearAuthCache()`。 +- 切回 `none` 时(包括以 `none` 启动时)调用 `Phi()`,把所有持久分区清空。 +- 切换档位时 `khi()` 把已打开的面板按原 URL 重新绑定到新分区。 +- 永久删除会话时调用 `Fhi()` 清掉该会话的分区;归档会话不清。 +- 分叉会话时 `Ihi()` 用 `cookies.get` + `cookies.set` 把父会话分区的 cookie 复制到子会话分区。 +- Electron 的落盘会话写死了 `persist_session_cookies = false`,所以 Claude 也不保存没有过期时间的 cookie。 + +**ACECode 现状:** + +- **Windows**:Desktop 启动时创建唯一的 WebView2 environment,用户数据目录为 `/agent_browser/webview2`(旧装机是 `agent-browser/webview2`)。创建 controller 时不传任何选项,所有页面都落在默认 Profile。见 `agent_browser_host.cpp:968-995` 和 `:1106-1107`。 +- **macOS**:使用固定 UUID 的 `dataStoreForIdentifier:`,所有页面共用。见 `agent_browser_host_mac.mm:855-870`、`:1208` 和 `:1304`。 +- 两端都没有任何清除代码。 +- 本机磁盘上已经存了 115 个站点、334 条带过期时间的 cookie,没有会话 cookie 落盘。也就是说,现状实际上就是 Claude 的 Shared 档。 +- 页面归属已有 `session_id` / `root_session_id`(`agent_browser_page_directory.hpp`),子代理页面的 `root_session_id` 指向父会话。 +- 会话数据在 daemon 进程,cookie 存储在 Desktop 进程。会话删除已有进程内通知 `add_session_purge_listener`(`session_purge_listeners.hpp`)。分叉的两个入口(Web 路由、线程服务)都走 `SessionManager::fork_session_to_new_id`。 +- 仓库钉的 WebView2 SDK 是 1.0.4078.44,头文件里以下接口都有: + - 创建与选项:`CreateCoreWebView2ControllerOptions`、`put_ProfileName`、`put_IsInPrivateModeEnabled`、`CreateCoreWebView2ControllerWithOptions` + - Profile 操作:`ICoreWebView2Profile2::ClearBrowsingDataAll`、`ICoreWebView2Profile5::get_CookieManager`、`ICoreWebView2Profile8::Delete` + +## Goals / Non-Goals + +**Goals:** + +- 存储键的计算、切档 / 清除 / 删除 / 分叉复制的判定集中在一个纯逻辑模块里,两端宿主共用,可以单测。 +- Shared 档沿用现有存储,升级零迁移、零掉登录。 +- 会话删除与分叉不要求 Desktop 当时在运行。 + +**Non-Goals:** + +- 不做从其他浏览器导入 cookie。 +- 不改变会话 cookie 的引擎行为。 +- 不为 Linux / Deepin 桌面壳实现。那边没有 Agent Browser 原生宿主。 +- 不做「按工作区」档。Claude 的 `launch-preview-` 只用于开发服务器预览,ACECode 没有对应的面板。 + +## Decisions + +### D1 存储键:三种键,子代理归根会话 + +纯逻辑函数 `cookie_store_key(mode, owner)` 只返回三种键: + +- `until_quit` +- `shared` +- `session:` + +规则: + +- 只有 `per_session` 档才会返回 `session:` 键。 +- `root_session_id` 为空时退回到 `session_id`。 +- owner 为空(旧 daemon、未绑定页面)时一律返回 `shared`。 + +为什么子代理归根会话:子代理是在替父会话干活,登录态理应相通。现有 owner 已经带 `root_session_id`,不用新增字段。 + +**备选**:按 `session_id` 各存一份。子代理每次都要重新登录,不采用。 + +### D2 Windows:每种键对应一个 WebView2 Profile,共用同一个 environment + +| 键 | ProfileName | InPrivate | +|---|---|---| +| `shared` | 不设置(默认 Profile) | 否 | +| `until_quit` | `acecode-until-quit` | 是 | +| `session:` | `s-` + SHA-256(id) 的前 24 个十六进制字符 | 否 | + +- 所有 controller 统一改用 `CreateCoreWebView2ControllerWithOptions`。 +- Shared 不设 ProfileName,是为了继续用现有的默认 Profile,升级后登录还在。 +- 会话 id 要转成哈希,因为 Profile 名有限制:最多 64 个字符,只允许字母、数字和 `#@$()+-_~.` 及空格,而且大小写不敏感。headless 允许的自定义会话 id 也可能超过 64 个字符。 +- 同一个 environment 下可以同时存在多个 Profile。这样只需要一个浏览器进程,环境创建与 manifest 发布流程都不用动。 + +**Until quit 用 InPrivate,外加一个隐藏的保活页面。** 选它,是因为 InPrivate 是 WebView2 里唯一不往磁盘写 cookie 的方式,最接近 Claude 的内存分区。Chromium 的无痕 Profile 在最后一个使用者关闭后就会销毁,而规格要求「关掉所有页面再打开仍保持登录」。所以第一次用到这个键时,宿主额外创建一个不可见的 `about:blank` controller,一直保持到切离该档或 Desktop 退出。tasks 第 2 组第一项会先实测:如果不加保活、关掉所有页面后数据仍在,就去掉保活。 + +**备选**:用普通 Profile,启动和退出时各清空一次。这样实现最简单,但 cookie 会在运行期间落盘;异常退出时,数据会一直留到下次启动。与 Claude「从不落盘」的语义不一致,不采用。 + +### D3 拿到 Profile 句柄:借用页面,没有页面时临时建一个隐藏页面 + +WebView2 只能从某个 webview 上通过 `ICoreWebView2_13::get_Profile` 拿到 Profile。 + +- 目标键已有页面时,直接借用该页面的 Profile。 +- 目标键没有页面时,创建一个不可见的 `about:blank` controller,操作完成后立即关闭。 + +清除、删除、复制 cookie 三种操作都走同一个 UI 线程上的工具函数 `with_profile(key, callback)`。 + +### D4 切档与「待清空」标记:不为每个会话存储逐个开隐藏页面 + +切到 `until_quit` 时的处理: + +- `shared` 存储立即执行 `ClearBrowsingDataAll`。它最可能正在用,不能等。 +- 每个 `session:` 存储只是在状态文件里记一个「待清空」标记。 + +按会话存储可能有上百个,逐个开隐藏页面去清会很慢。而且在 `until_quit` 档下这些存储根本不会被用到。待清空的存储有两条清理路径: + +- **下次启动**:在创建 environment 之前,直接删除磁盘上的 Profile 目录。这时 Desktop 是单实例,还没有浏览器进程占用这些目录。 +- **同一次运行中又切回 `per_session`**:该会话第一次建页面前,先对它执行 `ClearBrowsingDataAll`,再清掉标记。 + +`shared` 的清空同样靠持久标记保证不丢:切档时先把 `shared_wipe_pending` 写进状态文件,清空成功后才撤掉;清空失败或中途崩溃时标记留着,下次启动在 environment 创建后、第一个页面创建前再执行一次 `ClearBrowsingDataAll`。`until_quit` 档下没有页面使用 `shared` 存储,它不会再被写入,所以只需在标记存在时补清,不必每次以 `until_quit` 启动都清。 + +切换档位**不**使用 `Profile8::Delete`。被标记删除的 Profile 在同一次运行中不能再用同名重建,而用户可能马上又切回去。`Delete` 只用于永久删除会话,那种会话 id 不会再出现。 + +### D5 macOS:三种 data store + +| 键 | data store | +|---|---| +| `shared` | 现有固定 UUID 的 `dataStoreForIdentifier:` | +| `until_quit` | 宿主持有的单个 `nonPersistentDataStore`,切离该档或退出时释放 | +| `session:` | `dataStoreForIdentifier:`,UUID 由 SHA-256(命名空间前缀 + id) 派生,并设置版本位与变体位 | + +各项操作的实现: + +- **清空**:`removeDataOfTypes:allWebsiteDataTypes modifiedSince:distantPast`。 +- **删除**:先关闭页面、释放 store 对象,再调用 `+removeDataStoreForIdentifier:`。 +- **复制 cookie**:`httpCookieStore getAllCookies:` 读出,再逐条 `setCookie:` 写入。 + +macOS 没有 Windows 那种「必须借用页面才能拿到 Profile」的限制,D3 只适用于 Windows。D4 的「待清空」标记两端共用。macOS 下启动时的处理改为对每个待清空的 identifier 调用 `removeDataStoreForIdentifier:`。Agent Browser 本身只在 macOS 14+ 启用,而这些接口在 14+ 都可用。 + +### D6 共享状态:`state.json` 的 `agent_browser` 段 + +由新模块 `src/base/ipc/agent_browser_store_state` 独占读写,沿用 `update_state_json` 的跨进程锁与原子写。 + +```json +"agent_browser": { + "cookie_mode": "shared", + "session_stores": ["", "..."], + "pending_wipe": ["", "..."], + "pending_deletions": ["", "..."], + "pending_seeds": [{"session_id": "", "from": "", "at_ms": 0}] +} +``` + +- `cookie_mode`、`session_stores`、`pending_wipe` 只由 Desktop 写入。 +- `pending_deletions`、`pending_seeds` 由 daemon / TUI 追加,由 Desktop 消费。 + +**为什么放在 `state.json`,不放 `config.json`**:这个值和 Claude 一样,属于界面偏好,只由 Desktop 一个进程写;`state.json` 已经有跨进程锁。放进 `config.json` 会和 daemon 的整份配置写回互相覆盖。 + +`pending_seeds` 最多保留 500 条,超出时丢最旧的。这样在 shared 档下(这些记录永远不会被消费)不会无限增长。 + +### D7 daemon 侧:删除与分叉两个通知,一个服务 + +会话层新增 `add_session_fork_listener`,写法与现有 purge 通知对称,在 `fork_session_to_new_id` 成功后触发。 + +新服务 `host/session_host/agent_browser_store_lifecycle` 在 worker 与 TUI 启动时注册两个监听器: + +- **分叉**:向 `pending_seeds` 追加一条记录。 +- **永久删除**:向 `pending_deletions` 追加该 id,然后在服务自有的工作线程上向 Desktop 发一次代理操作 `drain_store_tasks`(超时 2 秒)。 + +这个代理操作不带任何数据,只是提醒 Desktop 去处理状态文件。提醒失败只记一条 debug 日志,包括旧版 Desktop 回复「operation is invalid」的情况。 + +监听器回调本身只做状态文件写入,不做网络 IO,符合「不应长时间阻塞」的约定。工作线程与回调捕获遵守 R15 所有权规则:使用 `JoiningThread`,`LifetimeToken` 放在最后一个成员。 + +**为什么不升协议版本**:分叉来源不进 owner,而是通过状态文件交接;提醒失败也无害。所以新旧版本混用时不需要互相拒绝,对应规格最后一条。 + +**备选**:把 `forked_from` 加进 owner。前端的会话列表没有这个字段,UI 自建页面时拿不到,还要升协议。不采用。 + +### D8 Desktop 侧:处理时机与顺序 + +**启动时,创建 environment 之前:** + +1. 读取 `agent_browser` 段。 +2. 对 `pending_wipe` 和 `pending_deletions` 中的会话,删除磁盘上的 Profile 目录(Windows)。macOS 改为调用 `removeDataStoreForIdentifier:`。 +3. 从 `session_stores` 中移除这些会话,清空这两个清单。 + +**运行中收到 `drain_store_tasks` 时:** + +1. 关闭该根会话的全部页面,同时发出页面状态事件,让前端页签消失。 +2. 用 D3 的方式调用 `Profile8::Delete`。 + +**首次为 `session:` 建页面之前:** + +1. 如果它在 `pending_wipe` 中,先清空数据。 +2. 如果它不在 `session_stores` 中,而 `pending_seeds` 里有它的记录,并且父会话在 `session_stores` 中,就把父会话的 cookie 复制过来(D3/D5)。 +3. 记入 `session_stores`,删除对应的 seed 记录。 + +复制的是父会话此刻的 cookie,不是分叉那一刻的快照。这与 Claude 的即时复制有细微差别,规格里已写明「as they are at that moment」。 + +### D9 切档时重建页面 + +纯逻辑函数 `pages_to_rebuild(pages, old_mode, new_mode)` 返回存储键发生变化的页面。宿主对其中每一页: + +1. 记下 URL;owner 与共享标记保存在页面对象上,重建时不变(设备模拟尚未实现,没有要恢复的状态)。 +2. 关闭旧的 controller。 +3. 在新键对应的 Profile 中用同一个 page id 创建 controller,并导航回原 URL。 + +页面目录(`agent_browser_page_directory`)里的显示页和 Agent 目标页不变,前端看到的是同一个页签重新加载了一次。后退历史会丢失,这一点可以接受,Claude 的重绑也是重新加载。 + +### D10 前端 + +**纯逻辑**:`web/src/lib/agentBrowserCookieMode.js`,负责: + +- 三档的取值、标签与说明文案; +- 每档下「清除浏览数据」确认框的标题、正文和作用范围; +- 一个单写者 store,供工具栏与设置页共同订阅。 + +**Bridge**,封装在 `agentBrowser.js`: + +| 函数 | 作用 | +|---|---| +| `aceDesktop_agentBrowserGetCookieMode` | 读取当前档位 | +| `aceDesktop_agentBrowserSetCookieMode(mode)` | 设置档位 | +| `aceDesktop_agentBrowserClearBrowsingData(owner)` | 清除浏览数据 | + +Desktop 在档位变化后推送状态事件,前端据此更新 store。 + +**工具栏**:在「开发者工具」按钮后加一个「更多」按钮,菜单结构仿照 Claude 截图: + +- 分组标题「Cookie」 +- 「保留 Cookie」:右侧显示当前档位,点开是子菜单,含三项,每项附一行说明 +- 「清除浏览数据」:危险色 + +菜单和确认框会盖在原生页面上方,所以必须接入现有的原生表面遮挡机制(`fix-agent-browser-overlay-stacking`),否则会被原生页面挡住。 + +**确认框**:走 `components/Modal.jsx`,危险操作默认聚焦「清除」。 + +**设置页**:「设置 > 工具」的 Agent 浏览器条目下方增加同样的三档单选,遵循 `acecode-frontend-style` 规范。 + +新增的中文文案先补 `i18n-en-overrides.mjs`,再运行 `pnpm i18n:catalog`。 + +## Risks / Trade-offs + +- **[InPrivate Profile 的生命周期]**(2026-10-21 实测,WebView2 SDK 1.0.4078.44 + 本机运行时) + 不加保活页:关掉所有「退出即清」页面后再打开,cookie 已丢失(`agent_browser_cookie_smoke` 在 `until_quit_after_closing_all_pages` 处失败)。加保活页:cookie 保留,Desktop 重启后消失。 + → 保活页面必须保留(D2 的做法成立)。 + +- **[WebView2 Profile 目录的实际位置]**(2026-10-21 实测) + 命名 Profile 的目录是 `/EBWebView/WV2Profile_`,不是最初假设的 `EBWebView/`;默认 Profile 是 `EBWebView/Default`。 + → 目录名由 `agent_browser_session_profile_directory_name` 统一派生并有单测;冒烟里核对目录存在与删除。这条布局属于运行时实现细节,若将来变化,启动删除只会落空(数据留在磁盘),冒烟的 `profile_exists` 检查会先报出来。 + +- **[会话 cookie 在重启后丢失,用户仍然觉得「没记住登录」]** + 2026-10-21 冒烟实测:共享档下没有过期时间的 cookie 在 Desktop 重启后消失(`session_cookie_after_restart false`),带过期时间的保留。 + → 这是引擎行为,Claude 也一样。帮助文档里说明:网站登录时勾选「记住我」,登录态才能跨重启保留。 + +- **[切档重建页面时丢失页内状态]** + 会丢失表单草稿、后退历史,以及运行中的 Agent 操作。 + → 切档是用户主动操作,重建前不做额外提示;正在执行的浏览器工具调用会因页面重载而失败,模型按页面已重新加载处理重试。 + +- **[按会话存储数量增长,数据目录迁移变慢]** + → 删除会话时会同步删除对应存储;迁移本来就把这棵子树当作「尽力复制」处理(迁移代码原先只认连字符旧目录名,已在同期单独修复,下划线新目录同样按尽力复制处理)。 + +- **[状态文件写入被迁移暂停]** + `set_state_file_writes_paused` 期间,`pending_deletions` 写不进去。 + → 监听器在写入失败时记一条 warn 日志,该会话的存储会成为孤儿目录,但不影响功能。迁移期间本来就拒绝删除忙碌会话,这种情况很少见。 + +- **[多个按会话存储同时打开,内存上涨]** + 每个 Profile 会有独立的网络上下文。 + → 只有已经打开过页面的会话才会加载 Profile,与现在每个页面一个渲染进程的开销相比可以忽略。 + +## Migration Plan + +- 升级后没有写入任何档位时按 `shared` 运行,沿用默认 Profile / 固定 UUID store,不需要迁移。 +- 回滚到旧版本时,旧版只认默认 Profile,`state.json` 里的 `agent_browser` 段会被忽略。按会话存储会作为孤儿目录留在用户数据目录里,不影响旧版运行。如果用户之前用过 `until_quit`,默认 Profile 已经被清空过,回滚后同样是未登录状态,与预期一致。 diff --git a/openspec/changes/add-agent-browser-cookie-modes/proposal.md b/openspec/changes/add-agent-browser-cookie-modes/proposal.md new file mode 100644 index 000000000..65c16801b --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/proposal.md @@ -0,0 +1,40 @@ +# Proposal + +## Why + +Agent Browser 现在只有一份全局共享、长期保存的 Profile:带过期时间的 cookie 会一直留着,但用户无法选择「退出就清」或「会话之间互不可见」,也没有任何清除入口,只能打开 DevTools 自己删。Claude 桌面版的内置浏览器已经给出了一套成熟做法:「保留 Cookie」三档(退出即清 / 共享 / 按会话)、按档位清除浏览数据、删除会话时清掉它的 cookie、分叉会话继承 cookie。ACECode 要按同一套语义复刻,让用户既能长期保持登录(不用每次重输用户名密码),又能在需要时隔离或清空登录态。 + +## What Changes + +- Agent Browser 增加「保留 Cookie」设置,三档: + - **退出即清**(对应 Claude 的 Until quit):cookie 只保留到 Desktop 退出。 + - **共享**(对应 Shared,**默认**):所有会话共用一份长期保存的 cookie。沿用现有 Profile,升级后已有登录不丢失。 + - **按会话**(对应 Per session):每个会话(子代理随其根会话)各存一份,互相看不到对方的登录。 +- 浏览器工具栏新增「更多」菜单,含「保留 Cookie」子菜单和「清除浏览数据」;「设置 > Agent 浏览器」里也能改档位。清除的范围随档位变化,执行前二次确认。 +- 切换档位时,已打开的页面按原 URL 在新档位的 cookie 存储里重建,页面 id 不变。 +- 切到「退出即清」时,立即删除所有已长期保存的 cookie(共享的和各会话的);以「退出即清」启动时也会补清一次,覆盖上次异常退出遗留的数据。 +- 「按会话」档下:永久删除会话时删除它那份 cookie(归档不删);分叉出的会话第一次打开浏览器时复制父会话的 cookie。 +- 会话的删除与分叉发生在 daemon,cookie 存储在 Desktop。daemon 把「待删除」「待复制」记录写进共享状态文件,Desktop 启动时和收到提醒时处理,所以 Desktop 没开时删除的会话也会在下次启动时清掉。提醒走现有 Agent Browser 代理的一个新操作,失败不影响任何功能,协议版本不变,新旧版本混用不会断连。 +- 不做:从 Chrome / Edge / Firefox 导入 cookie;把没有过期时间的会话 cookie 改成长期保存。Claude 也不保存会话 cookie,Electron 的落盘会话写死了 `persist_session_cookies = false`。 + +## Capabilities + +### New Capabilities + +- `agent-browser-cookie-storage`:Agent Browser 的 cookie / 网站数据存储策略。覆盖三档存储模式及默认值、档位持久化、切换档位时的页面重建与清理、按档位清除浏览数据,以及会话删除与分叉对 cookie 存储的影响。 + +### Modified Capabilities + +(无。仓库主 spec 中没有 Agent Browser 的 capability;页面归属规则在 `bind-agent-browser-pages-to-sessions` 变更的 `agent-browser-page-ownership` 中,本变更直接复用其中的 `session_id` / `root_session_id`,不改动它的要求。) + +## Impact + +- **Windows 原生宿主**(`src/apps/desktop/agent_browser_host.cpp`):页面创建改用 `CreateCoreWebView2ControllerWithOptions` 并按档位设置 `ProfileName` / InPrivate;用 Profile 接口清除数据、删除 Profile、复制 cookie;新增一个代理操作。 +- **macOS 原生宿主**(`src/apps/desktop/agent_browser_host_mac.mm`):按档位选择 `nonPersistentDataStore`、现有固定 UUID 的 store、或按会话派生 UUID 的 store;对应的清除、删除与 cookie 复制。 +- **新纯逻辑模块**(`src/apps/desktop/agent_browser_cookie_jars.{hpp,cpp}`):档位解析、存储键与 Profile 名派生、切档 / 清除 / 删除的判定,两端宿主共用,可单测。 +- **共享状态模块**(`src/base/ipc/agent_browser_store_state.{hpp,cpp}`):`state.json` 里 `agent_browser` 段的读写,Desktop 与 daemon 共用。 +- **Desktop bridge**(`src/apps/desktop/main.cpp`):读写档位、清除浏览数据。 +- **daemon / TUI**:会话层新增「分叉成功」通知(与现有的永久删除通知并列),新服务监听这两个通知并写入待处理记录(`src/domain/session/`、`src/host/session_host/`)。 +- **前端**:`web/src/components/AgentBrowserPanel.jsx` 工具栏菜单、设置页 Agent 浏览器分节、新纯逻辑 `web/src/lib/agentBrowserCookieMode.js`、`agentBrowser.js` bridge 封装、i18n 目录。 +- **状态文件**:`state.json` 新增 `agent_browser` 段(档位、按会话存储清单、待删记录)。 +- **文档**:`docs/agent-browser.md`、`docs/help/agent-browser.html`、`CLAUDE.md` 相应段落。 diff --git a/openspec/changes/add-agent-browser-cookie-modes/specs/agent-browser-cookie-storage/spec.md b/openspec/changes/add-agent-browser-cookie-modes/specs/agent-browser-cookie-storage/spec.md new file mode 100644 index 000000000..6f1f94088 --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/specs/agent-browser-cookie-storage/spec.md @@ -0,0 +1,155 @@ +# Spec Delta + +## Purpose + +Defines how Agent Browser keeps cookies and other site data between pages, sessions and Desktop restarts, so users can stay signed in to sites, isolate sign-ins per session, or have them discarded when Desktop quits, and can clear them on demand. + +## ADDED Requirements + +### Requirement: Cookie storage mode has three values with Shared as default +Agent Browser SHALL support exactly three cookie storage modes: `until_quit`, `shared` and `per_session`. When no mode has been chosen, the effective mode SHALL be `shared`. An unknown stored value SHALL be treated as `shared`. + +#### Scenario: Fresh install +- **WHEN** Desktop starts and no cookie storage mode has ever been stored +- **THEN** the effective mode is `shared` and the settings UI shows Shared as selected + +#### Scenario: Corrupted stored value +- **WHEN** the stored mode is a value other than the three defined ones +- **THEN** Agent Browser behaves as `shared` and the stored value is not rewritten until the user picks a mode + +### Requirement: Shared mode keeps one persistent store for all sessions +In `shared` mode every Agent Browser page, regardless of owner session, SHALL use a single persistent site-data store that survives Desktop restarts. This store SHALL be the same store Agent Browser used before cookie storage modes existed, so sign-ins made before upgrading remain available. + +#### Scenario: Sign-in survives restart +- **WHEN** the user signs in to a site whose sign-in cookie has an expiry, quits Desktop and starts it again in `shared` mode +- **THEN** opening that site in any session shows it signed in + +#### Scenario: Upgrade keeps existing sign-ins +- **WHEN** a user who signed in to sites with a previous ACECode version upgrades and does not change the mode +- **THEN** those sites are still signed in + +#### Scenario: Two sessions see the same sign-in +- **WHEN** session A signs in to a site in `shared` mode and session B then opens the same site +- **THEN** session B is signed in too + +### Requirement: Until-quit mode discards site data when Desktop quits +In `until_quit` mode pages SHALL use a store whose data lives only for the current Desktop process: it SHALL survive closing and reopening pages and switching sessions, and SHALL NOT be available after Desktop restarts. + +#### Scenario: Closing all pages keeps the sign-in +- **WHEN** in `until_quit` mode the user signs in, closes every Agent Browser page and opens the site again without quitting Desktop +- **THEN** the site is still signed in + +#### Scenario: Restart signs out +- **WHEN** in `until_quit` mode the user signs in and restarts Desktop +- **THEN** the site is no longer signed in + +### Requirement: Per-session mode isolates site data by root session +In `per_session` mode each root session SHALL have its own persistent store that survives Desktop restarts. Sub-agent sessions SHALL use their root session's store. Pages of different root sessions SHALL NOT see each other's cookies. Pages without an owner session SHALL use the `shared` store. + +#### Scenario: Sessions do not see each other's sign-in +- **WHEN** in `per_session` mode session A signs in to a site and session B opens the same site +- **THEN** session B is not signed in + +#### Scenario: Sub-agent uses its root session's sign-in +- **WHEN** in `per_session` mode session A signs in and a sub-agent spawned by A opens the same site +- **THEN** the sub-agent's page is signed in + +#### Scenario: Per-session sign-in survives restart +- **WHEN** in `per_session` mode session A signs in, Desktop restarts and session A opens the site again +- **THEN** the site is signed in + +#### Scenario: Page without owner +- **WHEN** in `per_session` mode a page is created without an owner session (for example by an older daemon) +- **THEN** the page uses the `shared` store + +### Requirement: Session cookies follow browser engine lifetime +Agent Browser SHALL NOT convert cookies without an expiry into persistent cookies in any mode. Cookies without an expiry SHALL be available while Desktop runs and MAY be lost when Desktop quits, in every mode. + +#### Scenario: Site uses session-only sign-in cookie +- **WHEN** a site signs the user in with a cookie that has no expiry and Desktop restarts in `shared` mode +- **THEN** the user may need to sign in again, and no setting changes this + +### Requirement: Cookie storage mode is persisted and changeable from two places +The chosen mode SHALL persist across Desktop restarts. The user SHALL be able to change it from the Agent Browser toolbar menu ("保留 Cookie" with the three options and a one-line description each) and from Settings > Agent 浏览器; both places SHALL show the same current value and update when it changes elsewhere. + +#### Scenario: Change from toolbar menu +- **WHEN** the user picks 按会话 in the toolbar menu and restarts Desktop +- **THEN** the effective mode is `per_session` and both the menu and the settings page show it as selected + +#### Scenario: Not running in Desktop +- **WHEN** the Web UI runs in a normal browser without the Desktop bridge +- **THEN** no cookie storage controls are shown + +### Requirement: Changing mode rebuilds open pages in the new store +When the mode changes, every open Agent Browser page whose store differs under the new mode SHALL be recreated in the new store, keeping its page id, owner and current URL. Pages whose store does not change SHALL NOT be reloaded. + +#### Scenario: Switch from Shared to Per session with open pages +- **WHEN** session A has an open page on a site and the user switches from `shared` to `per_session` +- **THEN** the page keeps its page id and URL, is reloaded in session A's own store, and browser tools addressing that page id keep working + +### Requirement: Switching to Until-quit deletes all persisted site data +Switching the mode to `until_quit` SHALL immediately delete the site data of the `shared` store and of every per-session store. Switching between `shared` and `per_session` SHALL NOT delete any data. Starting Desktop while the mode is `until_quit` SHALL delete any persisted store data left behind. + +#### Scenario: Switch to Until quit +- **WHEN** the user was signed in to sites in `shared` and `per_session` stores and switches to `until_quit` +- **THEN** switching back to `shared` or `per_session` shows those sites signed out + +#### Scenario: Switch between persisted modes +- **WHEN** the user switches from `shared` to `per_session` and back to `shared` +- **THEN** the sign-ins made in the `shared` store before the switch are still present + +#### Scenario: Leftover data after a crash +- **WHEN** Desktop was force-terminated and the stored mode is `until_quit` +- **THEN** on the next start no previously persisted sign-in is available + +### Requirement: Clear browsing data clears the store the current page uses +The toolbar menu SHALL offer "清除浏览数据". After confirmation it SHALL delete all site data (cookies, storage, cache, HTTP auth) of the store used by the current page under the current mode: the `shared` store, the current root session's store, or the until-quit store. The confirmation text SHALL state which scope will be cleared. Open pages using that store SHALL be reloaded. + +#### Scenario: Clear in Shared mode +- **WHEN** in `shared` mode the user confirms "清除浏览数据" +- **THEN** every session is signed out of all sites, and the dialog said the data of all sessions would be deleted + +#### Scenario: Clear in Per-session mode +- **WHEN** in `per_session` mode the user confirms "清除浏览数据" in session A +- **THEN** session A is signed out of all sites and session B's sign-ins are unchanged + +#### Scenario: User cancels +- **WHEN** the user dismisses the confirmation +- **THEN** no data is deleted + +### Requirement: Permanently deleting a session deletes its per-session store +When a root session is permanently deleted, its per-session store SHALL be deleted, whether or not Desktop is running at that time. Archiving a session SHALL NOT delete its store. Deleting a session SHALL NOT affect the `shared` store. + +#### Scenario: Delete while Desktop runs +- **WHEN** a root session that has a per-session store is permanently deleted while Desktop runs +- **THEN** its open pages are closed and its store is deleted + +#### Scenario: Delete while Desktop is not running +- **WHEN** a root session is permanently deleted from the TUI while Desktop is not running +- **THEN** its store is deleted the next time Desktop starts + +#### Scenario: Archive keeps the store +- **WHEN** a session is archived and later unarchived in `per_session` mode +- **THEN** its sign-ins are still present + +### Requirement: Forked sessions start with a copy of the parent's cookies +In `per_session` mode, when a session forked from another session opens its first Agent Browser page and has no store yet, its store SHALL be created with a copy of the parent session's cookies as they are at that moment. Later changes in either store SHALL NOT affect the other. If the parent has no store, the forked session SHALL start empty. Forks made while Desktop is not running SHALL also be seeded. + +#### Scenario: Fork then open +- **WHEN** in `per_session` mode session A is signed in, the user forks A into C and C opens the site +- **THEN** C is signed in, and signing out in C does not sign A out + +#### Scenario: Fork already has a store +- **WHEN** forked session C already has its own store +- **THEN** the parent's cookies are not copied again + +### Requirement: Cookie storage works across mixed Desktop and daemon versions +Cookie storage modes SHALL NOT change the Agent Browser runtime protocol version. A daemon and a Desktop where only one side supports cookie storage modes SHALL keep working; store deletions and fork copies recorded by a newer daemon SHALL be applied the next time a supporting Desktop starts. + +#### Scenario: New Desktop, old daemon +- **WHEN** a Desktop with cookie storage modes runs with a daemon from the previous release +- **THEN** browser tools work normally and pages use the store of the current mode + +#### Scenario: New daemon, old Desktop +- **WHEN** a newer daemon permanently deletes a session while an older Desktop runs +- **THEN** the deletion does not fail, and the store is deleted when a Desktop supporting cookie storage modes next starts diff --git a/openspec/changes/add-agent-browser-cookie-modes/tasks.md b/openspec/changes/add-agent-browser-cookie-modes/tasks.md new file mode 100644 index 000000000..6e48f6a2a --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/tasks.md @@ -0,0 +1,110 @@ +# Tasks + +## 1. 纯逻辑与共享状态 + +- [x] 1.1 新增 `src/apps/desktop/agent_browser_cookie_jars.{hpp,cpp}`,实现以下纯逻辑(设计见 D1、D4、D8、D9): + - 档位解析:未知值按 `shared` 处理; + - `cookie_store_key(mode, owner)`; + - Windows Profile 名派生:`s-` + SHA-256 的前 24 位十六进制; + - macOS 按会话 UUID 派生; + - 切到某档时哪些存储立即清空、哪些打「待清空」标记; + - `pages_to_rebuild`; + - 首次建存储时的清空与复制判定。 + + 验收:`tests/desktop/agent_browser_cookie_jars_test.cpp` 通过(中文注释写明场景),至少覆盖:子代理归根会话、空 owner 归 shared、超长会话 id 的 Profile 名合法、shared 与 per_session 互切不清数据。 +- [x] 1.2 新增 `src/base/ipc/agent_browser_store_state.{hpp,cpp}`,独占读写 `state.json` 的 `agent_browser` 段(D6),包括:读写档位,追加和消费 `pending_deletions` / `pending_seeds` / `pending_wipe`,维护 `session_stores`,`pending_seeds` 上限 500 条。 + + 验收:`tests/ipc/agent_browser_store_state_test.cpp` 通过,用 `set_state_file_path_for_test` 隔离;覆盖损坏段落容错、上限截断、并发追加不丢条目。 + +## 2. Windows 宿主 + +- [x] 2.1 实测并记录 WebView2 行为,结论写回 design 的 Risks: + - InPrivate 命名 Profile 在关闭所有页面后是否丢失 cookie、加保活页面后是否保留、重启后是否消失; + - 命名 Profile 在磁盘上的目录位置。 + + 验收:design.md 中两条风险更新为实测结论,并据此确定保活页面与启动删除方式。 +- [x] 2.2 `agent_browser_host.cpp` 的建页改用 `CreateCoreWebView2ControllerWithOptions`,按 `cookie_store_key` 设置 ProfileName / InPrivate;Until quit 档按 2.1 的结论创建保活页面;实现 `with_profile` 工具函数(D3)。 + + 验收:新增 `agent_browser_cookie_smoke`(独立的 Windows 冒烟,输出 `SMOKE_COOKIE_MODES_OK`),覆盖三档的隔离、子代理归根与 Until quit 关页后保留;共享档沿用无选项建 controller 的原路径(默认 Profile),升级不掉登录的手工确认在 6.2。 +- [x] 2.3 实现切档(D4、D9): + - shared 立即 `ClearBrowsingDataAll`,per_session 存储打待清空标记; + - 按 `pages_to_rebuild` 用原 page id 重建页面并恢复 URL、owner 与共享标记; + - 以 until_quit 启动时,在创建 environment 前删除待清空目录,创建后清空 shared。 + + 验收:smoke 段覆盖「切到 until_quit 后再切回 shared 已无 cookie」和「切档后原 page id 仍可用 CDP 调用」。 +- [x] 2.4 实现会话存储的生命周期(D8): + - 启动时处理 `pending_deletions`; + - 新代理操作 `drain_store_tasks`:关闭该根会话的页面并发状态事件,然后 `Profile8::Delete`; + - 首次建会话存储前处理待清空与分叉复制(用 Profile5 CookieManager 逐条读写)。 + + 验收:冒烟覆盖「运行中删除已加载的会话存储后其页面关闭、重启后目录被删」「未加载的会话存储经代理提醒直接删目录」「分叉复制后两边互不影响」。 +- [x] 2.5 `main.cpp` 新增三个 bridge:`aceDesktop_agentBrowserGetCookieMode`、`SetCookieMode`、`ClearBrowsingData(owner)`;档位变化时推送状态事件。 + + 验收:bridge 只是宿主接口的薄封装,宿主接口由冒烟覆盖;`agentBrowserArchitecture.test.js` 的 bridge 断言随第 5 组前端改动一起补。 + +## 3. macOS 宿主 + +- [ ] 3.1 `agent_browser_host_mac.mm` 按 D5 实现三种 data store,以及清空、删除、复制 cookie、切档重建、启动处理,与 Windows 共用第 1 组的纯逻辑和状态模块;同步接上三个 bridge。 + + 验收:本机无法编译 macOS,用 `ios-simulator-live.yml` 同类的手动 macOS 工作流(或推 `ci/` 分支跑 package.yml 的 macOS 构建)确认能编译通过;行为验收列入第 6 组的手工清单。 +- [x] 3.2 更新 `docs/agent-browser-macos-handoff.md` 的 Profile 一节,改为三种 data store 的说明。 + + 验收:文档描述与 D5 一致。 + +## 4. daemon / TUI + +- [x] 4.1 `src/domain/session/` 新增 `add_session_fork_listener` / `notify_session_forked`,在 `SessionManager::fork_session_to_new_id` 成功后触发。 + + 验收:`tests/session/` 新增用例,确认 Web 分叉路由与线程服务分叉两个入口都会触发,失败的分叉不触发。 +- [x] 4.2 新增 `src/host/session_host/agent_browser_store_lifecycle.{hpp,cpp}`(D7): + - 监听分叉,写 `pending_seeds`; + - 监听永久删除,写 `pending_deletions`; + - 自有 `JoiningThread` 发送 `drain_store_tasks` 提醒,超时 2 秒,失败只记 debug 日志; + - 在 `worker.cpp` 与 TUI 运行时初始化中各注册一次。 + + 验收:单测用假代理确认写入内容与提醒发送;旧版 Desktop 回复「operation is invalid」时不报错;R15 所有权检查(`check_ownership --strict --final`)通过。 +- [x] 4.3 在 Agent Browser 代理协议的说明里补充 `drain_store_tasks` 操作(代理操作列表写在 `docs/agent-browser.md`「运行时和安全边界」,`daemon-api.md` 没有这一节)。 + + 验收:文档列出该操作的请求、返回与失败语义。 + +## 5. Web UI + +- [x] 5.1 新增 `web/src/lib/agentBrowserCookieMode.js`(D10):三档取值与文案、每档清除确认的标题/正文/范围、单写者 store,以及 bridge 封装(在 `agentBrowser.js` 中)。 + + 验收:`agentBrowserCookieMode.test.js` 已注册进 `runTests.js` 并通过,覆盖非 Desktop 环境返回「不可用」、bridge 失败时保持原值。 +- [ ] 5.2 `AgentBrowserPanel.jsx` 工具栏新增「更多」菜单:Cookie 分组、「保留 Cookie」子菜单(三项各带说明、当前项打勾)、危险色的「清除浏览数据」与 `Modal` 确认框;菜单与确认框接入原生表面遮挡机制。 + + 验收:在 Desktop 中打开菜单不被原生页面遮住(截图确认);`agentBrowserArchitecture.test.js` 的 floatingSurfaceOwners 等合同测试通过。 +- [x] 5.3 「设置 > Agent 浏览器」新增三档单选,与工具栏共用 store,遵循 `acecode-frontend-style`。 + + 验收:在任一处改档位,另一处立即同步;设置搜索能用「cookie」「登录」找到该项(`settingsSearch.js` 索引用例)。 +- [x] 5.4 新增中文文案,先补 `i18n-en-overrides.mjs` 再运行 `pnpm i18n:catalog`。 + + 验收:i18n 目录检查测试通过,英文界面显示 Until quit / Shared / Per session 对应文案。 + +## 6. 集成验证与文档 + +- [x] 6.1 构建 `acecode-desktop` 与 `acecode_unit_tests`,运行第 1、2、4 组新增的单测与 Windows smoke;运行 `pnpm test` 与 `pnpm build`。 + + 验收:构建退出码为 0,所有测试通过。 +- [ ] 6.2 按 spec 的场景逐条手工验收(Windows 本机): + - 默认 Shared 升级不掉登录; + - 三档的重启与跨会话行为; + - 切到 Until quit 立即清空; + - 两种范围的清除; + - 删除会话(Desktop 开着、关着各一次)、归档不清、分叉继承。 + + macOS 各项列为待补验。 + + 验收:结果记录在本变更目录的 `verification.md`,未执行的项标为待补验,不宣称通过。 +- [x] 6.3 更新文档: + - `docs/agent-browser.md`:持久化一节改为三档说明,并修正目录名为 `agent_browser/webview2`(旧装机是 `agent-browser`); + - `docs/help/agent-browser.html`:用户说明,含「勾选记住我才能跨重启保持登录」; + - `CLAUDE.md` 的 Agent Browser 段:关键约束,包括 shared 不设 ProfileName、切档不用 `Profile8::Delete`、状态段读写归属。 + + 验收:文档描述与实现一致。 + +## Workflow follow-up + +- 数据目录迁移只认 `agent-browser/webview2`,不认 `agent_browser/webview2`:已在同期单独修复(`migration_is_best_effort_root` 两种都认,回归 `UnderscoreBrowserProfileChurnDoesNotFailMigration`)。 +- 实现、验收完成后归档本变更。 diff --git a/openspec/changes/add-agent-browser-cookie-modes/verification.md b/openspec/changes/add-agent-browser-cookie-modes/verification.md new file mode 100644 index 000000000..603a0af2b --- /dev/null +++ b/openspec/changes/add-agent-browser-cookie-modes/verification.md @@ -0,0 +1,74 @@ +# add-agent-browser-cookie-modes 验收记录 + +记录日期:2026-10-21。实施状态与验收状态分开记:下面「自动化」是已经实际跑过的结果, +「手工」一栏未执行的项一律标 **待补验**,不宣称通过。 + +## 1. 自动化结果(Windows 本机,构建目录 `N:/acecode-build/cookie-modes`) + +| 项目 | 结果 | +|---|---| +| `acecode-desktop`、`acecode_unit_tests` 构建 | 退出码 0 | +| 单测 `AgentBrowserCookieMode*` / `AgentBrowserCookieStoreKey*` / `AgentBrowserWindowsProfile*` / `AgentBrowserSessionStoreUuid*` / `AgentBrowserCookieModeSwitch*` / `AgentBrowserPagesToRebuild*` / `AgentBrowserSessionStorePreparation*` | 通过 | +| 单测 `AgentBrowserStoreStateTest.*`(10 条,含损坏值、500 条上限、只删本段不动其它键) | 通过 | +| 单测 `SessionForkListener.*`(4 条)、`AgentBrowserStoreLifecycleTest.*`(5 条,含旧 Desktop 不认识操作、连续删除合并提醒、停止时中止) | 通过 | +| 单测数据目录迁移(含新增 `UnderscoreBrowserProfileChurnDoesNotFailMigration`) | 通过 | +| 冒烟 `agent_browser_cookie_smoke`(真实 WebView2 + example.com,三个宿主模拟两次重启) | `SMOKE_COOKIE_MODES_OK` | +| 既有 Agent Browser 宿主冒烟 | 通过 | +| `pnpm test` | 3295 条全部通过(含 `agentBrowserCookieMode.test.js`、`submenuPosition.test.js`、`agentBrowserArchitecture.test.js`、`settingsSearch.test.js`、i18n 目录检查) | +| `pnpm build` | 通过;Desktop 已用新前端重新 configure + 构建 | +| R15 所有权检查(`check_ownership --strict --final`) | 本变更新增代码无发现;仍有一条 `linux_desktop` 的既有发现,HEAD `01d74217` 上已存在,与本变更无关 | +| macOS 编译 | **待补验**:本机无法编译,`agent_browser_host_mac.mm` 改动未编译 | + +冒烟的观察项(WebView2 运行时行为,写回 design 的 Risks): + +- 不加保活页时,关掉所有「退出时清除」页面后 cookie 丢失 → 保活页必须保留; +- 命名 Profile 目录是 `EBWebView/WV2Profile_<名>`; +- 共享档下无过期时间的会话 cookie 重启后丢失(`session_cookie_after_restart false`),带过期时间的保留。 + +## 2. 按 spec 场景对照 + +「自动化」列写覆盖它的测试;「手工」列是在真实 Desktop + daemon 上的界面验收。 + +| 场景 | 自动化 | 手工(Windows) | macOS | +|---|---|---|---| +| Fresh install(默认 Shared) | 冒烟宿主 1 开头断言;`StoredNamesResolveWithSharedAsDefault` | 待补验 | 待补验 | +| Corrupted stored value | `CorruptedSectionIsTreatedAsEmpty`、`UnknownCookieModeIsKeptVerbatimButBounded`、`StoredNamesResolveWithSharedAsDefault` | — | — | +| Sign-in survives restart(Shared) | 冒烟宿主 2:`smk_persist` 重启后仍在 | 待补验 | 待补验 | +| Upgrade keeps existing sign-ins | 无(Shared 沿用默认 Profile / 固定 UUID,按构造成立) | 待补验:用旧版登录某站,升级后打开仍登录 | 待补验 | +| Two sessions see the same sign-in | 冒烟宿主 2:会话 X 的页面读到会话 A 在共享档写入的 `smk_persist` | 待补验 | 待补验 | +| Until quit:Closing all pages keeps the sign-in | 冒烟 `until_quit_after_closing_all_pages` | 待补验 | 待补验 | +| Until quit:Restart signs out | 冒烟宿主 3:`smk_quit2` 重启后消失 | 待补验 | 待补验 | +| Per session:Sessions do not see each other's sign-in | 冒烟宿主 1:会话 A / B 互不可见 | 待补验 | 待补验 | +| Sub-agent uses its root session's sign-in | 冒烟:root=A 的子代理页读到 `smk_A`;`ModesMapOwnersToStores` | 待补验 | 待补验 | +| Per-session sign-in survives restart | 冒烟宿主 2 前的目录与状态检查(启动只删待清空 / 待删除的) | 待补验 | 待补验 | +| Page without owner | `OwnerlessOrInvalidOwnerFallsBackToShared` | — | — | +| Session-only sign-in cookie | 冒烟观察 `session_cookie_after_restart false`;帮助页已说明「记住我」 | — | — | +| Change from toolbar menu(两处同步) | `agentBrowserCookieMode.test.js`(共用 store) | 待补验:工具栏改档,设置页立即同步,反之亦然 | 待补验 | +| Not running in Desktop | `agentBrowserCookieMode.test.js`(无 bridge 时不可用,菜单与设置项不显示) | 待补验:普通浏览器打开 Web UI 不出现 cookie 项 | — | +| Switch Shared → Per session with open pages | 冒烟:切档后同一 page id 回到原地址;`OnlyPagesWhoseStoreChangesAreRebuilt` | 待补验:页签不消失、重新加载一次 | 待补验 | +| Switch to Until quit(立即清空) | 冒烟:shared 立即清空,A / B 记待清空,宿主 2 启动删目录 | 待补验 | 待补验 | +| Switch between persisted modes(不清数据) | `OnlyUntilQuitDeletesPersistedData` | 待补验 | 待补验 | +| Leftover data after a crash | 冒烟宿主 1→2:切到退出即清后 `shared_wipe_pending` 落盘、清完撤掉;按会话存储的待清空目录在下次启动删除。清空中途崩溃的分支(标记残留→启动补清)无自动化 | 待补验:until_quit 下强杀 Desktop 后重开 | 待补验 | +| Clear in Shared mode | 无独立冒烟(与按会话共用 `clear_browsing_data` 路径) | 待补验:确认框写明影响所有会话 | 待补验 | +| Clear in Per-session mode | 冒烟:清 A 不影响 C | 待补验:确认框写明只影响当前会话 | 待补验 | +| User cancels | 无(确认框文案由 `agentBrowserCookieMode.test.js` 覆盖,取消路径没有) | 待补验:取消后数据仍在 | 待补验 | +| Delete while Desktop runs | 冒烟:运行中删除 C(页面关闭、目录删除)、未加载的 Z 经 `drain_store_tasks` 删除;`PurgeRecordsDeletionAndNotifiesDesktop` | 待补验:在 Web 永久删除会话,Desktop 里它的页签消失 | 待补验 | +| Delete while Desktop is not running | 冒烟宿主 3:上次记下的 C 启动时删目录 | 待补验 | 待补验 | +| Archive keeps the store | 只有永久删除路径会写 `pending_deletions`(lifecycle 只监听 purge) | 待补验:归档后取消归档,登录仍在 | 待补验 | +| Fork then open | 冒烟:C 从 A 播种 `smk_A2`、之后互相独立;`ForkSeedsFromParentOnlyOnce`;`ForkRecordsSeedWithoutNotifying` | 待补验:Web 分叉后在新会话打开浏览器仍登录 | 待补验 | +| Fork already has a store | `SeedSkipsUnusableParentsAndExistingStores` | — | — | +| New Desktop, old daemon | `OwnerlessOrInvalidOwnerFallsBackToShared`(旧 daemon 不带 owner) | 待补验(需要旧版 daemon) | 待补验 | +| New daemon, old Desktop | `DesktopUnavailableIsNotAnError`(旧 Desktop 回复 operation is invalid 只记 debug) | 待补验(需要旧版 Desktop) | 待补验 | + +## 3. 界面检查(tasks 5.2,待补验) + +- 浏览器工具栏只剩「选择元素」「开发者工具」两个按钮,其右侧是「⋮」; +- 「⋮」菜单含「将控制台日志添加到聊天」、Cookie 分组、「保留 Cookie」子菜单(三项各带说明、当前项打勾)、危险色「清除浏览数据」; +- 菜单、子菜单与确认框都显示在原生网页**上方**,不被遮住(截图确认); +- 确认框打开时默认焦点在「清除」,Esc 取消; +- 英文界面显示 Until quit / Shared / Per session 对应文案。 + +## 4. 待补验汇总 + +1. 第 2 节「手工」列标待补验的各项与第 3 节界面检查(Windows 真实 Desktop)。 +2. macOS:先在 Mac(或 CI 的 macOS 构建)上编译 `agent_browser_host_mac.mm`,再按第 2 节逐项验收。 diff --git a/openspec/changes/add-sidebar-session-multiselect/.openspec.yaml b/openspec/changes/add-sidebar-session-multiselect/.openspec.yaml new file mode 100644 index 000000000..dc36fb1bf --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/add-sidebar-session-multiselect/design.md b/openspec/changes/add-sidebar-session-multiselect/design.md new file mode 100644 index 000000000..7f7816c3e --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/design.md @@ -0,0 +1,31 @@ +# Design + +## Context + +现有 Sidebar 使用两层 SessionTreeRows、指针捕获拖动和服务端 session-sidebar。分组标题是虚拟节点;真实会话仍有独立 pin、状态、归档入口。全局 DesktopContextMenu 已有同步右键委托,可复用菜单定位和键盘处理。动机见 proposal.md。 + +## Goals / Non-Goals + +**Goals:** 选择与当前打开会话分离;保持普通单击导航;让批量动作复用已有数据入口;批量树布局原子保存。 + +**Non-Goals:** 跨工作区搬迁会话、任意深度嵌套、选择折叠节点中的隐藏会话、修改子代理 parent_session_id。 + +## Decisions + +1. 提取选择纯函数和 hook,通过 Sidebar 内 context 给行提供状态。以范围加会话 ID 做身份,使用当前渲染行的 DOM 顺序决定范围,避免复刻分页、置顶和分组投影。每次提交后清理不再可见的选择;锚点消失则下一次 Shift 从目标建立选择。 +2. 保留点击打开会话;修饰键选择只改变集合。拖动已选项不缩减集合,拖动未选项只操作该项。分组标题保留原单组拖动与折叠,不进入多选。Escape、pointercancel、窗口失焦均结束拖动,点击空白清空选择。 +3. 普通树请求增加 sources 数组,旧 source 保持兼容。领域层在副本上验证和执行整批移动,成功后替换并原子写布局。after 使用反向插入以保持顺序;拖入独立定时行创建手工组后,后续源使用新组键。 +4. 置顶排序复用全局顺序 API 和逐 scope 顺序保存;保存期间禁止重入。批量置顶/归档顺序执行,复用已建立的单项操作,单项支持静默反馈和返回成功值;最终一条结果反馈,失败行保留。已读/未读复用现有连接状态命令。 +5. 右键通过现有菜单委托构造操作快照和自定义菜单项,不修改通用菜单识别优先级。混合 pin/read 状态给出全部置顶/全部标记未读,同时提供取消置顶/标记已读;全同态优先显示反向动作。 +6. 移除整块分组底色,子项缩进 18px 并以低对比度细线连接。选择行单独高亮。拖动预览随指针平移、最多显示三条和数量,复用现有图标与主题变量,兼容旧 WebView。 + +## Risks / Trade-offs + +- 轮询覆盖批量结果 → 保存期间阻止旧列表应用,沿用树请求代次。 +- 状态命令为既有异步连接协议 → 保留原乐观状态与服务端 ack,不另造持久化协议。 +- 选择跨范围可执行批量菜单但不能搬迁数据 → 非法普通树拖放不显示接收高亮且不提交,置顶排序仍可跨范围。 +- 工作区已有无关修改 → 只修改功能相关文件;已有脏文件内只追加对应 API 段落。 + +## Migration Plan + +布局文件格式不变,接口兼容旧 source。回滚前端仍可读批量后的普通布局;需要构建并重启 Desktop 才能加载新内嵌资源,本轮源码和浏览器验证不代表已安装程序更新。 diff --git a/openspec/changes/add-sidebar-session-multiselect/proposal.md b/openspec/changes/add-sidebar-session-multiselect/proposal.md new file mode 100644 index 000000000..c7ae2ec57 --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/proposal.md @@ -0,0 +1,26 @@ +# Proposal + +## Why + +左侧会话展开后的整块背景不利于识别树形层级,整理多条会话需要重复操作。按 2026-10-21 用户提供的截图,恢复缩进树线并支持范围选择与批量整理。 + +## What Changes + +- 父子节点以缩进和细树线区分,移除展开分组背景,保留行级选中和悬停反馈。此决定覆盖 `collapse-scheduled-sessions` 设计决策 7 的无缩进及整块背景约定。 +- 单击建立锚点,Shift 连选当前可见会话,Ctrl/Cmd 增减选择;隐藏节点和分组标题不加入范围。 +- 多选拖动显示最多三条叠放预览和总数,整体排序或移入/移出定时分组。 +- 复用现有右键菜单与操作实现,支持批量置顶/取消置顶、标记未读/已读、归档,准确反馈部分失败。 + +## Capabilities + +### New Capabilities + +- `sidebar-session-selection`: 会话树视觉、多选、批量菜单与拖动语义。 + +### Modified Capabilities + +无。已有 `sidebar-session-collapse` 描述根列表分页,不改变该行为。 + +## Impact + +涉及 Sidebar、侧栏 CSS、选择纯函数、指针拖动、现有菜单委托,以及 session-sidebar 移动请求对 sources 数组的兼容扩展。复用现有置顶、状态、归档接口,不引入依赖。保持两层树及工作区数据边界,普通树批量拖动限定同一范围,置顶区域继续支持跨范围排序。 diff --git a/openspec/changes/add-sidebar-session-multiselect/specs/sidebar-session-selection/spec.md b/openspec/changes/add-sidebar-session-multiselect/specs/sidebar-session-selection/spec.md new file mode 100644 index 000000000..eb2b90f5c --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/specs/sidebar-session-selection/spec.md @@ -0,0 +1,47 @@ +# Spec Delta + +## Purpose + +让用户在左侧会话树中清晰识别父子关系,并通过范围选择、批量拖放和批量菜单快速整理会话。选择集合始终对应当前可见的真实会话,保持导航、工作区归属以及持久化结果一致。 + +## ADDED Requirements + +### Requirement: 会话树以缩进表达层级 +侧栏 SHALL 用子项缩进和细树线表示父子关系,展开容器不添加背景差异,保留普通行的悬停、当前聊天及多选反馈。 + +#### Scenario: 展开定时分组 +- **WHEN** 用户展开一个定时任务分组 +- **THEN** 子会话缩进并显示连接线,组容器背景透明,组标题不导航到真实会话 + +### Requirement: 可见会话范围选择 +侧栏 SHALL 支持单击建立锚点、Shift 连选、Ctrl/Cmd 增减单项。范围选择只包含当前可见真实会话,修饰键选择不打开会话。Escape 或空白点击清空选择。 + +#### Scenario: 连续和反向选择 +- **WHEN** 用户先点击会话再 Shift 点击另一会话 +- **THEN** 锚点至目标之间的可见会话被选中,反向选择结果一致,分组标题与隐藏子项不参与 + +#### Scenario: 列表变化 +- **WHEN** 折叠、归档或刷新使已选行不可见 +- **THEN** 不可见项从选择集合移除,失效锚点不导致下次范围选择包含其他隐藏会话 + +### Requirement: 多选批量拖动 +侧栏 SHALL 将选中会话作为一批拖动,按可见顺序保持相对顺序,显示最多三行预览和总数。同一普通树范围允许批量排序、移出或移入合法定时组;置顶区允许跨范围排序。跨范围普通树拖动、选中目标、非法嵌套及过期目标 MUST 不保存任何移动。 + +#### Scenario: 多个子项拖入另一组 +- **WHEN** 用户拖动已选会话之一到同范围的合法定时组 +- **THEN** 全部选中会话按顺序移动并在刷新后保持位置,取消拖动不改变数据 + +#### Scenario: 移动中出现失效项 +- **WHEN** 保存时任一源或目标失效或不符合两层树规则 +- **THEN** 整次移动失败,原布局保持,界面显示失败反馈 + +### Requirement: 多选右键操作 +侧栏 SHALL 在右键已选行时保留选择并对整批提供置顶、未读和归档操作,以及对应取消置顶和已读操作。右键未选行只针对该行,不隐式包含其他选择。批量操作不得重入,部分失败保留可重试项并报告结果。 + +#### Scenario: 批量置顶及未读 +- **WHEN** 用户对多选会话执行置顶或标记未读 +- **THEN** 操作用于选择快照中的每个会话,当前打开会话标记未读后保持未读,隐藏项不被操作 + +#### Scenario: 部分归档失败 +- **WHEN** 一批归档中部分请求失败 +- **THEN** 成功项移除,失败项仍可选择重试,结果不宣称全部成功 diff --git a/openspec/changes/add-sidebar-session-multiselect/tasks.md b/openspec/changes/add-sidebar-session-multiselect/tasks.md new file mode 100644 index 000000000..189e1d97d --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. 批量移动协议 + +- [x] 1.1 增加领域层原子批量移动及兼容 sources 请求,单测覆盖顺序、失败回滚、手工组和刷新,更新 API 说明。 + +## 2. 侧栏选择与操作 + +- [x] 2.1 实现可见行选择状态及树形样式,纯函数和浏览器覆盖正反向 Shift、Ctrl/Cmd、清理隐藏选择及普通导航。 +- [x] 2.2 接入批量指针拖动、叠放预览和置顶批量排序,验证合法/非法放置、取消、刷新保序。 +- [x] 2.3 接入右键批量置顶、已读/未读、归档,验证选择快照、部分失败、当前聊天未读和防重入。 + +## 3. 集成验证 + +- [x] 3.1 完成相关 C++ 测试、Web 全量测试与构建、真实 Sidebar 的亮暗/窄屏浏览器验证和设计检测。 +- [x] 3.2 严格验证 OpenSpec、检查差异与无关修改保留,并记录 Desktop 验证边界。 + +验证详情见 validation.md。源码已完成,运行中的 Desktop 未重编译或重启。 diff --git a/openspec/changes/add-sidebar-session-multiselect/validation.md b/openspec/changes/add-sidebar-session-multiselect/validation.md new file mode 100644 index 000000000..19744935f --- /dev/null +++ b/openspec/changes/add-sidebar-session-multiselect/validation.md @@ -0,0 +1,23 @@ +# 验证记录 + +日期:2026-10-21(Asia/Taipei)。 + +## 已通过 + +- `cmake --build build/windows-x64-msvc --config Release --target acecode_unit_tests -- /m:2 /nologo /v:minimal`:MSVC Release 测试目标构建通过。 +- `build/windows-x64-msvc/tests/Release/acecode_unit_tests.exe --gtest_filter=SidebarTree.*:SidebarSessionsHttp.*`:23 个测试通过,包括新批量 before/after/inside 顺序、失败原子回滚、独立定时执行转手工组、HTTP 数组校验及保存重读。 +- `pnpm test`(web):全量测试通过,包含新增选择纯函数测试、侧栏刷新并发回归及 i18n 静态文案覆盖。 +- `pnpm build`(web):构建通过;4551 个正则字面量兼容性检查通过。 +- `node scripts/test-sidebar-session-tree.mjs`(web;使用外部 Playwright 与 Chromium 153.0.8010.12):46 组回归通过。挂载正式 Sidebar、DesktopContextMenu、Toaster,替换服务端响应;保留生产侧栏查询参数生成。 +- 浏览器覆盖:组容器透明、18px 缩进、普通导航、Shift 正反向选择、Ctrl/Cmd 增减、右键未选项、Escape、选择清理、批量未读/已读及当前聊天未读保护、四项拖动三行预览和数量、非法接收、取消、失败保留、刷新保序、批量置顶失败、保存防重入、批量置顶排序/取消、部分归档失败及窄屏菜单边界。 +- 亮/暗主题及 390px 窄屏截图人工检查完成。修正了预览角标遮挡、拖动/菜单遗留悬浮详情以及子行树线与放置指示线的伪元素冲突。 +- Impeccable detector:12 条既有 CSS 建议,均不在本次修改区域;本次 Sidebar 和 CSS 修改未新增检测项。 +- `openspec validate add-sidebar-session-multiselect --strict`、`git diff --check` 通过。 + +## 产物与边界 + +浏览器截图:`C:/Users/shao/AppData/Local/Temp/ace-sidebar-multiselect-validation/`,包括 `light-multiselect-menu.png`、`dark-multiselect-menu.png`、`batch-drag.png`、`narrow-multiselect.png`。 + +普通会话树批量移动保持同一工作区或无工作区范围;置顶区域可以跨范围排序。分组标题维持单组拖动和折叠,范围多选只覆盖可见真实会话。 + +本轮未编译或重启 Desktop/daemon 产品程序,浏览器回归和源码测试不表示运行中程序已加载。未提交、推送或发布。已有 CLAUDE、Agent Browser、响应式侧栏及数据目录迁移修改保留;docs/daemon-api.md 仅追加本次批量移动协议说明。 diff --git a/openspec/changes/simplify-transcript-tool-details/.openspec.yaml b/openspec/changes/simplify-transcript-tool-details/.openspec.yaml new file mode 100644 index 000000000..7cc41bb51 --- /dev/null +++ b/openspec/changes/simplify-transcript-tool-details/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/simplify-transcript-tool-details/design.md b/openspec/changes/simplify-transcript-tool-details/design.md new file mode 100644 index 000000000..4a77abb0b --- /dev/null +++ b/openspec/changes/simplify-transcript-tool-details/design.md @@ -0,0 +1,29 @@ +# Design + +## Context + +见 proposal.md。ToolBlock 直接显示 summary.verb 和 metrics;兜底把所有参数值拼成 object。历史投影仅为 Shell 恢复参数。ActivityLine 的箭头已支持 hover / focus 显示,应保留。 + +## Goals / Non-Goals + +统一显示层的工具身份、对象和详情规则,原始输出、工具协议和持久化记录保持完整。实现可随语言切换重新计算,避免在 reducer 中缓存翻译。保留问答、源码、diff、附件等专用内容。 + +## Decisions + +- 使用集中、可枚举的内置工具中英文词条,附常用协议别名;以原始工具身份选择名称,不依赖英文过去式。无工具身份的旧摘要仅做有依据的兼容。 +- 纯展示函数提取单个对象和必要字段:文件路径使用现有 basenameForPath,搜索使用 pattern,Shell 使用命令预览;不枚举全部请求值。输出参数或代码正文不再抄到参数表。 +- 历史投影为所有匹配调用恢复 name / args,并单独提供 resultOutput;保留原有 output 和 coveredItems,避免影响存储、复制之外的消费者或旧数据诊断。 +- 普通卡片内部顶部放完整路径、必要参数和统计;diff / 新文件源码继续原渲染器,避免嵌套卡片。无正文但有字段仍能展开。 +- Shell 使用已有输出卡的 transcript 变体,取消内部 Shell 标题和重复命令参数区。主行可完整显示单行命令时卡片不再重复;多行或因宽度截断时,仅在卡片一处保留完整命令。复制始终保留完整命令与结果。 +- ActivityLine 增加可选分隔符,工具传入本地化冒号,其余使用者保留现状;保持箭头 CSS、宽度和旋转规则。 + +## Risks / Trade-offs + +- 旧记录缺少参数 → 有真实摘要才恢复对象,不推测请求范围或把上限解释成结果数量。 +- 条件注册工具易遗漏 → 测试对照后端工具定义及动态工具家族,确保两种语言均有名称。 +- 长名称与窄屏 → 使用真实组件验证省略、换行和 hover / focus;不改变固定主行高度。 +- 输出本身包含重复文本 → 保留原始输出,去重仅针对界面生成的字段和包装,不删除用户内容。 + +## Migration Plan + +先增加词条和展示逻辑,再接入工具行与历史投影,完成定向测试、全量 Web 测试、构建、双语宽窄屏浏览器验证。无需数据迁移;Desktop 安装版需重新构建加载前端,本次不自动启动或替换安装版。 diff --git a/openspec/changes/simplify-transcript-tool-details/proposal.md b/openspec/changes/simplify-transcript-tool-details/proposal.md new file mode 100644 index 000000000..1aefba8b5 --- /dev/null +++ b/openspec/changes/simplify-transcript-tool-details/proposal.md @@ -0,0 +1,26 @@ +# Proposal + +## Why + +工具 transcript 混用英文动词和原始工具名,参数直接拼接且缺少含义,右侧统计挤占主行。用户确认统一为「动作:最小必要对象」,展开后补充信息,避免重复。 + +## What Changes + +- 全部内置工具提供随界面语言切换的中文、英文名称,覆盖运行、完成、失败和历史记录。 +- 工具行使用冒号,文件对象仅显示文件名;参数和统计在展开卡片中显示。 +- 展开内容按工具选择必要字段,保留完整路径、原始结果与复制能力;命令不重复展示,不增加冗余 Shell 标题或参数卡。 +- 保留现有展开箭头的尺寸、位置、旋转、hover / 键盘焦点显隐设计。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `web-chat-activity-collapse`: 增加单个工具行的本地化、精简对象和展开详情去重要求。 + +## Impact + +Web/Desktop 共用的 ToolBlock、ActivityLine、ShellCommandCard、transcript 投影、翻译目录及测试。仅改变展示,不更改工具协议、执行逻辑、存储内容和 TUI。保留当前工作区的其它改动。 diff --git a/openspec/changes/simplify-transcript-tool-details/specs/web-chat-activity-collapse/spec.md b/openspec/changes/simplify-transcript-tool-details/specs/web-chat-activity-collapse/spec.md new file mode 100644 index 000000000..77325769e --- /dev/null +++ b/openspec/changes/simplify-transcript-tool-details/specs/web-chat-activity-collapse/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: 内置工具使用本地化的紧凑主行 +Web 和 Desktop 的工具主行 SHALL 使用随界面语言切换的可读动作名称与冒号分隔的最小必要对象;所有内置工具(包括条件注册工具)均须覆盖。文件对象 MUST 仅显示文件名;主行不得展示无标签参数或统计。 + +#### Scenario: 同一工具的生命周期与语言切换 +- **WHEN** 读取文件处于运行、成功或失败状态,或从历史恢复 +- **THEN** 中文界面显示「读取文件:index.vue」,英文界面显示「Read File: index.vue」 +- **AND** 切换语言直接更新显示,不写入或修改原始 transcript + +#### Scenario: 搜索限制不冒充结果数量 +- **WHEN** 搜索内容包含 pattern、include_pattern 和 head_limit=50 +- **THEN** 主行仅显示动作和搜索词,展开后显示文件过滤条件和最多 50 条的限制 + +### Requirement: 工具展开卡片补齐必要信息并避免重复 +展开卡片 SHALL 展示完整路径、必要请求条件、统计与结果,省略空值和冗余默认信息;界面生成的同一信息 MUST 仅展示一次,保留原始输出和复制内容。统计 SHALL 位于卡片内;只有统计或参数时也须可展开。 + +#### Scenario: 读取文件详情 +- **WHEN** 用户展开读取文件 +- **THEN** 卡片顶部显示完整路径和可用的读取行数、大小、编码、换行信息,下方显示文件内容 +- **AND** 不额外显示重复的工具名称或整张请求 JSON 表 + +#### Scenario: Shell 不重复命令 +- **WHEN** 单行命令在主行完整可见且用户展开 +- **THEN** 卡片显示统计和输出,不增加 Shell 标题或重复命令字段 +- **AND** 多行或主行被截断的命令在卡片中仅完整展示一次,复制仍包含完整命令和输出 + +#### Scenario: 历史调用详情 +- **WHEN** 旧记录的请求与结果能够按调用标识配对 +- **THEN** 展示层恢复工具身份和参数,结果区不再重复请求包装,原始记录保持不变 + +### Requirement: 工具箭头保持既有交互设计 +工具行展开箭头 SHALL 保持既有图标、位置、尺寸与旋转规则,默认隐藏,在 hover 或键盘焦点进入时显示。 + +#### Scenario: 展开前后的箭头显隐 +- **WHEN** 工具行处于展开或收起状态且既无 hover 也无键盘焦点 +- **THEN** 箭头不可见但保留占位;hover 或焦点进入后可见,点击和 Enter / Space 均可切换详情 diff --git a/openspec/changes/simplify-transcript-tool-details/tasks.md b/openspec/changes/simplify-transcript-tool-details/tasks.md new file mode 100644 index 000000000..a3ceb074c --- /dev/null +++ b/openspec/changes/simplify-transcript-tool-details/tasks.md @@ -0,0 +1,27 @@ +# Tasks + +## 1. 本地化展示数据 + +- [x] 1.1 增加全部内置工具中英文名称及紧凑对象/必要字段映射;用后端定义覆盖测试和纯函数测试验证别名、文件名、搜索限制与去重。 +- [x] 1.2 历史投影恢复所有已配对工具的身份和参数,提供独立结果;验证历史与实时一致且原始数据不变。 + +## 2. 工具行和详情 + +- [x] 2.1 接入冒号、文件名和卡片内元数据,保留源码/diff/问答/附件展示;验证运行、完成、失败和仅元数据展开。 +- [x] 2.2 精简 Shell 卡片,验证短命令不重复、长命令完整显示、复制完整命令和输出;箭头保持 hover / focus 设计。 + +## 3. 集成验证 + +- [x] 3.1 完成双语、宽窄屏、明暗主题及 hover / focus 浏览器验证,保存截图。 +- [x] 3.2 通过全量 pnpm test、pnpm build、OpenSpec 严格校验和差异检查,记录实际验证范围。 + +## 验证记录(2026-10-22) + +- 后端工具定义对照:81 个内置工具名称,中英文均覆盖,包含条件注册和动态工具家族。 +- `node src/lib/toolPresentation.test.js`:11 项展示/历史回归用例通过。 +- `pnpm test`:最终全量运行退出 0;历史恢复用例同步验证参数恢复且原始记录不变。 +- `pnpm build`:最终构建成功,ES2020 正则兼容检查通过。 +- `node scripts/test-tool-transcript.mjs`:真实 ToolBlock / ActivityLine / ShellCommandCard 的 1280px 浅色和 390px 深色、中文和英文共 4 组通过;额外覆盖 230px 到 1280px 的命令截断恢复。验证复制、失败、运行、仅统计、新建源码、diff、问答和 hover / focus / Enter / Space。 +- 最终截图:`C:/Users/shao/AppData/Local/Temp/ace-tool-transcript-fXMG2s/`;文件名格式为 `1280-light-zh-CN.png`、`1280-light-en-US.png`、`390-dark-zh-CN.png`、`390-dark-en-US.png`。 +- Impeccable 检测在 UI 实现后执行一次,无发现;OpenSpec 严格校验和所改文件的差异检查通过。 +- 验证范围为源码、Web 构建及 Edge 浏览器组件集成;未重建或替换已安装的 Desktop,不宣称安装版已加载修改。 diff --git a/openspec/changes/sync-preview-file-to-list/.openspec.yaml b/openspec/changes/sync-preview-file-to-list/.openspec.yaml new file mode 100644 index 000000000..7cc41bb51 --- /dev/null +++ b/openspec/changes/sync-preview-file-to-list/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/sync-preview-file-to-list/design.md b/openspec/changes/sync-preview-file-to-list/design.md new file mode 100644 index 000000000..5f8f0be02 --- /dev/null +++ b/openspec/changes/sync-preview-file-to-list/design.md @@ -0,0 +1,26 @@ +## Context + +ChatView 保存定位请求,SidePanel 已能展开、选中与滚动。现有目录定位请求总是 includeSelf,不能直接用于文件。详情通过全局上下文菜单派发事件;快捷键已有统一目录、配置与作用域控制。实际根目录是 sidePanelCwd,预览标签 cwd 可能是外部文件父目录。 + +## Goals / Non-Goals + +统一菜单和快捷键的文件定位入口,在任何界面变化之前校验实际工作区归属。仅修改本功能相关前端和文档,不改其他工作区已有修改,不改变系统文件管理器入口。 + +## Decisions + +1. 文件绝对路径由标签 cwd/path 解析,再与当前实际根目录比较;复用路径规范化约定,处理 Windows 大小写、UNC、点段和相邻前缀。无工作区直接拒绝,不以外部预览 cwd 充当根目录。 +2. 新动作由详情面板统一接收并调用 ChatView 文件定位回调;右键使用对应标签/内容的路径,快捷键使用活动文件。菜单事件限定在当前面板,避免跨视图重复执行。沿用统一快捷键仓库,默认 Ctrl+[,编辑器内可用,弹窗和输入法组合期间不执行。 +3. 扩展定位请求的文件/目录语义及强调标记;沿用递增 token 支持同路径重复定位。SidePanel 在请求挂载时也必须消费尚未处理的请求,目录加载完成后再滚动和强调。 +4. 高亮仅作用于目标行,760ms 内放大到 1.06 倍再恢复,使用现有主题色。缩放图标与文件名,行背景强调,避免改变列表尺寸或横向溢出。减少动态效果取消缩放。下一帧确认列表挂载与可见后滚动并播放,避免 StrictMode 重放挂载提前消耗请求;卸载取消待执行帧并清理旧效果。按请求 token 记录已播放状态,不因缓存更新、普通选中或会话恢复重新播放。 +5. 使用真实 App 的隔离浏览器测试覆盖菜单、快捷键、折叠列表、深层懒加载、重复定位、外部文件及减少动态效果;补充路径纯函数与菜单/快捷键测试。 + +## Risks / Trade-offs + +- 列表挂载或加载时丢失请求 → 按 owner/cwd 和 token 消费,只有实际目标可见时记录完成。 +- 窄窗口或详情最大化遮住列表 → 使用现有列表展开和响应式右栏入口。 +- 外部文件在简单前缀检查中误命中 → 规范化路径后检查完整目录边界。 +- 旧 WebView 动画能力 → 使用 CSS keyframes 和主题 token,不引入新依赖。 + +## Migration Plan + +构建 Web 产物即可交付源码;已安装 Desktop 需后续重新嵌入前端并启动后才能宣称桌面运行验证。无数据迁移。 diff --git a/openspec/changes/sync-preview-file-to-list/proposal.md b/openspec/changes/sync-preview-file-to-list/proposal.md new file mode 100644 index 000000000..0a86eb49d --- /dev/null +++ b/openspec/changes/sync-preview-file-to-list/proposal.md @@ -0,0 +1,24 @@ +## Why + +文件详情缺少一键同步到文件列表的入口,用户需要手动逐层查找。定位后还需要明确的缩放高亮反馈,让用户快速辨认目标文件。 + +## What Changes + +- 文件详情内容和文件标签右键菜单增加“在文件列表展示”,Ctrl+[ 定位当前活动文件。 +- 展开文件列表和父目录,选中文件、滚动到可见位置后播放一次放大再恢复的高亮动画;重复定位重新播放。 +- 当前工作区外文件及无工作区场景静默无操作,判断使用当前实际文件树根目录(包括 worktree)。 +- 接入统一快捷键配置和帮助;减少动态效果时保留高亮、取消缩放。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `webui-side-panel`:新增文件详情同步定位和定位反馈行为。 + +## Impact + +仅前端:详情菜单、快捷键、ChatView 定位入口、SidePanel 文件树与样式;增加对应测试及用户说明。不改后端接口或依赖。复用已完成变更中的独立详情面板、实际工作目录及工作区外预览能力,主规范中的旧三页签布局不作为本次布局依据。 diff --git a/openspec/changes/sync-preview-file-to-list/specs/webui-side-panel/spec.md b/openspec/changes/sync-preview-file-to-list/specs/webui-side-panel/spec.md new file mode 100644 index 000000000..e36bc187c --- /dev/null +++ b/openspec/changes/sync-preview-file-to-list/specs/webui-side-panel/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: 从文件详情同步到文件列表 + +文件详情内容与文件标签右键菜单 SHALL 提供“在文件列表展示”。Ctrl+[ SHALL 对当前活动文件执行相同定位动作并纳入统一快捷键配置。定位 SHALL 展开文件列表、切换到文件页、展开祖先目录、选中目标并滚动到可见位置,保留当前详情与编辑草稿。 + +#### Scenario: 定位尚未加载的深层文件 +- **WHEN** 用户对当前工作区内的深层文件执行定位,列表收起或停在变更页 +- **THEN** 列表展开并切换到文件页,加载祖先目录,选中目标并滚动到可见位置 +- **AND** 不把文件作为目录请求,详情和未保存编辑保持不变 + +#### Scenario: 右键非活动标签 +- **WHEN** 用户在非活动文件标签的右键菜单点击“在文件列表展示” +- **THEN** 定位右键对应的文件,保留当前活动详情 + +#### Scenario: 键盘与菜单遵循相同归属边界 +- **WHEN** 文件位于当前实际工作区或 worktree 根目录之外,或当前没有工作区 +- **THEN** 点击菜单或按快捷键均不改变面板、选中项、工作区或滚动位置,也不显示提示 + +#### Scenario: 非文件详情与弹窗 +- **WHEN** 当前活动详情不是文件,或有阻断快捷键的弹窗 +- **THEN** 快捷键不触发文件定位 + +### Requirement: 定位结果具有可重复的缩放高亮反馈 + +显式文件定位 SHALL 在目标行实际出现且滚动完成后,播放一次短暂放大再恢复的高亮效果;重复定位同一文件 SHALL 重新播放,结束后保留选中态。动画 SHALL 不改变列表排版,不因无关重新渲染反复播放。减少动态效果偏好 SHALL 取消缩放并保留高亮反馈。 + +#### Scenario: 目标已在可见范围内 +- **WHEN** 用户再次定位同一个已可见文件 +- **THEN** 目标重新播放缩放高亮,随后恢复常规选中态 + +#### Scenario: 延迟目录响应与会话切换 +- **WHEN** 目录加载尚未完成或用户切换会话 +- **THEN** 未出现的目标不提前消耗动画,旧请求不在其他会话产生高亮 + +#### Scenario: 减少动态效果 +- **WHEN** 系统启用减少动态效果并执行定位 +- **THEN** 文件行显示高亮反馈但不发生缩放 diff --git a/openspec/changes/sync-preview-file-to-list/tasks.md b/openspec/changes/sync-preview-file-to-list/tasks.md new file mode 100644 index 000000000..89d18ed58 --- /dev/null +++ b/openspec/changes/sync-preview-file-to-list/tasks.md @@ -0,0 +1,21 @@ +## 1. 定位动作与入口 + +- [x] 1.1 增加严格工作区文件定位计划,路径单测覆盖外部路径、点段、Windows/UNC、worktree 与空工作区。 +- [x] 1.2 接入详情内容/标签菜单及统一 Ctrl+[ 快捷键,验证菜单与快捷键单测并更新用户说明和翻译。 + +## 2. 文件树反馈 + +- [x] 2.1 实现文件/目录请求区分、懒加载后选中滚动、重复定位缩放高亮和减少动态效果;在真实 App 浏览器测试中验证折叠、变更页、重复定位、外部文件和动效。 + +## 3. 集成验证 + +- [x] 3.1 运行 pnpm test、pnpm build、OpenSpec strict 校验与差异检查,记录结果和运行验证边界。 + +## 验证记录 + +- `pnpm test`:最终全量通过。初次运行时工作区并行修改中的工具消息用例失败,相关修改更新后复跑通过;本变更未修改那些文件。 +- `pnpm build`:通过,生成的 Web 产物通过旧 WebView 正则兼容检查。 +- `node web/scripts/test-preview-file-locate.mjs` 与 `--desktop`:均通过。真实 App + 隔离 API 验证了目录懒加载、折叠/变更页、重复动画、内容及非活动标签菜单、外部文件无副作用、普通选中不重播、减少动态效果与窄窗口;桌面模式夹具额外验证编辑器内快捷键与未保存草稿保留。 +- 路径、菜单、快捷键定向测试及 `git diff --check` 通过;`openspec validate sync-preview-file-to-list --strict` 通过。 +- 动效使用现有主题色,不引入依赖;视觉检查未发现目标行或页面横向溢出。 +- 上述桌面模式验证在浏览器夹具中运行,未重新编译或启动已安装 Desktop,不宣称桌面壳已加载此次变更。 diff --git a/src/adapters/tool/agent_browser/cdp_client.cpp b/src/adapters/tool/agent_browser/cdp_client.cpp index e9acf8003..01666b761 100644 --- a/src/adapters/tool/agent_browser/cdp_client.cpp +++ b/src/adapters/tool/agent_browser/cdp_client.cpp @@ -516,6 +516,15 @@ bool AgentBrowserCdpClient::close_page( return error.empty(); } +bool AgentBrowserCdpClient::drain_store_tasks( + std::chrono::milliseconds timeout, + const std::atomic* abort_flag, + std::string& error) { + request("drain_store_tasks", "", nlohmann::json::object(), timeout, + abort_flag, error); + return error.empty(); +} + nlohmann::json AgentBrowserCdpClient::command( const std::string& method, const nlohmann::json& params, diff --git a/src/adapters/tool/agent_browser/cdp_client.hpp b/src/adapters/tool/agent_browser/cdp_client.hpp index 1115cbc1a..0e7ccf551 100644 --- a/src/adapters/tool/agent_browser/cdp_client.hpp +++ b/src/adapters/tool/agent_browser/cdp_client.hpp @@ -54,6 +54,11 @@ class AgentBrowserCdpClient { bool close_page(std::chrono::milliseconds timeout, const std::atomic* abort_flag, std::string& error); + // 提醒 Desktop 处理状态文件里的 cookie 存储待办(永久删除会话后的存储删除)。 + // 不带数据;旧版 Desktop 不认识这个操作时返回 false 与它的报错。 + bool drain_store_tasks(std::chrono::milliseconds timeout, + const std::atomic* abort_flag, + std::string& error); nlohmann::json command( const std::string& method, diff --git a/src/apps/daemon/worker.cpp b/src/apps/daemon/worker.cpp index 07afb0bbd..a07c92e72 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -62,6 +62,7 @@ #include "tool/glob_tool.hpp" #include "tool/goal_tool.hpp" #include "daemon_memory.hpp" +#include "session_host/agent_browser_store_lifecycle.hpp" #include "session_host/memory_runtime.hpp" #include "tool/skill_view_tool.hpp" #include "tool/skills_tool.hpp" @@ -440,6 +441,8 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // 先于 ToolExecutor 构造 —— 工具持有它的服务,析构顺序要反过来。 auto memory_runtime = acecode::create_memory_runtime( cfg_mut, acecode::get_acecode_dir(), acecode::MemorySurface::Daemon); + // Agent Browser 按会话 cookie 存储:会话永久删除 / 分叉时给 Desktop 记待办。 + acecode::AgentBrowserStoreLifecycle agent_browser_stores; acecode::ToolExecutor tools; acecode::register_session_builtin_tools(tools, cfg_mut); diff --git a/src/apps/desktop/agent_browser_cookie_bridge.cpp b/src/apps/desktop/agent_browser_cookie_bridge.cpp new file mode 100644 index 000000000..b0cfedf23 --- /dev/null +++ b/src/apps/desktop/agent_browser_cookie_bridge.cpp @@ -0,0 +1,78 @@ +#include "agent_browser_cookie_bridge.hpp" +#include "agent_browser_host.hpp" +#include "web_host.hpp" +#include + +namespace acecode::desktop { +void install_agent_browser_cookie_bridge(WebHost& host, AgentBrowserHost& agent_browser) { + // cookie 存储的异步结果(切档、清除浏览数据、删除会话存储)推给前端; + // 同步 bridge 只能返回「已开始」。 + agent_browser.set_storage_event_handler( + [&host](const acecode::desktop::AgentBrowserStorageEvent& event) { + const nlohmann::json detail{ + {"type", event.type}, + {"ok", event.ok}, + {"cookie_mode", event.cookie_mode}, + {"store", event.store}, + {"page_id", event.page_id}, + {"error", event.error}, + }; + host.eval( + "(function(){try{window.dispatchEvent(new CustomEvent(" + "'acecode:agent-browser-storage',{detail:" + detail.dump() + + "}));}catch(e){}})();"); + }); + // 「保留 Cookie」档位:until_quit / shared / per_session(没选过 = shared)。 + host.bind("aceDesktop_agentBrowserGetCookieMode", + [&agent_browser](const std::string& /*req*/) -> std::string { + return nlohmann::json{ + {"ok", true}, + {"supported", agent_browser.supported()}, + {"mode", agent_browser.cookie_mode()}, + }.dump(); + }); + + // 参数 [mode:string]。切档会按原地址重建存储变了的页面;切到 until_quit 会清空 + // 已保存的 cookie。 + host.bind("aceDesktop_agentBrowserSetCookieMode", + [&agent_browser](const std::string& req) -> std::string { + const auto args = nlohmann::json::parse(req, nullptr, false); + if (!args.is_array() || args.empty() || !args[0].is_string()) { + return nlohmann::json{ + {"ok", false}, {"error", "expect [mode:string]"} + }.dump(); + } + std::string error; + if (!agent_browser.set_cookie_mode(args[0].get(), &error)) { + return nlohmann::json{{"ok", false}, {"error", error}}.dump(); + } + return nlohmann::json{ + {"ok", true}, + {"mode", agent_browser.cookie_mode()}, + }.dump(); + }); + + // 参数 [page_id:string]:清除该页当前所用存储的全部网站数据。异步完成,结果经 + // acecode:agent-browser-storage 事件(type=browsing_data_cleared)回报。 + host.bind("aceDesktop_agentBrowserClearBrowsingData", + [&agent_browser](const std::string& req) -> std::string { + const auto args = nlohmann::json::parse(req, nullptr, false); + if (!args.is_array() || args.empty() || !args[0].is_string()) { + return nlohmann::json{ + {"ok", false}, {"error", "expect [page_id:string]"} + }.dump(); + } + const std::string page_id = args[0].get(); + std::string error; + if (!agent_browser.clear_browsing_data(page_id, &error)) { + return nlohmann::json{{"ok", false}, {"error", error}}.dump(); + } + return nlohmann::json{ + {"ok", true}, + {"started", true}, + {"page_id", page_id}, + {"mode", agent_browser.cookie_mode()}, + }.dump(); + }); +} +} // namespace acecode::desktop diff --git a/src/apps/desktop/agent_browser_cookie_bridge.hpp b/src/apps/desktop/agent_browser_cookie_bridge.hpp new file mode 100644 index 000000000..ffee9e5db --- /dev/null +++ b/src/apps/desktop/agent_browser_cookie_bridge.hpp @@ -0,0 +1,7 @@ +#pragma once +namespace acecode::desktop { +class WebHost; +class AgentBrowserHost; +// Host and browser are owned by main and outlive their registered callbacks. +void install_agent_browser_cookie_bridge(WebHost& host, AgentBrowserHost& agent_browser); +} // namespace acecode::desktop diff --git a/src/apps/desktop/agent_browser_cookie_jars.cpp b/src/apps/desktop/agent_browser_cookie_jars.cpp new file mode 100644 index 000000000..d297295a7 --- /dev/null +++ b/src/apps/desktop/agent_browser_cookie_jars.cpp @@ -0,0 +1,194 @@ +#include "agent_browser_cookie_jars.hpp" + +#include "utils/sha256.hpp" + +#include +#include + +namespace acecode::desktop { +namespace { + +bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +void erase_value(std::vector& values, const std::string& value) { + values.erase(std::remove(values.begin(), values.end(), value), values.end()); +} + +} // namespace + +const char* agent_browser_cookie_mode_name(AgentBrowserCookieMode mode) { + switch (mode) { + case AgentBrowserCookieMode::UntilQuit: return kAgentBrowserCookieModeUntilQuit; + case AgentBrowserCookieMode::PerSession: return kAgentBrowserCookieModePerSession; + case AgentBrowserCookieMode::Shared: break; + } + return kAgentBrowserCookieModeShared; +} + +std::optional agent_browser_cookie_mode_from_name( + const std::string& name) { + if (name == kAgentBrowserCookieModeUntilQuit) return AgentBrowserCookieMode::UntilQuit; + if (name == kAgentBrowserCookieModeShared) return AgentBrowserCookieMode::Shared; + if (name == kAgentBrowserCookieModePerSession) return AgentBrowserCookieMode::PerSession; + return std::nullopt; +} + +AgentBrowserCookieMode effective_agent_browser_cookie_mode(const std::string& stored) { + return agent_browser_cookie_mode_from_name(stored).value_or( + AgentBrowserCookieMode::Shared); +} + +std::string AgentBrowserCookieStoreKey::id() const { + switch (kind) { + case AgentBrowserCookieStoreKind::UntilQuit: return kAgentBrowserCookieModeUntilQuit; + case AgentBrowserCookieStoreKind::Session: return "session:" + session_id; + case AgentBrowserCookieStoreKind::Shared: break; + } + return kAgentBrowserCookieModeShared; +} + +std::string agent_browser_cookie_store_session(const AgentBrowserPageOwner& owner) { + if (owner.empty()) return {}; + return owner.root_session_id.empty() ? owner.session_id : owner.root_session_id; +} + +AgentBrowserCookieStoreKey agent_browser_cookie_store_key( + AgentBrowserCookieMode mode, + const AgentBrowserPageOwner& owner) { + AgentBrowserCookieStoreKey key; + if (mode == AgentBrowserCookieMode::UntilQuit) { + key.kind = AgentBrowserCookieStoreKind::UntilQuit; + return key; + } + if (mode == AgentBrowserCookieMode::PerSession) { + const std::string session = agent_browser_cookie_store_session(owner); + if (valid_agent_browser_store_session_id(session)) { + key.kind = AgentBrowserCookieStoreKind::Session; + key.session_id = session; + } + } + return key; +} + +std::string agent_browser_session_profile_name(const std::string& session_id) { + return "s-" + acecode::sha256_hex(session_id).substr(0, 24); +} + +std::string agent_browser_session_profile_directory_name(const std::string& session_id) { + return "WV2Profile_" + agent_browser_session_profile_name(session_id); +} + +AgentBrowserWindowsProfile agent_browser_windows_profile( + const AgentBrowserCookieStoreKey& key) { + AgentBrowserWindowsProfile profile; + switch (key.kind) { + case AgentBrowserCookieStoreKind::UntilQuit: + profile.name = kAgentBrowserUntilQuitProfileName; + profile.in_private = true; + break; + case AgentBrowserCookieStoreKind::Session: + profile.name = agent_browser_session_profile_name(key.session_id); + break; + case AgentBrowserCookieStoreKind::Shared: + break; + } + return profile; +} + +std::string agent_browser_session_store_uuid(const std::string& session_id) { + // 加前缀做命名空间,避免与别处对同一 id 取的哈希重合。 + std::string hex = acecode::sha256_hex( + "acecode.agent-browser.session-store:" + session_id).substr(0, 32); + for (auto& c : hex) c = static_cast(std::toupper(static_cast(c))); + // 版本 8(自定义)放在第 13 个十六进制位;变体 10xx 放在第 17 位。 + hex[12] = '8'; + static const char kVariant[] = "89AB"; + const int nibble = std::isdigit(static_cast(hex[16])) + ? hex[16] - '0' : hex[16] - 'A' + 10; + hex[16] = kVariant[nibble & 0x3]; + return hex.substr(0, 8) + "-" + hex.substr(8, 4) + "-" + hex.substr(12, 4) + + "-" + hex.substr(16, 4) + "-" + hex.substr(20, 12); +} + +AgentBrowserCookieModeSwitch plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode from, + AgentBrowserCookieMode to) { + AgentBrowserCookieModeSwitch plan; + if (from == to) return plan; + plan.changed = true; + if (to == AgentBrowserCookieMode::UntilQuit) { + plan.clear_shared_now = true; + plan.mark_session_stores_for_wipe = true; + } + if (from == AgentBrowserCookieMode::UntilQuit) { + plan.release_until_quit_store = true; + } + return plan; +} + +std::vector agent_browser_pages_to_rebuild( + const std::vector& pages, + AgentBrowserCookieMode new_mode) { + std::vector result; + for (const auto& page : pages) { + if (agent_browser_cookie_store_key(new_mode, page.owner) != page.store) { + result.push_back(page.page_id); + } + } + return result; +} + +std::vector agent_browser_pages_of_root_session( + const std::vector& pages, + const std::string& root_session_id) { + std::vector result; + if (root_session_id.empty()) return result; + for (const auto& page : pages) { + if (agent_browser_cookie_store_session(page.owner) == root_session_id) { + result.push_back(page.page_id); + } + } + return result; +} + +AgentBrowserSessionStorePreparation prepare_agent_browser_session_store( + const std::string& session_id, + const AgentBrowserStoreState& state) { + AgentBrowserSessionStorePreparation preparation; + if (!valid_agent_browser_store_session_id(session_id)) return preparation; + preparation.wipe_first = contains(state.pending_wipe, session_id); + const bool registered = contains(state.session_stores, session_id); + preparation.register_store = !registered; + if (registered) return preparation; + // 只给全新的存储复制:已经用过的存储(哪怕刚被清空)不再从父会话补 cookie。 + for (auto it = state.pending_seeds.rbegin(); it != state.pending_seeds.rend(); ++it) { + if (it->session_id != session_id) continue; + const std::string& parent = it->from; + const bool parent_usable = contains(state.session_stores, parent) && + !contains(state.pending_wipe, parent) && + !contains(state.pending_deletions, parent); + if (parent_usable) preparation.seed_from = parent; + break; + } + return preparation; +} + +void apply_agent_browser_session_store_preparation( + const std::string& session_id, + AgentBrowserStoreState& state) { + if (!valid_agent_browser_store_session_id(session_id)) return; + if (!contains(state.session_stores, session_id)) { + state.session_stores.push_back(session_id); + } + erase_value(state.pending_wipe, session_id); + state.pending_seeds.erase( + std::remove_if(state.pending_seeds.begin(), state.pending_seeds.end(), + [&session_id](const AgentBrowserStoreSeed& seed) { + return seed.session_id == session_id; + }), + state.pending_seeds.end()); +} + +} // namespace acecode::desktop diff --git a/src/apps/desktop/agent_browser_cookie_jars.hpp b/src/apps/desktop/agent_browser_cookie_jars.hpp new file mode 100644 index 000000000..747bda61b --- /dev/null +++ b/src/apps/desktop/agent_browser_cookie_jars.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include "agent_browser_page_directory.hpp" +#include "ipc/agent_browser_store_state.hpp" + +#include +#include +#include + +namespace acecode::desktop { + +// Agent Browser「保留 Cookie」三档(复刻 Claude 桌面版 launchPreviewStorage): +// UntilQuit —— cookie 只活到 Desktop 退出(Claude: none / Until quit) +// Shared —— 所有会话共用一份长期存储(Claude: shared),本项目默认 +// PerSession —— 每个根会话一份长期存储,子代理归根会话(Claude: session) +// 这里只有纯逻辑:Windows / macOS 宿主各自把存储键映射到 WebView2 Profile / +// WKWebsiteDataStore。设计见 openspec/changes/add-agent-browser-cookie-modes/design.md。 +enum class AgentBrowserCookieMode { + UntilQuit, + Shared, + PerSession, +}; + +inline constexpr const char kAgentBrowserCookieModeUntilQuit[] = "until_quit"; +inline constexpr const char kAgentBrowserCookieModeShared[] = "shared"; +inline constexpr const char kAgentBrowserCookieModePerSession[] = "per_session"; + +const char* agent_browser_cookie_mode_name(AgentBrowserCookieMode mode); +// 只认三个规范名;其它返回空。 +std::optional agent_browser_cookie_mode_from_name( + const std::string& name); +// 状态文件里的原文 → 生效档位:空(从未选过)与未知值一律按 Shared。 +AgentBrowserCookieMode effective_agent_browser_cookie_mode(const std::string& stored); + +enum class AgentBrowserCookieStoreKind { + Shared, + UntilQuit, + Session, +}; + +struct AgentBrowserCookieStoreKey { + AgentBrowserCookieStoreKind kind = AgentBrowserCookieStoreKind::Shared; + std::string session_id; // 仅 Session 有值:根会话 id + + // 稳定的字符串形态:"shared" / "until_quit" / "session:"。 + std::string id() const; + bool operator==(const AgentBrowserCookieStoreKey& other) const { + return kind == other.kind && session_id == other.session_id; + } + bool operator!=(const AgentBrowserCookieStoreKey& other) const { + return !(*this == other); + } +}; + +// 页面归属到哪一份存储的会话:根会话优先(子代理归父会话),没有就是自己; +// 空 owner 返回空串。 +std::string agent_browser_cookie_store_session(const AgentBrowserPageOwner& owner); + +// 当前档位下某个 owner 的页面用哪份存储。只有 PerSession 档才可能返回 +// Session 键;owner 为空(旧 daemon、未绑定页面)或会话 id 非法时退回 Shared。 +AgentBrowserCookieStoreKey agent_browser_cookie_store_key( + AgentBrowserCookieMode mode, + const AgentBrowserPageOwner& owner); + +// ---- Windows:存储键 → WebView2 Profile ---- +inline constexpr const char kAgentBrowserUntilQuitProfileName[] = "acecode-until-quit"; + +struct AgentBrowserWindowsProfile { + std::string name; // 空 = WebView2 默认 Profile(Shared 沿用升级前的那份) + bool in_private = false; +}; +AgentBrowserWindowsProfile agent_browser_windows_profile( + const AgentBrowserCookieStoreKey& key); +// "s-" + SHA-256(会话 id) 前 24 个十六进制字符。WebView2 Profile 名最多 64 字符、 +// 只允许字母数字与少数符号、大小写不敏感,所以不能直接用会话 id。 +std::string agent_browser_session_profile_name(const std::string& session_id); +// 命名 Profile 在磁盘上的目录:/EBWebView/WV2Profile_ +// (WebView2 运行时的布局,2026-10-21 冒烟实测;默认 Profile 是 EBWebView/Default)。 +std::string agent_browser_session_profile_directory_name(const std::string& session_id); + +// ---- macOS:按会话存储的 dataStoreForIdentifier UUID ---- +// 由会话 id 确定性派生(SHA-256 前 16 字节,置 RFC 9562 版本 8 与变体位), +// 形如 "XXXXXXXX-XXXX-8XXX-YXXX-XXXXXXXXXXXX"(大写)。 +std::string agent_browser_session_store_uuid(const std::string& session_id); + +// ---- 切换档位 ---- +struct AgentBrowserCookieModeSwitch { + bool changed = false; + // 切到 UntilQuit:共享存储立即清空(清空完成前在状态里挂着 shared_wipe_pending)。 + bool clear_shared_now = false; + // 切到 UntilQuit:所有按会话存储打「待清空」标记,不逐个开页面去清。 + bool mark_session_stores_for_wipe = false; + // 离开 UntilQuit:释放「退出即清」存储(及其保活页)。 + bool release_until_quit_store = false; +}; +AgentBrowserCookieModeSwitch plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode from, + AgentBrowserCookieMode to); + +struct AgentBrowserPageStoreBinding { + std::string page_id; + AgentBrowserPageOwner owner; + AgentBrowserCookieStoreKey store; // 页面当前实际所在的存储 +}; +// 切到 new_mode 后存储键会变的页面(按传入顺序)。 +std::vector agent_browser_pages_to_rebuild( + const std::vector& pages, + AgentBrowserCookieMode new_mode); +// 归属某个根会话的页面(永久删除会话时关闭它们)。 +std::vector agent_browser_pages_of_root_session( + const std::vector& pages, + const std::string& root_session_id); + +// ---- 按会话存储第一次被用到时 ---- +struct AgentBrowserSessionStorePreparation { + bool wipe_first = false; // 在待清空清单里:先清空再用 + std::string seed_from; // 非空:从这个父会话的存储复制 cookie + bool register_store = false; // 还不在 session_stores 里:用过之后登记 +}; +AgentBrowserSessionStorePreparation prepare_agent_browser_session_store( + const std::string& session_id, + const AgentBrowserStoreState& state); +// 把一次准备的结果写回状态:登记存储、移出待清空、消费分叉记录。 +void apply_agent_browser_session_store_preparation( + const std::string& session_id, + AgentBrowserStoreState& state); + +} // namespace acecode::desktop diff --git a/src/apps/desktop/agent_browser_host.cpp b/src/apps/desktop/agent_browser_host.cpp index c4ada8abb..9d508f2e9 100644 --- a/src/apps/desktop/agent_browser_host.cpp +++ b/src/apps/desktop/agent_browser_host.cpp @@ -1,7 +1,9 @@ #include "agent_browser_host.hpp" +#include "agent_browser_cookie_jars.hpp" #include "agent_browser_navigation_state.hpp" #include "ipc/agent_browser_runtime.hpp" +#include "ipc/agent_browser_store_state.hpp" #include "window_chrome.hpp" #include "platform/process/os_process.hpp" @@ -22,6 +24,7 @@ #include #include "utils/joining_thread.hpp" #include +#include #include #include @@ -42,616 +45,14 @@ # include #endif -namespace acecode::desktop { -namespace { - -std::int64_t now_unix_ms() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -void assign_error(std::string* target, const std::string& value) { - if (target) *target = value; -} - -std::string agent_browser_title_or_default(std::string title) { - return title.find_first_not_of(" \t\r\n") == std::string::npos - ? std::string(kAgentBrowserDefaultTitle) - : std::move(title); -} +#include "agent_browser_windows_helpers.hpp" +#include "agent_browser_host_common.hpp" +namespace acecode::desktop { +using namespace agent_browser_common; #ifdef _WIN32 -constexpr std::size_t kAgentBrowserMaxConsoleEntries = 1000; -constexpr std::size_t kAgentBrowserMaxConsoleEntryBytes = 16 * 1024; -constexpr std::size_t kAgentBrowserMaxFaviconBytes = 256 * 1024; -constexpr char kAgentBrowserElementPickerKey[] = - "__acecodeAgentBrowserElementPickerV1"; - -bool valid_agent_browser_favicon(const std::string& value) { - if (value.empty() || value.size() > kAgentBrowserMaxFaviconBytes) { - return false; - } - return value.rfind("https://", 0) == 0 || - value.rfind("http://", 0) == 0 || - value.rfind("data:image/", 0) == 0; -} - -const char* agent_browser_favicon_expression() { - return R"JS((async () => { - const maxDataUrlLength = 256 * 1024; - const links = [...document.querySelectorAll('link[rel][href]')]; - const icon = links.find((link) => String(link.rel || '').toLowerCase().split(/\s+/).includes('icon')); - let href = icon?.href || ''; - if (!href && (location.protocol === 'http:' || location.protocol === 'https:')) { - href = new URL('/favicon.ico', location.href).href; - } - if (!href) return ''; - try { - const parsed = new URL(href, location.href); - if (!['http:', 'https:', 'data:'].includes(parsed.protocol)) return ''; - href = parsed.href; - } catch (_) { - return ''; - } - if (href.startsWith('data:')) { - return href.startsWith('data:image/') && href.length <= maxDataUrlLength ? href : ''; - } - if (new URL(href).origin !== location.origin) { - return href.length <= 4096 ? href : ''; - } - try { - const response = await fetch(href, { credentials: 'include', cache: 'force-cache' }); - if (!response.ok) return href.length <= 4096 ? href : ''; - const declaredLength = Number(response.headers.get('content-length') || 0); - if (declaredLength > 128 * 1024) return href.length <= 4096 ? href : ''; - const blob = await response.blob(); - if (!String(blob.type || '').toLowerCase().startsWith('image/') || blob.size > 128 * 1024) { - return href.length <= 4096 ? href : ''; - } - const dataUrl = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); - reader.onerror = () => resolve(''); - reader.readAsDataURL(blob); - }); - return dataUrl.length <= maxDataUrlLength ? dataUrl : (href.length <= 4096 ? href : ''); - } catch (_) { - return href.length <= 4096 ? href : ''; - } -})())JS"; -} - -// 可以交给操作系统打开的外部 scheme。http/https/file/about 由 WebView 自己承载; -// javascript/data/blob 不代表外部应用,edge/devtools 是引擎内部页面。 -bool agent_browser_external_handoff_candidate(const std::string& uri) { - const auto colon = uri.find(':'); - if (colon == std::string::npos || colon == 0) return false; - std::string scheme = uri.substr(0, colon); - std::transform(scheme.begin(), scheme.end(), scheme.begin(), - [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - if (std::isalpha(static_cast(scheme.front())) == 0) { - return false; - } - static const char* const blocked[] = { - "http", "https", "file", "about", "javascript", "data", "blob", - "edge", "devtools", "ws", "wss", - }; - for (const char* name : blocked) { - if (scheme == name) return false; - } - return true; -} - -const char* agent_browser_web_error_kind( - COREWEBVIEW2_WEB_ERROR_STATUS status) { - switch (status) { - case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_COMMON_NAME_IS_INCORRECT: - case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_EXPIRED: - case COREWEBVIEW2_WEB_ERROR_STATUS_CLIENT_CERTIFICATE_CONTAINS_ERRORS: - case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_REVOKED: - case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_IS_INVALID: - return "certificate"; - case COREWEBVIEW2_WEB_ERROR_STATUS_SERVER_UNREACHABLE: - return "server_unreachable"; - case COREWEBVIEW2_WEB_ERROR_STATUS_TIMEOUT: - return "timeout"; - case COREWEBVIEW2_WEB_ERROR_STATUS_ERROR_HTTP_INVALID_SERVER_RESPONSE: - return "invalid_response"; - case COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED: - return "connection_aborted"; - case COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET: - return "connection_reset"; - case COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED: - return "disconnected"; - case COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT: - return "cannot_connect"; - case COREWEBVIEW2_WEB_ERROR_STATUS_HOST_NAME_NOT_RESOLVED: - return "name_not_resolved"; - case COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED: - return "cancelled"; - case COREWEBVIEW2_WEB_ERROR_STATUS_REDIRECT_FAILED: - return "redirect_failed"; - case COREWEBVIEW2_WEB_ERROR_STATUS_VALID_AUTHENTICATION_CREDENTIALS_REQUIRED: - return "authentication_required"; - case COREWEBVIEW2_WEB_ERROR_STATUS_VALID_PROXY_AUTHENTICATION_REQUIRED: - return "proxy_authentication_required"; - case COREWEBVIEW2_WEB_ERROR_STATUS_UNEXPECTED_ERROR: - case COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN: - default: - return "unexpected"; - } -} - -const char* agent_browser_process_failure_kind( - COREWEBVIEW2_PROCESS_FAILED_KIND kind, - COREWEBVIEW2_PROCESS_FAILED_REASON reason) { - const bool top_level_failure = - kind == COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED || - kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED || - kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE; - if (!top_level_failure) return ""; - if (reason == COREWEBVIEW2_PROCESS_FAILED_REASON_OUT_OF_MEMORY) { - return "out_of_memory"; - } - if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE || - reason == COREWEBVIEW2_PROCESS_FAILED_REASON_UNRESPONSIVE) { - return "unresponsive"; - } - if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED) { - return "browser_process_exited"; - } - if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED) { - return "render_process_exited"; - } - return ""; -} - -std::string clip_agent_browser_text(std::string value, - std::size_t max_bytes) { - if (value.size() <= max_bytes) return value; - value.resize(max_bytes); - value += "\n[truncated]"; - return value; -} - -std::string cdp_remote_object_text(const nlohmann::json& object) { - if (!object.is_object()) return object.dump(); - if (object.contains("value") && !object["value"].is_null()) { - return object["value"].is_string() - ? object["value"].get() - : object["value"].dump(); - } - if (object.contains("unserializableValue") && - object["unserializableValue"].is_string()) { - return object["unserializableValue"].get(); - } - if (object.contains("description") && object["description"].is_string()) { - return object["description"].get(); - } - return object.value("type", std::string("value")); -} - -const char* agent_browser_element_picker_expression() { - return R"JS((() => { - const key = '__acecodeAgentBrowserElementPickerV1'; - const previous = globalThis[key]; - if (previous && typeof previous.cancel === 'function') previous.cancel(); - return new Promise((resolve) => { - const root = document.documentElement || document.body; - if (!root) { resolve({ cancelled: true }); return; } - let finished = false; - let commitPending = false; - let dragStart = null; - let dragTarget = null; - let highlighted = null; - const host = document.createElement('div'); - host.setAttribute('data-acecode-element-picker', ''); - host.style.cssText = 'position:fixed;inset:0;width:0;height:0;z-index:2147483647;pointer-events:none'; - const shadow = host.attachShadow({ mode: 'closed' }); - const styleNode = document.createElement('style'); - styleNode.textContent = ` - .box{display:none;position:fixed;box-sizing:border-box;border:2px solid #0e70c0;background:rgba(14,112,192,.14);pointer-events:none;z-index:2} - .label{display:none;position:fixed;max-width:70vw;padding:3px 6px;border-radius:3px;background:#0e70c0;color:#fff;font:11px/16px "Segoe UI",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;z-index:3;box-shadow:0 2px 8px rgba(0,0,0,.22)} - .drag{display:none;position:fixed;box-sizing:border-box;border:1px dashed #0e70c0;background:rgba(14,112,192,.08);pointer-events:none;z-index:1} - `; - const box = document.createElement('div'); box.className = 'box'; - const label = document.createElement('div'); label.className = 'label'; - const drag = document.createElement('div'); drag.className = 'drag'; - shadow.append(styleNode, box, drag, label); - root.appendChild(host); - - const cursor = document.createElement('style'); - cursor.setAttribute('data-acecode-element-picker-cursor', ''); - cursor.textContent = '*{cursor:default!important}'; - (document.head || root).appendChild(cursor); - - const clip = (value, limit) => { - const text = String(value == null ? '' : value); - return text.length > limit ? `${text.slice(0, limit)}\n[truncated]` : text; - }; - const elementAt = (x, y) => { - const values = document.elementsFromPoint(x, y); - return values.find((value) => value !== host && !host.contains(value)); - }; - const commonAncestor = (values) => { - const unique = [...new Set(values.filter(Boolean))]; - if (!unique.length) return null; - let chain = []; - for (let node = unique[0]; node; node = node.parentElement) chain.unshift(node); - for (let i = 1; i < unique.length && chain.length; i += 1) { - const other = []; - for (let node = unique[i]; node; node = node.parentElement) other.unshift(node); - let index = 0; - while (index < chain.length && index < other.length && chain[index] === other[index]) index += 1; - chain = chain.slice(0, index); - } - return chain[chain.length - 1] || null; - }; - const regionTarget = (left, top, width, height) => { - const right = left + width; - const bottom = top + height; - const centerX = left + width / 2; - const centerY = top + height / 2; - return commonAncestor([ - elementAt(left, top), elementAt(right, top), elementAt(left, bottom), - elementAt(right, bottom), elementAt(centerX, top), elementAt(centerX, bottom), - elementAt(left, centerY), elementAt(right, centerY), elementAt(centerX, centerY), - ]); - }; - const selectorName = (element) => { - if (!element) return ''; - const tag = String(element.tagName || 'element').toLowerCase(); - const id = element.id ? `#${element.id}` : ''; - const classes = [...element.classList].slice(0, 4).map((name) => `.${name}`).join(''); - return `${tag}${id}${classes}`; - }; - const render = (element) => { - highlighted = element || null; - if (!element) { box.style.display = 'none'; label.style.display = 'none'; return; } - const rect = element.getBoundingClientRect(); - box.style.display = 'block'; - box.style.left = `${rect.left}px`; box.style.top = `${rect.top}px`; - box.style.width = `${Math.max(0, rect.width)}px`; box.style.height = `${Math.max(0, rect.height)}px`; - label.textContent = `${selectorName(element)} ${Math.round(rect.width)} × ${Math.round(rect.height)}`; - label.style.display = 'block'; - label.style.left = `${Math.max(2, Math.min(rect.left, innerWidth - 180))}px`; - label.style.top = `${Math.max(2, rect.top >= 24 ? rect.top - 22 : rect.bottom + 2)}px`; - }; - const collect = (element) => { - const rect = element.getBoundingClientRect(); - const attributes = {}; - [...element.attributes].slice(0, 100).forEach((attr) => { attributes[attr.name] = clip(attr.value, 2048); }); - const computed = getComputedStyle(element); - const computedStyles = {}; - const cssLines = []; - for (let i = 0; i < computed.length && i < 200; i += 1) { - const name = computed[i]; - const value = clip(computed.getPropertyValue(name), 512); - computedStyles[name] = value; - cssLines.push(`${name}: ${value};`); - } - const ancestors = []; - for (let node = element; node && ancestors.length < 16; node = node.parentElement) { - ancestors.unshift({ - tagName: String(node.tagName || '').toLowerCase(), - id: clip(node.id || '', 512), - classNames: [...node.classList].slice(0, 12).map((name) => clip(name, 256)), - }); - } - return { - url: location.href, - title: document.title, - name: selectorName(element), - tagName: String(element.tagName || '').toLowerCase(), - outerHTML: clip(element.outerHTML || '', 40000), - innerText: clip(element.innerText || element.textContent || '', 20000), - attributes, - computedStyle: clip(cssLines.join('\n'), 40000), - computedStyles, - ancestors, - bounds: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, - dimensions: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, - }; - }; - const listeners = [ - ['pointermove', onPointerMove], ['pointerdown', onPointerDown], - ['pointerup', onPointerUp], ['click', suppress], ['contextmenu', suppress], - ['keydown', onKeyDown], ['scroll', onViewportChange], ['resize', onViewportChange], - ]; - function cleanup() { - listeners.forEach(([name, handler]) => window.removeEventListener(name, handler, true)); - cursor.remove(); host.remove(); - try { delete globalThis[key]; } catch (_) { globalThis[key] = undefined; } - } - function finish(value) { - if (finished) return; - finished = true; cleanup(); resolve(value); - } - function suppress(event) { - event.preventDefault(); event.stopImmediatePropagation(); - } - function onPointerMove(event) { - suppress(event); - if (commitPending) return; - if (!dragStart) { render(elementAt(event.clientX, event.clientY)); return; } - const left = Math.min(dragStart.x, event.clientX); - const top = Math.min(dragStart.y, event.clientY); - const width = Math.abs(event.clientX - dragStart.x); - const height = Math.abs(event.clientY - dragStart.y); - if (width < 4 && height < 4) return; - drag.style.display = 'block'; drag.style.left = `${left}px`; drag.style.top = `${top}px`; - drag.style.width = `${width}px`; drag.style.height = `${height}px`; - render(regionTarget(left, top, width, height)); - } - function onPointerDown(event) { - if (commitPending) { suppress(event); return; } - if (event.button !== 0) { suppress(event); return; } - dragStart = { x: event.clientX, y: event.clientY }; - dragTarget = elementAt(event.clientX, event.clientY); - cursor.textContent = '*{cursor:crosshair!important}'; - suppress(event); - } - function onPointerUp(event) { - if (!dragStart) { suppress(event); return; } - const start = dragStart; dragStart = null; - const width = Math.abs(event.clientX - start.x); - const height = Math.abs(event.clientY - start.y); - const target = width < 4 && height < 4 - ? (dragTarget || elementAt(event.clientX, event.clientY)) - : regionTarget(Math.min(start.x, event.clientX), Math.min(start.y, event.clientY), width, height); - dragTarget = null; suppress(event); - if (target) { - const selection = { cancelled: false, element: collect(target) }; - commitPending = true; - requestAnimationFrame(() => finish(selection)); - } - } - function onKeyDown(event) { - if (event.key === 'Escape') { suppress(event); finish({ cancelled: true }); } - } - function onViewportChange() { if (highlighted) render(highlighted); } - listeners.forEach(([name, handler]) => window.addEventListener(name, handler, true)); - const controller = { cancel: () => finish({ cancelled: true }) }; - try { Object.defineProperty(globalThis, key, { configurable: true, value: controller }); } - catch (_) { globalThis[key] = controller; } - }); -})())JS"; -} - -constexpr wchar_t kAgentBrowserWidgetClassName[] = - L"ACECodeAgentBrowserWidget"; - -HWND create_agent_browser_widget(HWND parent) { - const HINSTANCE instance = ::GetModuleHandleW(nullptr); - WNDCLASSEXW window_class{}; - window_class.cbSize = sizeof(window_class); - window_class.hInstance = instance; - window_class.lpfnWndProc = ::DefWindowProcW; - window_class.lpszClassName = kAgentBrowserWidgetClassName; - window_class.hCursor = - ::LoadCursorW(nullptr, reinterpret_cast(IDC_ARROW)); - window_class.hbrBackground = - reinterpret_cast(COLOR_WINDOW + 1); - if (!::RegisterClassExW(&window_class) && - ::GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { - return nullptr; - } - - // Keep the parent HWND visible while WebView2 creates controllers. A - // hidden parent can leave a controller permanently unpainted on some - // WebView2 runtimes. The parked 1x1 child is outside the client area and - // therefore cannot cover the main ACECode WebView. - return ::CreateWindowExW( - WS_EX_CONTROLPARENT | WS_EX_NOPARENTNOTIFY, - kAgentBrowserWidgetClassName, - L"", - WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_VISIBLE, - -1, - -1, - 1, - 1, - parent, - nullptr, - instance, - nullptr); -} - -void park_agent_browser_widget(HWND widget) { - if (!widget || !::IsWindow(widget)) return; - ::SetWindowPos(widget, - HWND_TOP, - -1, - -1, - 1, - 1, - SWP_NOACTIVATE | SWP_SHOWWINDOW); -} - -void hide_agent_browser_widget(HWND widget) { - if (!widget || !::IsWindow(widget)) return; - ::SetWindowPos(widget, - nullptr, - 0, - 0, - 0, - 0, - SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_HIDEWINDOW); -} - -// 页面给的 bounds 是主 WebView 视口坐标,而 Agent Browser 子窗口挂在宿主窗口 -// 上。两者原点通常重合;Win10 非最大化时主 WebView 下移了 1px 给自绘顶边线 -// (web_host.cpp resize_webview_widget),这里要把偏移加回来,否则整块往上错位。 -POINT main_web_content_origin(HWND parent) { - POINT origin{0, 0}; - HWND widget = ::FindWindowExW(parent, nullptr, kWebViewWidgetClassName, nullptr); - RECT rect{}; - if (!widget || !::GetWindowRect(widget, &rect)) return origin; - origin.x = rect.left; - origin.y = rect.top; - ::MapWindowPoints(HWND_DESKTOP, parent, &origin, 1); - return origin; -} - -bool apply_agent_browser_widget_region( - HWND widget, - int width, - int height, - const std::vector& occlusions) { - if (!widget || !::IsWindow(widget)) return false; - if (occlusions.empty()) { - return ::SetWindowRgn(widget, nullptr, TRUE) != 0; - } - - HRGN visible_region = ::CreateRectRgn(0, 0, width, height); - if (!visible_region) return false; - for (const auto& occlusion : occlusions) { - const int left = (std::max)(0, (std::min)(width, occlusion.x)); - const int top = (std::max)(0, (std::min)(height, occlusion.y)); - const int right = (std::max)(left, (std::min)( - width, occlusion.x + occlusion.width)); - const int bottom = (std::max)(top, (std::min)( - height, occlusion.y + occlusion.height)); - if (right <= left || bottom <= top) continue; - - HRGN hole = ::CreateRectRgn(left, top, right, bottom); - if (!hole) { - ::DeleteObject(visible_region); - return false; - } - const int combine_result = - ::CombineRgn(visible_region, visible_region, hole, RGN_DIFF); - ::DeleteObject(hole); - if (combine_result == ERROR) { - ::DeleteObject(visible_region); - return false; - } - } - - // SetWindowRgn takes ownership only on success. - if (::SetWindowRgn(widget, visible_region, TRUE) == 0) { - ::DeleteObject(visible_region); - return false; - } - return true; -} - -std::string hresult_text(HRESULT result) { - return "HRESULT 0x" + [] (unsigned long value) { - constexpr char digits[] = "0123456789ABCDEF"; - std::string output(8, '0'); - for (int index = 7; index >= 0; --index) { - output[static_cast(index)] = digits[value & 0xF]; - value >>= 4; - } - return output; - }(static_cast(result)); -} - -bool proxy_aborted(const std::atomic& stopping, - std::chrono::steady_clock::time_point deadline) { - return stopping.load() || std::chrono::steady_clock::now() >= deadline; -} - -bool pipe_transfer(HANDLE pipe, - void* buffer, - std::size_t size, - bool write, - const std::atomic& stopping, - std::chrono::steady_clock::time_point deadline) { - std::size_t offset = 0; - while (offset < size && !proxy_aborted(stopping, deadline)) { - OVERLAPPED operation{}; - operation.hEvent = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (!operation.hEvent) return false; - const DWORD chunk = static_cast((std::min)( - size - offset, static_cast(1024u * 1024u))); - DWORD transferred = 0; - BOOL started = write - ? ::WriteFile(pipe, - static_cast(buffer) + offset, - chunk, &transferred, &operation) - : ::ReadFile(pipe, - static_cast(buffer) + offset, - chunk, &transferred, &operation); - DWORD operation_error = started ? ERROR_SUCCESS : ::GetLastError(); - bool pending_io = false; - if (!started && operation_error == ERROR_IO_PENDING) { - pending_io = true; - while (!proxy_aborted(stopping, deadline)) { - const DWORD wait = ::WaitForSingleObject(operation.hEvent, 50); - if (wait == WAIT_OBJECT_0) { - started = ::GetOverlappedResult( - pipe, &operation, &transferred, FALSE); - operation_error = started ? ERROR_SUCCESS : ::GetLastError(); - break; - } - if (wait == WAIT_FAILED) { - operation_error = ::GetLastError(); - break; - } - } - } - if (!started || proxy_aborted(stopping, deadline)) { - if (pending_io) { - ::CancelIoEx(pipe, &operation); - ::WaitForSingleObject(operation.hEvent, INFINITE); - } - ::CloseHandle(operation.hEvent); - if (!stopping.load() && - std::chrono::steady_clock::now() < deadline) { - LOG_WARN( - std::string("[agent-browser] proxy pipe ") + - (write ? "write" : "read") + " failed (Windows error " + - std::to_string(operation_error) + ")"); - } - return false; - } - ::CloseHandle(operation.hEvent); - if (transferred == 0) return false; - offset += transferred; - } - return offset == size; -} - -bool connect_pipe(HANDLE pipe, - const std::atomic& stopping) { - OVERLAPPED operation{}; - operation.hEvent = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (!operation.hEvent) return false; - BOOL connected = ::ConnectNamedPipe(pipe, &operation); - const DWORD error = connected ? ERROR_SUCCESS : ::GetLastError(); - bool pending_io = false; - if (!connected && error == ERROR_PIPE_CONNECTED) connected = TRUE; - if (!connected && error == ERROR_IO_PENDING) { - pending_io = true; - while (!stopping.load()) { - const DWORD wait = ::WaitForSingleObject(operation.hEvent, 50); - if (wait == WAIT_OBJECT_0) { - DWORD transferred = 0; - connected = ::GetOverlappedResult( - pipe, &operation, &transferred, FALSE); - break; - } - if (wait == WAIT_FAILED) break; - } - } - if (!connected) { - if (pending_io) { - ::CancelIoEx(pipe, &operation); - ::WaitForSingleObject(operation.hEvent, INFINITE); - } - } - ::CloseHandle(operation.hEvent); - return connected != FALSE && !stopping.load(); -} +using namespace agent_browser_windows; #endif - -} // namespace - struct AgentBrowserHost::Impl : public std::enable_shared_from_this { struct PendingProxyCall { @@ -667,9 +68,12 @@ struct AgentBrowserHost::Impl std::string acecode_dir; StateHandler state_handler; DispatchHandler dispatch_handler; + StorageEventHandler storage_handler; mutable std::mutex state_mutex; AgentBrowserState host_state; bool parent_surface_visible = true; + // 「保留 Cookie」档位;启动时从状态文件读取,之后只在 UI 线程上改。 + AgentBrowserCookieMode cookie_mode = AgentBrowserCookieMode::Shared; #ifdef _WIN32 struct QueuedCdpCall { @@ -720,6 +124,15 @@ struct AgentBrowserHost::Impl kAgentBrowserContentStateEmpty; std::string favicon_before_navigation; std::vector queued_cdp; + // 当前 controller 所在的 cookie 存储(建 controller 时按档位与 owner 决定)。 + AgentBrowserCookieStoreKey store; + // 存储准备好(按会话存储的先清空 / 从父会话复制做完)之前,CDP 调用与 + // 地址栏导航都要排队,否则会落在还没准备好的存储上。 + bool store_prepared = false; + // 切档重建时记下的地址,新 controller 就绪后导航回去。 + std::string restore_url; + // 每次(重新)创建 controller 自增;迟到的旧 controller 回调按它丢弃。 + std::uint64_t controller_generation = 0; }; Microsoft::WRL::ComPtr environment; @@ -733,6 +146,17 @@ struct AgentBrowserHost::Impl std::string proxy_pipe_name; std::string proxy_auth_token; std::atomic proxy_stopping{false}; + // 退出即清档:InPrivate Profile 在最后一个使用者关闭后就会销毁,用一个隐藏 + // 页面把它保活到 Desktop 退出或切离该档(规格:关掉所有页面再打开仍保持登录)。 + std::shared_ptr until_quit_keepalive; + bool until_quit_keepalive_requested = false; + // 本次运行建过 controller 的存储(浏览器进程可能还占着它的目录)。 + std::unordered_set loaded_store_ids; + // 本次运行已经准备好的按会话存储,以及正在准备的(等待者回调)。 + std::unordered_set prepared_session_stores; + std::unordered_map> session_store_waiters; + // 启动时状态文件里还挂着「共享存储待清空」,environment 就绪后补清。 + bool startup_shared_wipe_pending = false; acecode::JoiningThread proxy_thread; #endif @@ -768,6 +192,8 @@ struct AgentBrowserHost::Impl directory.clear(); } for (const auto& page : remaining) teardown_page(page); + until_quit_keepalive_requested = false; + until_quit_keepalive.reset(); environment.Reset(); if (browser_widget && ::IsWindow(browser_widget)) { ::DestroyWindow(browser_widget); @@ -781,6 +207,13 @@ struct AgentBrowserHost::Impl if (state_handler) state_handler(state); } + void emit_storage_event(AgentBrowserStorageEvent event) const { + if (event.cookie_mode.empty()) { + event.cookie_mode = agent_browser_cookie_mode_name(cookie_mode); + } + if (storage_handler) storage_handler(event); + } + AgentBrowserState state(const std::string& requested_page = {}) const { std::lock_guard lock(state_mutex); #ifdef _WIN32 @@ -972,6 +405,7 @@ struct AgentBrowserHost::Impl fail("failed to create Agent Browser profile: " + ec.message()); return; } + run_startup_store_tasks(user_data_path); auto options = Microsoft::WRL::Make(); const HRESULT browser_arguments_result = options->put_AdditionalBrowserArguments( @@ -999,6 +433,48 @@ struct AgentBrowserHost::Impl } } + // WebView2 把命名 Profile 放在 /EBWebView/WV2Profile_。 + // 目录名由会话 id 的哈希派生,不会越出用户数据目录。 + // 建 environment 之前:此时 Desktop 是单实例、还没有浏览器进程占用这些目录, + // 待清空与待删除的按会话存储直接删目录(比逐个开隐藏页面清空快得多)。 + void run_startup_store_tasks(const std::filesystem::path& user_data_path) { + const AgentBrowserStoreState state = read_agent_browser_store_state(); + cookie_mode = effective_agent_browser_cookie_mode(state.cookie_mode); + startup_shared_wipe_pending = state.shared_wipe_pending; + std::vector removed; + for (const auto* list : {&state.pending_wipe, &state.pending_deletions}) { + for (const auto& session_id : *list) { + if (std::find(removed.begin(), removed.end(), session_id) != removed.end()) { + continue; + } + if (remove_session_profile_directory(user_data_path, session_id)) { + removed.push_back(session_id); + } + } + } + if (!removed.empty()) { + const auto drop = [&removed](std::vector& values) { + values.erase( + std::remove_if(values.begin(), values.end(), + [&removed](const std::string& value) { + return std::find(removed.begin(), removed.end(), + value) != removed.end(); + }), + values.end()); + }; + update_agent_browser_store_state([&drop](AgentBrowserStoreState& current) { + drop(current.pending_wipe); + drop(current.pending_deletions); + drop(current.session_stores); + return true; + }); + LOG_INFO("[agent-browser] removed " + std::to_string(removed.size()) + + " session cookie store(s) at startup"); + } + LOG_INFO(std::string("[agent-browser] cookie mode: ") + + agent_browser_cookie_mode_name(cookie_mode)); + } + bool publish_proxy() { if (!dispatch_handler) { fail("Agent Browser UI dispatcher is unavailable"); @@ -1043,6 +519,10 @@ struct AgentBrowserHost::Impl state.ready = true; state.error.clear(); }); + if (startup_shared_wipe_pending) { + startup_shared_wipe_pending = false; + clear_shared_store_and_mark_done(); + } std::vector> pending_pages; { std::lock_guard lock(state_mutex); @@ -1086,31 +566,77 @@ struct AgentBrowserHost::Impl return page->id; } + // 按存储键建 controller。共享存储沿用无选项的默认 Profile(升级前的登录数据 + // 就在那里);其它存储走 ControllerOptions 选命名 Profile / InPrivate。 void begin_create_controller(const std::shared_ptr& page) { if (!page || page->creation_started || page->closing || !environment) return; page->creation_started = true; + page->store_prepared = false; + page->store = agent_browser_cookie_store_key(cookie_mode, page->state.owner); + const std::uint64_t generation = ++page->controller_generation; + if (page->store.kind == AgentBrowserCookieStoreKind::UntilQuit) { + ensure_until_quit_keepalive(); + } const auto weak = weak_from_this(); const std::string page_id = page->id; auto completed = Microsoft::WRL::Callback< ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>( - [weak, page_id](HRESULT result, - ICoreWebView2Controller* value) -> HRESULT { + [weak, page_id, generation](HRESULT result, + ICoreWebView2Controller* value) -> HRESULT { if (const auto self = weak.lock()) { - self->controller_created(page_id, result, value); + self->controller_created(page_id, generation, result, value); } else if (value) { value->Close(); } return S_OK; }); park_agent_browser_widget(browser_widget); - const HRESULT result = environment->CreateCoreWebView2Controller( - browser_widget, completed.Get()); + const HRESULT result = create_controller_for_store(environment, browser_widget, page->store, completed.Get()); if (FAILED(result)) { page_failed(page, "failed to create Agent Browser controller (" + hresult_text(result) + ")"); } } + void ensure_until_quit_keepalive() { + if (until_quit_keepalive || until_quit_keepalive_requested || !environment) return; + until_quit_keepalive_requested = true; + AgentBrowserCookieStoreKey key; + key.kind = AgentBrowserCookieStoreKind::UntilQuit; + const auto weak = weak_from_this(); + auto completed = Microsoft::WRL::Callback< + ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>( + [weak](HRESULT result, ICoreWebView2Controller* value) -> HRESULT { + const auto self = weak.lock(); + if (!self || !self->until_quit_keepalive_requested || + FAILED(result) || !value) { + if (value) value->Close(); + if (self && FAILED(result)) { + self->until_quit_keepalive_requested = false; + LOG_WARN("[agent-browser] until-quit keepalive failed (" + + hresult_text(result) + ")"); + } + return S_OK; + } + auto lease = std::make_shared(); + lease->controller = value; + lease->controller->put_IsVisible(FALSE); + self->until_quit_keepalive = std::move(lease); + return S_OK; + }); + const HRESULT result = create_controller_for_store(environment, browser_widget, key, completed.Get()); + if (FAILED(result)) { + until_quit_keepalive_requested = false; + LOG_WARN("[agent-browser] failed to start until-quit keepalive (" + + hresult_text(result) + ")"); + } + } + + void release_until_quit_keepalive() { + until_quit_keepalive_requested = false; + until_quit_keepalive.reset(); + } + void page_failed(const std::shared_ptr& page, const std::string& message) { LOG_ERROR("[agent-browser] " + page->id + ": " + message); @@ -1130,10 +656,11 @@ struct AgentBrowserHost::Impl } void controller_created(const std::string& page_id, + std::uint64_t generation, HRESULT result, ICoreWebView2Controller* value) { auto page = find_page(page_id); - if (!page || page->closing) { + if (!page || page->closing || page->controller_generation != generation) { if (value) value->Close(); return; } @@ -1181,6 +708,26 @@ struct AgentBrowserHost::Impl } install_events(page); install_console_capture(page); + const auto weak = weak_from_this(); + prepare_page_store(page, [weak, page_id, generation](bool ok, + const std::string& error) { + const auto self = weak.lock(); + if (!self) return; + const auto ready_page = self->find_page(page_id); + if (!ready_page || ready_page->closing || + ready_page->controller_generation != generation) { + return; + } + if (!ok) { + self->page_failed(ready_page, error); + return; + } + self->finish_page_ready(ready_page); + }); + } + + void finish_page_ready(const std::shared_ptr& page) { + page->store_prepared = true; apply_bounds(page); update_page(page, [](AgentBrowserState& state) { state.ready = true; @@ -1193,7 +740,201 @@ struct AgentBrowserHost::Impl // 排队的调用在入队时已经解析到这一页,显式 page_id 下 owner 不参与。 call_cdp_on_ui(page->id, {}, call.method, call.params, call.pending); } - LOG_INFO("[agent-browser] page ready: " + page->id); + if (!page->restore_url.empty() && page->webview) { + const std::string url = std::move(page->restore_url); + page->restore_url.clear(); + const HRESULT navigated = + page->webview->Navigate(acecode::utf8_to_wide(url).c_str()); + if (FAILED(navigated)) { + LOG_WARN("[agent-browser] failed to restore " + page->id + + " after cookie store change (" + hresult_text(navigated) + ")"); + } + } + LOG_INFO("[agent-browser] page ready: " + page->id + " (store " + + page->store.id() + ")"); + } + + // ---- cookie 存储:取 Profile、清空、复制、准备 ---- + + std::shared_ptr live_page_in_store(const AgentBrowserCookieStoreKey& key) const { + std::lock_guard lock(state_mutex); + for (const auto& [id, page] : pages) { + if (!page->closing && page->webview && page->store == key) return page; + } + return nullptr; + } + + std::vector page_store_bindings() const { + std::vector bindings; + std::lock_guard lock(state_mutex); + for (const std::string& id : directory.ordered_page_ids()) { + const auto found = pages.find(id); + if (found == pages.end() || found->second->closing) continue; + bindings.push_back({id, found->second->state.owner, found->second->store}); + } + return bindings; + } + + // 拿到某份存储的 Profile:优先借用该存储里活着的页面,否则建一个隐藏的临时 + // controller(lease 交给回调,异步操作完成前由回调持有)。 + void with_profile(const AgentBrowserCookieStoreKey& key, ProfileCallback callback) { + if (const auto page = live_page_in_store(key)) { + if (const auto profile = profile_of(page->webview)) { + callback(profile, {}, nullptr); + return; + } + } + if (!environment) { + callback(nullptr, "Agent Browser environment is not ready", nullptr); + return; + } + auto lease = std::make_shared(); + auto completed = Microsoft::WRL::Callback< + ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>( + [lease, callback](HRESULT result, ICoreWebView2Controller* value) -> HRESULT { + if (FAILED(result) || !value) { + callback(nullptr, + "failed to open Agent Browser cookie store (" + + hresult_text(result) + ")", + nullptr); + return S_OK; + } + lease->controller = value; + lease->controller->put_IsVisible(FALSE); + if (FAILED(lease->controller->get_CoreWebView2(&lease->webview)) || + !lease->webview) { + callback(nullptr, "failed to open Agent Browser cookie store", nullptr); + return S_OK; + } + const auto profile = profile_of(lease->webview); + callback(profile, + profile ? std::string() + : std::string("WebView2 runtime does not expose profiles"), + lease); + return S_OK; + }); + const HRESULT result = create_controller_for_store(environment, browser_widget, key, completed.Get()); + if (FAILED(result)) { + callback(nullptr, + "failed to open Agent Browser cookie store (" + hresult_text(result) + ")", + nullptr); + } + } + + void clear_store(const AgentBrowserCookieStoreKey& key, StoreDone done) { + with_profile(key, [done](const Microsoft::WRL::ComPtr& profile, + const std::string& error, + const std::shared_ptr& lease) { + if (!profile) { + done(false, error); + return; + } + clear_profile(profile, lease, done); + }); + } + + // 切到「退出即清」或启动补清:清空共享存储,成功后撤掉状态里的待清空标记。 + void clear_shared_store_and_mark_done() { + clear_store(AgentBrowserCookieStoreKey{}, [](bool ok, const std::string& error) { + if (!ok) { + LOG_WARN("[agent-browser] failed to clear shared cookie store: " + error); + return; + } + update_agent_browser_store_state([](AgentBrowserStoreState& state) { + if (!state.shared_wipe_pending) return false; + state.shared_wipe_pending = false; + return true; + }); + LOG_INFO("[agent-browser] shared cookie store cleared"); + }); + } + + // 分叉会话第一次建存储:把父会话存储里的全部 cookie 复制进来。复制失败不阻断 + // 页面(子会话只是从未登录开始),只记日志。 + void seed_store_cookies(const std::string& parent_session_id, + const Microsoft::WRL::ComPtr& target_webview, + const std::function& done) { + const auto target = cookie_manager_of(profile_of(target_webview)); + if (!target) { + LOG_WARN("[agent-browser] cannot seed forked cookie store: no cookie manager"); + done(); + return; + } + AgentBrowserCookieStoreKey parent; + parent.kind = AgentBrowserCookieStoreKind::Session; + parent.session_id = parent_session_id; + with_profile(parent, [target, done, parent_session_id]( + const Microsoft::WRL::ComPtr& profile, + const std::string& error, + const std::shared_ptr& lease) { + const auto source = cookie_manager_of(profile); + if (!source) { + LOG_WARN("[agent-browser] cannot read parent cookie store " + + parent_session_id + ": " + error); + done(); + return; + } + copy_profile_cookies(source, target, lease, parent_session_id, done); + }); + } + + // 页面的 controller 建好之后、对外可用之前:按会话存储第一次被用到时按状态文件 + // 先清空(待清空)/ 从父会话复制(分叉),再登记。同一存储并发建多页时只做一次。 + void prepare_page_store(const std::shared_ptr& page, StoreDone done) { + const AgentBrowserCookieStoreKey key = page->store; + loaded_store_ids.insert(key.id()); + if (key.kind != AgentBrowserCookieStoreKind::Session || + prepared_session_stores.count(key.session_id) != 0) { + done(true, {}); + return; + } + auto& waiters = session_store_waiters[key.session_id]; + waiters.push_back(std::move(done)); + if (waiters.size() > 1) return; + + const std::string session_id = key.session_id; + const AgentBrowserSessionStorePreparation preparation = + prepare_agent_browser_session_store(session_id, read_agent_browser_store_state()); + const auto weak = weak_from_this(); + const auto finish = [weak, session_id](bool ok, const std::string& error) { + const auto self = weak.lock(); + if (!self) return; + if (ok) { + self->prepared_session_stores.insert(session_id); + if (!update_agent_browser_store_state([&session_id](AgentBrowserStoreState& state) { + apply_agent_browser_session_store_preparation(session_id, state); + return true; + })) { + LOG_WARN("[agent-browser] failed to record cookie store " + session_id); + } + } + auto pending = std::move(self->session_store_waiters[session_id]); + self->session_store_waiters.erase(session_id); + for (auto& waiter : pending) waiter(ok, error); + }; + const std::string seed_from = preparation.seed_from; + const auto webview = page->webview; + const auto seed_then_finish = [weak, seed_from, webview, finish]() { + const auto self = weak.lock(); + if (!self) return; + if (seed_from.empty()) { + finish(true, {}); + return; + } + self->seed_store_cookies(seed_from, webview, [finish] { finish(true, {}); }); + }; + if (!preparation.wipe_first) { + seed_then_finish(); + return; + } + clear_profile(profile_of(webview), nullptr, + [seed_then_finish, finish](bool ok, const std::string& error) { + if (!ok) { + finish(false, error); + return; + } + seed_then_finish(); + }); } void append_console_log(const std::shared_ptr& page, @@ -1987,6 +1728,220 @@ struct AgentBrowserHost::Impl return true; } + // 切档后页面换存储:关掉旧 controller,用同一个 page id、owner 与共享标记在新 + // 存储里重建,并导航回原地址。期间的 CDP 调用排队,就绪后照常执行。 + void rebuild_page_on_ui(const std::shared_ptr& page) { + if (!page || page->closing) return; + std::string url; + { + std::lock_guard lock(state_mutex); + url = page->state.url; + } + teardown_page(page); + page->creation_started = false; + page->store_prepared = false; + page->navigation = AgentBrowserNavigationTracker{}; + page->external_handoff_uri.clear(); + page->current_navigation_uri.clear(); + ++page->element_selection_generation; + ++page->favicon_generation; + page->restore_url = (url.empty() || url == "about:blank") ? std::string() : url; + const bool restoring = !page->restore_url.empty(); + update_page(page, [restoring](AgentBrowserState& state) { + state.ready = false; + state.loading = restoring; + state.visible = false; + state.can_go_back = false; + state.can_go_forward = false; + state.element_selection_active = false; + state.failure_kind.clear(); + state.error.clear(); + state.diagnostic.clear(); + state.content_state = restoring ? kAgentBrowserContentStateLoading + : kAgentBrowserContentStateEmpty; + }); + apply_bounds(page); + begin_create_controller(page); + } + + bool set_cookie_mode_on_ui(const std::string& name, std::string* error) { + const auto mode = agent_browser_cookie_mode_from_name(name); + if (!mode) { + assign_error(error, "unknown Agent Browser cookie mode: " + name); + return false; + } + const AgentBrowserCookieModeSwitch plan = + plan_agent_browser_cookie_mode_switch(cookie_mode, *mode); + // 档位相同也写一次:状态里可能是空(从未选过)或别的版本写入的未知值。 + const bool persisted = update_agent_browser_store_state( + [&mode, &plan](AgentBrowserStoreState& state) { + state.cookie_mode = agent_browser_cookie_mode_name(*mode); + if (plan.mark_session_stores_for_wipe) { + state.pending_wipe.insert(state.pending_wipe.end(), + state.session_stores.begin(), + state.session_stores.end()); + } + if (plan.clear_shared_now) state.shared_wipe_pending = true; + return true; + }); + if (!persisted) { + assign_error(error, "failed to save the Agent Browser cookie setting"); + return false; + } + if (!plan.changed) return true; + + const AgentBrowserCookieMode previous = cookie_mode; + cookie_mode = *mode; + if (plan.mark_session_stores_for_wipe) prepared_session_stores.clear(); + // 先把页面搬进新存储;清空走临时 controller,与页面互不影响。 + const auto rebuild = agent_browser_pages_to_rebuild(page_store_bindings(), cookie_mode); + for (const auto& page_id : rebuild) { + rebuild_page_on_ui(find_page(page_id)); + } + if (plan.clear_shared_now) clear_shared_store_and_mark_done(); + if (plan.release_until_quit_store) release_until_quit_keepalive(); + LOG_INFO(std::string("[agent-browser] cookie mode ") + + agent_browser_cookie_mode_name(previous) + " -> " + + agent_browser_cookie_mode_name(cookie_mode) + "; rebuilt " + + std::to_string(rebuild.size()) + " page(s)"); + AgentBrowserStorageEvent event; + event.type = "cookie_mode_changed"; + emit_storage_event(std::move(event)); + return true; + } + + // 清除该页当前所用存储的全部网站数据,完成后重新加载同一存储里的页面。 + bool clear_browsing_data_on_ui(const std::string& page_id, std::string* error) { + const auto page = find_page(page_id); + if (!page || page->closing) { + assign_error(error, "Agent Browser page was not found"); + return false; + } + if (!environment) { + assign_error(error, "Agent Browser is still starting"); + return false; + } + const AgentBrowserCookieStoreKey key = page->creation_started + ? page->store : agent_browser_cookie_store_key(cookie_mode, page->state.owner); + const auto weak = weak_from_this(); + clear_store(key, [weak, key, page_id](bool ok, const std::string& clear_error) { + const auto self = weak.lock(); + if (!self) return; + if (ok) { + std::vector> reload; + { + std::lock_guard lock(self->state_mutex); + for (const auto& [id, candidate] : self->pages) { + if (!candidate->closing && candidate->webview && + candidate->store == key) { + reload.push_back(candidate); + } + } + } + for (const auto& candidate : reload) candidate->webview->Reload(); + LOG_INFO("[agent-browser] browsing data cleared: " + key.id()); + } else { + LOG_WARN("[agent-browser] failed to clear browsing data for " + key.id() + + ": " + clear_error); + } + AgentBrowserStorageEvent event; + event.type = "browsing_data_cleared"; + event.ok = ok; + event.store = key.id(); + event.page_id = page_id; + event.error = clear_error; + self->emit_storage_event(std::move(event)); + }); + return true; + } + + // daemon 永久删除会话后:关闭该根会话的页面并删除它的按会话存储。本次运行没 + // 加载过的存储直接删目录;加载过的由 WebView2 删除 Profile(浏览器进程占着 + // 目录),状态里的记录留到下次启动删目录时再撤。 + void process_store_tasks_on_ui() { + const AgentBrowserStoreState state = read_agent_browser_store_state(); + if (state.pending_deletions.empty()) return; + const auto user_data_path = agent_browser_user_data_path(acecode_dir); + std::vector finished; + for (const auto& session_id : state.pending_deletions) { + for (const auto& page_id : + agent_browser_pages_of_root_session(page_store_bindings(), session_id)) { + close_page_on_ui(page_id, nullptr, nullptr); + } + prepared_session_stores.erase(session_id); + AgentBrowserCookieStoreKey key; + key.kind = AgentBrowserCookieStoreKind::Session; + key.session_id = session_id; + if (loaded_store_ids.erase(key.id()) != 0) { + delete_loaded_store(key); + continue; + } + if (remove_session_profile_directory(user_data_path, session_id)) { + finished.push_back(session_id); + } + } + if (!finished.empty()) { + update_agent_browser_store_state([&finished](AgentBrowserStoreState& current) { + const auto drop = [&finished](std::vector& values) { + values.erase( + std::remove_if(values.begin(), values.end(), + [&finished](const std::string& value) { + return std::find(finished.begin(), finished.end(), + value) != finished.end(); + }), + values.end()); + }; + drop(current.pending_deletions); + drop(current.session_stores); + drop(current.pending_wipe); + return true; + }); + } + for (const auto& session_id : finished) { + AgentBrowserStorageEvent event; + event.type = "session_store_deleted"; + event.store = "session:" + session_id; + emit_storage_event(std::move(event)); + } + } + + void delete_loaded_store(const AgentBrowserCookieStoreKey& key) { + const auto weak = weak_from_this(); + with_profile(key, [weak, key](const Microsoft::WRL::ComPtr& profile, + const std::string& error, + const std::shared_ptr& lease) { + const auto report = [weak, key](bool ok, const std::string& message) { + const auto self = weak.lock(); + if (!self) return; + AgentBrowserStorageEvent event; + event.type = "session_store_deleted"; + event.ok = ok; + event.store = key.id(); + event.error = message; + self->emit_storage_event(std::move(event)); + }; + if (!profile) { + LOG_WARN("[agent-browser] cannot open cookie store " + key.id() + + " for deletion: " + error); + report(false, error); + return; + } + Microsoft::WRL::ComPtr profile8; + if (SUCCEEDED(profile.As(&profile8)) && profile8 && + SUCCEEDED(profile8->Delete())) { + LOG_INFO("[agent-browser] deleted cookie store " + key.id()); + report(true, {}); + return; + } + // 老运行时没有 Profile 删除接口:先清空数据,目录留给下次启动删除。 + clear_profile(profile, lease, [key, report](bool ok, const std::string& message) { + LOG_INFO("[agent-browser] cleared deleted cookie store " + key.id() + + (ok ? std::string() : ": " + message)); + report(ok, message); + }); + }); + } + void apply_bounds(const std::shared_ptr& page) { if (!page) return; const int width = (std::max)(0, page->requested_bounds.width); @@ -2056,7 +2011,7 @@ struct AgentBrowserHost::Impl const std::string& input, std::string* error) { auto page = find_page(page_id); - if (!page || !page->webview) { + if (!page || !page->webview || !page->store_prepared) { assign_error(error, "Agent Browser page is still starting"); return false; } @@ -2242,7 +2197,7 @@ struct AgentBrowserHost::Impl return; } if (!require_agent_shared_page(page, page_id, pending)) return; - if (!page->webview) { + if (!page->webview || !page->store_prepared) { std::string host_error; { std::lock_guard lock(state_mutex); @@ -2311,6 +2266,12 @@ struct AgentBrowserHost::Impl const std::string requested_page = request.value("page_id", ""); const AgentBrowserPageOwner owner = parse_agent_browser_page_owner( request.contains("owner") ? request["owner"] : nlohmann::json()); + if (operation == "drain_store_tasks") { + // 提醒本身不带数据,要做的事都在状态文件里(daemon 写入的待删除会话)。 + process_store_tasks_on_ui(); + finish_proxy_call(pending, {{"ok", true}, {"result", nlohmann::json::object()}}); + return; + } if (operation == "create_page") { const std::string page_id = create_page_on_ui(true, owner, true); finish_proxy_call(pending, @@ -2417,7 +2378,8 @@ struct AgentBrowserHost::Impl const std::string operation = request.value("operation", "cdp"); if (operation != "cdp" && operation != "create_page" && operation != "claim_page" && - operation != "close_page" && operation != "select_page") { + operation != "close_page" && operation != "select_page" && + operation != "drain_store_tasks") { return {{"ok", false}, {"error", "Agent Browser proxy operation is invalid"}}; } @@ -2829,4 +2791,40 @@ void AgentBrowserHost::hide(const std::string& page_id) { #endif } +std::string AgentBrowserHost::cookie_mode() const { + return agent_browser_cookie_mode_name( + impl_ ? impl_->cookie_mode : AgentBrowserCookieMode::Shared); +} + +bool AgentBrowserHost::set_cookie_mode(const std::string& mode, std::string* error) { +#ifdef _WIN32 + return impl_ && impl_->set_cookie_mode_on_ui(mode, error); +#else + (void)mode; + assign_error(error, "Agent Browser is unavailable on this platform"); + return false; +#endif +} + +bool AgentBrowserHost::clear_browsing_data(const std::string& page_id, + std::string* error) { +#ifdef _WIN32 + return impl_ && impl_->clear_browsing_data_on_ui(page_id, error); +#else + (void)page_id; + assign_error(error, "Agent Browser is unavailable on this platform"); + return false; +#endif +} + +void AgentBrowserHost::process_store_tasks() { +#ifdef _WIN32 + if (impl_) impl_->process_store_tasks_on_ui(); +#endif +} + +void AgentBrowserHost::set_storage_event_handler(StorageEventHandler handler) { + if (impl_) impl_->storage_handler = std::move(handler); +} + } // namespace acecode::desktop diff --git a/src/apps/desktop/agent_browser_host.hpp b/src/apps/desktop/agent_browser_host.hpp index 0a4d5e4d0..8d3aa049e 100644 --- a/src/apps/desktop/agent_browser_host.hpp +++ b/src/apps/desktop/agent_browser_host.hpp @@ -69,9 +69,23 @@ struct AgentBrowserState { std::string selected_element_json; }; +// cookie 存储相关的异步结果(切档、清除浏览数据、删除会话存储)。清除 / 删除是 +// WebView2 / WebKit 的异步操作,同步 bridge 只能返回「已开始」,结果经它回推前端。 +struct AgentBrowserStorageEvent { + // "cookie_mode_changed" | "browsing_data_cleared" | "session_store_deleted" + std::string type; + bool ok = true; + std::string cookie_mode; // 事件发生时的档位名 + std::string store; // 存储键:shared / until_quit / session: + std::string page_id; // 由某一页触发时(清除浏览数据) + std::string error; +}; + // Native browser pages hosted in a dedicated child HWND above the ACECode main // WebView. Each page owns its own controller/history while all pages share one -// isolated WebView2 environment/Profile. +// isolated WebView2 environment. Pages use the WebView2 Profile chosen by the +// cookie storage mode (agent_browser_cookie_jars.hpp): the default Profile for +// shared, one InPrivate Profile for until_quit, one Profile per root session. // It intentionally exposes no host objects or web-message bridge to pages. // The daemon-side agent reaches this exact page through the authenticated // Desktop proxy published by agent_browser_runtime; no external CDP port is @@ -80,6 +94,7 @@ class AgentBrowserHost { public: using StateHandler = std::function; using DispatchHandler = std::function)>; + using StorageEventHandler = std::function; AgentBrowserHost(void* parent_window, std::int64_t desktop_pid, @@ -129,6 +144,18 @@ class AgentBrowserHost { void refresh_layout(); void hide(const std::string& page_id = {}); + // 「保留 Cookie」档位:"until_quit" / "shared" / "per_session"(没选过 = shared)。 + std::string cookie_mode() const; + // 切换档位并持久化;存储变了的已打开页面按原 page id 与 URL 在新存储里重建。 + // 切到 until_quit 会清空已保存的 cookie。未知档位名返回 false。 + bool set_cookie_mode(const std::string& mode, std::string* error = nullptr); + // 清除该页面当前所用存储的全部网站数据(异步,结果经存储事件回报)。 + bool clear_browsing_data(const std::string& page_id, + std::string* error = nullptr); + // 处理状态文件里待删除的会话存储(daemon 永久删除会话后经代理提醒)。 + void process_store_tasks(); + void set_storage_event_handler(StorageEventHandler handler); + private: struct Impl; std::shared_ptr impl_; diff --git a/src/apps/desktop/agent_browser_host_common.cpp b/src/apps/desktop/agent_browser_host_common.cpp new file mode 100644 index 000000000..4fe1a8511 --- /dev/null +++ b/src/apps/desktop/agent_browser_host_common.cpp @@ -0,0 +1,22 @@ +#include "agent_browser_host_common.hpp" +#include "agent_browser_host.hpp" +#include +#include +namespace acecode::desktop::agent_browser_common { +std::int64_t now_unix_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +void assign_error(std::string* target, const std::string& value) { + if (target) *target = value; +} + +std::string agent_browser_title_or_default(std::string title) { + return title.find_first_not_of(" \t\r\n") == std::string::npos + ? std::string(kAgentBrowserDefaultTitle) + : std::move(title); +} + +} // namespace acecode::desktop::agent_browser_common diff --git a/src/apps/desktop/agent_browser_host_common.hpp b/src/apps/desktop/agent_browser_host_common.hpp new file mode 100644 index 000000000..30795da6f --- /dev/null +++ b/src/apps/desktop/agent_browser_host_common.hpp @@ -0,0 +1,8 @@ +#pragma once +#include +#include +namespace acecode::desktop::agent_browser_common { +std::int64_t now_unix_ms(); +void assign_error(std::string* target, const std::string& value); +std::string agent_browser_title_or_default(std::string title); +} // namespace acecode::desktop::agent_browser_common diff --git a/src/apps/desktop/agent_browser_host_mac.mm b/src/apps/desktop/agent_browser_host_mac.mm index 0f58d0678..298dcb8cf 100644 --- a/src/apps/desktop/agent_browser_host_mac.mm +++ b/src/apps/desktop/agent_browser_host_mac.mm @@ -2,8 +2,11 @@ #ifdef __APPLE__ +#include "agent_browser_cookie_jars.hpp" +#include "agent_browser_macos_helpers.hpp" #include "agent_browser_navigation_state.hpp" #include "ipc/agent_browser_runtime.hpp" +#include "ipc/agent_browser_store_state.hpp" #include "platform/process/os_process.hpp" #include "utils/atomic_file.hpp" @@ -39,6 +42,7 @@ #include #include "utils/joining_thread.hpp" #include +#include #include #include #include @@ -46,439 +50,9 @@ #include namespace acecode::desktop { +using namespace agent_browser_macos; namespace { -using json = nlohmann::json; - -constexpr std::size_t kMaxConsoleEntries = 1000; -constexpr std::size_t kMaxConsoleEntryBytes = 16 * 1024; -constexpr std::size_t kMaxFaviconBytes = 256 * 1024; -constexpr std::size_t kMaxNativeErrorFieldBytes = 4 * 1024; -constexpr std::size_t kMaxNativeErrorDiagnosticBytes = 16 * 1024; -constexpr std::size_t kMaxUnderlyingErrorDepth = 4; -constexpr char kConsoleHandlerName[] = "__acecodeAgentBrowserConsoleV1"; -constexpr char kAutomationWorldName[] = "dev.acecode.agent-browser.automation"; -constexpr char kElementPickerKey[] = "__acecodeAgentBrowserElementPickerV1"; - -std::int64_t now_unix_ms() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -void assign_error(std::string* target, const std::string& value) { - if (target) *target = value; -} - -NSString* ns_string(const std::string& value, NSString* fallback = @"") { - if (value.empty()) return fallback; - NSString* converted = [[[NSString alloc] - initWithBytes:value.data() - length:value.size() - encoding:NSUTF8StringEncoding] autorelease]; - return converted ?: fallback; -} - -std::string utf8_string(id value) { - if (![value isKindOfClass:[NSString class]]) return {}; - const char* text = [(NSString*)value UTF8String]; - return text ? std::string(text) : std::string(); -} - -std::string clipped(std::string value, std::size_t max_bytes) { - if (value.size() <= max_bytes) return value; - value.resize(max_bytes); - value += "\n[truncated]"; - return value; -} - -std::string native_description(id value) { - if (!value) return {}; - NSString* description = [value isKindOfClass:[NSString class]] - ? static_cast(value) - : [value description]; - return clipped(utf8_string(description), kMaxNativeErrorFieldBytes); -} - -json native_error_details(NSError* error, std::size_t depth = 0) { - if (!error) { - return { - {"domain", "unknown"}, - {"code", 0}, - {"description", "WKWebView navigation failed"}, - }; - } - - json details{ - {"domain", native_description([error domain])}, - {"code", static_cast([error code])}, - {"description", native_description([error localizedDescription])}, - }; - const std::string failure_reason = - native_description([error localizedFailureReason]); - const std::string recovery_suggestion = - native_description([error localizedRecoverySuggestion]); - if (!failure_reason.empty()) details["failure_reason"] = failure_reason; - if (!recovery_suggestion.empty()) { - details["recovery_suggestion"] = recovery_suggestion; - } - - NSDictionary* user_info = [error userInfo]; - id failing_url = [user_info objectForKey:@"NSErrorFailingURLKey"]; - if (!failing_url) { - failing_url = [user_info objectForKey:@"NSErrorFailingURLStringKey"]; - } - const std::string failing_url_text = native_description(failing_url); - if (!failing_url_text.empty()) details["failing_url"] = failing_url_text; - - std::vector user_info_keys; - for (id key in user_info) { - const std::string key_text = native_description(key); - if (!key_text.empty()) user_info_keys.push_back(key_text); - } - std::sort(user_info_keys.begin(), user_info_keys.end()); - if (!user_info_keys.empty()) details["user_info_keys"] = user_info_keys; - details["peer_trust_present"] = - [user_info objectForKey:@"NSURLErrorFailingURLPeerTrustErrorKey"] != nil; - - id underlying = [user_info objectForKey:NSUnderlyingErrorKey]; - if (depth + 1 < kMaxUnderlyingErrorDepth && - [underlying isKindOfClass:[NSError class]] && underlying != error) { - details["underlying"] = native_error_details( - static_cast(underlying), depth + 1); - } - return details; -} - -void append_native_error_diagnostic(std::ostringstream& output, - const json& details, - std::size_t depth = 0) { - output << (depth == 0 ? "NSError" : "Underlying NSError") - << " domain=" << details.value("domain", "unknown") - << " code=" << details.value("code", 0LL) << '\n'; - output << "Description: " - << details.value("description", "WKWebView navigation failed") - << '\n'; - for (const auto* field : {"failing_url", "failure_reason", - "recovery_suggestion"}) { - const auto found = details.find(field); - if (found == details.end() || !found->is_string() || found->empty()) { - continue; - } - if (std::strcmp(field, "failing_url") == 0) output << "Failing URL: "; - else if (std::strcmp(field, "failure_reason") == 0) { - output << "Failure reason: "; - } else { - output << "Recovery suggestion: "; - } - output << found->get() << '\n'; - } - const auto keys = details.find("user_info_keys"); - if (keys != details.end() && keys->is_array() && !keys->empty()) { - output << "User info keys: "; - bool first = true; - for (const auto& key : *keys) { - if (!key.is_string()) continue; - if (!first) output << ", "; - output << key.get(); - first = false; - } - output << '\n'; - } - if (details.value("peer_trust_present", false)) { - output << "Peer trust: present\n"; - } - const auto underlying = details.find("underlying"); - if (underlying != details.end() && underlying->is_object()) { - append_native_error_diagnostic(output, *underlying, depth + 1); - } -} - -std::string native_error_diagnostic(NSError* error) { - std::ostringstream output; - append_native_error_diagnostic(output, native_error_details(error)); - return clipped(output.str(), kMaxNativeErrorDiagnosticBytes); -} - -NSError* url_loading_error(NSError* error) { - NSError* current = error; - for (std::size_t depth = 0; - current && depth < kMaxUnderlyingErrorDepth; - ++depth) { - NSString* domain = [current domain]; - if ([domain isEqualToString:NSURLErrorDomain] || - [domain isEqualToString:@"kCFErrorDomainCFNetwork"]) { - return current; - } - id underlying = [[current userInfo] objectForKey:NSUnderlyingErrorKey]; - current = [underlying isKindOfClass:[NSError class]] - ? static_cast(underlying) : nil; - } - return error; -} - -bool valid_favicon(const std::string& value) { - if (value.empty() || value.size() > kMaxFaviconBytes) return false; - return value.rfind("https://", 0) == 0 || - value.rfind("http://", 0) == 0 || - value.rfind("data:image/", 0) == 0; -} - -NSString* safari_compatible_application_name() { - NSString* safari_version = nil; - NSURL* safari_url = [[NSWorkspace sharedWorkspace] - URLForApplicationWithBundleIdentifier:@"com.apple.Safari"]; - if (safari_url) { - NSBundle* safari_bundle = [NSBundle bundleWithURL:safari_url]; - id version = [safari_bundle - objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; - if ([version isKindOfClass:[NSString class]] && - [(NSString*)version length] > 0) { - NSCharacterSet* invalid = [[NSCharacterSet - characterSetWithCharactersInString:@"0123456789."] - invertedSet]; - if ([(NSString*)version rangeOfCharacterFromSet:invalid].location == - NSNotFound) { - safari_version = (NSString*)version; - } - } - } - - // WKWebView uses the system WebKit engine but omits Safari product tokens - // from its default user agent. Some sites consequently serve legacy - // downgrade pages that can loop under WebKit's HTTPS upgrade policy. - // Preserve WebKit's generated platform/engine fields and append the local - // Safari identity through the public configuration API. - return safari_version - ? [NSString stringWithFormat:@"Version/%@ Safari/605.1.15", - safari_version] - : @"Safari/605.1.15"; -} - -const char* favicon_expression() { - return R"JS((() => { - const links = [...document.querySelectorAll('link[rel][href]')]; - const icon = links.find((link) => String(link.rel || '').toLowerCase() - .split(/\s+/).includes('icon')); - let href = icon?.href || ''; - if (!href && (location.protocol === 'http:' || location.protocol === 'https:')) { - href = new URL('/favicon.ico', location.href).href; - } - if (!href) return ''; - try { - const parsed = new URL(href, location.href); - return ['http:', 'https:', 'data:'].includes(parsed.protocol) - ? parsed.href.slice(0, 262144) : ''; - } catch (_) { return ''; } -})())JS"; -} - -const char* console_capture_script() { - return R"JS((() => { - if (globalThis.__acecodeConsoleCaptureV1) return; - globalThis.__acecodeConsoleCaptureV1 = true; - const post = (level, values) => { - try { - const text = values.map((value) => { - if (typeof value === 'string') return value; - if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`; - try { return JSON.stringify(value); } catch (_) { return String(value); } - }).join(' ').slice(0, 16384); - globalThis.webkit?.messageHandlers?.__acecodeAgentBrowserConsoleV1 - ?.postMessage({ level, text, timestamp: Date.now() }); - } catch (_) {} - }; - for (const level of ['debug', 'info', 'log', 'warn', 'error']) { - const original = console[level]; - console[level] = function(...values) { - post(level, values); - return original.apply(this, values); - }; - } - addEventListener('error', (event) => post('error', [event.error || event.message])); - addEventListener('unhandledrejection', (event) => post('error', [event.reason])); -})())JS"; -} - -const char* element_picker_expression() { - return R"JS((() => { - const key = '__acecodeAgentBrowserElementPickerV1'; - const previous = globalThis[key]; - if (previous && typeof previous.cancel === 'function') previous.cancel(); - return new Promise((resolve) => { - const root = document.documentElement || document.body; - if (!root) { resolve({cancelled:true}); return; } - let current = null; - let finished = false; - const overlay = document.createElement('div'); - overlay.style.cssText = 'display:none;position:fixed;box-sizing:border-box;border:2px solid #0e70c0;background:rgba(14,112,192,.14);pointer-events:none;z-index:2147483647'; - root.appendChild(overlay); - const elementAt = (x, y) => document.elementsFromPoint(x, y) - .find((value) => value !== overlay && !overlay.contains(value)); - const render = (element) => { - current = element || null; - if (!current) { overlay.style.display = 'none'; return; } - const rect = current.getBoundingClientRect(); - Object.assign(overlay.style, {display:'block',left:`${rect.left}px`,top:`${rect.top}px`,width:`${rect.width}px`,height:`${rect.height}px`}); - }; - const collect = (element) => { - const rect = element.getBoundingClientRect(); - const attributes = {}; - [...element.attributes].slice(0, 100).forEach((item) => { - attributes[item.name] = String(item.value || '').slice(0, 2048); - }); - const tag = String(element.tagName || '').toLowerCase(); - const name = `${tag}${element.id ? `#${element.id}` : ''}` + - [...element.classList].slice(0, 4).map((value) => `.${value}`).join(''); - return { - url: location.href, - title: document.title, - name, - tagName: tag, - outerHTML: String(element.outerHTML || '').slice(0, 40000), - innerText: String(element.innerText || element.textContent || '').slice(0, 20000), - attributes, - bounds: {x:rect.left,y:rect.top,width:rect.width,height:rect.height}, - dimensions: {top:rect.top,left:rect.left,width:rect.width,height:rect.height}, - }; - }; - const suppress = (event) => { event.preventDefault(); event.stopImmediatePropagation(); }; - const onMove = (event) => { suppress(event); render(elementAt(event.clientX, event.clientY)); }; - const onClick = (event) => { - suppress(event); - const target = current || elementAt(event.clientX, event.clientY); - finish(target ? {cancelled:false,element:collect(target)} : {cancelled:true}); - }; - const onKey = (event) => { - suppress(event); - if (event.key === 'Escape') finish({cancelled:true}); - }; - const cleanup = () => { - removeEventListener('pointermove', onMove, true); - removeEventListener('pointerdown', suppress, true); - removeEventListener('click', onClick, true); - removeEventListener('contextmenu', suppress, true); - removeEventListener('keydown', onKey, true); - overlay.remove(); - try { delete globalThis[key]; } catch (_) { globalThis[key] = undefined; } - }; - const finish = (value) => { - if (finished) return; - finished = true; - cleanup(); - resolve(value); - }; - addEventListener('pointermove', onMove, true); - addEventListener('pointerdown', suppress, true); - addEventListener('click', onClick, true); - addEventListener('contextmenu', suppress, true); - addEventListener('keydown', onKey, true); - globalThis[key] = {cancel:() => finish({cancelled:true})}; - }); -})())JS"; -} - -std::string navigation_failure_kind(NSError* error, - bool proxy_authentication_challenge) { - error = url_loading_error(error); - if (!error) return "unexpected"; - switch ([error code]) { - case NSURLErrorTimedOut: return "timeout"; - case NSURLErrorCannotFindHost: - case NSURLErrorDNSLookupFailed: - return "name_not_resolved"; - case NSURLErrorCannotConnectToHost: return "cannot_connect"; - case NSURLErrorResourceUnavailable: - case NSURLErrorCannotLoadFromNetwork: - return "server_unreachable"; - case NSURLErrorNetworkConnectionLost: return "connection_reset"; - case NSURLErrorNotConnectedToInternet: return "disconnected"; - case NSURLErrorCancelled: return "connection_aborted"; - case NSURLErrorHTTPTooManyRedirects: - case NSURLErrorRedirectToNonExistentLocation: - return "redirect_failed"; - case NSURLErrorBadServerResponse: - case NSURLErrorCannotDecodeRawData: - case NSURLErrorCannotDecodeContentData: - case NSURLErrorCannotParseResponse: - case NSURLErrorZeroByteResource: - return "invalid_response"; - case NSURLErrorUserCancelledAuthentication: - case NSURLErrorUserAuthenticationRequired: - return proxy_authentication_challenge - ? "proxy_authentication_required" : "authentication_required"; - case NSURLErrorAppTransportSecurityRequiresSecureConnection: - return "app_transport_security"; - case NSURLErrorSecureConnectionFailed: - return "secure_connection_failed"; - case NSURLErrorClientCertificateRejected: - case NSURLErrorClientCertificateRequired: - return "authentication_required"; - case NSURLErrorServerCertificateUntrusted: - case NSURLErrorServerCertificateHasBadDate: - case NSURLErrorServerCertificateHasUnknownRoot: - case NSURLErrorServerCertificateNotYetValid: - return "certificate"; - default: return "unexpected"; - } -} - -const char* navigation_type_name(WKNavigationType type) { - switch (type) { - case WKNavigationTypeLinkActivated: return "link_activated"; - case WKNavigationTypeFormSubmitted: return "form_submitted"; - case WKNavigationTypeBackForward: return "back_forward"; - case WKNavigationTypeReload: return "reload"; - case WKNavigationTypeFormResubmitted: return "form_resubmitted"; - case WKNavigationTypeOther: return "other"; - default: return "unknown"; - } -} - -bool safe_navigation_url(NSURL* url) { - if (!url) return true; - NSString* scheme_value = [[url scheme] lowercaseString]; - if (!scheme_value) return true; - return [scheme_value isEqualToString:@"http"] || - [scheme_value isEqualToString:@"https"] || - [scheme_value isEqualToString:@"file"] || - ([scheme_value isEqualToString:@"about"] && - [[[url absoluteString] lowercaseString] isEqualToString:@"about:blank"]); -} - -// WKNavigation 对象指针即导航身份:同一次导航的 start/finish/fail 回调拿到的是 -// 同一个对象,而被取代的旧导航拿到的是另一个。nil 折算成 0,由纯逻辑层按「当前 -// 导航」处理。 -std::uint64_t navigation_identity(WKNavigation* navigation) { - return static_cast( - reinterpret_cast(navigation)); -} - -// 可以交给操作系统打开的外部 scheme。http/https/file/about 由 WebView 自己承载; -// javascript/data/blob 不代表外部应用,交给系统既无意义也不安全。 -bool external_handoff_candidate_url(NSURL* url) { - if (!url) return false; - NSString* scheme_value = [[url scheme] lowercaseString]; - if (!scheme_value || [scheme_value length] == 0) return false; - for (NSString* blocked in @[@"javascript", @"data", @"about", @"blob", - @"http", @"https", @"file"]) { - if ([scheme_value isEqualToString:blocked]) return false; - } - return true; -} - -void load_agent_browser_request(WKWebView* webview, NSURLRequest* request) { - if (!webview || !request.URL) return; - if ([request.URL isFileURL]) { - NSURL* root_read_access = - [NSURL fileURLWithPath:@"/" isDirectory:YES]; - [webview loadFileURL:request.URL - allowingReadAccessToURL:root_read_access]; - return; - } - [webview loadRequest:request]; -} - class MacAgentBrowserDelegateSink { public: virtual ~MacAgentBrowserDelegateSink() = default; @@ -852,106 +426,6 @@ - (void)webView:(WKWebView*)webView namespace acecode::desktop { namespace { -std::string profile_identifier(const std::string& acecode_dir) { - const auto path = agent_browser_macos_profile_identifier_path(acecode_dir); - std::ifstream input(path, std::ios::binary); - std::string value; - if (input) std::getline(input, value); - NSUUID* parsed = value.empty() - ? nil : [[[NSUUID alloc] initWithUUIDString:ns_string(value)] autorelease]; - if (parsed) return value; - - value = acecode::generate_uuid(); - if (!acecode::atomic_write_file( - acecode::path_to_utf8(path), value, true)) { - return {}; - } - return value; -} - -int bounded_poll_timeout(std::chrono::steady_clock::time_point deadline) { - const auto remaining = std::chrono::duration_cast( - deadline - std::chrono::steady_clock::now()).count(); - return static_cast((std::max)( - std::int64_t{0}, (std::min)(std::int64_t{50}, remaining))); -} - -bool socket_transfer(int socket_fd, - void* buffer, - std::size_t size, - bool write, - const std::atomic& stopping, - std::chrono::steady_clock::time_point deadline) { - std::size_t offset = 0; - while (offset < size && !stopping.load() && - std::chrono::steady_clock::now() < deadline) { - pollfd descriptor{socket_fd, static_cast(write ? POLLOUT : POLLIN), 0}; - const int polled = ::poll( - &descriptor, 1, bounded_poll_timeout(deadline)); - if (polled < 0) { - if (errno == EINTR) continue; - return false; - } - if (polled == 0) continue; - if ((descriptor.revents & descriptor.events) == 0) return false; - const std::size_t chunk = (std::min)( - size - offset, static_cast(1024u * 1024u)); - const ssize_t transferred = write - ? ::send(socket_fd, static_cast(buffer) + offset, - chunk, MSG_NOSIGNAL) - : ::recv(socket_fd, static_cast(buffer) + offset, - chunk, 0); - if (transferred > 0) { - offset += static_cast(transferred); - } else if (transferred == 0) { - return false; - } else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { - return false; - } - } - return offset == size; -} - -CGEventFlags native_modifier_flags(int modifiers) { - CGEventFlags flags = 0; - if ((modifiers & 1) != 0) flags |= kCGEventFlagMaskAlternate; - if ((modifiers & 2) != 0) flags |= kCGEventFlagMaskControl; - if ((modifiers & 4) != 0) flags |= kCGEventFlagMaskCommand; - if ((modifiers & 8) != 0) flags |= kCGEventFlagMaskShift; - return flags; -} - -std::optional native_key_code(std::string key) { - std::transform(key.begin(), key.end(), key.begin(), [](char ch) { - return static_cast(std::tolower(static_cast(ch))); - }); - static const std::unordered_map codes{ - {"a", 0}, {"s", 1}, {"d", 2}, {"f", 3}, {"h", 4}, {"g", 5}, - {"z", 6}, {"x", 7}, {"c", 8}, {"v", 9}, {"b", 11}, {"q", 12}, - {"w", 13}, {"e", 14}, {"r", 15}, {"y", 16}, {"t", 17}, - {"1", 18}, {"2", 19}, {"3", 20}, {"4", 21}, {"6", 22}, - {"5", 23}, {"9", 25}, {"7", 26}, {"8", 28}, {"0", 29}, - {"o", 31}, {"u", 32}, {"i", 34}, {"p", 35}, {"l", 37}, - {"j", 38}, {"k", 40}, {"n", 45}, {"m", 46}, - {"enter", 36}, {"return", 36}, {"tab", 48}, {"space", 49}, - {"backspace", 51}, {"delete", 51}, {"escape", 53}, - {"left", 123}, {"arrowleft", 123}, {"right", 124}, - {"arrowright", 124}, {"down", 125}, {"arrowdown", 125}, - {"up", 126}, {"arrowup", 126}, {"home", 115}, {"end", 119}, - {"pageup", 116}, {"pagedown", 121}, - }; - const auto found = codes.find(key); - return found == codes.end() ? std::nullopt - : std::optional(found->second); -} - -bool request_native_input_permission() { - NSDictionary* options = @{ - (NSString*)kAXTrustedCheckOptionPrompt: @YES, - }; - return AXIsProcessTrustedWithOptions((CFDictionaryRef)options) == TRUE; -} - } // namespace struct AgentBrowserHost::Impl final @@ -989,7 +463,16 @@ bool request_native_input_permission() { void (^alert_completion)(void) = nil; void (^confirm_completion)(BOOL) = nil; void (^prompt_completion)(NSString*) = nil; + // 当前 webview 所在的 cookie 存储(建 webview 时按档位与 owner 决定)。 + AgentBrowserCookieStoreKey store; + // 按会话存储第一次被用到时要先清空 / 从父会话复制;做完之前命令排队、 + // 地址栏导航拒绝(同 Windows host)。 + bool store_prepared = true; + std::vector> ready_waiters; + // 切档重建时记下的地址,新 webview 就绪后导航回去。 + std::string restore_url; }; + using StoreDone = std::function; void* parent_window = nullptr; std::int64_t desktop_pid = 0; @@ -1004,7 +487,16 @@ bool request_native_input_permission() { // 共用,见 agent_browser_page_directory.hpp)。所有读写都在 state_mutex 下。 AgentBrowserPageDirectory directory; std::uint64_t next_page_sequence = 0; + // 共享档的存储:固定 UUID 的 identifier store(升级前就是它,登录数据都在这里)。 WKWebsiteDataStore* data_store = nil; + // 退出即清档:进程内持有的非持久存储,切离该档或 Desktop 退出时释放。 + WKWebsiteDataStore* until_quit_store = nil; + // 本次运行打开过的按会话存储(retain),key 是根会话 id。 + std::unordered_map session_stores; + std::unordered_set prepared_session_stores; + std::unordered_map> session_store_waiters; + StorageEventHandler storage_handler; + AgentBrowserCookieMode cookie_mode = AgentBrowserCookieMode::Shared; WKContentWorld* automation_world = nil; std::string proxy_socket_path; std::string proxy_auth_token; @@ -1052,6 +544,9 @@ bool request_native_input_permission() { for (const auto& page : remaining) teardown_page(page); [automation_world release]; [data_store release]; + [until_quit_store release]; + for (auto& item : session_stores) [item.second release]; + session_stores.clear(); cleanup_agent_browser_runtime_manifest(desktop_instance_id, acecode_dir); } @@ -1212,6 +707,7 @@ void start() { fail("failed to initialize Agent Browser WKWebView resources"); return; } + run_startup_store_tasks(); if (!publish_proxy()) return; update_host_state([](AgentBrowserState& value) { value.supported = true; @@ -1221,6 +717,269 @@ void start() { LOG_INFO("[agent-browser] WKWebView host ready; macOS proxy published"); } + // ---- cookie 存储(openspec add-agent-browser-cookie-modes D5) ---- + + void emit_storage_event(AgentBrowserStorageEvent event) const { + if (event.cookie_mode.empty()) { + event.cookie_mode = agent_browser_cookie_mode_name(cookie_mode); + } + if (storage_handler) storage_handler(event); + } + + static NSUUID* session_store_identifier(const std::string& session_id) { + return [[[NSUUID alloc] initWithUUIDString: + ns_string(agent_browser_session_store_uuid(session_id))] autorelease]; + } + + // 存储键 -> data store。共享 = 固定 UUID 的 identifier store;退出即清 = 进程内 + // 非持久存储(持有到切离该档或退出,所以关掉所有页面也不丢);按会话 = + // 由会话 id 派生 UUID 的 identifier store。 + WKWebsiteDataStore* store_for_key(const AgentBrowserCookieStoreKey& key) { + if (key.kind == AgentBrowserCookieStoreKind::UntilQuit) { + if (!until_quit_store) { + until_quit_store = [[WKWebsiteDataStore nonPersistentDataStore] retain]; + } + return until_quit_store; + } + if (key.kind == AgentBrowserCookieStoreKind::Session) { + const auto found = session_stores.find(key.session_id); + if (found != session_stores.end()) return found->second; + NSUUID* identifier = session_store_identifier(key.session_id); + WKWebsiteDataStore* store = nil; + if (@available(macOS 14.0, *)) { + if (identifier) { + store = [[WKWebsiteDataStore dataStoreForIdentifier:identifier] retain]; + } + } + if (!store) { + LOG_WARN("[agent-browser][macos] cannot open session cookie store " + + key.session_id + "; using the shared store"); + return data_store; + } + session_stores.emplace(key.session_id, store); + return store; + } + return data_store; + } + + void release_session_store(const std::string& session_id) { + const auto found = session_stores.find(session_id); + if (found == session_stores.end()) return; + [found->second release]; + session_stores.erase(found); + } + + static void clear_data_store(WKWebsiteDataStore* store, StoreDone done) { + if (!store) { + done(false, "Agent Browser cookie store is unavailable"); + return; + } + [store removeDataOfTypes:[WKWebsiteDataStore allWebsiteDataTypes] + modifiedSince:[NSDate distantPast] + completionHandler:^{ + done(true, std::string()); + }]; + } + + // 从磁盘删除一份按会话存储(macOS 14+)。存储还被 WebKit 占用时失败,记录留到 + // 下次启动再删。 + static void remove_session_store_from_disk(const std::string& session_id, + StoreDone done) { + NSUUID* identifier = session_store_identifier(session_id); + if (!identifier) { + done(false, "session cookie store identifier is invalid"); + return; + } + if (@available(macOS 14.0, *)) { + [WKWebsiteDataStore removeDataStoreForIdentifier:identifier + completionHandler:^(NSError* error) { + if (error) { + done(false, utf8_string(error.localizedDescription)); + } else { + done(true, std::string()); + } + }]; + return; + } + done(false, "removing cookie stores requires macOS 14"); + } + + static void drop_session_from_store_lists(const std::string& session_id) { + update_agent_browser_store_state([&session_id](AgentBrowserStoreState& current) { + const auto drop = [&session_id](std::vector& values) { + values.erase(std::remove(values.begin(), values.end(), session_id), + values.end()); + }; + drop(current.pending_wipe); + drop(current.pending_deletions); + drop(current.session_stores); + return true; + }); + } + + // 启动时:待清空 / 待删除的按会话存储直接从磁盘删除(此时还没有页面在用); + // 共享存储欠一次清空的,补清。都是异步完成,完成后才撤掉状态里的记录。 + void run_startup_store_tasks() { + const AgentBrowserStoreState state = read_agent_browser_store_state(); + cookie_mode = effective_agent_browser_cookie_mode(state.cookie_mode); + std::vector ids = state.pending_wipe; + for (const auto& id : state.pending_deletions) { + if (std::find(ids.begin(), ids.end(), id) == ids.end()) ids.push_back(id); + } + for (const auto& session_id : ids) { + const std::string id = session_id; + remove_session_store_from_disk(id, [id](bool ok, const std::string& error) { + if (ok) { + drop_session_from_store_lists(id); + } else { + LOG_WARN("[agent-browser][macos] failed to remove session cookie store " + + id + ": " + error); + } + }); + } + if (state.shared_wipe_pending) clear_shared_store_and_mark_done(); + LOG_INFO(std::string("[agent-browser][macos] cookie mode: ") + + agent_browser_cookie_mode_name(cookie_mode)); + } + + void clear_shared_store_and_mark_done() { + clear_data_store(data_store, [](bool ok, const std::string& error) { + if (!ok) { + LOG_WARN("[agent-browser][macos] failed to clear shared cookie store: " + error); + return; + } + update_agent_browser_store_state([](AgentBrowserStoreState& current) { + if (!current.shared_wipe_pending) return false; + current.shared_wipe_pending = false; + return true; + }); + LOG_INFO("[agent-browser][macos] shared cookie store cleared"); + }); + } + + // 分叉会话第一次建存储:把父会话存储里的全部 cookie 复制进来;失败不阻断页面。 + void seed_store_cookies(const std::string& parent_session_id, + WKWebsiteDataStore* target, + const std::function& done) { + AgentBrowserCookieStoreKey parent; + parent.kind = AgentBrowserCookieStoreKind::Session; + parent.session_id = parent_session_id; + WKWebsiteDataStore* source = store_for_key(parent); + if (!source || !target || source == target) { + done(); + return; + } + WKHTTPCookieStore* target_cookies = target.httpCookieStore; + const std::string parent_id = parent_session_id; + const std::function finished = done; + [source.httpCookieStore getAllCookies:^(NSArray* cookies) { + dispatch_group_t group = dispatch_group_create(); + for (NSHTTPCookie* cookie in cookies) { + dispatch_group_enter(group); + [target_cookies setCookie:cookie completionHandler:^{ + dispatch_group_leave(group); + }]; + } + const std::size_t count = static_cast(cookies.count); + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + LOG_INFO("[agent-browser][macos] seeded forked cookie store from " + + parent_id + ": copied=" + std::to_string(count)); + finished(); + }); + dispatch_release(group); + }]; + } + + // 页面的 webview 建好之后:按会话存储第一次被用到时按状态文件先清空 / 从父会话 + // 复制,再登记;同一存储并发建多页时只做一次,其余页面等它完成。 + void prepare_page_store(const std::shared_ptr& page) { + if (!page || page->store_prepared) return; + const std::string session_id = page->store.session_id; + const std::string page_id = page->id; + const auto weak = weak_from_this(); + auto& waiters = session_store_waiters[session_id]; + waiters.push_back([weak, page_id](bool ok, const std::string& error) { + const auto self = weak.lock(); + if (!self) return; + const auto ready_page = self->find_page(page_id); + if (!ready_page || ready_page->closing) return; + self->finish_page_store(ready_page, ok, error); + }); + if (waiters.size() > 1) return; + + const AgentBrowserSessionStorePreparation preparation = + prepare_agent_browser_session_store(session_id, read_agent_browser_store_state()); + WKWebsiteDataStore* store = store_for_key(page->store); + const StoreDone finish = [weak, session_id](bool ok, const std::string& error) { + const auto self = weak.lock(); + if (!self) return; + if (ok) { + self->prepared_session_stores.insert(session_id); + if (!update_agent_browser_store_state([&session_id](AgentBrowserStoreState& state) { + apply_agent_browser_session_store_preparation(session_id, state); + return true; + })) { + LOG_WARN("[agent-browser][macos] failed to record cookie store " + session_id); + } + } + auto pending = std::move(self->session_store_waiters[session_id]); + self->session_store_waiters.erase(session_id); + for (auto& waiter : pending) waiter(ok, error); + }; + const std::string seed_from = preparation.seed_from; + const std::function seed_then_finish = [weak, seed_from, store, finish]() { + const auto self = weak.lock(); + if (!self) return; + if (seed_from.empty()) { + finish(true, std::string()); + return; + } + self->seed_store_cookies(seed_from, store, [finish] { finish(true, std::string()); }); + }; + if (!preparation.wipe_first) { + seed_then_finish(); + return; + } + clear_data_store(store, [seed_then_finish, finish](bool ok, const std::string& error) { + if (!ok) { + finish(false, error); + return; + } + seed_then_finish(); + }); + } + + void finish_page_store(const std::shared_ptr& page, + bool ok, + const std::string& error) { + auto waiters = std::move(page->ready_waiters); + page->ready_waiters.clear(); + if (!ok) { + update_page(page, [&error](AgentBrowserState& value) { + value.ready = false; + value.error = error; + }); + for (auto& waiter : waiters) waiter(false, error); + return; + } + page->store_prepared = true; + update_page(page, [](AgentBrowserState& value) { + value.ready = true; + value.error.clear(); + }); + for (auto& waiter : waiters) waiter(true, std::string()); + if (!page->restore_url.empty()) { + const std::string url = page->restore_url; + page->restore_url.clear(); + std::string navigate_error; + if (!navigate_on_ui(page->id, url, &navigate_error)) { + LOG_WARN("[agent-browser][macos] failed to restore " + page->id + + " after cookie store change: " + navigate_error); + } + } + apply_bounds(page); + } + void clear_dialog(const std::shared_ptr& page, bool dismiss) { if (!page) return; if (page->alert_completion) { @@ -1288,20 +1047,17 @@ void teardown_page(const std::shared_ptr& page) { } } - std::string create_page_on_ui(bool shared_with_agent, - const AgentBrowserPageOwner& owner = {}, - bool agent_created = false) { - auto page = std::make_shared(); - page->id = "browser-" + std::to_string(desktop_pid) + "-" + - std::to_string(++next_page_sequence); - page->state.page_id = page->id; - page->state.supported = true; - page->state.shared_with_agent = shared_with_agent; - page->state.owner = owner; + // 为页面建 WKWebView 与承载它的 surface。data store 按当前档位与页面 owner 选 + // (agent_browser_cookie_store_key);新建页面与切档重建共用这一段。 + void attach_webview(const std::shared_ptr& page) { + page->store = agent_browser_cookie_store_key(cookie_mode, page->state.owner); + page->store_prepared = + page->store.kind != AgentBrowserCookieStoreKind::Session || + prepared_session_stores.count(page->store.session_id) != 0; WKWebViewConfiguration* configuration = [[[WKWebViewConfiguration alloc] init] autorelease]; - configuration.websiteDataStore = data_store; + configuration.websiteDataStore = store_for_key(page->store); configuration.applicationNameForUserAgent = safari_compatible_application_name(); configuration.preferences.javaScriptCanOpenWindowsAutomatically = YES; @@ -1354,10 +1110,25 @@ void teardown_page(const std::shared_ptr& page) { positioned:NSWindowAbove relativeTo:nil]; } - page->state.ready = page->webview != nil && page->surface != nil; - if (!page->state.ready) { + const bool created_view = page->webview != nil && page->surface != nil; + // 按会话存储还没准备好(先清空 / 从父会话复制)时先不报就绪。 + page->state.ready = created_view && page->store_prepared; + if (!created_view) { page->state.error = "failed to create WKWebView page surface"; } + } + + std::string create_page_on_ui(bool shared_with_agent, + const AgentBrowserPageOwner& owner = {}, + bool agent_created = false) { + auto page = std::make_shared(); + page->id = "browser-" + std::to_string(desktop_pid) + "-" + + std::to_string(++next_page_sequence); + page->state.page_id = page->id; + page->state.supported = true; + page->state.shared_with_agent = shared_with_agent; + page->state.owner = owner; + attach_webview(page); std::vector changed; AgentBrowserState created; @@ -1385,6 +1156,7 @@ void teardown_page(const std::shared_ptr& page) { std::string ignored; select_page_on_ui(page->id, &ignored); } + if (!page->store_prepared && page->webview) prepare_page_store(page); return page->id; } @@ -1511,6 +1283,177 @@ bool close_page_on_ui(const std::string& requested_page, return true; } + std::vector page_store_bindings() const { + std::vector bindings; + std::lock_guard lock(state_mutex); + for (const std::string& id : directory.ordered_page_ids()) { + const auto found = pages.find(id); + if (found == pages.end() || found->second->closing) continue; + bindings.push_back({id, found->second->state.owner, found->second->store}); + } + return bindings; + } + + // 切档后页面换存储:拆掉旧 webview,用同一个 page id、owner 与共享标记在新存储 + // 里重建并导航回原地址。期间的命令排队,就绪后照常执行。 + void rebuild_page_on_ui(const std::shared_ptr& page) { + if (!page || page->closing) return; + std::string url; + { + std::lock_guard lock(state_mutex); + url = page->state.url; + } + teardown_page(page); + page->navigation = AgentBrowserNavigationTracker{}; + ++page->element_selection_generation; + page->console_logs.clear(); + page->restore_url = (url.empty() || url == "about:blank") ? std::string() : url; + const bool restoring = !page->restore_url.empty(); + update_page(page, [restoring](AgentBrowserState& value) { + value.ready = false; + value.loading = restoring; + value.visible = false; + value.can_go_back = false; + value.can_go_forward = false; + value.element_selection_active = false; + value.failure_kind.clear(); + value.error.clear(); + value.diagnostic.clear(); + value.content_state = restoring ? kAgentBrowserContentStateLoading + : kAgentBrowserContentStateEmpty; + }); + attach_webview(page); + if (!page->webview) { + update_page(page, [](AgentBrowserState& value) { + value.error = "failed to create WKWebView page surface"; + }); + return; + } + if (page->store_prepared) { + finish_page_store(page, true, std::string()); + } else { + prepare_page_store(page); + } + } + + bool set_cookie_mode_on_ui(const std::string& name, std::string* error) { + const auto mode = agent_browser_cookie_mode_from_name(name); + if (!mode) { + assign_error(error, "unknown Agent Browser cookie mode: " + name); + return false; + } + const AgentBrowserCookieModeSwitch plan = + plan_agent_browser_cookie_mode_switch(cookie_mode, *mode); + const bool persisted = update_agent_browser_store_state( + [&mode, &plan](AgentBrowserStoreState& state) { + state.cookie_mode = agent_browser_cookie_mode_name(*mode); + if (plan.mark_session_stores_for_wipe) { + state.pending_wipe.insert(state.pending_wipe.end(), + state.session_stores.begin(), + state.session_stores.end()); + } + if (plan.clear_shared_now) state.shared_wipe_pending = true; + return true; + }); + if (!persisted) { + assign_error(error, "failed to save the Agent Browser cookie setting"); + return false; + } + if (!plan.changed) return true; + + const AgentBrowserCookieMode previous = cookie_mode; + cookie_mode = *mode; + if (plan.mark_session_stores_for_wipe) prepared_session_stores.clear(); + const auto rebuild = agent_browser_pages_to_rebuild(page_store_bindings(), cookie_mode); + for (const auto& page_id : rebuild) rebuild_page_on_ui(find_page(page_id)); + if (plan.clear_shared_now) clear_shared_store_and_mark_done(); + if (plan.release_until_quit_store) { + [until_quit_store release]; + until_quit_store = nil; + } + LOG_INFO(std::string("[agent-browser][macos] cookie mode ") + + agent_browser_cookie_mode_name(previous) + " -> " + + agent_browser_cookie_mode_name(cookie_mode) + "; rebuilt " + + std::to_string(rebuild.size()) + " page(s)"); + AgentBrowserStorageEvent event; + event.type = "cookie_mode_changed"; + emit_storage_event(std::move(event)); + return true; + } + + // 清除该页当前所用存储的全部网站数据,完成后重新加载同一存储里的页面。 + bool clear_browsing_data_on_ui(const std::string& page_id, std::string* error) { + const auto page = find_page(page_id); + if (!page || page->closing) { + assign_error(error, "Agent Browser page was not found"); + return false; + } + const AgentBrowserCookieStoreKey key = page->store; + const auto weak = weak_from_this(); + const std::string requested_page = page_id; + clear_data_store(store_for_key(key), + [weak, key, requested_page](bool ok, const std::string& clear_error) { + const auto self = weak.lock(); + if (!self) return; + if (ok) { + std::vector> reload; + { + std::lock_guard lock(self->state_mutex); + for (const auto& item : self->pages) { + if (!item.second->closing && item.second->webview && + item.second->store == key) { + reload.push_back(item.second); + } + } + } + for (const auto& candidate : reload) [candidate->webview reload]; + LOG_INFO("[agent-browser][macos] browsing data cleared: " + key.id()); + } else { + LOG_WARN("[agent-browser][macos] failed to clear browsing data for " + + key.id() + ": " + clear_error); + } + AgentBrowserStorageEvent event; + event.type = "browsing_data_cleared"; + event.ok = ok; + event.store = key.id(); + event.page_id = requested_page; + event.error = clear_error; + self->emit_storage_event(std::move(event)); + }); + return true; + } + + // daemon 永久删除会话后:关闭该根会话的页面,释放并从磁盘删除它的按会话存储。 + // WebKit 仍占用时删除失败,记录留到下次启动处理。 + void process_store_tasks_on_ui() { + const AgentBrowserStoreState state = read_agent_browser_store_state(); + for (const auto& session_id : state.pending_deletions) { + for (const auto& page_id : + agent_browser_pages_of_root_session(page_store_bindings(), session_id)) { + close_page_on_ui(page_id, nullptr, nullptr); + } + prepared_session_stores.erase(session_id); + release_session_store(session_id); + const auto weak = weak_from_this(); + const std::string id = session_id; + remove_session_store_from_disk(id, [weak, id](bool ok, const std::string& error) { + if (ok) drop_session_from_store_lists(id); + const auto self = weak.lock(); + if (!self) return; + if (!ok) { + LOG_WARN("[agent-browser][macos] session cookie store " + id + + " stays pending: " + error); + } + AgentBrowserStorageEvent event; + event.type = "session_store_deleted"; + event.ok = ok; + event.store = "session:" + id; + event.error = error; + self->emit_storage_event(std::move(event)); + }); + } + } + bool set_shared_with_agent_on_ui(const std::string& page_id, bool shared, std::string* error) { @@ -1529,7 +1472,7 @@ bool navigate_on_ui(const std::string& page_id, const std::string& input, std::string* error) { auto page = find_page(page_id); - if (!page || !page->webview || page->closing) { + if (!page || !page->webview || page->closing || !page->store_prepared) { assign_error(error, "Agent Browser page is still starting"); return false; } @@ -2452,6 +2395,27 @@ CGEventType cg_mouse_event_type(const std::string& type, return; } if (!require_shared(page, page_id, pending)) return; + if (!page->store_prepared) { + // 按会话存储还在准备(先清空 / 从父会话复制):就绪后按显式 page id 重放。 + const auto weak = weak_from_this(); + const std::string queued_page = page_id; + const std::string queued_method = method; + const json queued_params = params; + page->ready_waiters.push_back( + [weak, queued_page, queued_method, queued_params, pending]( + bool ok, const std::string& error) { + const auto self = weak.lock(); + if (!self || !ok) { + finish_proxy_call(pending, + {{"ok", false}, {"page_id", queued_page}, + {"error", ok ? std::string("Agent Browser stopped") : error}}); + return; + } + self->call_command_on_ui(queued_page, {}, queued_method, + queued_params, pending); + }); + return; + } if (method == "Runtime.evaluate") { const std::string expression = params.value("expression", ""); @@ -2588,6 +2552,12 @@ CGEventType cg_mouse_event_type(const std::string& type, const std::string requested_page = request.value("page_id", ""); const AgentBrowserPageOwner owner = parse_agent_browser_page_owner( request.contains("owner") ? request["owner"] : json()); + if (operation == "drain_store_tasks") { + // 提醒本身不带数据,要做的事都在状态文件里(daemon 写入的待删除会话)。 + process_store_tasks_on_ui(); + finish_proxy_call(pending, {{"ok", true}, {"result", json::object()}}); + return; + } if (operation == "create_page") { const std::string page_id = create_page_on_ui(true, owner, true); finish_proxy_call(pending, @@ -2682,7 +2652,7 @@ CGEventType cg_mouse_event_type(const std::string& type, const std::string operation = request.value("operation", "cdp"); if (operation != "cdp" && operation != "create_page" && operation != "claim_page" && operation != "close_page" && - operation != "select_page") { + operation != "select_page" && operation != "drain_store_tasks") { return {{"ok", false}, {"error", "Agent Browser proxy operation is invalid"}}; } @@ -3152,6 +3122,36 @@ CGEventType cg_mouse_event_type(const std::string& type, } } +std::string AgentBrowserHost::cookie_mode() const { + return agent_browser_cookie_mode_name( + impl_ ? impl_->cookie_mode : AgentBrowserCookieMode::Shared); +} + +bool AgentBrowserHost::set_cookie_mode(const std::string& mode, std::string* error) { + if (!impl_ || !impl_->state().supported) { + assign_error(error, "Agent Browser is unavailable on this platform"); + return false; + } + return impl_->set_cookie_mode_on_ui(mode, error); +} + +bool AgentBrowserHost::clear_browsing_data(const std::string& page_id, + std::string* error) { + if (!impl_ || !impl_->state().supported) { + assign_error(error, "Agent Browser is unavailable on this platform"); + return false; + } + return impl_->clear_browsing_data_on_ui(page_id, error); +} + +void AgentBrowserHost::process_store_tasks() { + if (impl_ && impl_->state().supported) impl_->process_store_tasks_on_ui(); +} + +void AgentBrowserHost::set_storage_event_handler(StorageEventHandler handler) { + if (impl_) impl_->storage_handler = std::move(handler); +} + } // namespace acecode::desktop #endif // __APPLE__ diff --git a/src/apps/desktop/agent_browser_macos_helpers.hpp b/src/apps/desktop/agent_browser_macos_helpers.hpp new file mode 100644 index 000000000..14b4f6fd9 --- /dev/null +++ b/src/apps/desktop/agent_browser_macos_helpers.hpp @@ -0,0 +1,63 @@ +#pragma once +#ifdef __APPLE__ +#import +#import +#import +#include +#include +#include +#include +#include +#include +#include +namespace acecode::desktop::agent_browser_macos { +using json = nlohmann::json; + +inline constexpr std::size_t kMaxConsoleEntries = 1000; +inline constexpr std::size_t kMaxConsoleEntryBytes = 16 * 1024; +inline constexpr std::size_t kMaxFaviconBytes = 256 * 1024; +inline constexpr std::size_t kMaxNativeErrorFieldBytes = 4 * 1024; +inline constexpr std::size_t kMaxNativeErrorDiagnosticBytes = 16 * 1024; +inline constexpr std::size_t kMaxUnderlyingErrorDepth = 4; +inline constexpr char kConsoleHandlerName[] = "__acecodeAgentBrowserConsoleV1"; +inline constexpr char kAutomationWorldName[] = "dev.acecode.agent-browser.automation"; +inline constexpr char kElementPickerKey[] = "__acecodeAgentBrowserElementPickerV1"; + +std::int64_t now_unix_ms(); +void assign_error(std::string* target, const std::string& value); +NSString* ns_string(const std::string& value, NSString* fallback = @""); +std::string utf8_string(id value); +std::string clipped(std::string value, std::size_t max_bytes); +std::string native_description(id value); +json native_error_details(NSError* error, std::size_t depth = 0); +void append_native_error_diagnostic(std::ostringstream& output, + const json& details, + std::size_t depth = 0); +std::string native_error_diagnostic(NSError* error); +NSError* url_loading_error(NSError* error); +bool valid_favicon(const std::string& value); +NSString* safari_compatible_application_name(); +const char* favicon_expression(); +const char* console_capture_script(); +const char* element_picker_expression(); +std::string navigation_failure_kind(NSError* error, + bool proxy_authentication_challenge); +const char* navigation_type_name(WKNavigationType type); +bool safe_navigation_url(NSURL* url); +std::uint64_t navigation_identity(WKNavigation* navigation); +bool external_handoff_candidate_url(NSURL* url); +void load_agent_browser_request(WKWebView* webview, NSURLRequest* request); +std::string profile_identifier(const std::string& acecode_dir); +int bounded_poll_timeout(std::chrono::steady_clock::time_point deadline); +bool socket_transfer(int socket_fd, + void* buffer, + std::size_t size, + bool write, + const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline); +CGEventFlags native_modifier_flags(int modifiers); +std::optional native_key_code(std::string key); +bool request_native_input_permission(); + +} // namespace acecode::desktop::agent_browser_macos +#endif diff --git a/src/apps/desktop/agent_browser_macos_helpers.mm b/src/apps/desktop/agent_browser_macos_helpers.mm new file mode 100644 index 000000000..8f22e9c98 --- /dev/null +++ b/src/apps/desktop/agent_browser_macos_helpers.mm @@ -0,0 +1,577 @@ +#include "agent_browser_macos_helpers.hpp" + +#include "agent_browser_host.hpp" + +#ifdef __APPLE__ + +#include "agent_browser_cookie_jars.hpp" +#include "agent_browser_navigation_state.hpp" +#include "ipc/agent_browser_runtime.hpp" +#include "ipc/agent_browser_store_state.hpp" + +#include "platform/process/os_process.hpp" +#include "utils/atomic_file.hpp" +#include "utils/base64.hpp" +#include "utils/logger.hpp" +#include "utils/token.hpp" +#include "utils/utf8_path.hpp" +#include "utils/uuid.hpp" + +#import +#import +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "utils/joining_thread.hpp" +#include +#include +#include +#include +#include + +#include + + +namespace acecode::desktop::agent_browser_macos { + +std::int64_t now_unix_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +void assign_error(std::string* target, const std::string& value) { + if (target) *target = value; +} + +NSString* ns_string(const std::string& value, NSString* fallback) { + if (value.empty()) return fallback; + NSString* converted = [[[NSString alloc] + initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding] autorelease]; + return converted ?: fallback; +} + +std::string utf8_string(id value) { + if (![value isKindOfClass:[NSString class]]) return {}; + const char* text = [(NSString*)value UTF8String]; + return text ? std::string(text) : std::string(); +} + +std::string clipped(std::string value, std::size_t max_bytes) { + if (value.size() <= max_bytes) return value; + value.resize(max_bytes); + value += "\n[truncated]"; + return value; +} + +std::string native_description(id value) { + if (!value) return {}; + NSString* description = [value isKindOfClass:[NSString class]] + ? static_cast(value) + : [value description]; + return clipped(utf8_string(description), kMaxNativeErrorFieldBytes); +} + +json native_error_details(NSError* error, std::size_t depth) { + if (!error) { + return { + {"domain", "unknown"}, + {"code", 0}, + {"description", "WKWebView navigation failed"}, + }; + } + + json details{ + {"domain", native_description([error domain])}, + {"code", static_cast([error code])}, + {"description", native_description([error localizedDescription])}, + }; + const std::string failure_reason = + native_description([error localizedFailureReason]); + const std::string recovery_suggestion = + native_description([error localizedRecoverySuggestion]); + if (!failure_reason.empty()) details["failure_reason"] = failure_reason; + if (!recovery_suggestion.empty()) { + details["recovery_suggestion"] = recovery_suggestion; + } + + NSDictionary* user_info = [error userInfo]; + id failing_url = [user_info objectForKey:@"NSErrorFailingURLKey"]; + if (!failing_url) { + failing_url = [user_info objectForKey:@"NSErrorFailingURLStringKey"]; + } + const std::string failing_url_text = native_description(failing_url); + if (!failing_url_text.empty()) details["failing_url"] = failing_url_text; + + std::vector user_info_keys; + for (id key in user_info) { + const std::string key_text = native_description(key); + if (!key_text.empty()) user_info_keys.push_back(key_text); + } + std::sort(user_info_keys.begin(), user_info_keys.end()); + if (!user_info_keys.empty()) details["user_info_keys"] = user_info_keys; + details["peer_trust_present"] = + [user_info objectForKey:@"NSURLErrorFailingURLPeerTrustErrorKey"] != nil; + + id underlying = [user_info objectForKey:NSUnderlyingErrorKey]; + if (depth + 1 < kMaxUnderlyingErrorDepth && + [underlying isKindOfClass:[NSError class]] && underlying != error) { + details["underlying"] = native_error_details( + static_cast(underlying), depth + 1); + } + return details; +} + +void append_native_error_diagnostic(std::ostringstream& output, + const json& details, + std::size_t depth) { + output << (depth == 0 ? "NSError" : "Underlying NSError") + << " domain=" << details.value("domain", "unknown") + << " code=" << details.value("code", 0LL) << '\n'; + output << "Description: " + << details.value("description", "WKWebView navigation failed") + << '\n'; + for (const auto* field : {"failing_url", "failure_reason", + "recovery_suggestion"}) { + const auto found = details.find(field); + if (found == details.end() || !found->is_string() || found->empty()) { + continue; + } + if (std::strcmp(field, "failing_url") == 0) output << "Failing URL: "; + else if (std::strcmp(field, "failure_reason") == 0) { + output << "Failure reason: "; + } else { + output << "Recovery suggestion: "; + } + output << found->get() << '\n'; + } + const auto keys = details.find("user_info_keys"); + if (keys != details.end() && keys->is_array() && !keys->empty()) { + output << "User info keys: "; + bool first = true; + for (const auto& key : *keys) { + if (!key.is_string()) continue; + if (!first) output << ", "; + output << key.get(); + first = false; + } + output << '\n'; + } + if (details.value("peer_trust_present", false)) { + output << "Peer trust: present\n"; + } + const auto underlying = details.find("underlying"); + if (underlying != details.end() && underlying->is_object()) { + append_native_error_diagnostic(output, *underlying, depth + 1); + } +} + +std::string native_error_diagnostic(NSError* error) { + std::ostringstream output; + append_native_error_diagnostic(output, native_error_details(error)); + return clipped(output.str(), kMaxNativeErrorDiagnosticBytes); +} + +NSError* url_loading_error(NSError* error) { + NSError* current = error; + for (std::size_t depth; + current && depth < kMaxUnderlyingErrorDepth; + ++depth) { + NSString* domain = [current domain]; + if ([domain isEqualToString:NSURLErrorDomain] || + [domain isEqualToString:@"kCFErrorDomainCFNetwork"]) { + return current; + } + id underlying = [[current userInfo] objectForKey:NSUnderlyingErrorKey]; + current = [underlying isKindOfClass:[NSError class]] + ? static_cast(underlying) : nil; + } + return error; +} + +bool valid_favicon(const std::string& value) { + if (value.empty() || value.size() > kMaxFaviconBytes) return false; + return value.rfind("https://", 0) == 0 || + value.rfind("http://", 0) == 0 || + value.rfind("data:image/", 0) == 0; +} + +NSString* safari_compatible_application_name() { + NSString* safari_version = nil; + NSURL* safari_url = [[NSWorkspace sharedWorkspace] + URLForApplicationWithBundleIdentifier:@"com.apple.Safari"]; + if (safari_url) { + NSBundle* safari_bundle = [NSBundle bundleWithURL:safari_url]; + id version = [safari_bundle + objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + if ([version isKindOfClass:[NSString class]] && + [(NSString*)version length] > 0) { + NSCharacterSet* invalid = [[NSCharacterSet + characterSetWithCharactersInString:@"0123456789."] + invertedSet]; + if ([(NSString*)version rangeOfCharacterFromSet:invalid].location == + NSNotFound) { + safari_version = (NSString*)version; + } + } + } + + // WKWebView uses the system WebKit engine but omits Safari product tokens + // from its default user agent. Some sites consequently serve legacy + // downgrade pages that can loop under WebKit's HTTPS upgrade policy. + // Preserve WebKit's generated platform/engine fields and append the local + // Safari identity through the public configuration API. + return safari_version + ? [NSString stringWithFormat:@"Version/%@ Safari/605.1.15", + safari_version] + : @"Safari/605.1.15"; +} + +const char* favicon_expression() { + return R"JS((() => { + const links = [...document.querySelectorAll('link[rel][href]')]; + const icon = links.find((link) => String(link.rel || '').toLowerCase() + .split(/\s+/).includes('icon')); + let href = icon?.href || ''; + if (!href && (location.protocol === 'http:' || location.protocol === 'https:')) { + href = new URL('/favicon.ico', location.href).href; + } + if (!href) return ''; + try { + const parsed = new URL(href, location.href); + return ['http:', 'https:', 'data:'].includes(parsed.protocol) + ? parsed.href.slice(0, 262144) : ''; + } catch (_) { return ''; } +})())JS"; +} + +const char* console_capture_script() { + return R"JS((() => { + if (globalThis.__acecodeConsoleCaptureV1) return; + globalThis.__acecodeConsoleCaptureV1 = true; + const post = (level, values) => { + try { + const text = values.map((value) => { + if (typeof value === 'string') return value; + if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`; + try { return JSON.stringify(value); } catch (_) { return String(value); } + }).join(' ').slice(0, 16384); + globalThis.webkit?.messageHandlers?.__acecodeAgentBrowserConsoleV1 + ?.postMessage({ level, text, timestamp: Date.now() }); + } catch (_) {} + }; + for (const level of ['debug', 'info', 'log', 'warn', 'error']) { + const original = console[level]; + console[level] = function(...values) { + post(level, values); + return original.apply(this, values); + }; + } + addEventListener('error', (event) => post('error', [event.error || event.message])); + addEventListener('unhandledrejection', (event) => post('error', [event.reason])); +})())JS"; +} + +const char* element_picker_expression() { + return R"JS((() => { + const key = '__acecodeAgentBrowserElementPickerV1'; + const previous = globalThis[key]; + if (previous && typeof previous.cancel === 'function') previous.cancel(); + return new Promise((resolve) => { + const root = document.documentElement || document.body; + if (!root) { resolve({cancelled:true}); return; } + let current = null; + let finished = false; + const overlay = document.createElement('div'); + overlay.style.cssText = 'display:none;position:fixed;box-sizing:border-box;border:2px solid #0e70c0;background:rgba(14,112,192,.14);pointer-events:none;z-index:2147483647'; + root.appendChild(overlay); + const elementAt = (x, y) => document.elementsFromPoint(x, y) + .find((value) => value !== overlay && !overlay.contains(value)); + const render = (element) => { + current = element || null; + if (!current) { overlay.style.display = 'none'; return; } + const rect = current.getBoundingClientRect(); + Object.assign(overlay.style, {display:'block',left:`${rect.left}px`,top:`${rect.top}px`,width:`${rect.width}px`,height:`${rect.height}px`}); + }; + const collect = (element) => { + const rect = element.getBoundingClientRect(); + const attributes = {}; + [...element.attributes].slice(0, 100).forEach((item) => { + attributes[item.name] = String(item.value || '').slice(0, 2048); + }); + const tag = String(element.tagName || '').toLowerCase(); + const name = `${tag}${element.id ? `#${element.id}` : ''}` + + [...element.classList].slice(0, 4).map((value) => `.${value}`).join(''); + return { + url: location.href, + title: document.title, + name, + tagName: tag, + outerHTML: String(element.outerHTML || '').slice(0, 40000), + innerText: String(element.innerText || element.textContent || '').slice(0, 20000), + attributes, + bounds: {x:rect.left,y:rect.top,width:rect.width,height:rect.height}, + dimensions: {top:rect.top,left:rect.left,width:rect.width,height:rect.height}, + }; + }; + const suppress = (event) => { event.preventDefault(); event.stopImmediatePropagation(); }; + const onMove = (event) => { suppress(event); render(elementAt(event.clientX, event.clientY)); }; + const onClick = (event) => { + suppress(event); + const target = current || elementAt(event.clientX, event.clientY); + finish(target ? {cancelled:false,element:collect(target)} : {cancelled:true}); + }; + const onKey = (event) => { + suppress(event); + if (event.key === 'Escape') finish({cancelled:true}); + }; + const cleanup = () => { + removeEventListener('pointermove', onMove, true); + removeEventListener('pointerdown', suppress, true); + removeEventListener('click', onClick, true); + removeEventListener('contextmenu', suppress, true); + removeEventListener('keydown', onKey, true); + overlay.remove(); + try { delete globalThis[key]; } catch (_) { globalThis[key] = undefined; } + }; + const finish = (value) => { + if (finished) return; + finished = true; + cleanup(); + resolve(value); + }; + addEventListener('pointermove', onMove, true); + addEventListener('pointerdown', suppress, true); + addEventListener('click', onClick, true); + addEventListener('contextmenu', suppress, true); + addEventListener('keydown', onKey, true); + globalThis[key] = {cancel:() => finish({cancelled:true})}; + }); +})())JS"; +} + +std::string navigation_failure_kind(NSError* error, + bool proxy_authentication_challenge) { + error = url_loading_error(error); + if (!error) return "unexpected"; + switch ([error code]) { + case NSURLErrorTimedOut: return "timeout"; + case NSURLErrorCannotFindHost: + case NSURLErrorDNSLookupFailed: + return "name_not_resolved"; + case NSURLErrorCannotConnectToHost: return "cannot_connect"; + case NSURLErrorResourceUnavailable: + case NSURLErrorCannotLoadFromNetwork: + return "server_unreachable"; + case NSURLErrorNetworkConnectionLost: return "connection_reset"; + case NSURLErrorNotConnectedToInternet: return "disconnected"; + case NSURLErrorCancelled: return "connection_aborted"; + case NSURLErrorHTTPTooManyRedirects: + case NSURLErrorRedirectToNonExistentLocation: + return "redirect_failed"; + case NSURLErrorBadServerResponse: + case NSURLErrorCannotDecodeRawData: + case NSURLErrorCannotDecodeContentData: + case NSURLErrorCannotParseResponse: + case NSURLErrorZeroByteResource: + return "invalid_response"; + case NSURLErrorUserCancelledAuthentication: + case NSURLErrorUserAuthenticationRequired: + return proxy_authentication_challenge + ? "proxy_authentication_required" : "authentication_required"; + case NSURLErrorAppTransportSecurityRequiresSecureConnection: + return "app_transport_security"; + case NSURLErrorSecureConnectionFailed: + return "secure_connection_failed"; + case NSURLErrorClientCertificateRejected: + case NSURLErrorClientCertificateRequired: + return "authentication_required"; + case NSURLErrorServerCertificateUntrusted: + case NSURLErrorServerCertificateHasBadDate: + case NSURLErrorServerCertificateHasUnknownRoot: + case NSURLErrorServerCertificateNotYetValid: + return "certificate"; + default: return "unexpected"; + } +} + +const char* navigation_type_name(WKNavigationType type) { + switch (type) { + case WKNavigationTypeLinkActivated: return "link_activated"; + case WKNavigationTypeFormSubmitted: return "form_submitted"; + case WKNavigationTypeBackForward: return "back_forward"; + case WKNavigationTypeReload: return "reload"; + case WKNavigationTypeFormResubmitted: return "form_resubmitted"; + case WKNavigationTypeOther: return "other"; + default: return "unknown"; + } +} + +bool safe_navigation_url(NSURL* url) { + if (!url) return true; + NSString* scheme_value = [[url scheme] lowercaseString]; + if (!scheme_value) return true; + return [scheme_value isEqualToString:@"http"] || + [scheme_value isEqualToString:@"https"] || + [scheme_value isEqualToString:@"file"] || + ([scheme_value isEqualToString:@"about"] && + [[[url absoluteString] lowercaseString] isEqualToString:@"about:blank"]); +} + +// WKNavigation 对象指针即导航身份:同一次导航的 start/finish/fail 回调拿到的是 +// 同一个对象,而被取代的旧导航拿到的是另一个。nil 折算成 0,由纯逻辑层按「当前 +// 导航」处理。 +std::uint64_t navigation_identity(WKNavigation* navigation) { + return static_cast( + reinterpret_cast(navigation)); +} + +// 可以交给操作系统打开的外部 scheme。http/https/file/about 由 WebView 自己承载; +// javascript/data/blob 不代表外部应用,交给系统既无意义也不安全。 +bool external_handoff_candidate_url(NSURL* url) { + if (!url) return false; + NSString* scheme_value = [[url scheme] lowercaseString]; + if (!scheme_value || [scheme_value length] == 0) return false; + for (NSString* blocked in @[@"javascript", @"data", @"about", @"blob", + @"http", @"https", @"file"]) { + if ([scheme_value isEqualToString:blocked]) return false; + } + return true; +} + +void load_agent_browser_request(WKWebView* webview, NSURLRequest* request) { + if (!webview || !request.URL) return; + if ([request.URL isFileURL]) { + NSURL* root_read_access = + [NSURL fileURLWithPath:@"/" isDirectory:YES]; + [webview loadFileURL:request.URL + allowingReadAccessToURL:root_read_access]; + return; + } + [webview loadRequest:request]; +} + +std::string profile_identifier(const std::string& acecode_dir) { + const auto path = agent_browser_macos_profile_identifier_path(acecode_dir); + std::ifstream input(path, std::ios::binary); + std::string value; + if (input) std::getline(input, value); + NSUUID* parsed = value.empty() + ? nil : [[[NSUUID alloc] initWithUUIDString:ns_string(value)] autorelease]; + if (parsed) return value; + + value = acecode::generate_uuid(); + if (!acecode::atomic_write_file( + acecode::path_to_utf8(path), value, true)) { + return {}; + } + return value; +} + +int bounded_poll_timeout(std::chrono::steady_clock::time_point deadline) { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()).count(); + return static_cast((std::max)( + std::int64_t{0}, (std::min)(std::int64_t{50}, remaining))); +} + +bool socket_transfer(int socket_fd, + void* buffer, + std::size_t size, + bool write, + const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline) { + std::size_t offset = 0; + while (offset < size && !stopping.load() && + std::chrono::steady_clock::now() < deadline) { + pollfd descriptor{socket_fd, static_cast(write ? POLLOUT : POLLIN), 0}; + const int polled = ::poll( + &descriptor, 1, bounded_poll_timeout(deadline)); + if (polled < 0) { + if (errno == EINTR) continue; + return false; + } + if (polled == 0) continue; + if ((descriptor.revents & descriptor.events) == 0) return false; + const std::size_t chunk = (std::min)( + size - offset, static_cast(1024u * 1024u)); + const ssize_t transferred = write + ? ::send(socket_fd, static_cast(buffer) + offset, + chunk, MSG_NOSIGNAL) + : ::recv(socket_fd, static_cast(buffer) + offset, + chunk, 0); + if (transferred > 0) { + offset += static_cast(transferred); + } else if (transferred == 0) { + return false; + } else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + return false; + } + } + return offset == size; +} + +CGEventFlags native_modifier_flags(int modifiers) { + CGEventFlags flags = 0; + if ((modifiers & 1) != 0) flags |= kCGEventFlagMaskAlternate; + if ((modifiers & 2) != 0) flags |= kCGEventFlagMaskControl; + if ((modifiers & 4) != 0) flags |= kCGEventFlagMaskCommand; + if ((modifiers & 8) != 0) flags |= kCGEventFlagMaskShift; + return flags; +} + +std::optional native_key_code(std::string key) { + std::transform(key.begin(), key.end(), key.begin(), [](char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + static const std::unordered_map codes{ + {"a", 0}, {"s", 1}, {"d", 2}, {"f", 3}, {"h", 4}, {"g", 5}, + {"z", 6}, {"x", 7}, {"c", 8}, {"v", 9}, {"b", 11}, {"q", 12}, + {"w", 13}, {"e", 14}, {"r", 15}, {"y", 16}, {"t", 17}, + {"1", 18}, {"2", 19}, {"3", 20}, {"4", 21}, {"6", 22}, + {"5", 23}, {"9", 25}, {"7", 26}, {"8", 28}, {"0", 29}, + {"o", 31}, {"u", 32}, {"i", 34}, {"p", 35}, {"l", 37}, + {"j", 38}, {"k", 40}, {"n", 45}, {"m", 46}, + {"enter", 36}, {"return", 36}, {"tab", 48}, {"space", 49}, + {"backspace", 51}, {"delete", 51}, {"escape", 53}, + {"left", 123}, {"arrowleft", 123}, {"right", 124}, + {"arrowright", 124}, {"down", 125}, {"arrowdown", 125}, + {"up", 126}, {"arrowup", 126}, {"home", 115}, {"end", 119}, + {"pageup", 116}, {"pagedown", 121}, + }; + const auto found = codes.find(key); + return found == codes.end() ? std::nullopt + : std::optional(found->second); +} + +bool request_native_input_permission() { + NSDictionary* options = @{ + (NSString*)kAXTrustedCheckOptionPrompt: @YES, + }; + return AXIsProcessTrustedWithOptions((CFDictionaryRef)options) == TRUE; +} + + +} // namespace acecode::desktop::agent_browser_macos +#endif diff --git a/src/apps/desktop/agent_browser_windows_helpers.cpp b/src/apps/desktop/agent_browser_windows_helpers.cpp new file mode 100644 index 000000000..6dc2da6f3 --- /dev/null +++ b/src/apps/desktop/agent_browser_windows_helpers.cpp @@ -0,0 +1,759 @@ +#include "agent_browser_host.hpp" + +#include "agent_browser_cookie_jars.hpp" +#include "agent_browser_navigation_state.hpp" +#include "ipc/agent_browser_runtime.hpp" +#include "ipc/agent_browser_store_state.hpp" +#include "window_chrome.hpp" + +#include "platform/process/os_process.hpp" +#include "utils/encoding.hpp" +#include "utils/logger.hpp" +#include "utils/token.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "utils/joining_thread.hpp" +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include +# include +# include +# include +#endif + + +#include "agent_browser_windows_helpers.hpp" + +#ifdef _WIN32 +namespace acecode::desktop::agent_browser_windows { + + +bool valid_agent_browser_favicon(const std::string& value) { + if (value.empty() || value.size() > kAgentBrowserMaxFaviconBytes) { + return false; + } + return value.rfind("https://", 0) == 0 || + value.rfind("http://", 0) == 0 || + value.rfind("data:image/", 0) == 0; +} + +const char* agent_browser_favicon_expression() { + return R"JS((async () => { + const maxDataUrlLength = 256 * 1024; + const links = [...document.querySelectorAll('link[rel][href]')]; + const icon = links.find((link) => String(link.rel || '').toLowerCase().split(/\s+/).includes('icon')); + let href = icon?.href || ''; + if (!href && (location.protocol === 'http:' || location.protocol === 'https:')) { + href = new URL('/favicon.ico', location.href).href; + } + if (!href) return ''; + try { + const parsed = new URL(href, location.href); + if (!['http:', 'https:', 'data:'].includes(parsed.protocol)) return ''; + href = parsed.href; + } catch (_) { + return ''; + } + if (href.startsWith('data:')) { + return href.startsWith('data:image/') && href.length <= maxDataUrlLength ? href : ''; + } + if (new URL(href).origin !== location.origin) { + return href.length <= 4096 ? href : ''; + } + try { + const response = await fetch(href, { credentials: 'include', cache: 'force-cache' }); + if (!response.ok) return href.length <= 4096 ? href : ''; + const declaredLength = Number(response.headers.get('content-length') || 0); + if (declaredLength > 128 * 1024) return href.length <= 4096 ? href : ''; + const blob = await response.blob(); + if (!String(blob.type || '').toLowerCase().startsWith('image/') || blob.size > 128 * 1024) { + return href.length <= 4096 ? href : ''; + } + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => resolve(''); + reader.readAsDataURL(blob); + }); + return dataUrl.length <= maxDataUrlLength ? dataUrl : (href.length <= 4096 ? href : ''); + } catch (_) { + return href.length <= 4096 ? href : ''; + } +})())JS"; +} + +// 可以交给操作系统打开的外部 scheme。http/https/file/about 由 WebView 自己承载; +// javascript/data/blob 不代表外部应用,edge/devtools 是引擎内部页面。 +bool agent_browser_external_handoff_candidate(const std::string& uri) { + const auto colon = uri.find(':'); + if (colon == std::string::npos || colon == 0) return false; + std::string scheme = uri.substr(0, colon); + std::transform(scheme.begin(), scheme.end(), scheme.begin(), + [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + if (std::isalpha(static_cast(scheme.front())) == 0) { + return false; + } + static const char* const blocked[] = { + "http", "https", "file", "about", "javascript", "data", "blob", + "edge", "devtools", "ws", "wss", + }; + for (const char* name : blocked) { + if (scheme == name) return false; + } + return true; +} + +const char* agent_browser_web_error_kind( + COREWEBVIEW2_WEB_ERROR_STATUS status) { + switch (status) { + case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_COMMON_NAME_IS_INCORRECT: + case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_EXPIRED: + case COREWEBVIEW2_WEB_ERROR_STATUS_CLIENT_CERTIFICATE_CONTAINS_ERRORS: + case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_REVOKED: + case COREWEBVIEW2_WEB_ERROR_STATUS_CERTIFICATE_IS_INVALID: + return "certificate"; + case COREWEBVIEW2_WEB_ERROR_STATUS_SERVER_UNREACHABLE: + return "server_unreachable"; + case COREWEBVIEW2_WEB_ERROR_STATUS_TIMEOUT: + return "timeout"; + case COREWEBVIEW2_WEB_ERROR_STATUS_ERROR_HTTP_INVALID_SERVER_RESPONSE: + return "invalid_response"; + case COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_ABORTED: + return "connection_aborted"; + case COREWEBVIEW2_WEB_ERROR_STATUS_CONNECTION_RESET: + return "connection_reset"; + case COREWEBVIEW2_WEB_ERROR_STATUS_DISCONNECTED: + return "disconnected"; + case COREWEBVIEW2_WEB_ERROR_STATUS_CANNOT_CONNECT: + return "cannot_connect"; + case COREWEBVIEW2_WEB_ERROR_STATUS_HOST_NAME_NOT_RESOLVED: + return "name_not_resolved"; + case COREWEBVIEW2_WEB_ERROR_STATUS_OPERATION_CANCELED: + return "cancelled"; + case COREWEBVIEW2_WEB_ERROR_STATUS_REDIRECT_FAILED: + return "redirect_failed"; + case COREWEBVIEW2_WEB_ERROR_STATUS_VALID_AUTHENTICATION_CREDENTIALS_REQUIRED: + return "authentication_required"; + case COREWEBVIEW2_WEB_ERROR_STATUS_VALID_PROXY_AUTHENTICATION_REQUIRED: + return "proxy_authentication_required"; + case COREWEBVIEW2_WEB_ERROR_STATUS_UNEXPECTED_ERROR: + case COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN: + default: + return "unexpected"; + } +} + +const char* agent_browser_process_failure_kind( + COREWEBVIEW2_PROCESS_FAILED_KIND kind, + COREWEBVIEW2_PROCESS_FAILED_REASON reason) { + const bool top_level_failure = + kind == COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED || + kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED || + kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE; + if (!top_level_failure) return ""; + if (reason == COREWEBVIEW2_PROCESS_FAILED_REASON_OUT_OF_MEMORY) { + return "out_of_memory"; + } + if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE || + reason == COREWEBVIEW2_PROCESS_FAILED_REASON_UNRESPONSIVE) { + return "unresponsive"; + } + if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED) { + return "browser_process_exited"; + } + if (kind == COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED) { + return "render_process_exited"; + } + return ""; +} + +std::string clip_agent_browser_text(std::string value, + std::size_t max_bytes) { + if (value.size() <= max_bytes) return value; + value.resize(max_bytes); + value += "\n[truncated]"; + return value; +} + +std::string cdp_remote_object_text(const nlohmann::json& object) { + if (!object.is_object()) return object.dump(); + if (object.contains("value") && !object["value"].is_null()) { + return object["value"].is_string() + ? object["value"].get() + : object["value"].dump(); + } + if (object.contains("unserializableValue") && + object["unserializableValue"].is_string()) { + return object["unserializableValue"].get(); + } + if (object.contains("description") && object["description"].is_string()) { + return object["description"].get(); + } + return object.value("type", std::string("value")); +} + +const char* agent_browser_element_picker_expression() { + return R"JS((() => { + const key = '__acecodeAgentBrowserElementPickerV1'; + const previous = globalThis[key]; + if (previous && typeof previous.cancel === 'function') previous.cancel(); + return new Promise((resolve) => { + const root = document.documentElement || document.body; + if (!root) { resolve({ cancelled: true }); return; } + let finished = false; + let commitPending = false; + let dragStart = null; + let dragTarget = null; + let highlighted = null; + const host = document.createElement('div'); + host.setAttribute('data-acecode-element-picker', ''); + host.style.cssText = 'position:fixed;inset:0;width:0;height:0;z-index:2147483647;pointer-events:none'; + const shadow = host.attachShadow({ mode: 'closed' }); + const styleNode = document.createElement('style'); + styleNode.textContent = ` + .box{display:none;position:fixed;box-sizing:border-box;border:2px solid #0e70c0;background:rgba(14,112,192,.14);pointer-events:none;z-index:2} + .label{display:none;position:fixed;max-width:70vw;padding:3px 6px;border-radius:3px;background:#0e70c0;color:#fff;font:11px/16px "Segoe UI",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;z-index:3;box-shadow:0 2px 8px rgba(0,0,0,.22)} + .drag{display:none;position:fixed;box-sizing:border-box;border:1px dashed #0e70c0;background:rgba(14,112,192,.08);pointer-events:none;z-index:1} + `; + const box = document.createElement('div'); box.className = 'box'; + const label = document.createElement('div'); label.className = 'label'; + const drag = document.createElement('div'); drag.className = 'drag'; + shadow.append(styleNode, box, drag, label); + root.appendChild(host); + + const cursor = document.createElement('style'); + cursor.setAttribute('data-acecode-element-picker-cursor', ''); + cursor.textContent = '*{cursor:default!important}'; + (document.head || root).appendChild(cursor); + + const clip = (value, limit) => { + const text = String(value == null ? '' : value); + return text.length > limit ? `${text.slice(0, limit)}\n[truncated]` : text; + }; + const elementAt = (x, y) => { + const values = document.elementsFromPoint(x, y); + return values.find((value) => value !== host && !host.contains(value)); + }; + const commonAncestor = (values) => { + const unique = [...new Set(values.filter(Boolean))]; + if (!unique.length) return null; + let chain = []; + for (let node = unique[0]; node; node = node.parentElement) chain.unshift(node); + for (let i = 1; i < unique.length && chain.length; i += 1) { + const other = []; + for (let node = unique[i]; node; node = node.parentElement) other.unshift(node); + let index = 0; + while (index < chain.length && index < other.length && chain[index] === other[index]) index += 1; + chain = chain.slice(0, index); + } + return chain[chain.length - 1] || null; + }; + const regionTarget = (left, top, width, height) => { + const right = left + width; + const bottom = top + height; + const centerX = left + width / 2; + const centerY = top + height / 2; + return commonAncestor([ + elementAt(left, top), elementAt(right, top), elementAt(left, bottom), + elementAt(right, bottom), elementAt(centerX, top), elementAt(centerX, bottom), + elementAt(left, centerY), elementAt(right, centerY), elementAt(centerX, centerY), + ]); + }; + const selectorName = (element) => { + if (!element) return ''; + const tag = String(element.tagName || 'element').toLowerCase(); + const id = element.id ? `#${element.id}` : ''; + const classes = [...element.classList].slice(0, 4).map((name) => `.${name}`).join(''); + return `${tag}${id}${classes}`; + }; + const render = (element) => { + highlighted = element || null; + if (!element) { box.style.display = 'none'; label.style.display = 'none'; return; } + const rect = element.getBoundingClientRect(); + box.style.display = 'block'; + box.style.left = `${rect.left}px`; box.style.top = `${rect.top}px`; + box.style.width = `${Math.max(0, rect.width)}px`; box.style.height = `${Math.max(0, rect.height)}px`; + label.textContent = `${selectorName(element)} ${Math.round(rect.width)} × ${Math.round(rect.height)}`; + label.style.display = 'block'; + label.style.left = `${Math.max(2, Math.min(rect.left, innerWidth - 180))}px`; + label.style.top = `${Math.max(2, rect.top >= 24 ? rect.top - 22 : rect.bottom + 2)}px`; + }; + const collect = (element) => { + const rect = element.getBoundingClientRect(); + const attributes = {}; + [...element.attributes].slice(0, 100).forEach((attr) => { attributes[attr.name] = clip(attr.value, 2048); }); + const computed = getComputedStyle(element); + const computedStyles = {}; + const cssLines = []; + for (let i = 0; i < computed.length && i < 200; i += 1) { + const name = computed[i]; + const value = clip(computed.getPropertyValue(name), 512); + computedStyles[name] = value; + cssLines.push(`${name}: ${value};`); + } + const ancestors = []; + for (let node = element; node && ancestors.length < 16; node = node.parentElement) { + ancestors.unshift({ + tagName: String(node.tagName || '').toLowerCase(), + id: clip(node.id || '', 512), + classNames: [...node.classList].slice(0, 12).map((name) => clip(name, 256)), + }); + } + return { + url: location.href, + title: document.title, + name: selectorName(element), + tagName: String(element.tagName || '').toLowerCase(), + outerHTML: clip(element.outerHTML || '', 40000), + innerText: clip(element.innerText || element.textContent || '', 20000), + attributes, + computedStyle: clip(cssLines.join('\n'), 40000), + computedStyles, + ancestors, + bounds: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + dimensions: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + }; + }; + const listeners = [ + ['pointermove', onPointerMove], ['pointerdown', onPointerDown], + ['pointerup', onPointerUp], ['click', suppress], ['contextmenu', suppress], + ['keydown', onKeyDown], ['scroll', onViewportChange], ['resize', onViewportChange], + ]; + function cleanup() { + listeners.forEach(([name, handler]) => window.removeEventListener(name, handler, true)); + cursor.remove(); host.remove(); + try { delete globalThis[key]; } catch (_) { globalThis[key] = undefined; } + } + function finish(value) { + if (finished) return; + finished = true; cleanup(); resolve(value); + } + function suppress(event) { + event.preventDefault(); event.stopImmediatePropagation(); + } + function onPointerMove(event) { + suppress(event); + if (commitPending) return; + if (!dragStart) { render(elementAt(event.clientX, event.clientY)); return; } + const left = Math.min(dragStart.x, event.clientX); + const top = Math.min(dragStart.y, event.clientY); + const width = Math.abs(event.clientX - dragStart.x); + const height = Math.abs(event.clientY - dragStart.y); + if (width < 4 && height < 4) return; + drag.style.display = 'block'; drag.style.left = `${left}px`; drag.style.top = `${top}px`; + drag.style.width = `${width}px`; drag.style.height = `${height}px`; + render(regionTarget(left, top, width, height)); + } + function onPointerDown(event) { + if (commitPending) { suppress(event); return; } + if (event.button !== 0) { suppress(event); return; } + dragStart = { x: event.clientX, y: event.clientY }; + dragTarget = elementAt(event.clientX, event.clientY); + cursor.textContent = '*{cursor:crosshair!important}'; + suppress(event); + } + function onPointerUp(event) { + if (!dragStart) { suppress(event); return; } + const start = dragStart; dragStart = null; + const width = Math.abs(event.clientX - start.x); + const height = Math.abs(event.clientY - start.y); + const target = width < 4 && height < 4 + ? (dragTarget || elementAt(event.clientX, event.clientY)) + : regionTarget(Math.min(start.x, event.clientX), Math.min(start.y, event.clientY), width, height); + dragTarget = null; suppress(event); + if (target) { + const selection = { cancelled: false, element: collect(target) }; + commitPending = true; + requestAnimationFrame(() => finish(selection)); + } + } + function onKeyDown(event) { + if (event.key === 'Escape') { suppress(event); finish({ cancelled: true }); } + } + function onViewportChange() { if (highlighted) render(highlighted); } + listeners.forEach(([name, handler]) => window.addEventListener(name, handler, true)); + const controller = { cancel: () => finish({ cancelled: true }) }; + try { Object.defineProperty(globalThis, key, { configurable: true, value: controller }); } + catch (_) { globalThis[key] = controller; } + }); +})())JS"; +} + +constexpr wchar_t kAgentBrowserWidgetClassName[] = + L"ACECodeAgentBrowserWidget"; + +HWND create_agent_browser_widget(HWND parent) { + const HINSTANCE instance = ::GetModuleHandleW(nullptr); + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.hInstance = instance; + window_class.lpfnWndProc = ::DefWindowProcW; + window_class.lpszClassName = kAgentBrowserWidgetClassName; + window_class.hCursor = + ::LoadCursorW(nullptr, reinterpret_cast(IDC_ARROW)); + window_class.hbrBackground = + reinterpret_cast(COLOR_WINDOW + 1); + if (!::RegisterClassExW(&window_class) && + ::GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + return nullptr; + } + + // Keep the parent HWND visible while WebView2 creates controllers. A + // hidden parent can leave a controller permanently unpainted on some + // WebView2 runtimes. The parked 1x1 child is outside the client area and + // therefore cannot cover the main ACECode WebView. + return ::CreateWindowExW( + WS_EX_CONTROLPARENT | WS_EX_NOPARENTNOTIFY, + kAgentBrowserWidgetClassName, + L"", + WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_VISIBLE, + -1, + -1, + 1, + 1, + parent, + nullptr, + instance, + nullptr); +} + +void park_agent_browser_widget(HWND widget) { + if (!widget || !::IsWindow(widget)) return; + ::SetWindowPos(widget, + HWND_TOP, + -1, + -1, + 1, + 1, + SWP_NOACTIVATE | SWP_SHOWWINDOW); +} + +void hide_agent_browser_widget(HWND widget) { + if (!widget || !::IsWindow(widget)) return; + ::SetWindowPos(widget, + nullptr, + 0, + 0, + 0, + 0, + SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | + SWP_NOZORDER | SWP_HIDEWINDOW); +} + +// 页面给的 bounds 是主 WebView 视口坐标,而 Agent Browser 子窗口挂在宿主窗口 +// 上。两者原点通常重合;Win10 非最大化时主 WebView 下移了 1px 给自绘顶边线 +// (web_host.cpp resize_webview_widget),这里要把偏移加回来,否则整块往上错位。 +POINT main_web_content_origin(HWND parent) { + POINT origin{0, 0}; + HWND widget = ::FindWindowExW(parent, nullptr, kWebViewWidgetClassName, nullptr); + RECT rect{}; + if (!widget || !::GetWindowRect(widget, &rect)) return origin; + origin.x = rect.left; + origin.y = rect.top; + ::MapWindowPoints(HWND_DESKTOP, parent, &origin, 1); + return origin; +} + +bool apply_agent_browser_widget_region( + HWND widget, + int width, + int height, + const std::vector& occlusions) { + if (!widget || !::IsWindow(widget)) return false; + if (occlusions.empty()) { + return ::SetWindowRgn(widget, nullptr, TRUE) != 0; + } + + HRGN visible_region = ::CreateRectRgn(0, 0, width, height); + if (!visible_region) return false; + for (const auto& occlusion : occlusions) { + const int left = (std::max)(0, (std::min)(width, occlusion.x)); + const int top = (std::max)(0, (std::min)(height, occlusion.y)); + const int right = (std::max)(left, (std::min)( + width, occlusion.x + occlusion.width)); + const int bottom = (std::max)(top, (std::min)( + height, occlusion.y + occlusion.height)); + if (right <= left || bottom <= top) continue; + + HRGN hole = ::CreateRectRgn(left, top, right, bottom); + if (!hole) { + ::DeleteObject(visible_region); + return false; + } + const int combine_result = + ::CombineRgn(visible_region, visible_region, hole, RGN_DIFF); + ::DeleteObject(hole); + if (combine_result == ERROR) { + ::DeleteObject(visible_region); + return false; + } + } + + // SetWindowRgn takes ownership only on success. + if (::SetWindowRgn(widget, visible_region, TRUE) == 0) { + ::DeleteObject(visible_region); + return false; + } + return true; +} + +std::string hresult_text(HRESULT result) { + return "HRESULT 0x" + [] (unsigned long value) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string output(8, '0'); + for (int index = 7; index >= 0; --index) { + output[static_cast(index)] = digits[value & 0xF]; + value >>= 4; + } + return output; + }(static_cast(result)); +} + +bool proxy_aborted(const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline) { + return stopping.load() || std::chrono::steady_clock::now() >= deadline; +} + +bool pipe_transfer(HANDLE pipe, + void* buffer, + std::size_t size, + bool write, + const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline) { + std::size_t offset = 0; + while (offset < size && !proxy_aborted(stopping, deadline)) { + OVERLAPPED operation{}; + operation.hEvent = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!operation.hEvent) return false; + const DWORD chunk = static_cast((std::min)( + size - offset, static_cast(1024u * 1024u))); + DWORD transferred = 0; + BOOL started = write + ? ::WriteFile(pipe, + static_cast(buffer) + offset, + chunk, &transferred, &operation) + : ::ReadFile(pipe, + static_cast(buffer) + offset, + chunk, &transferred, &operation); + DWORD operation_error = started ? ERROR_SUCCESS : ::GetLastError(); + bool pending_io = false; + if (!started && operation_error == ERROR_IO_PENDING) { + pending_io = true; + while (!proxy_aborted(stopping, deadline)) { + const DWORD wait = ::WaitForSingleObject(operation.hEvent, 50); + if (wait == WAIT_OBJECT_0) { + started = ::GetOverlappedResult( + pipe, &operation, &transferred, FALSE); + operation_error = started ? ERROR_SUCCESS : ::GetLastError(); + break; + } + if (wait == WAIT_FAILED) { + operation_error = ::GetLastError(); + break; + } + } + } + if (!started || proxy_aborted(stopping, deadline)) { + if (pending_io) { + ::CancelIoEx(pipe, &operation); + ::WaitForSingleObject(operation.hEvent, INFINITE); + } + ::CloseHandle(operation.hEvent); + if (!stopping.load() && + std::chrono::steady_clock::now() < deadline) { + LOG_WARN( + std::string("[agent-browser] proxy pipe ") + + (write ? "write" : "read") + " failed (Windows error " + + std::to_string(operation_error) + ")"); + } + return false; + } + ::CloseHandle(operation.hEvent); + if (transferred == 0) return false; + offset += transferred; + } + return offset == size; +} + +bool connect_pipe(HANDLE pipe, + const std::atomic& stopping) { + OVERLAPPED operation{}; + operation.hEvent = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!operation.hEvent) return false; + BOOL connected = ::ConnectNamedPipe(pipe, &operation); + const DWORD error = connected ? ERROR_SUCCESS : ::GetLastError(); + bool pending_io = false; + if (!connected && error == ERROR_PIPE_CONNECTED) connected = TRUE; + if (!connected && error == ERROR_IO_PENDING) { + pending_io = true; + while (!stopping.load()) { + const DWORD wait = ::WaitForSingleObject(operation.hEvent, 50); + if (wait == WAIT_OBJECT_0) { + DWORD transferred = 0; + connected = ::GetOverlappedResult( + pipe, &operation, &transferred, FALSE); + break; + } + if (wait == WAIT_FAILED) break; + } + } + if (!connected) { + if (pending_io) { + ::CancelIoEx(pipe, &operation); + ::WaitForSingleObject(operation.hEvent, INFINITE); + } + } + ::CloseHandle(operation.hEvent); + return connected != FALSE && !stopping.load(); +} + +bool remove_session_profile_directory( + const std::filesystem::path& user_data_path, + const std::string& session_id) { + const auto directory = user_data_path / "EBWebView" / + acecode::path_from_utf8(agent_browser_session_profile_directory_name(session_id)); + std::error_code ec; + std::filesystem::remove_all(directory, ec); + if (ec) { + LOG_WARN("[agent-browser] failed to remove session store " + + acecode::path_to_utf8(directory) + ": " + ec.message()); + return false; + } + return true; +} + +HRESULT create_controller_for_store( + const Microsoft::WRL::ComPtr& environment, + HWND browser_widget, + const AgentBrowserCookieStoreKey& key, + ICoreWebView2CreateCoreWebView2ControllerCompletedHandler* handler) { + if (!environment) return E_UNEXPECTED; + if (key.kind == AgentBrowserCookieStoreKind::Shared) { + return environment->CreateCoreWebView2Controller(browser_widget, handler); + } + Microsoft::WRL::ComPtr environment10; + if (FAILED(environment.As(&environment10)) || !environment10) { + return E_NOINTERFACE; + } + Microsoft::WRL::ComPtr options; + HRESULT result = environment10->CreateCoreWebView2ControllerOptions(&options); + if (FAILED(result) || !options) return FAILED(result) ? result : E_FAIL; + const AgentBrowserWindowsProfile profile = agent_browser_windows_profile(key); + result = options->put_ProfileName(acecode::utf8_to_wide(profile.name).c_str()); + if (FAILED(result)) return result; + result = options->put_IsInPrivateModeEnabled(profile.in_private ? TRUE : FALSE); + if (FAILED(result)) return result; + return environment10->CreateCoreWebView2ControllerWithOptions( + browser_widget, options.Get(), handler); +} + +Microsoft::WRL::ComPtr profile_of( + const Microsoft::WRL::ComPtr& webview) { + Microsoft::WRL::ComPtr webview13; + Microsoft::WRL::ComPtr profile; + if (webview && SUCCEEDED(webview.As(&webview13)) && webview13 && + SUCCEEDED(webview13->get_Profile(&profile))) { + return profile; + } + return nullptr; +} + +void clear_profile(const Microsoft::WRL::ComPtr& profile, + const std::shared_ptr& lease, + const StoreDone& done) { + Microsoft::WRL::ComPtr profile2; + if (!profile || FAILED(profile.As(&profile2)) || !profile2) { + done(false, "WebView2 runtime cannot clear browsing data"); + return; + } + auto completed = Microsoft::WRL::Callback< + ICoreWebView2ClearBrowsingDataCompletedHandler>( + [lease, done](HRESULT result) -> HRESULT { + done(SUCCEEDED(result), + SUCCEEDED(result) ? std::string() + : "failed to clear browsing data (" + + hresult_text(result) + ")"); + return S_OK; + }); + const HRESULT result = profile2->ClearBrowsingDataAll(completed.Get()); + if (FAILED(result)) { + done(false, "failed to clear browsing data (" + hresult_text(result) + ")"); + } +} + +Microsoft::WRL::ComPtr cookie_manager_of( + const Microsoft::WRL::ComPtr& profile) { + Microsoft::WRL::ComPtr profile5; + Microsoft::WRL::ComPtr manager; + if (profile && SUCCEEDED(profile.As(&profile5)) && profile5 && + SUCCEEDED(profile5->get_CookieManager(&manager))) { + return manager; + } + return nullptr; +} + + +void copy_profile_cookies( + const Microsoft::WRL::ComPtr& source, + const Microsoft::WRL::ComPtr& target, + const std::shared_ptr& lease, + const std::string& parent_session_id, + const std::function& done) { + auto completed = Microsoft::WRL::Callback< + ICoreWebView2GetCookiesCompletedHandler>( + [target, done, lease, parent_session_id]( + HRESULT result, ICoreWebView2CookieList* list) -> HRESULT { + UINT32 count = 0; + std::size_t copied = 0; + if (SUCCEEDED(result) && list && SUCCEEDED(list->get_Count(&count))) { + for (UINT32 index = 0; index < count; ++index) { + Microsoft::WRL::ComPtr cookie; + Microsoft::WRL::ComPtr copy; + if (SUCCEEDED(list->GetValueAtIndex(index, &cookie)) && cookie && + SUCCEEDED(target->CopyCookie(cookie.Get(), ©)) && copy && + SUCCEEDED(target->AddOrUpdateCookie(copy.Get()))) { + ++copied; + } + } + } + LOG_INFO("[agent-browser] seeded forked cookie store from " + + parent_session_id + ": copied=" + std::to_string(copied) + + " of " + std::to_string(count)); + done(); + return S_OK; + }); + if (FAILED(source->GetCookies(L"", completed.Get()))) { + LOG_WARN("[agent-browser] failed to read parent cookie store " + + parent_session_id); + done(); + } +} + +} // namespace acecode::desktop::agent_browser_windows +#endif diff --git a/src/apps/desktop/agent_browser_windows_helpers.hpp b/src/apps/desktop/agent_browser_windows_helpers.hpp new file mode 100644 index 000000000..bdd9b5db3 --- /dev/null +++ b/src/apps/desktop/agent_browser_windows_helpers.hpp @@ -0,0 +1,107 @@ +#pragma once + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "agent_browser_cookie_jars.hpp" +#include "agent_browser_host.hpp" + +namespace acecode::desktop::agent_browser_windows { +inline constexpr std::size_t kAgentBrowserMaxConsoleEntries = 1000; +inline constexpr std::size_t kAgentBrowserMaxConsoleEntryBytes = 16 * 1024; +inline constexpr std::size_t kAgentBrowserMaxFaviconBytes = 256 * 1024; +inline constexpr char kAgentBrowserElementPickerKey[] = + "__acecodeAgentBrowserElementPickerV1"; + +// 隐藏的临时 controller:没有页面时借它拿到某个 Profile(清空、删除、复制 +// cookie);最后一个引用释放时关闭。 +struct ProfileLease { + Microsoft::WRL::ComPtr controller; + Microsoft::WRL::ComPtr webview; + ProfileLease() = default; + ProfileLease(const ProfileLease&) = delete; + ProfileLease& operator=(const ProfileLease&) = delete; + ~ProfileLease() { + webview.Reset(); + if (controller) controller->Close(); + } +}; +using ProfileCallback = std::function& profile, + const std::string& error, + const std::shared_ptr& lease)>; +using StoreDone = std::function; + + +bool valid_agent_browser_favicon(const std::string& value); +const char* agent_browser_favicon_expression(); +bool agent_browser_external_handoff_candidate(const std::string& uri); +const char* agent_browser_web_error_kind( + COREWEBVIEW2_WEB_ERROR_STATUS status); +const char* agent_browser_process_failure_kind( + COREWEBVIEW2_PROCESS_FAILED_KIND kind, + COREWEBVIEW2_PROCESS_FAILED_REASON reason); +std::string clip_agent_browser_text(std::string value, + std::size_t max_bytes); +std::string cdp_remote_object_text(const nlohmann::json& object); +const char* agent_browser_element_picker_expression(); +HWND create_agent_browser_widget(HWND parent); +void park_agent_browser_widget(HWND widget); +void hide_agent_browser_widget(HWND widget); +POINT main_web_content_origin(HWND parent); +bool apply_agent_browser_widget_region( + HWND widget, + int width, + int height, + const std::vector& occlusions); +std::string hresult_text(HRESULT result); +bool proxy_aborted(const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline); +bool pipe_transfer(HANDLE pipe, + void* buffer, + std::size_t size, + bool write, + const std::atomic& stopping, + std::chrono::steady_clock::time_point deadline); +bool connect_pipe(HANDLE pipe, + const std::atomic& stopping); +bool remove_session_profile_directory( + const std::filesystem::path& user_data_path, + const std::string& session_id); +HRESULT create_controller_for_store( + const Microsoft::WRL::ComPtr& environment, + HWND browser_widget, + const AgentBrowserCookieStoreKey& key, + ICoreWebView2CreateCoreWebView2ControllerCompletedHandler* handler); +Microsoft::WRL::ComPtr profile_of( + const Microsoft::WRL::ComPtr& webview); +void clear_profile(const Microsoft::WRL::ComPtr& profile, + const std::shared_ptr& lease, + const StoreDone& done); +Microsoft::WRL::ComPtr cookie_manager_of( + const Microsoft::WRL::ComPtr& profile); + +void copy_profile_cookies( + const Microsoft::WRL::ComPtr& source, + const Microsoft::WRL::ComPtr& target, + const std::shared_ptr& lease, + const std::string& parent_session_id, + const std::function& done); + +} // namespace acecode::desktop::agent_browser_windows +#endif diff --git a/src/apps/desktop/main.cpp b/src/apps/desktop/main.cpp index 2f52b4534..33d1d5a4b 100644 --- a/src/apps/desktop/main.cpp +++ b/src/apps/desktop/main.cpp @@ -15,6 +15,7 @@ #include "daemon_pool.hpp" #include "agent_browser_host.hpp" +#include "agent_browser_cookie_bridge.hpp" #include "ios_simulator_view.hpp" #include "ipc/agent_browser_runtime.hpp" #include "context_items.hpp" @@ -43,7 +44,6 @@ #include "web_host.hpp" #include "web_host_close_policy.hpp" #include "workspace/workspace_registry.hpp" - #ifdef ACECODE_DEEPIN #include "deepin_window_effects.hpp" #endif @@ -1113,6 +1113,7 @@ int main(int argc, char** argv) { "}));}catch(e){}})();"); }, [&host](std::function task) { host.dispatch(std::move(task)); }); + acecode::desktop::install_agent_browser_cookie_bridge(host, agent_browser); const auto ios_simulator_view = install_ios_simulator_view(host); std::atomic restart_requested{false}; host.set_title("ACECode"); diff --git a/src/apps/tui/app/tui_services.cpp b/src/apps/tui/app/tui_services.cpp index 595f744af..85443c476 100644 --- a/src/apps/tui/app/tui_services.cpp +++ b/src/apps/tui/app/tui_services.cpp @@ -12,6 +12,7 @@ #include "security/audit_log.hpp" #include "skills/skill_registry.hpp" #include "skills/skill_usage_store.hpp" +#include "session_host/agent_browser_store_lifecycle.hpp" #include "session_host/memory_runtime.hpp" #include "workspace/workspace_registry.hpp" #include "utils/paths.hpp" @@ -53,6 +54,7 @@ bool TuiServices::initialize(const InteractiveCliOptions& cli, const std::string workspace_tool_deps->projects_dir = projects; register_workspace_tools(*tools, workspace_tool_deps); skill_usage = std::make_shared(get_acecode_dir() + "/.skill_usage_state.json"); + agent_browser_stores = std::make_unique(); return true; } } diff --git a/src/apps/tui/app/tui_services.hpp b/src/apps/tui/app/tui_services.hpp index e2af1574e..c626643bf 100644 --- a/src/apps/tui/app/tui_services.hpp +++ b/src/apps/tui/app/tui_services.hpp @@ -8,6 +8,7 @@ namespace acecode { struct InteractiveCliOptions; class HookManager; class ToolExecutor; class SkillRegistry; class MemoryRuntime; class McpManager; class SkillUsageStore; struct WorkspaceToolDeps; +class AgentBrowserStoreLifecycle; namespace desktop { class WorkspaceRegistry; } } namespace acecode::tui { @@ -31,6 +32,8 @@ class TuiServices { std::shared_ptr workspace_tool_deps; // Shared with UI state and session skill callbacks, persisted at one store. std::shared_ptr skill_usage; + // TUI 删除 / 分叉会话时给 Desktop 记 Agent Browser cookie 存储待办。 + std::unique_ptr agent_browser_stores; std::optional cwd_model_override; ModelProfile initial_model; }; diff --git a/src/apps/web/handlers/agent_browser_top_sites.cpp b/src/apps/web/handlers/agent_browser_top_sites.cpp new file mode 100644 index 000000000..2f2ecdf55 --- /dev/null +++ b/src/apps/web/handlers/agent_browser_top_sites.cpp @@ -0,0 +1,358 @@ +#include "web/handlers/agent_browser_top_sites.hpp" + +#include "utils/atomic_file.hpp" +#include "utils/encoding.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace acecode::web::agent_browser_top_sites { +using nlohmann::json; + +namespace { + +constexpr int kDocumentVersion = 1; +constexpr std::int64_t kDayMs = 24LL * 60 * 60 * 1000; + +bool starts_with_ascii_ci(const std::string& value, const char* prefix) { + std::size_t i = 0; + for (; prefix[i] != '\0'; ++i) { + if (i >= value.size()) return false; + const auto a = static_cast(value[i]); + const auto b = static_cast(prefix[i]); + if (std::tolower(a) != std::tolower(b)) return false; + } + return true; +} + +bool has_control_or_space(const std::string& value) { + return std::any_of(value.begin(), value.end(), [](char c) { + const auto byte = static_cast(c); + return byte <= 0x20 || byte == 0x7F; + }); +} + +std::string string_field(const json& object, const char* key) { + if (!object.is_object()) return {}; + const auto it = object.find(key); + return it != object.end() && it->is_string() ? it->get() : std::string(); +} + +std::int64_t int_field(const json& object, const char* key) { + if (!object.is_object()) return 0; + const auto it = object.find(key); + if (it == object.end() || !it->is_number()) return 0; + if (it->is_number_float()) { + const double value = it->get(); + return value > 0 ? static_cast(value) : 0; + } + const auto value = it->get(); + return value > 0 ? value : 0; +} + +std::string clean_title(const std::string& title) { + std::string out; + out.reserve(title.size()); + for (char c : title) { + const auto byte = static_cast(c); + out.push_back(byte < 0x20 || byte == 0x7F ? ' ' : c); + } + const auto first = out.find_first_not_of(' '); + if (first == std::string::npos) return {}; + const auto last = out.find_last_not_of(' '); + out = out.substr(first, last - first + 1); + return ensure_utf8(truncate_utf8_prefix(out, kMaxTitleBytes)); +} + +// "https://user@Example.com:8080/a" → "Example.com:8080"(推荐无标题时显示)。 +std::string host_of(const std::string& url) { + const auto scheme_end = url.find("://"); + if (scheme_end == std::string::npos) return url; + const auto start = scheme_end + 3; + auto end = url.find_first_of("/?#", start); + if (end == std::string::npos) end = url.size(); + std::string authority = url.substr(start, end - start); + const auto at = authority.rfind('@'); + if (at != std::string::npos) authority = authority.substr(at + 1); + return authority; +} + +double recency_weight(std::int64_t age_ms) { + if (age_ms < 0) age_ms = 0; + if (age_ms <= 4 * kDayMs) return 1.0; + if (age_ms <= 14 * kDayMs) return 0.7; + if (age_ms <= 31 * kDayMs) return 0.5; + if (age_ms <= 90 * kDayMs) return 0.3; + return 0.1; +} + +json site_entry(const std::string& url, + const std::string& title, + const std::string& favicon, + std::int64_t visit_count, + std::int64_t last_visit_ms) { + json site = json::object(); + site["url"] = url; + if (!title.empty()) site["title"] = title; + if (!favicon.empty()) site["favicon"] = favicon; + site["visit_count"] = visit_count; + site["last_visit_ms"] = last_visit_ms; + return site; +} + +// 分数从高到低;同分时最近访问的在前,再按 URL 保证顺序稳定。 +void sort_by_score(std::vector& sites, std::int64_t now_ms) { + std::stable_sort(sites.begin(), sites.end(), [now_ms](const json& a, const json& b) { + const double score_a = site_score(a, now_ms); + const double score_b = site_score(b, now_ms); + if (score_a != score_b) return score_a > score_b; + const auto last_a = int_field(a, "last_visit_ms"); + const auto last_b = int_field(b, "last_visit_ms"); + if (last_a != last_b) return last_a > last_b; + return string_field(a, "url") < string_field(b, "url"); + }); +} + +std::int64_t latest_visit(const json& doc) { + std::int64_t latest = 0; + for (const auto& site : doc["sites"]) { + latest = std::max(latest, int_field(site, "last_visit_ms")); + } + return latest; +} + +// 超出上限时丢分数最低的网站;分数的「现在」取文档里最近一次访问, +// 让同一份磁盘内容每次归一出相同结果。 +void trim_sites(json& doc, std::int64_t now_ms) { + auto& sites = doc["sites"]; + if (sites.size() <= kMaxStoredSites) return; + std::vector sorted(sites.begin(), sites.end()); + sort_by_score(sorted, now_ms); + sorted.resize(kMaxStoredSites); + sites = json(sorted); +} + +json::iterator find_site(json& doc, const std::string& url) { + auto& sites = doc["sites"]; + return std::find_if(sites.begin(), sites.end(), [&url](const json& site) { + return string_field(site, "url") == url; + }); +} + +} // namespace + +std::string file_path(const std::string& data_dir) { + return path_to_utf8(path_from_utf8(data_dir) / "agent_browser_top_sites.json"); +} + +std::string normalize_url(const std::string& raw) { + std::string url = raw; + const auto hash = url.find('#'); + if (hash != std::string::npos) url.resize(hash); + if (url.empty() || url.size() > kMaxUrlBytes || has_control_or_space(url)) return {}; + std::size_t scheme_length = 0; + if (starts_with_ascii_ci(url, "https://")) { + scheme_length = 5; + } else if (starts_with_ascii_ci(url, "http://")) { + scheme_length = 4; + } else { + return {}; + } + for (std::size_t i = 0; i < scheme_length; ++i) { + url[i] = static_cast(std::tolower(static_cast(url[i]))); + } + if (host_of(url).empty()) return {}; + return url; +} + +std::string normalize_favicon(const std::string& favicon) { + if (favicon.empty()) return {}; + if (starts_with_ascii_ci(favicon, "data:image/")) { + return favicon.size() <= kMaxFaviconDataUrlBytes ? favicon : std::string(); + } + if (favicon.size() > kMaxUrlBytes || has_control_or_space(favicon)) return {}; + if (starts_with_ascii_ci(favicon, "https://") || starts_with_ascii_ci(favicon, "http://")) { + return favicon; + } + return {}; +} + +json empty_document() { + return json{{"version", kDocumentVersion}, + {"sites", json::array()}, + {"blocked", json::array()}}; +} + +json normalize_document(const json& raw) { + json doc = empty_document(); + if (!raw.is_object()) return doc; + + std::unordered_map index; + const auto sites_it = raw.find("sites"); + if (sites_it != raw.end() && sites_it->is_array()) { + for (const auto& site : *sites_it) { + const auto url = normalize_url(string_field(site, "url")); + if (url.empty()) continue; + const auto title = clean_title(string_field(site, "title")); + const auto favicon = normalize_favicon(string_field(site, "favicon")); + const auto count = std::max(1, int_field(site, "visit_count")); + const auto last = int_field(site, "last_visit_ms"); + const auto existing = index.find(url); + if (existing == index.end()) { + index.emplace(url, doc["sites"].size()); + doc["sites"].push_back(site_entry(url, title, favicon, count, last)); + continue; + } + // 同一 URL 出现两次(手改文件 / 旧版本 fragment 未去掉):合并计数, + // 标题图标取最近那次。 + auto& merged = doc["sites"][existing->second]; + merged["visit_count"] = int_field(merged, "visit_count") + count; + if (last >= int_field(merged, "last_visit_ms")) { + merged["last_visit_ms"] = last; + if (!title.empty()) merged["title"] = title; + if (!favicon.empty()) merged["favicon"] = favicon; + } + } + } + + const auto blocked_it = raw.find("blocked"); + if (blocked_it != raw.end() && blocked_it->is_array()) { + std::unordered_set seen; + for (const auto& value : *blocked_it) { + if (!value.is_string()) continue; + const auto url = normalize_url(value.get()); + if (url.empty() || !seen.insert(url).second) continue; + doc["blocked"].push_back(url); + } + auto& blocked = doc["blocked"]; + if (blocked.size() > kMaxBlockedUrls) { + blocked.erase(blocked.begin(), + blocked.begin() + static_cast(blocked.size() - kMaxBlockedUrls)); + } + } + + trim_sites(doc, latest_visit(doc)); + return doc; +} + +double site_score(const json& site, std::int64_t now_ms) { + const auto count = std::max(1, int_field(site, "visit_count")); + return static_cast(count) * + recency_weight(now_ms - int_field(site, "last_visit_ms")); +} + +bool record_visit(json& doc, const Visit& visit, std::int64_t now_ms) { + const auto url = normalize_url(visit.url); + if (url.empty()) return false; + const auto title = clean_title(visit.title); + const auto favicon = normalize_favicon(visit.favicon); + + auto site = find_site(doc, url); + if (site == doc["sites"].end()) { + if (!visit.new_visit) return false; + doc["sites"].push_back(site_entry(url, title, favicon, 1, now_ms)); + trim_sites(doc, now_ms); + return true; + } + + bool changed = false; + if (visit.new_visit) { + (*site)["visit_count"] = int_field(*site, "visit_count") + 1; + (*site)["last_visit_ms"] = now_ms; + changed = true; + } + if (!title.empty() && string_field(*site, "title") != title) { + (*site)["title"] = title; + changed = true; + } + if (!favicon.empty() && string_field(*site, "favicon") != favicon) { + (*site)["favicon"] = favicon; + changed = true; + } + return changed; +} + +bool dismiss(json& doc, const std::string& raw_url) { + const auto url = normalize_url(raw_url); + if (url.empty()) return false; + auto& blocked = doc["blocked"]; + if (std::find(blocked.begin(), blocked.end(), json(url)) != blocked.end()) return false; + blocked.push_back(url); + if (blocked.size() > kMaxBlockedUrls) blocked.erase(blocked.begin()); + return true; +} + +bool restore(json& doc, const std::string& raw_url) { + const auto url = normalize_url(raw_url); + if (url.empty()) return false; + auto& blocked = doc["blocked"]; + const auto it = std::find(blocked.begin(), blocked.end(), json(url)); + if (it == blocked.end()) return false; + blocked.erase(it); + return true; +} + +json display_sites(const json& doc, std::size_t limit, std::int64_t now_ms) { + std::unordered_set blocked; + for (const auto& value : doc["blocked"]) { + if (value.is_string()) blocked.insert(value.get()); + } + std::vector candidates; + for (const auto& site : doc["sites"]) { + if (blocked.count(string_field(site, "url")) == 0) candidates.push_back(site); + } + sort_by_score(candidates, now_ms); + + json out = json::array(); + for (const auto& site : candidates) { + if (out.size() >= limit) break; + const auto url = string_field(site, "url"); + auto title = string_field(site, "title"); + if (title.empty()) title = host_of(url); + json item{{"url", url}, {"title", title}}; + const auto favicon = string_field(site, "favicon"); + if (!favicon.empty()) item["favicon"] = favicon; + out.push_back(std::move(item)); + } + return out; +} + +json load(const std::string& path, std::string* warning) { + if (warning) warning->clear(); + std::error_code ec; + const auto fs_path = path_from_utf8(path); + if (!std::filesystem::exists(fs_path, ec) || ec) return empty_document(); + std::ifstream in(fs_path, std::ios::binary); + if (!in.is_open()) { + if (warning) *warning = "cannot open " + path; + return empty_document(); + } + const std::string text((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + const auto parsed = json::parse(text, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) { + if (warning) *warning = path + ": invalid JSON"; + return empty_document(); + } + return normalize_document(parsed); +} + +bool save(const std::string& path, const json& doc, std::string* error) { + if (error) error->clear(); + const std::string text = doc.dump(-1, ' ', false, json::error_handler_t::replace); + if (!atomic_write_file(path, text)) { + if (error) *error = "cannot write " + path; + return false; + } + return true; +} + +} // namespace acecode::web::agent_browser_top_sites diff --git a/src/apps/web/handlers/agent_browser_top_sites.hpp b/src/apps/web/handlers/agent_browser_top_sites.hpp new file mode 100644 index 000000000..e9c785614 --- /dev/null +++ b/src/apps/web/handlers/agent_browser_top_sites.hpp @@ -0,0 +1,74 @@ +#pragma once + +// Agent Browser 新标签页「推荐」的数据来源:常访问网站。 +// +// 对照 Codex 桌面版:它的新标签页读 Chromium 内核的 TopSites(按访问频率排出的 +// 常访问网站),取前 4 个,允许用户移除单个推荐并撤销。ACECode 的 Agent Browser 是 +// WebView2 / WKWebView,拿不到浏览历史,所以由前端在页面加载完成时上报访问, +// daemon 记在 /agent_browser_top_sites.json(Desktop 每次启动端口会变, +// 前端 localStorage 跨启动保不住)。 +// +// 本文件只有纯逻辑(文档归一、记访问、排序、移除 / 恢复)与整份读写,路由在 +// routes/routes_agent_browser.cpp。 + +#include + +#include +#include +#include + +namespace acecode::web::agent_browser_top_sites { + +// 存多少个网站 / 多少条已移除记录。超出时丢掉分数最低的网站、最早移除的记录。 +inline constexpr std::size_t kMaxStoredSites = 200; +inline constexpr std::size_t kMaxBlockedUrls = 200; +// 新标签页一次最多展示几个推荐(Codex 同样是 4 个)。 +inline constexpr std::size_t kDefaultDisplayLimit = 4; +inline constexpr std::size_t kMaxDisplayLimit = 12; +inline constexpr std::size_t kMaxUrlBytes = 2048; +inline constexpr std::size_t kMaxTitleBytes = 300; +// 同源图标由 Desktop 抓成 data URL(上限 256KB),推荐只要 32px 图标,过大的不存, +// 前端退回地球图标。 +inline constexpr std::size_t kMaxFaviconDataUrlBytes = 64 * 1024; + +struct Visit { + std::string url; + std::string title; + std::string favicon; + // true = 一次新的导航(计数 +1);false = 同一次访问后到的标题 / 图标补充, + // 只更新已有记录,不新建。 + bool new_visit = true; +}; + +std::string file_path(const std::string& data_dir); + +// 只接受 http / https,去掉 #fragment;不合法返回空串。 +std::string normalize_url(const std::string& url); +// http(s) 地址或 data:image/ 且不超上限;不合法返回空串。 +std::string normalize_favicon(const std::string& favicon); + +// 把磁盘内容整理成合法文档:丢弃坏条目、合并重复 URL、套用上限。 +nlohmann::json normalize_document(const nlohmann::json& raw); +nlohmann::json empty_document(); + +// 访问的推荐分数:访问次数 × 按最近访问时间分档的权重(4 天内 1.0、14 天内 0.7、 +// 31 天内 0.5、90 天内 0.3、更早 0.1,取自 Firefox frecency 的时间分档)。 +double site_score(const nlohmann::json& site, std::int64_t now_ms); + +// 记一次访问;返回文档是否变化(需要落盘)。 +bool record_visit(nlohmann::json& doc, const Visit& visit, std::int64_t now_ms); +// 移除 / 恢复一个推荐;返回文档是否变化。 +bool dismiss(nlohmann::json& doc, const std::string& url); +bool restore(nlohmann::json& doc, const std::string& url); + +// 前端展示用:去掉已移除的,按分数排序取前 limit 个,每项 {url, title, favicon}。 +// 没有标题时用主机名。 +nlohmann::json display_sites(const nlohmann::json& doc, + std::size_t limit, + std::int64_t now_ms); + +// 读不到 / 损坏都返回空文档(损坏时 warning 非空)。 +nlohmann::json load(const std::string& path, std::string* warning = nullptr); +bool save(const std::string& path, const nlohmann::json& doc, std::string* error = nullptr); + +} // namespace acecode::web::agent_browser_top_sites diff --git a/src/apps/web/handlers/session_batch_handler.cpp b/src/apps/web/handlers/session_batch_handler.cpp new file mode 100644 index 000000000..c4869bea0 --- /dev/null +++ b/src/apps/web/handlers/session_batch_handler.cpp @@ -0,0 +1,172 @@ +#include "web/handlers/session_batch_handler.hpp" + +#include +#include + +namespace acecode::web { + +using nlohmann::json; + +namespace { + +bool valid_session_id(const std::string& id) { + return !id.empty() && id.size() <= 128 && + std::all_of(id.begin(), id.end(), [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + }); +} + +bool parse_action(const std::string& name, SessionBatchAction& out) { + static const std::pair kActions[] = { + {"pin", SessionBatchAction::Pin}, + {"unpin", SessionBatchAction::Unpin}, + {"archive", SessionBatchAction::Archive}, + {"unarchive", SessionBatchAction::Unarchive}, + {"read", SessionBatchAction::Read}, + {"unread", SessionBatchAction::Unread}, + }; + for (const auto& [key, action] : kActions) { + if (name == key) { + out = action; + return true; + } + } + return false; +} + +std::string order_key(const PinnedSessionOrderItem& item) { + return item.workspace_hash + '\0' + item.session_id; +} + +} // namespace + +const char* session_batch_action_name(SessionBatchAction action) { + switch (action) { + case SessionBatchAction::Pin: return "pin"; + case SessionBatchAction::Unpin: return "unpin"; + case SessionBatchAction::Archive: return "archive"; + case SessionBatchAction::Unarchive: return "unarchive"; + case SessionBatchAction::Read: return "read"; + case SessionBatchAction::Unread: return "unread"; + } + return ""; +} + +bool parse_session_batch_request(const json& body, + SessionBatchRequest& out, + std::string& error) { + out = SessionBatchRequest{}; + if (!body.is_object()) { + error = "request body must be an object"; + return false; + } + if (!body.contains("action") || !body["action"].is_string() || + !parse_action(body["action"].get(), out.action)) { + error = "action must be one of pin, unpin, archive, unarchive, read, unread"; + return false; + } + if (!body.contains("items") || !body["items"].is_array() || body["items"].empty()) { + error = "items must be a non-empty array"; + return false; + } + if (body["items"].size() > kMaxSessionBatchItems) { + error = "too many items (max " + std::to_string(kMaxSessionBatchItems) + ")"; + return false; + } + + std::unordered_set seen; + for (const auto& raw : body["items"]) { + if (!raw.is_object()) { + error = "each item must be an object"; + return false; + } + SessionBatchItem item; + if (raw.contains("session_id") && raw["session_id"].is_string()) { + item.session_id = raw["session_id"].get(); + } + if (!valid_session_id(item.session_id)) { + error = "invalid session_id"; + return false; + } + if (raw.contains("workspace_hash") && raw["workspace_hash"].is_string()) { + item.workspace_hash = raw["workspace_hash"].get(); + } + if (raw.contains("no_workspace") && raw["no_workspace"].is_boolean()) { + item.no_workspace = raw["no_workspace"].get(); + } + // 前端的置顶作用域写法(workspace_hash = "__no_workspace__")也接受。 + if (item.workspace_hash == kNoWorkspacePinScope) { + item.no_workspace = true; + item.workspace_hash.clear(); + } + if (item.no_workspace) item.workspace_hash.clear(); + // 浏览器发来的 JSON 整数在 nlohmann 里可能是有符号类型,只要非负就接受。 + if (raw.contains("cursor") && raw["cursor"].is_number_integer() && + (raw["cursor"].is_number_unsigned() || raw["cursor"].get() >= 0)) { + item.cursor = raw["cursor"].get(); + } + if (!seen.insert(item.pin_scope() + '\0' + item.session_id).second) continue; + out.items.push_back(std::move(item)); + } + return true; +} + +std::vector apply_batch_pin_session_ids( + const std::vector& current, + const std::vector& batch_ids, + bool pin) { + const auto normalized = normalize_pinned_session_ids(current); + std::unordered_set batch; + for (const auto& id : batch_ids) { + if (!id.empty()) batch.insert(id); + } + if (!pin) { + std::vector out; + for (const auto& id : normalized) { + if (!batch.count(id)) out.push_back(id); + } + return out; + } + const std::unordered_set existing(normalized.begin(), normalized.end()); + std::vector out; + std::unordered_set added; + for (const auto& id : batch_ids) { + if (id.empty() || existing.count(id) || !added.insert(id).second) continue; + out.push_back(id); + } + out.insert(out.end(), normalized.begin(), normalized.end()); + return out; +} + +std::vector apply_batch_pin_order_items( + const std::vector& current, + const std::vector& batch_items, + bool pin) { + const auto normalized = normalize_pinned_session_order_items(current); + std::unordered_set batch; + for (const auto& item : batch_items) { + if (!item.workspace_hash.empty() && !item.session_id.empty()) batch.insert(order_key(item)); + } + if (!pin) { + std::vector out; + for (const auto& item : normalized) { + if (!batch.count(order_key(item))) out.push_back(item); + } + return out; + } + std::unordered_set existing; + for (const auto& item : normalized) existing.insert(order_key(item)); + std::vector out; + std::unordered_set added; + for (const auto& item : batch_items) { + if (item.workspace_hash.empty() || item.session_id.empty()) continue; + const auto key = order_key(item); + if (existing.count(key) || !added.insert(key).second) continue; + out.push_back(item); + } + out.insert(out.end(), normalized.begin(), normalized.end()); + return out; +} + +} // namespace acecode::web diff --git a/src/apps/web/handlers/session_batch_handler.hpp b/src/apps/web/handlers/session_batch_handler.hpp new file mode 100644 index 000000000..3dcfc2976 --- /dev/null +++ b/src/apps/web/handlers/session_batch_handler.hpp @@ -0,0 +1,69 @@ +#pragma once + +// 侧栏多选批量操作(POST /api/session-batch)的纯函数层:请求解析与置顶顺序计算。 +// 曾经前端对每个会话逐个调用单项接口(置顶一次 2 个请求、归档还要再拉一次列表), +// 选 20 个就是几十个往返;批量接口一次请求做完,并把置顶文件 / 全局顺序 / 已读状态 +// 的落盘合并成每个文件一次。磁盘与会话状态的读写在 routes_session_batch.cpp。 + +#include "web/handlers/pinned_sessions_handler.hpp" + +#include + +#include +#include +#include +#include + +namespace acecode::web { + +inline constexpr const char* kNoWorkspacePinScope = "__no_workspace__"; +// 单次请求的上限。侧栏一屏最多几百行,超过就是异常请求。 +inline constexpr std::size_t kMaxSessionBatchItems = 500; + +enum class SessionBatchAction { Pin, Unpin, Archive, Unarchive, Read, Unread }; + +struct SessionBatchItem { + std::string session_id; + // 会话所属工作区;无工作区任务为空串并置 no_workspace。 + std::string workspace_hash; + bool no_workspace = false; + // 仅 read 使用:客户端已看到的事件游标,与 WS mark_session_read 同义。 + std::uint64_t cursor = 0; + + // 置顶存储的作用域:工作区 hash 或 "__no_workspace__"。 + std::string pin_scope() const { + return no_workspace ? std::string(kNoWorkspacePinScope) : workspace_hash; + } +}; + +struct SessionBatchRequest { + SessionBatchAction action = SessionBatchAction::Pin; + // 保持请求顺序(即侧栏可见顺序);同一会话重复出现只保留第一次。 + std::vector items; +}; + +const char* session_batch_action_name(SessionBatchAction action); + +// 解析请求体。失败返回 false 并写 error(调用方回 400)。会话 id 只接受 +// [A-Za-z0-9_-]、长度 1..128,与置顶存储的 id 规则一致。 +bool parse_session_batch_request(const nlohmann::json& body, + SessionBatchRequest& out, + std::string& error); + +// 一个作用域内的置顶 id 列表应用一批置顶 / 取消置顶。 +// 置顶:尚未置顶的会话按请求顺序整体插到最前(请求里第一个排在最上面), +// 已经置顶的保持原位置不动 —— 与逐个点「置顶」的结果一致,不会把已置顶的会话 +// 挤乱。取消置顶:全部移除。 +std::vector apply_batch_pin_session_ids( + const std::vector& current, + const std::vector& batch_ids, + bool pin); + +// 跨作用域的全局置顶视觉顺序,规则同上。pin 时 newly_pinned 只应包含本次 +// 新置顶的项(原本就置顶的会话不移动)。 +std::vector apply_batch_pin_order_items( + const std::vector& current, + const std::vector& batch_items, + bool pin); + +} // namespace acecode::web diff --git a/src/apps/web/routes/routes_agent_browser.cpp b/src/apps/web/routes/routes_agent_browser.cpp new file mode 100644 index 000000000..726d5704f --- /dev/null +++ b/src/apps/web/routes/routes_agent_browser.cpp @@ -0,0 +1,162 @@ +#include "web/server_impl.hpp" +#include "web/auth.hpp" +#include "web/handlers/agent_browser_top_sites.hpp" + +#include "environment/data_dir_migration.hpp" +#include "utils/logger.hpp" +#include "utils/utf8_path.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace acecode::web { +using nlohmann::json; +namespace top_sites = agent_browser_top_sites; + +namespace { + +// 进程内串行化整份「读-改-写」。Desktop 只连一个共享 daemon,Agent Browser +// 的访问只从它的页面上报,不需要跨进程锁;atomic_write_file 保证文件本身不写坏。 +std::mutex& top_sites_mutex() { + static std::mutex mu; + return mu; +} + +std::int64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +std::size_t parse_limit(const char* raw) { + if (!raw || !*raw) return top_sites::kDefaultDisplayLimit; + try { + const long value = std::stol(raw); + if (value <= 0) return top_sites::kDefaultDisplayLimit; + return std::min(static_cast(value), + top_sites::kMaxDisplayLimit); + } catch (...) { + return top_sites::kDefaultDisplayLimit; + } +} + +} // namespace + +// Agent Browser 新标签页的「推荐」(常访问网站)。浏览记录属于隐私数据, +// 只给本机直连客户端(远程 Web 代理转发进来的 127.0.0.2 也拒绝);Agent Browser +// 本来就只存在于本机 Desktop。 +void WebServer::Impl::register_agent_browser() { + const auto respond = [this](const crow::request& req, int status, const json& body) { + crow::response response(status); + response.add_header("Content-Type", "application/json"); + response.add_header("Cache-Control", "no-store"); + response.body = body.dump(-1, ' ', false, json::error_handler_t::replace); + return with_cors(req, std::move(response)); + }; + const auto gate = [this, respond](const crow::request& req) -> std::optional { + if (!is_trusted_local_client_address(req.remote_ip_address)) { + log_unauthorized(req.url, req.remote_ip_address, "agent browser non-local"); + return respond(req, 403, {{"error", "LOCAL_ONLY"}}); + } + if (auto rejected = require_auth(req)) return rejected; + if (deps.config_path.empty()) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + return std::nullopt; + }; + const auto store_path = [this]() { + const auto data_dir = path_from_utf8(deps.config_path).parent_path(); + return top_sites::file_path(path_to_utf8(data_dir)); + }; + // 修改并落盘;mutate 返回 false 表示无变化。数据目录迁移期间拒绝写入, + // 否则迁移的变更复检会因为这个文件被改而失败。 + const auto mutate = [this, respond, store_path]( + const crow::request& req, + const std::function& change) { + std::shared_lock migration_lock( + environment::data_dir_write_mutex()); + if (environment::data_dir_writes_blocked()) { + return respond(req, 409, {{"error", "DATA_DIR_MIGRATION_ACTIVE"}}); + } + std::lock_guard lock(top_sites_mutex()); + const auto path = store_path(); + std::string warning; + auto doc = top_sites::load(path, &warning); + if (!warning.empty()) LOG_WARN("[agent-browser] top sites reset: " + warning); + if (!change(doc)) return respond(req, 200, {{"ok", true}, {"changed", false}}); + std::string error; + if (!top_sites::save(path, doc, &error)) { + LOG_WARN("[agent-browser] failed to save top sites: " + error); + return respond(req, 500, {{"error", "PERSIST_FAILED"}}); + } + return respond(req, 200, {{"ok", true}, {"changed", true}}); + }; + + for (const char* path : {"/api/agent-browser/top-sites", + "/api/agent-browser/visits", + "/api/agent-browser/top-sites/dismiss", + "/api/agent-browser/top-sites/restore"}) { + app.route_dynamic(path).methods(crow::HTTPMethod::Options)( + [this](const crow::request& req) { return cors_preflight(req); }); + } + + CROW_ROUTE(app, "/api/agent-browser/top-sites").methods(crow::HTTPMethod::GET) + ([gate, respond, store_path](const crow::request& req) { + if (auto rejected = gate(req)) return std::move(*rejected); + json doc; + { + std::lock_guard lock(top_sites_mutex()); + doc = top_sites::load(store_path()); + } + const auto limit = parse_limit(req.url_params.get("limit")); + return respond(req, 200, {{"sites", top_sites::display_sites(doc, limit, now_ms())}}); + }); + + // body {url, title?, favicon?, new_visit?}:新导航 new_visit=true 计一次访问, + // 同一次访问后到的标题 / 图标用 new_visit=false 补充。 + CROW_ROUTE(app, "/api/agent-browser/visits").methods(crow::HTTPMethod::POST) + ([gate, respond, mutate](const crow::request& req) { + if (auto rejected = gate(req)) return std::move(*rejected); + const auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.is_object()) { + return respond(req, 400, {{"error", "BAD_JSON"}}); + } + top_sites::Visit visit; + visit.url = body.value("url", std::string()); + visit.title = body.value("title", std::string()); + visit.favicon = body.value("favicon", std::string()); + visit.new_visit = body.value("new_visit", true); + if (top_sites::normalize_url(visit.url).empty()) { + return respond(req, 400, {{"error", "BAD_URL"}}); + } + return mutate(req, [&visit](json& doc) { + return top_sites::record_visit(doc, visit, now_ms()); + }); + }); + + const auto url_change = [gate, respond, mutate]( + const crow::request& req, + bool (*change)(json&, const std::string&)) { + if (auto rejected = gate(req)) return std::move(*rejected); + const auto body = json::parse(req.body, nullptr, false); + const auto url = body.is_object() ? body.value("url", std::string()) : std::string(); + if (top_sites::normalize_url(url).empty()) { + return respond(req, 400, {{"error", "BAD_URL"}}); + } + return mutate(req, [&url, change](json& doc) { return change(doc, url); }); + }; + + CROW_ROUTE(app, "/api/agent-browser/top-sites/dismiss").methods(crow::HTTPMethod::POST) + ([url_change](const crow::request& req) { + return url_change(req, &top_sites::dismiss); + }); + + CROW_ROUTE(app, "/api/agent-browser/top-sites/restore").methods(crow::HTTPMethod::POST) + ([url_change](const crow::request& req) { + return url_change(req, &top_sites::restore); + }); +} + +} // namespace acecode::web diff --git a/src/apps/web/routes/routes_session_batch.cpp b/src/apps/web/routes/routes_session_batch.cpp new file mode 100644 index 000000000..3cadd22c4 --- /dev/null +++ b/src/apps/web/routes/routes_session_batch.cpp @@ -0,0 +1,285 @@ +// routes_session_batch.cpp — 侧栏多选批量操作 POST /api/session-batch。 +// 置顶 / 取消置顶 / 归档 / 取消归档 / 已读 / 未读一次请求做完:每个置顶文件、 +// 全局置顶顺序文件、每个 workspace 的 attention 文件各只写一次。单项失败不影响 +// 其它项,整体仍回 200,逐项结果在 results 里。协议见 docs/daemon-api.md。 +#ifdef DELETE +#undef DELETE +#endif +#include "web/server_impl.hpp" +#include "web/handlers/session_batch_handler.hpp" + +#include +#include +#include +#include + +namespace acecode::web { + +using nlohmann::json; + +namespace { + +json batch_item_json(const SessionBatchItem& item) { + return json{ + {"session_id", item.session_id}, + {"workspace_hash", item.workspace_hash}, + {"no_workspace", item.no_workspace}, + }; +} + +void mark_failed(json& result, const std::string& error) { + result["ok"] = false; + result["error"] = error; +} + +bool contains_id(const std::vector& ids, const std::string& id) { + return std::find(ids.begin(), ids.end(), id) != ids.end(); +} + +// 按置顶作用域分组,保持首次出现的顺序与组内的请求顺序。 +std::vector>> group_by_pin_scope( + const std::vector& items, + const std::function& include) { + std::vector>> groups; + std::unordered_map index; + for (std::size_t i = 0; i < items.size(); ++i) { + if (!include(i)) continue; + const auto scope = items[i].pin_scope(); + auto [it, inserted] = index.emplace(scope, groups.size()); + if (inserted) groups.push_back({scope, {}}); + groups[it->second].second.push_back(i); + } + return groups; +} + +} // namespace + +void WebServer::Impl::register_session_batch() { + CROW_ROUTE(app, "/api/session-batch").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { + return cors_preflight(req); + }); + + CROW_ROUTE(app, "/api/session-batch").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + return handle_session_batch(req); + }); +} + +crow::response WebServer::Impl::handle_session_batch(const crow::request& req) { + const auto reply = [this, &req](int status, const json& body) { + crow::response r(status); + r.body = body.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }; + + SessionBatchRequest batch; + try { + const auto body = json::parse(req.body.empty() ? "{}" : req.body); + std::string error; + if (!parse_session_batch_request(body, batch, error)) { + return reply(400, json{{"error", error}}); + } + } catch (const std::exception& e) { + return reply(400, json{{"error", std::string("bad json: ") + e.what()}}); + } + + const auto& items = batch.items; + std::vector results; + results.reserve(items.size()); + for (const auto& item : items) { + auto result = batch_item_json(item); + result["ok"] = true; + results.push_back(std::move(result)); + } + + json pinned_scopes = json::array(); + std::optional pinned_order; + + // 读取某个置顶作用域的文件路径与「哪些 id 可以置顶」的判定;作用域不存在返回 false。 + const auto pin_scope_store = [this](const std::string& scope, + std::filesystem::path& path, + std::function( + const std::vector&)>& available) { + if (scope == kNoWorkspacePinScope) { + path = no_workspace_pinned_sessions_path(); + available = [this](const std::vector& ids) { + return session_ids_for_no_workspace(ids); + }; + return true; + } + auto ws = scope.empty() ? std::nullopt : resolve_workspace(scope); + if (!ws.has_value()) return false; + path = pinned_sessions_path_for_cwd(ws->cwd); + available = [this, ws = *ws](const std::vector& ids) { + return session_ids_for_workspace(ws, ids); + }; + return true; + }; + + // 从全局置顶顺序里加入 / 移除一批项,只在内容变化时写盘。 + const auto update_pinned_order = [this, &pinned_order]( + const std::vector& changed_items, + bool pin) { + const auto path = pinned_session_order_path(); + const auto state = read_pinned_session_order_state(path); + auto next = apply_batch_pin_order_items(state.items, changed_items, pin); + if (pin) { + // 与 PUT /api/pinned-sessions/order 一致:只保留各作用域里确实置顶的项。 + next = prune_pinned_session_order_items(next, available_pinned_session_order_items()); + } + if (next != state.items) { + std::string error; + if (!write_pinned_session_order_state(path, PinnedSessionOrderState{next}, &error)) { + LOG_WARN("[web] session batch failed to write pinned order: " + error); + next = state.items; + } + } + pinned_order = pinned_session_order_to_json(next); + }; + + switch (batch.action) { + case SessionBatchAction::Pin: + case SessionBatchAction::Unpin: { + const bool pin = batch.action == SessionBatchAction::Pin; + // 全局顺序按请求顺序(侧栏可见顺序)排,不能按作用域分组后的顺序。 + std::vector order_touched(items.size(), false); + for (const auto& [scope, indexes] : group_by_pin_scope(items, [](std::size_t) { return true; })) { + std::filesystem::path path; + std::function(const std::vector&)> available; + if (!pin_scope_store(scope, path, available)) { + for (const auto i : indexes) mark_failed(results[i], "workspace not found"); + continue; + } + std::vector ids; + for (const auto i : indexes) ids.push_back(items[i].session_id); + const auto state = read_pinned_sessions_state(path); + const auto next = apply_batch_pin_session_ids(state.session_ids, ids, pin); + const auto pruned = prune_pinned_session_ids(next, available(next)); + if (pruned != state.session_ids) { + std::string error; + if (!write_pinned_sessions_state(path, PinnedSessionsState{pruned}, &error)) { + for (const auto i : indexes) { + mark_failed(results[i], "failed to write pinned sessions: " + error); + } + continue; + } + } + for (const auto i : indexes) { + const bool pinned_now = contains_id(pruned, items[i].session_id); + if (pin && !pinned_now) { + // 不存在、已归档或子会话:置顶存储会把它剪掉。 + mark_failed(results[i], "session cannot be pinned"); + continue; + } + order_touched[i] = true; + } + pinned_scopes.push_back(json{{"pin_scope", scope}, {"session_ids", pruned}}); + } + std::vector order_items; + for (std::size_t i = 0; i < items.size(); ++i) { + if (order_touched[i]) { + order_items.push_back(PinnedSessionOrderItem{items[i].pin_scope(), items[i].session_id}); + } + } + if (!pinned_scopes.empty()) update_pinned_order(order_items, pin); + break; + } + case SessionBatchAction::Archive: + case SessionBatchAction::Unarchive: { + const bool archive = batch.action == SessionBatchAction::Archive; + for (std::size_t i = 0; i < items.size(); ++i) { + const auto& item = items[i]; + // 与单项路由相同:无工作区任务与未知工作区走兼容 workspace。 + std::optional ws; + if (item.no_workspace || item.workspace_hash.empty() || + item.workspace_hash == "__local__") { + ws = compatibility_workspace(); + } else { + ws = resolve_workspace(item.workspace_hash); + } + if (!ws.has_value()) { + mark_failed(results[i], "workspace not found"); + continue; + } + auto outcome = apply_session_archive_state(*ws, item.session_id, archive); + if (outcome.status != 200) { + mark_failed(results[i], outcome.body.value("error", std::string("archive failed"))); + continue; + } + results[i]["session"] = std::move(outcome.body); + } + if (!archive) break; + // 已归档的会话从置顶里移除:每个作用域一次写盘,全局顺序一次写盘。 + std::vector order_items; + const auto archived_ok = [&results](std::size_t i) { + return results[i].value("ok", false); + }; + for (const auto& [scope, indexes] : group_by_pin_scope(items, archived_ok)) { + std::filesystem::path path; + std::function(const std::vector&)> available; + if (!pin_scope_store(scope, path, available)) continue; + std::vector ids; + for (const auto i : indexes) { + ids.push_back(items[i].session_id); + order_items.push_back(PinnedSessionOrderItem{scope, items[i].session_id}); + } + const auto state = read_pinned_sessions_state(path); + const auto next = apply_batch_pin_session_ids(state.session_ids, ids, false); + if (next != state.session_ids) { + std::string error; + if (!write_pinned_sessions_state(path, PinnedSessionsState{next}, &error)) { + // 归档已经成功;残留的置顶会在下次读取时按归档状态剪掉。 + LOG_WARN("[web] session batch archive failed to unpin: " + error); + continue; + } + } + pinned_scopes.push_back(json{{"pin_scope", scope}, {"session_ids", next}}); + } + if (!order_items.empty()) update_pinned_order(order_items, false); + break; + } + case SessionBatchAction::Read: + case SessionBatchAction::Unread: { + std::vector marks; + marks.reserve(items.size()); + for (const auto& item : items) { + // 与 WS mark_session_read / mark_session_unread 同一解析口径。 + const auto ws = resolve_session_workspace( + item.session_id, item.no_workspace ? std::string{} : item.workspace_hash); + marks.push_back(SessionAttentionMark{ + item.session_id, + ws ? ws->hash : item.workspace_hash, + ws ? ws->cwd : std::string{}, + item.cursor, + }); + } + auto statuses = mark_sessions_attention( + marks, batch.action == SessionBatchAction::Read); + for (std::size_t i = 0; i < statuses.size(); ++i) { + results[i]["status"] = std::move(statuses[i]); + } + break; + } + } + + std::size_t succeeded = 0; + json results_json = json::array(); + for (auto& result : results) { + if (result.value("ok", false)) ++succeeded; + results_json.push_back(std::move(result)); + } + json body{ + {"action", session_batch_action_name(batch.action)}, + {"results", std::move(results_json)}, + {"succeeded", succeeded}, + {"failed", items.size() - succeeded}, + }; + if (!pinned_scopes.empty()) body["pinned_scopes"] = std::move(pinned_scopes); + if (pinned_order) body["pinned_order"] = std::move(*pinned_order); + return reply(200, body); +} + +} // namespace acecode::web diff --git a/src/apps/web/server.cpp b/src/apps/web/server.cpp index 9c5eebea2..8e1121766 100644 --- a/src/apps/web/server.cpp +++ b/src/apps/web/server.cpp @@ -136,6 +136,7 @@ void WebServer::Impl::register_routes() { register_usage(); register_workspaces(); register_pinned_sessions(); + register_session_batch(); register_sessions(); register_desktop_office(); register_task_suggestions(); @@ -146,6 +147,7 @@ void WebServer::Impl::register_routes() { register_ios_simulator(); register_summary_generation(); register_tool_rewrites(); + register_agent_browser(); register_memory(); register_tool_preamble(); register_security(); diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 4ac800ea0..13a928254 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -1402,24 +1402,30 @@ std::optional WebServer::Impl::find_session_meta_for_workspace( crow::response WebServer::Impl::set_session_archive_state( const crow::request& req, + const acecode::desktop::WorkspaceMeta& ws, + const std::string& id, + bool archived) { + const auto outcome = apply_session_archive_state(ws, id, archived); + crow::response r(outcome.status); + r.body = outcome.body.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); +} + +WebServer::Impl::SessionArchiveOutcome WebServer::Impl::apply_session_archive_state( const acecode::desktop::WorkspaceMeta& ws, const std::string& id, bool archived) { auto maybe_meta = find_session_meta_for_workspace(ws, id); if (!maybe_meta.has_value()) { - crow::response r(404); - r.body = R"({"error":"session not found"})"; - r.add_header("Content-Type", "application/json"); - return with_cors(req, std::move(r)); + return {404, json{{"error", "session not found"}}}; } SessionMeta meta = *maybe_meta; if (archived && !meta.parent_session_id.empty()) { // 子会话跟随主会话,没有单独归档(用户决策,见 session_tree_purge.hpp)。 - crow::response r(409); - r.body = R"({"error":"subagent sessions follow their main session and cannot be archived separately"})"; - r.add_header("Content-Type", "application/json"); - return with_cors(req, std::move(r)); + return {409, json{{"error", + "subagent sessions follow their main session and cannot be archived separately"}}}; } if (archived && deps.session_client) { deps.session_client->destroy_session(id); @@ -1443,9 +1449,7 @@ crow::response WebServer::Impl::set_session_archive_state( global_session_search->invalidate_project(ws.hash); } - crow::response r(session_meta_to_json(meta, ws.hash).dump()); - r.add_header("Content-Type", "application/json"); - return with_cors(req, std::move(r)); + return {200, session_meta_to_json(meta, ws.hash)}; } crow::response WebServer::Impl::purge_session_data( @@ -2320,68 +2324,71 @@ void WebServer::Impl::note_session_event_for_attention( if (changed) broadcast_session_status(payload); } -json WebServer::Impl::mark_session_read_status( - const std::string& session_id, - const std::string& workspace_hash, - const std::string& cwd, - std::uint64_t cursor) { - json payload; - bool changed = false; - bool current_busy = false; +std::vector WebServer::Impl::mark_sessions_attention( + const std::vector& marks, + bool read) { + // busy 来自会话注册表,在 attention_mu 之外取,避免与 AgentLoop 线程互锁。 + std::vector busy(marks.size(), false); if (deps.session_registry) { - if (auto entry = deps.session_registry->acquire(session_id)) { - current_busy = entry->loop && entry->loop->is_busy(); + for (std::size_t i = 0; i < marks.size(); ++i) { + if (auto entry = deps.session_registry->acquire(marks[i].session_id)) { + busy[i] = entry->loop && entry->loop->is_busy(); + } } } + std::vector payloads(marks.size()); + std::vector changed; { std::lock_guard lk(attention_mu); - load_attention_workspace_locked(workspace_hash, cwd); - auto& record = attention_by_workspace[workspace_hash][session_id]; - record.busy = current_busy; - const auto before_state = session_attention_state_for(record); - const auto before_record = record; - record = mark_session_attention_read(record, cursor, now_unix_ms()); - const auto after_state = session_attention_state_for(record); - changed = before_state != after_state || before_record.read_cursor != record.read_cursor; - if (changed || before_record.updated_at_ms != record.updated_at_ms) { + std::unordered_set dirty; + const auto now = now_unix_ms(); + for (std::size_t i = 0; i < marks.size(); ++i) { + const auto& mark = marks[i]; + load_attention_workspace_locked(mark.workspace_hash, mark.cwd); + auto& record = attention_by_workspace[mark.workspace_hash][mark.session_id]; + record.busy = busy[i]; + const auto before_state = session_attention_state_for(record); + const auto before_record = record; + bool did_change = false; + if (read) { + record = mark_session_attention_read(record, mark.cursor, now); + did_change = before_state != session_attention_state_for(record) || + before_record.read_cursor != record.read_cursor; + } else { + record = mark_session_attention_unread(record, now); + // 运行中的会话状态仍是 in_progress,但游标已退回,回合结束后照样显示未读; + // 所以这里按游标判断是否变化,而不只看状态。 + did_change = before_record.read_cursor != record.read_cursor || + before_record.update_cursor != record.update_cursor; + } + if (did_change || before_record.updated_at_ms != record.updated_at_ms) { + dirty.insert(mark.workspace_hash); + } + payloads[i] = attention_payload_for_record( + mark.session_id, mark.workspace_hash, mark.cwd, record); + if (did_change) changed.push_back(i); + } + for (const auto& workspace_hash : dirty) { save_attention_workspace_locked(workspace_hash); } - payload = attention_payload_for_record(session_id, workspace_hash, cwd, record); } - if (changed) broadcast_session_status(payload); - return payload; + for (const auto i : changed) broadcast_session_status(payloads[i]); + return payloads; +} + +json WebServer::Impl::mark_session_read_status( + const std::string& session_id, + const std::string& workspace_hash, + const std::string& cwd, + std::uint64_t cursor) { + return mark_sessions_attention({{session_id, workspace_hash, cwd, cursor}}, true).front(); } json WebServer::Impl::mark_session_unread_status( const std::string& session_id, const std::string& workspace_hash, const std::string& cwd) { - json payload; - bool changed = false; - bool current_busy = false; - if (deps.session_registry) { - if (auto entry = deps.session_registry->acquire(session_id)) { - current_busy = entry->loop && entry->loop->is_busy(); - } - } - { - std::lock_guard lk(attention_mu); - load_attention_workspace_locked(workspace_hash, cwd); - auto& record = attention_by_workspace[workspace_hash][session_id]; - record.busy = current_busy; - const auto before_record = record; - record = mark_session_attention_unread(record, now_unix_ms()); - // 运行中的会话状态仍是 in_progress,但游标已退回,回合结束后照样显示未读; - // 所以这里按游标判断是否变化,而不只看状态。 - changed = before_record.read_cursor != record.read_cursor || - before_record.update_cursor != record.update_cursor; - if (changed || before_record.updated_at_ms != record.updated_at_ms) { - save_attention_workspace_locked(workspace_hash); - } - payload = attention_payload_for_record(session_id, workspace_hash, cwd, record); - } - if (changed) broadcast_session_status(payload); - return payload; + return mark_sessions_attention({{session_id, workspace_hash, cwd, 0}}, false).front(); } void WebServer::Impl::send_status_snapshot(crow::websocket::connection& conn, diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index d9b0a2d29..9669f6eec 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -494,6 +494,15 @@ struct WebServer::Impl { const acecode::desktop::WorkspaceMeta& ws, const std::string& id, bool archived); + // 归档 / 取消归档的核心逻辑,单项路由与批量接口共用。body 成功时是会话 meta, + // 失败时是 {"error": ...}。 + struct SessionArchiveOutcome { + int status = 200; + nlohmann::json body; + }; + SessionArchiveOutcome apply_session_archive_state(const acecode::desktop::WorkspaceMeta& ws, + const std::string& id, + bool archived); // 永久删除已归档主会话,连同它的全部子会话;子会话不能单独删(409)。 crow::response purge_session_data(const crow::request& req, const acecode::desktop::WorkspaceMeta& ws, @@ -590,6 +599,16 @@ struct WebServer::Impl { // 订阅需发现、发现需广播」的死锁)。订阅 id 保存在 Impl 中并在析构时 // 显式 unsubscribe,避免 WebServer 先于 SessionRegistry 析构时留下悬空回调。 void track_subagent(const std::string& child_session_id); + // 批量标记已读 / 未读:一次加锁,每个 workspace 的 attention 文件只写一次, + // 状态变化的会话照常广播 session_status。返回值与 marks 一一对应。 + struct SessionAttentionMark { + std::string session_id; + std::string workspace_hash; + std::string cwd; + std::uint64_t cursor = 0; + }; + std::vector mark_sessions_attention( + const std::vector& marks, bool read); nlohmann::json mark_session_read_status(const std::string& session_id, const std::string& workspace_hash, const std::string& cwd, @@ -632,6 +651,7 @@ struct WebServer::Impl { void register_ios_simulator(); void register_summary_generation(); void register_tool_rewrites(); + void register_agent_browser(); void register_memory(); void register_tool_preamble(); void register_security(); @@ -641,6 +661,8 @@ struct WebServer::Impl { void register_usage(); void register_workspaces(); void register_pinned_sessions(); + void register_session_batch(); + crow::response handle_session_batch(const crow::request& req); void register_sessions(); void register_desktop_office(); void register_task_suggestions(); diff --git a/src/apps/web/sidebar_sessions.cpp b/src/apps/web/sidebar_sessions.cpp index 69e463165..c1fd54230 100644 --- a/src/apps/web/sidebar_sessions.cpp +++ b/src/apps/web/sidebar_sessions.cpp @@ -83,16 +83,31 @@ json WebServer::Impl::sidebar_session_page(const acecode::desktop::WorkspaceMeta auto tree = build_sidebar_tree(summaries, layout); if (move.is_object() && !move.empty()) { std::string error; - const auto source_key = move.value("source", ""); - if (!move_sidebar_node(tree, layout, move.value("source", ""), move.value("target", ""), - move.value("placement", ""), error)) return {{"error", error}}; + std::vector sources; + if (move.contains("sources")) { + if (!move["sources"].is_array()) return {{"error", "SIDEBAR_INVALID_SOURCES"}}; + for (const auto& source : move["sources"]) { + if (!source.is_string()) return {{"error", "SIDEBAR_INVALID_SOURCES"}}; + sources.push_back(source.get()); + } + } else if (move.contains("source") && move["source"].is_string()) { + sources.push_back(move["source"].get()); + } + if (!move.contains("target") || !move["target"].is_string() + || !move.contains("placement") || !move["placement"].is_string()) { + return {{"error", "SIDEBAR_INVALID_PLACEMENT"}}; + } + if (!move_sidebar_nodes(tree, layout, sources, move["target"].get(), + move["placement"].get(), error)) return {{"error", error}}; if (!write_sidebar_layout(layout_path, layout, error)) return {{"error", "SIDEBAR_SAVE_FAILED"}, {"detail", error}}; tree = build_sidebar_tree(summaries, layout); - const auto& moved = tree.nodes.at(source_key); - const auto root = moved.parent.empty() ? moved.key : moved.parent; - if (!moved.parent.empty()) expanded.insert(moved.parent); - const auto index = std::find(tree.roots.begin(), tree.roots.end(), root) - tree.roots.begin(); - if (limit > 0) limit = std::max(limit, static_cast((index / 5 + 1) * 5)); + for (const auto& source : sources) { + const auto& moved = tree.nodes.at(source); + const auto root = moved.parent.empty() ? moved.key : moved.parent; + if (!moved.parent.empty()) expanded.insert(moved.parent); + const auto index = std::find(tree.roots.begin(), tree.roots.end(), root) - tree.roots.begin(); + if (limit > 0) limit = std::max(limit, static_cast((index / 5 + 1) * 5)); + } } for (const auto& [key, node] : tree.nodes) { if (reveal.empty() || node.session_id != reveal) continue; @@ -195,8 +210,9 @@ crow::response WebServer::Impl::handle_sidebar_sessions(const crow::request& req } const auto* raw_reveal = req.url_params.get("reveal"); const auto move = req.method == crow::HTTPMethod::POST ? json::parse(req.body) : json{}; - if (!move.is_null() && (!move.is_object() || !move.contains("source") || !move.contains("target") || !move.contains("placement"))) { - return respond(400, {{"error", "source, target and placement required"}}); + if (!move.is_null() && (!move.is_object() || (!move.contains("source") && !move.contains("sources")) + || !move.contains("target") || !move.contains("placement"))) { + return respond(400, {{"error", "source or sources, target and placement required"}}); } auto body = sidebar_session_page(ws, no_workspace, parse_session_list_limit(req.url_params.get("limit")), std::move(expanded), raw_reveal ? raw_reveal : "", move); diff --git a/src/base/environment/data_dir_migration.cpp b/src/base/environment/data_dir_migration.cpp index 8401183a4..7966c253b 100644 --- a/src/base/environment/data_dir_migration.cpp +++ b/src/base/environment/data_dir_migration.cpp @@ -212,8 +212,8 @@ std::uintmax_t measure_left_behind_bytes(const fs::path& io_path) { return total; } -// 尽力复制子树(agent-browser/webview2)的容错遍历:用栈 + directory_iterator(ec) 手动 -// 走,任何一层出错只记日志然后继续。不能交给主枚举的 recursive_directory_iterator:浏览器 +// 尽力复制子树(Agent Browser 的 WebView2 profile,见 migration_is_best_effort_root)的 +// 容错遍历:用栈 + directory_iterator(ec) 手动走,任何一层出错只记日志然后继续。不能交给主枚举的 recursive_directory_iterator:浏览器 // 在枚举中途删掉子目录时 increment 会报错,整个迁移随之失败。结果按 relative 排序,保证 // 父目录在子项之前、主库排在它的 -wal / -shm / -journal 之前(快照后才能跳过旁路文件)。 // 被跳过的可再生缓存与排除项不复制,但其大小累加进 left_behind_bytes。 @@ -377,7 +377,11 @@ bool migration_excludes_entry(const fs::path& relative) { } bool migration_is_best_effort_root(const fs::path& relative) { - return path_to_utf8_generic(relative) == "agent-browser/webview2"; + // 运行数据改用下划线命名后,新装环境是 agent_browser/webview2;升级前已有连字符目录的 + // 安装沿用 agent-browser/webview2(compatible_generated_path)。两种都要认,否则新装用户 + // 的 profile 走严格复制,Desktop 开着时浏览器删改文件就让迁移整体失败。 + const std::string generic = path_to_utf8_generic(relative); + return generic == "agent-browser/webview2" || generic == "agent_browser/webview2"; } bool migration_skips_rebuildable_browser_entry(const fs::path& relative) { @@ -456,7 +460,7 @@ MigrationProgress run_data_dir_migration(const std::string& current_dir, using Item = MigrationItem; std::vector regular; std::vector sqlite; - std::vector best_effort; // agent-browser/webview2 子树,容错遍历得到 + std::vector best_effort; // Agent Browser profile 子树,容错遍历得到 // 最终路径 >= MAX_PATH 的文件:迁移能复制过去,但运行期普通 API 读不到,只告警留痕。 std::size_t over_max_path_files = 0; std::string over_max_path_sample; @@ -525,7 +529,7 @@ MigrationProgress run_data_dir_migration(const std::string& current_dir, std::set snapshots; std::vector skipped_samples; // 复制一个条目,返回空串 = 继续,非空 = 整个迁移失败的原因。 - // best_effort(仅 agent-browser/webview2 子树):源端读不到时不失败 —— not-found(浏览器 + // best_effort(仅 Agent Browser profile 子树):源端读不到时不失败 —— not-found(浏览器 // 在枚举与复制之间删掉了临时文件)视为正常、不计数,其它错误(共享冲突、拒绝访问)计入 // skipped_files;无论成败都推进进度,进度条能走满。目标端建目录失败仍然整体失败。 auto copy_one = [&](const Item& item, bool best_effort) -> std::string { diff --git a/src/base/environment/data_dir_migration.hpp b/src/base/environment/data_dir_migration.hpp index a88ab612e..4a87a30ac 100644 --- a/src/base/environment/data_dir_migration.hpp +++ b/src/base/environment/data_dir_migration.hpp @@ -13,8 +13,8 @@ // profile)、指针文件本身、任何 *.lock,以及 cache/no-workspace//.acecode/tmp(无工作区 // 会话的 ACECODE_TMPDIR,可再生;代价是旧目录删除后历史里对这些 scratch 文件的别名引用失效)。 // -// 尽力复制:agent-browser/webview2 是 Desktop 启动即建立、持续在写的 WebView2 profile, -// 迁移又只能从 Desktop 发起,它会让变更复检每次都失败。所以这个子树单独容错遍历、跳过 +// 尽力复制:agent_browser/webview2(旧装机沿用 agent-browser/webview2)是 Desktop 启动即 +// 建立、持续在写的 WebView2 profile,迁移又只能从 Desktop 发起,它会让变更复检每次都失败。所以这个子树单独容错遍历、跳过 // 可再生缓存,复制失败(not-found 除外)只计入 skipped_files,且不参与变更复检;其它路径 // 仍然严格失败。 // @@ -123,7 +123,8 @@ MigrationTargetCheck validate_migration_target(const std::string& current_dir, // 其它产出文件照常复制)。 bool migration_excludes_entry(const std::filesystem::path& relative); -// 相对数据根的路径是否恰好是尽力复制子树的根(generic 形态 == "agent-browser/webview2")。 +// 相对数据根的路径是否恰好是尽力复制子树的根(generic 形态为 "agent_browser/webview2", +// 或升级前已存在的旧名 "agent-browser/webview2")。 bool migration_is_best_effort_root(const std::filesystem::path& relative); // 尽力复制子树里可以直接跳过的可再生条目:任一路径段是 Chromium 缓存目录(Cache、 diff --git a/src/base/ipc/agent_browser_store_state.cpp b/src/base/ipc/agent_browser_store_state.cpp new file mode 100644 index 000000000..f363af3dc --- /dev/null +++ b/src/base/ipc/agent_browser_store_state.cpp @@ -0,0 +1,224 @@ +#include "agent_browser_store_state.hpp" + +#include "utils/state_file.hpp" + +#include +#include +#include + +#include + +namespace acecode::desktop { +namespace { + +bool valid_session_char(char c) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9')) { + return true; + } + switch (c) { + case '-': case '_': case '.': case '~': case ':': case '@': case '+': + return true; + default: + return false; + } +} + +// 去重(保留首次出现)并只保留最新的 max_entries 条(清单尾部是最新追加的)。 +std::vector normalize_ids(std::vector ids, + std::size_t max_entries) { + std::vector result; + result.reserve(ids.size()); + std::unordered_set seen; + for (auto& id : ids) { + if (!valid_agent_browser_store_session_id(id)) continue; + if (!seen.insert(id).second) continue; + result.push_back(std::move(id)); + } + if (result.size() > max_entries) { + result.erase(result.begin(), + result.begin() + + static_cast(result.size() - max_entries)); + } + return result; +} + +// 同一子会话只保留最后一条;再按上限丢最旧的。 +std::vector normalize_seeds( + std::vector seeds) { + std::vector reversed; + reversed.reserve(seeds.size()); + std::unordered_set seen; + for (auto it = seeds.rbegin(); it != seeds.rend(); ++it) { + if (!valid_agent_browser_store_session_id(it->session_id) || + !valid_agent_browser_store_session_id(it->from) || + it->session_id == it->from) { + continue; + } + if (!seen.insert(it->session_id).second) continue; + reversed.push_back(std::move(*it)); + } + std::vector result( + std::make_move_iterator(reversed.rbegin()), + std::make_move_iterator(reversed.rend())); + if (result.size() > kAgentBrowserMaxPendingSeeds) { + result.erase(result.begin(), + result.begin() + static_cast( + result.size() - kAgentBrowserMaxPendingSeeds)); + } + return result; +} + +std::vector read_id_list(const nlohmann::json& section, + const char* key) { + std::vector ids; + if (!section.is_object()) return ids; + const auto found = section.find(key); + if (found == section.end() || !found->is_array()) return ids; + for (const auto& item : *found) { + if (item.is_string()) ids.push_back(item.get()); + } + return ids; +} + +void normalize(AgentBrowserStoreState& state) { + state.session_stores = + normalize_ids(std::move(state.session_stores), kAgentBrowserMaxStoreListEntries); + state.pending_wipe = + normalize_ids(std::move(state.pending_wipe), kAgentBrowserMaxStoreListEntries); + state.pending_deletions = + normalize_ids(std::move(state.pending_deletions), kAgentBrowserMaxStoreListEntries); + state.pending_seeds = normalize_seeds(std::move(state.pending_seeds)); + // 档位原文只保留短 ASCII,防止异常写入把任意内容带进界面。 + if (state.cookie_mode.size() > 32) state.cookie_mode.clear(); +} + +} // namespace + +bool valid_agent_browser_store_session_id(const std::string& session_id) { + if (session_id.empty() || session_id.size() > 128) return false; + return std::all_of(session_id.begin(), session_id.end(), valid_session_char); +} + +AgentBrowserStoreState agent_browser_store_state_from_json( + const nlohmann::json& section) { + AgentBrowserStoreState state; + if (!section.is_object()) return state; + const auto mode = section.find("cookie_mode"); + if (mode != section.end() && mode->is_string()) { + state.cookie_mode = mode->get(); + } + state.session_stores = read_id_list(section, "session_stores"); + state.pending_wipe = read_id_list(section, "pending_wipe"); + state.pending_deletions = read_id_list(section, "pending_deletions"); + const auto shared_wipe = section.find("shared_wipe_pending"); + state.shared_wipe_pending = shared_wipe != section.end() && + shared_wipe->is_boolean() && + shared_wipe->get(); + const auto seeds = section.find("pending_seeds"); + if (seeds != section.end() && seeds->is_array()) { + for (const auto& item : *seeds) { + if (!item.is_object()) continue; + const auto child = item.find("session_id"); + const auto from = item.find("from"); + if (child == item.end() || !child->is_string() || + from == item.end() || !from->is_string()) { + continue; + } + AgentBrowserStoreSeed seed; + seed.session_id = child->get(); + seed.from = from->get(); + const auto at = item.find("at_ms"); + if (at != item.end() && at->is_number_integer()) { + seed.at_ms = at->get(); + } + state.pending_seeds.push_back(std::move(seed)); + } + } + normalize(state); + return state; +} + +nlohmann::json agent_browser_store_state_to_json(const AgentBrowserStoreState& input) { + AgentBrowserStoreState state = input; + normalize(state); + nlohmann::json section = nlohmann::json::object(); + if (!state.cookie_mode.empty()) section["cookie_mode"] = state.cookie_mode; + if (!state.session_stores.empty()) section["session_stores"] = state.session_stores; + if (!state.pending_wipe.empty()) section["pending_wipe"] = state.pending_wipe; + if (state.shared_wipe_pending) section["shared_wipe_pending"] = true; + if (!state.pending_deletions.empty()) { + section["pending_deletions"] = state.pending_deletions; + } + if (!state.pending_seeds.empty()) { + nlohmann::json seeds = nlohmann::json::array(); + for (const auto& seed : state.pending_seeds) { + seeds.push_back({{"session_id", seed.session_id}, + {"from", seed.from}, + {"at_ms", seed.at_ms}}); + } + section["pending_seeds"] = std::move(seeds); + } + return section; +} + +AgentBrowserStoreState read_agent_browser_store_state() { + const nlohmann::json state = read_state_json(); + if (!state.is_object()) return {}; + const auto found = state.find(kAgentBrowserStoreStateKey); + if (found == state.end()) return {}; + return agent_browser_store_state_from_json(*found); +} + +bool update_agent_browser_store_state( + const std::function& mutate) { + return update_state_json([&mutate](nlohmann::json& state) { + if (!state.is_object()) state = nlohmann::json::object(); + const auto found = state.find(kAgentBrowserStoreStateKey); + AgentBrowserStoreState section = found == state.end() + ? AgentBrowserStoreState{} + : agent_browser_store_state_from_json(*found); + if (!mutate(section)) return false; + nlohmann::json written = agent_browser_store_state_to_json(section); + if (written.empty()) { + if (found == state.end()) return false; + state.erase(kAgentBrowserStoreStateKey); + } else { + state[kAgentBrowserStoreStateKey] = std::move(written); + } + return true; + }); +} + +bool record_agent_browser_store_deletion(const std::string& session_id) { + if (!valid_agent_browser_store_session_id(session_id)) return false; + return update_agent_browser_store_state([&session_id](AgentBrowserStoreState& state) { + state.pending_deletions.push_back(session_id); + // 子会话被删掉之后它的分叉记录就没用了;以它为父的记录保留 —— 复制 + // 发生在子会话第一次建存储时,父会话的存储那时若已删除,复制自然跳过。 + state.pending_seeds.erase( + std::remove_if(state.pending_seeds.begin(), state.pending_seeds.end(), + [&session_id](const AgentBrowserStoreSeed& seed) { + return seed.session_id == session_id; + }), + state.pending_seeds.end()); + return true; + }); +} + +bool record_agent_browser_store_seed(const std::string& session_id, + const std::string& from_session_id, + std::int64_t at_ms) { + if (!valid_agent_browser_store_session_id(session_id) || + !valid_agent_browser_store_session_id(from_session_id) || + session_id == from_session_id) { + return false; + } + return update_agent_browser_store_state( + [&session_id, &from_session_id, at_ms](AgentBrowserStoreState& state) { + state.pending_seeds.push_back({session_id, from_session_id, at_ms}); + return true; + }); +} + +} // namespace acecode::desktop diff --git a/src/base/ipc/agent_browser_store_state.hpp b/src/base/ipc/agent_browser_store_state.hpp new file mode 100644 index 000000000..b3c8c4e70 --- /dev/null +++ b/src/base/ipc/agent_browser_store_state.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace acecode::desktop { + +// Agent Browser 的 cookie 存储状态,落在 state.json 的 `agent_browser` 段。 +// Desktop(持有 WebView2 / WebKit 存储)与 daemon / TUI(会话的删除、分叉发生在 +// 这里)都要读写它,所以放在 base/ipc,两边共用同一份读写与校验规则。 +// +// 写入归属(openspec add-agent-browser-cookie-modes D6): +// cookie_mode / session_stores / pending_wipe / shared_wipe_pending 只由 Desktop 写; +// pending_deletions / pending_seeds 由 daemon / TUI 追加,由 Desktop 消费。 +// 本模块只保证格式与上限,不做存储操作。 + +inline constexpr const char kAgentBrowserStoreStateKey[] = "agent_browser"; +// pending_seeds 上限:shared / until_quit 档下这些记录永远不会被消费,超出丢最旧的。 +inline constexpr std::size_t kAgentBrowserMaxPendingSeeds = 500; +// 其它清单的上限只防异常膨胀(正常远小于此值),超出丢最旧的。 +inline constexpr std::size_t kAgentBrowserMaxStoreListEntries = 20000; + +struct AgentBrowserStoreSeed { + std::string session_id; // 分叉出的会话 + std::string from; // 父会话 + std::int64_t at_ms = 0; +}; + +struct AgentBrowserStoreState { + // 用户选过的档位原文;空 = 从未选过(按 shared 处理)。未知值原样保留, + // 由使用方按 shared 处理,直到用户重新选择。 + std::string cookie_mode; + // 磁盘上已经建立过的按会话存储(根会话 id)。切到「退出即清」时据此枚举。 + std::vector session_stores; + // 待清空的按会话存储:下次启动在建 environment 之前删除磁盘目录,本次运行里 + // 若又被用到,先清空再用。 + std::vector pending_wipe; + // 共享存储还欠一次清空(切到「退出即清」时立即清空;清空完成才清掉这个标记, + // 失败或异常退出时下次启动补清)。 + bool shared_wipe_pending = false; + // 已永久删除的会话,其存储待删除。 + std::vector pending_deletions; + // 分叉记录:子会话第一次建存储时从父会话复制 cookie。 + std::vector pending_seeds; +}; + +// 会话 id 的字符集与长度(与页面 owner 的 session_id 规则一致): +// [A-Za-z0-9-_.~:@+],1..128 字符。不合法的 id 一律不进状态文件。 +bool valid_agent_browser_store_session_id(const std::string& session_id); + +// 容错解析:非对象 / 字段类型不符 / 非法 id 的条目丢弃,清单去重(保留首次出现的 +// 顺序),超上限时丢最旧的。永不抛异常。 +AgentBrowserStoreState agent_browser_store_state_from_json(const nlohmann::json& section); +nlohmann::json agent_browser_store_state_to_json(const AgentBrowserStoreState& state); + +// 读 state.json 的 agent_browser 段;文件缺失 / 损坏 / 段缺失都返回空状态。 +AgentBrowserStoreState read_agent_browser_store_state(); +// 在 state_file 的跨进程锁下读-改-写 agent_browser 段。mutate 返回 false 表示 +// 放弃写入(仍视为成功)。拿不到锁或写失败返回 false。写入前统一规范化 +// (去重、上限、丢弃非法 id),mutate 不必自己处理。 +bool update_agent_browser_store_state( + const std::function& mutate); + +// daemon / TUI 用:记录永久删除的会话(同时撤掉以它为子会话的分叉记录)。 +// id 不合法时返回 false 且不写入。 +bool record_agent_browser_store_deletion(const std::string& session_id); +// daemon / TUI 用:记录分叉。同一子会话再次记录时以最新一条为准。 +bool record_agent_browser_store_seed(const std::string& session_id, + const std::string& from_session_id, + std::int64_t at_ms); + +} // namespace acecode::desktop diff --git a/src/domain/session/session_fork_listeners.cpp b/src/domain/session/session_fork_listeners.cpp new file mode 100644 index 000000000..2f77acc70 --- /dev/null +++ b/src/domain/session/session_fork_listeners.cpp @@ -0,0 +1,58 @@ +#include "session_fork_listeners.hpp" + +#include "utils/logger.hpp" + +#include +#include +#include + +namespace acecode { + +namespace { + +struct ListenerTable { + std::mutex mu; + std::uint64_t next_id = 1; + std::map listeners; +}; + +ListenerTable& table() { + static ListenerTable instance; + return instance; +} + +} // namespace + +std::uint64_t add_session_fork_listener(SessionForkListener listener) { + auto& t = table(); + std::lock_guard lock(t.mu); + const std::uint64_t id = t.next_id++; + t.listeners.emplace(id, std::move(listener)); + return id; +} + +void remove_session_fork_listener(std::uint64_t id) { + auto& t = table(); + std::lock_guard lock(t.mu); + t.listeners.erase(id); +} + +void notify_session_forked(const std::string& project_dir, + const std::string& new_session_id, + const std::string& forked_from_id) { + std::vector snapshot; + { + auto& t = table(); + std::lock_guard lock(t.mu); + for (const auto& [id, listener] : t.listeners) snapshot.push_back(listener); + } + for (const auto& listener : snapshot) { + try { + if (listener) listener(project_dir, new_session_id, forked_from_id); + } catch (const std::exception& e) { + LOG_WARN(std::string("[session] fork listener failed: ") + e.what()); + } + } +} + +} // namespace acecode diff --git a/src/domain/session/session_fork_listeners.hpp b/src/domain/session/session_fork_listeners.hpp new file mode 100644 index 000000000..31080ec0b --- /dev/null +++ b/src/domain/session/session_fork_listeners.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace acecode { + +// 会话分叉成功(SessionManager::fork_session_to_new_id 写好新会话)后的进程内通知。 +// Web 分叉路由与线程服务分叉都走 fork_session_to_new_id,所以只挂在这一处,与 +// session_purge_listeners 对称。用途:Agent Browser 按会话存储的 cookie 复制 +// (openspec add-agent-browser-cookie-modes D7)。 +// +// 回调在分叉的调用线程上同步执行,且仍持有源会话 SessionManager 的锁:监听者不得 +// 回调 SessionManager,不得抛异常,也不应长时间阻塞(写一下状态文件即可)。 +using SessionForkListener = std::function; + +std::uint64_t add_session_fork_listener(SessionForkListener listener); +void remove_session_fork_listener(std::uint64_t id); +void notify_session_forked(const std::string& project_dir, + const std::string& new_session_id, + const std::string& forked_from_id); + +} // namespace acecode diff --git a/src/domain/session/session_manager.cpp b/src/domain/session/session_manager.cpp index 5ccefb39f..844c45ae0 100644 --- a/src/domain/session/session_manager.cpp +++ b/src/domain/session/session_manager.cpp @@ -7,6 +7,7 @@ #include "permissions/permissions.hpp" #include "session_serializer.hpp" #include "fork_attachment_context.hpp" +#include "session_fork_listeners.hpp" #include "session_rewind.hpp" #include "session_title_text.hpp" #include "session_user_message_search.hpp" @@ -1012,6 +1013,9 @@ std::string SessionManager::fork_session_to_new_id( } } + if (!forked_from_id.empty()) { + notify_session_forked(project_dir_, new_session_id, forked_from_id); + } return new_session_id; } diff --git a/src/domain/session/sidebar_tree.cpp b/src/domain/session/sidebar_tree.cpp index aaaa75989..5a5e0214e 100644 --- a/src/domain/session/sidebar_tree.cpp +++ b/src/domain/session/sidebar_tree.cpp @@ -188,6 +188,38 @@ bool move_sidebar_node(SidebarTree& tree, json& layout, return true; } +bool move_sidebar_nodes(SidebarTree& tree, json& layout, + const std::vector& sources, const std::string& target, + const std::string& placement, std::string& error) { + error.clear(); + const std::set selected(sources.begin(), sources.end()); + if (sources.empty() || selected.size() != sources.size() || selected.count(target)) { + error = "SIDEBAR_TARGET_CHANGED"; + return false; + } + for (const auto& source : sources) { + const auto found = tree.nodes.find(source); + if (found == tree.nodes.end() || selected.count(found->second.parent)) { + error = "SIDEBAR_TARGET_CHANGED"; + return false; + } + } + auto next_tree = tree; + auto next_layout = layout; + auto ordered = sources; + if (placement == "after") std::reverse(ordered.begin(), ordered.end()); + auto receiver = target; + for (const auto& source : ordered) { + if (!move_sidebar_node(next_tree, next_layout, source, receiver, placement, error)) return false; + // The first insertion can turn a detached scheduled session into a + // manual group. Remaining insertions must address that new group. + if (placement == "inside") receiver = next_tree.nodes.at(source).parent; + } + tree = std::move(next_tree); + layout = std::move(next_layout); + return true; +} + json read_sidebar_layout(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); if (!input) return json::object(); diff --git a/src/domain/session/sidebar_tree.hpp b/src/domain/session/sidebar_tree.hpp index bc635323c..039632b2f 100644 --- a/src/domain/session/sidebar_tree.hpp +++ b/src/domain/session/sidebar_tree.hpp @@ -32,6 +32,10 @@ SidebarTree build_sidebar_tree(const nlohmann::json& sessions, bool move_sidebar_node(SidebarTree& tree, nlohmann::json& layout, const std::string& source, const std::string& target, const std::string& placement, std::string& error); +// All sources move in caller order, or neither tree nor layout is changed. +bool move_sidebar_nodes(SidebarTree& tree, nlohmann::json& layout, + const std::vector& sources, const std::string& target, + const std::string& placement, std::string& error); nlohmann::json read_sidebar_layout(const std::filesystem::path& path); bool write_sidebar_layout(const std::filesystem::path& path, const nlohmann::json& layout, std::string& error); diff --git a/src/host/session_host/agent_browser_store_lifecycle.cpp b/src/host/session_host/agent_browser_store_lifecycle.cpp new file mode 100644 index 000000000..012e83ac1 --- /dev/null +++ b/src/host/session_host/agent_browser_store_lifecycle.cpp @@ -0,0 +1,125 @@ +#include "agent_browser_store_lifecycle.hpp" + +#include "ipc/agent_browser_store_state.hpp" +#include "session/session_fork_listeners.hpp" +#include "session/session_purge_listeners.hpp" +#include "tool/agent_browser/cdp_client.hpp" +#include "utils/logger.hpp" + +#include +#include + +namespace acecode { +namespace { + +std::int64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +// Desktop 没开时 runtime manifest 不存在,connect 立即失败;开着时一次本机管道往返。 +bool notify_desktop_over_proxy(const std::atomic& abort, std::string& error) { + agent_browser::AgentBrowserCdpClient client; + if (!client.connect(std::chrono::seconds(2), &abort, error)) return false; + return client.drain_store_tasks(std::chrono::seconds(5), &abort, error); +} + +} // namespace + +AgentBrowserStoreLifecycle::AgentBrowserStoreLifecycle(NotifyDesktop notify) + : notify_(notify ? std::move(notify) : NotifyDesktop(notify_desktop_over_proxy)), + notifier_(&AgentBrowserStoreLifecycle::notify_loop, this) { + const auto owner = lifetime_.ref(*this); + purge_listener_ = add_session_purge_listener( + [owner](const std::string& /*project_dir*/, const std::string& session_id) { + owner.with([&session_id](AgentBrowserStoreLifecycle& self) { + self.on_session_purged(session_id); + }); + }); + fork_listener_ = add_session_fork_listener( + [owner](const std::string& /*project_dir*/, + const std::string& new_session_id, + const std::string& forked_from_id) { + owner.with([&new_session_id, &forked_from_id](AgentBrowserStoreLifecycle& self) { + self.on_session_forked(new_session_id, forked_from_id); + }); + }); +} + +AgentBrowserStoreLifecycle::~AgentBrowserStoreLifecycle() { + stop(); +} + +void AgentBrowserStoreLifecycle::stop() { + { + std::lock_guard lock(mu_); + if (stopped_) return; + stopped_ = true; + } + remove_session_purge_listener(purge_listener_); + remove_session_fork_listener(fork_listener_); + lifetime_.revoke(); + abort_.store(true); + { + std::lock_guard lock(mu_); + stopping_ = true; + } + cv_.notify_all(); + notifier_.join(); +} + +bool AgentBrowserStoreLifecycle::wait_until_idle_for_test(std::chrono::milliseconds timeout) { + std::unique_lock lock(mu_); + return cv_.wait_for(lock, timeout, + [&pending = notify_pending_, ¬ifying = notifying_] { + return !pending && !notifying; + }); +} + +void AgentBrowserStoreLifecycle::on_session_purged(const std::string& session_id) { + if (!desktop::record_agent_browser_store_deletion(session_id)) { + LOG_WARN("[agent-browser] failed to record cookie store deletion for " + session_id); + return; + } + { + std::lock_guard lock(mu_); + notify_pending_ = true; + } + cv_.notify_all(); +} + +void AgentBrowserStoreLifecycle::on_session_forked(const std::string& new_session_id, + const std::string& forked_from_id) { + if (!desktop::record_agent_browser_store_seed(new_session_id, forked_from_id, now_ms())) { + LOG_WARN("[agent-browser] failed to record cookie store fork " + forked_from_id + + " -> " + new_session_id); + } +} + +void AgentBrowserStoreLifecycle::notify_loop() { + std::unique_lock lock(mu_); + while (true) { + cv_.wait(lock, [&pending = notify_pending_, &stopping = stopping_] { + return pending || stopping; + }); + if (stopping_) return; + // 一次提醒处理状态文件里全部待删除项,期间到来的删除合并到下一次。 + notify_pending_ = false; + notifying_ = true; + lock.unlock(); + std::string error; + const bool ok = notify_(abort_, error); + if (ok) { + LOG_INFO("[agent-browser] asked Desktop to delete removed session cookie stores"); + } else { + LOG_DEBUG("[agent-browser] Desktop not notified about removed cookie stores " + "(deleted at its next start): " + error); + } + lock.lock(); + notifying_ = false; + cv_.notify_all(); + } +} + +} // namespace acecode diff --git a/src/host/session_host/agent_browser_store_lifecycle.hpp b/src/host/session_host/agent_browser_store_lifecycle.hpp new file mode 100644 index 000000000..6fdad7f75 --- /dev/null +++ b/src/host/session_host/agent_browser_store_lifecycle.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "utils/joining_thread.hpp" +#include "utils/lifetime_token.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace acecode { + +// Agent Browser 按会话 cookie 存储的生命周期(openspec add-agent-browser-cookie-modes D7)。 +// 会话的永久删除与分叉发生在 daemon / TUI,cookie 存储却在 Desktop 进程里,所以这里 +// 只把待办写进共享的 state.json(agent_browser 段),由 Desktop 启动时或收到提醒时处理: +// - 分叉:记一条 pending_seeds(子会话第一次打开浏览器时从父会话复制 cookie); +// - 永久删除:记一条 pending_deletions,再在后台线程经 Agent Browser 代理发一次 +// drain_store_tasks 提醒(Desktop 没开 / 旧版 Desktop 不认识都只记 debug 日志, +// 下次 Desktop 启动时照样会删)。 +// 监听回调只写状态文件(很快,不碰网络),提醒在自有线程里做,连续删除合并成一次提醒。 +class AgentBrowserStoreLifecycle { +public: + // 发提醒的实现,测试可注入假实现;abort 在 stop() 时置位,实现应尽快返回。 + // 返回 false 时 error 说明原因。 + using NotifyDesktop = + std::function& abort, std::string& error)>; + + explicit AgentBrowserStoreLifecycle(NotifyDesktop notify = {}); + ~AgentBrowserStoreLifecycle(); + + AgentBrowserStoreLifecycle(const AgentBrowserStoreLifecycle&) = delete; + AgentBrowserStoreLifecycle& operator=(const AgentBrowserStoreLifecycle&) = delete; + + // 注销监听、等在途回调结束、停掉提醒线程。析构时自动调用,可重复调用。 + void stop(); + + // 测试用:等到当前没有待发的提醒(或超时)。 + bool wait_until_idle_for_test(std::chrono::milliseconds timeout); + +private: + void on_session_purged(const std::string& session_id); + void on_session_forked(const std::string& new_session_id, + const std::string& forked_from_id); + void notify_loop(); + + NotifyDesktop notify_; + std::uint64_t purge_listener_ = 0; + std::uint64_t fork_listener_ = 0; + std::mutex mu_; + std::condition_variable cv_; + bool notify_pending_ = false; + bool notifying_ = false; + bool stopping_ = false; + bool stopped_ = false; + std::atomic abort_{false}; + JoiningThread notifier_; + LifetimeToken lifetime_; +}; + +} // namespace acecode diff --git a/src/layers.tsv b/src/layers.tsv index 0825cbca5..f31953cb7 100644 --- a/src/layers.tsv +++ b/src/layers.tsv @@ -189,3 +189,5 @@ ownership_allow src/host/session_host/task_suggestion_service.cpp 1 R15:unsafe ownership_allow src/host/session_host/thread_service.cpp 1 R15:unsafe_capture ThreadService::repair ownership/final-audit call=history_on_worker; worker history accessor invokes its visitor immediately without retaining it ownership_allow src/domain/session/global_session_search.cpp 1 R15:unsafe_capture GlobalSessionSearchService::recent_user_sessions ownership/release-1.0.10 call=score; GlobalSessionCatalogIndex::select_entries invokes this predicate synchronously without storing it; recent_mu protects borrowed cache and stack state until selection returns ownership_allow src/domain/session/global_session_search.cpp 1 R15:unsafe_capture GlobalSessionSearchService::recent_user_sessions ownership/release-1.0.10 call=before; GlobalSessionCatalogIndex::select_entries invokes this ordering callback only in synchronous sort/partial_sort and never retains it; borrowed user_time and cache outlive selection +ownership_allow src/apps/desktop/linux_desktop.cpp 1 R15:delayed_injection acecode::desktop ownership/release-1.0.14 set_linux_trusted_page_origin only borrows the widget during g_object_set_data_full; it stores an owned copy of the origin string and never retains the widget or injects a service +ownership_allow src/apps/desktop/linux_desktop.hpp 1 R15:delayed_injection acecode::desktop ownership/release-1.0.14 set_linux_trusted_page_origin only borrows the widget during g_object_set_data_full; it stores an owned copy of the origin string and never retains the widget or injects a service diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 020ad56f0..876533cc7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -213,6 +213,26 @@ if(WIN32 AND ACECODE_BUILD_DESKTOP) ) endif() + # Opt-in: cookie storage modes (shared / until_quit / per_session) against a + # real WebView2 runtime and https://example.com. Prints SMOKE_COOKIE_MODES_OK. + add_executable(agent_browser_cookie_smoke EXCLUDE_FROM_ALL + ${ACECODE_AGENT_BROWSER_COOKIE_SMOKE_SOURCES} + ) + target_link_libraries(agent_browser_cookie_smoke PRIVATE acecode_include_roots) + target_link_libraries(agent_browser_cookie_smoke PRIVATE + acecode_desktop_support + acecode_base_core + webview::core + cpr::cpr + nlohmann_json::nlohmann_json + ole32 user32 ws2_32 + ) + if(MSVC) + set_target_properties(agent_browser_cookie_smoke PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + ) + endif() + # Manual visual/runtime demo for the exact AI pointer overlay used by # mouse tools and synthetic events from browser_evaluate. It runs in an # isolated native WebView2 host so it never takes over the user's active diff --git a/tests/cpp_source_paths.json b/tests/cpp_source_paths.json index 57c43f4cb..68b081e37 100644 --- a/tests/cpp_source_paths.json +++ b/tests/cpp_source_paths.json @@ -18,5 +18,10 @@ "tool/agent_browser/browser_tools.hpp": "src/adapters/tool/agent_browser/browser_tools.hpp", "tool/agent_browser/cdp_client.cpp": "src/adapters/tool/agent_browser/cdp_client.cpp", "tool/agent_browser/pointer_overlay.cpp": "src/adapters/tool/agent_browser/browser_pointer_overlay.cpp", - "tool/tool_executor.hpp": "src/adapters/tool/tool_executor.hpp" + "tool/tool_executor.hpp": "src/adapters/tool/tool_executor.hpp", + "desktop/agent_browser_cookie_bridge.cpp": "src/apps/desktop/agent_browser_cookie_bridge.cpp", + "desktop/agent_browser_windows_helpers.cpp": "src/apps/desktop/agent_browser_windows_helpers.cpp", + "desktop/agent_browser_windows_helpers.hpp": "src/apps/desktop/agent_browser_windows_helpers.hpp", + "desktop/agent_browser_macos_helpers.mm": "src/apps/desktop/agent_browser_macos_helpers.mm", + "desktop/agent_browser_macos_helpers.hpp": "src/apps/desktop/agent_browser_macos_helpers.hpp" } diff --git a/tests/desktop/agent_browser_cookie_jars_test.cpp b/tests/desktop/agent_browser_cookie_jars_test.cpp new file mode 100644 index 000000000..2826d0aa1 --- /dev/null +++ b/tests/desktop/agent_browser_cookie_jars_test.cpp @@ -0,0 +1,253 @@ +// 本文件覆盖 desktop/agent_browser_cookie_jars 的纯逻辑(openspec +// add-agent-browser-cookie-modes): +// +// 1. 档位名解析与「未选过 / 未知值按共享」。 +// 2. 存储键:三档下页面落到哪份存储,子代理归根会话,空 owner 归共享。 +// 3. Windows Profile 名与 macOS 按会话 UUID 的派生(合法、稳定、互不相同)。 +// 4. 切档计划、需要重建的页面、按根会话找页面。 +// 5. 按会话存储第一次被用到时:先清空 / 从父会话复制 / 登记。 + +#include + +#include "desktop/agent_browser_cookie_jars.hpp" + +#include +#include + +using namespace acecode::desktop; + +namespace { + +AgentBrowserPageOwner owner_of(const std::string& session_id, + const std::string& root = {}) { + AgentBrowserPageOwner owner; + owner.session_id = session_id; + owner.root_session_id = root; + return owner; +} + +AgentBrowserCookieStoreKey session_key(const std::string& id) { + AgentBrowserCookieStoreKey key; + key.kind = AgentBrowserCookieStoreKind::Session; + key.session_id = id; + return key; +} + +// WebView2 Profile 名规则:最多 64 字符;只允许字母数字与 #@$()+-_~. 空格;不能以 . 或空格结尾。 +bool valid_webview2_profile_name(const std::string& name) { + if (name.empty() || name.size() > 64) return false; + for (const char c : name) { + if (std::isalnum(static_cast(c))) continue; + if (std::string("#@$()+-_~. ").find(c) == std::string::npos) return false; + } + return name.back() != '.' && name.back() != ' '; +} + +} // namespace + +// 触发场景:状态文件里存的档位原文 —— 三个规范名、从未选过(空)、未来版本写入的未知名。 +// 期望行为:规范名按原义;空与未知一律按共享(Shared 是本项目默认,升级用户不掉登录)。 +TEST(AgentBrowserCookieMode, StoredNamesResolveWithSharedAsDefault) { + EXPECT_EQ(effective_agent_browser_cookie_mode("until_quit"), + AgentBrowserCookieMode::UntilQuit); + EXPECT_EQ(effective_agent_browser_cookie_mode("per_session"), + AgentBrowserCookieMode::PerSession); + EXPECT_EQ(effective_agent_browser_cookie_mode("shared"), + AgentBrowserCookieMode::Shared); + EXPECT_EQ(effective_agent_browser_cookie_mode(""), AgentBrowserCookieMode::Shared); + EXPECT_EQ(effective_agent_browser_cookie_mode("per_workspace"), + AgentBrowserCookieMode::Shared); + EXPECT_FALSE(agent_browser_cookie_mode_from_name("Shared").has_value()); + for (const auto mode : {AgentBrowserCookieMode::UntilQuit, + AgentBrowserCookieMode::Shared, + AgentBrowserCookieMode::PerSession}) { + EXPECT_EQ(agent_browser_cookie_mode_from_name( + agent_browser_cookie_mode_name(mode)), + mode); + } +} + +// 触发场景:同一个页面 owner 在三档下各落到哪份存储。 +// 期望行为:退出即清 → until_quit;共享 → shared;按会话 → session:<根会话>, +// 子代理(root_session_id 指向父会话)用父会话那份,这样子代理能沿用父会话的登录。 +TEST(AgentBrowserCookieStoreKey, ModesMapOwnersToStores) { + const auto main_owner = owner_of("main"); + const auto child_owner = owner_of("child", "main"); + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::UntilQuit, main_owner).id(), + "until_quit"); + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::Shared, main_owner).id(), + "shared"); + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::PerSession, main_owner).id(), + "session:main"); + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::PerSession, child_owner), + session_key("main")); +} + +// 触发场景:按会话档下,页面没有 owner(旧版 daemon 建的页、未绑定的页)或会话 id 非法。 +// 期望行为:落到共享存储,而不是凭空造一份按会话存储(那份存储永远不会被删除)。 +TEST(AgentBrowserCookieStoreKey, OwnerlessOrInvalidOwnerFallsBackToShared) { + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::PerSession, + AgentBrowserPageOwner{}).id(), + "shared"); + EXPECT_EQ(agent_browser_cookie_store_key(AgentBrowserCookieMode::PerSession, + owner_of("bad/id")).id(), + "shared"); +} + +// 触发场景:Windows 宿主为三种存储选 WebView2 Profile。 +// 期望行为:共享 = 默认 Profile(名字为空,沿用升级前的那份登录数据);退出即清 = 固定名的 +// InPrivate Profile;按会话 = s-<哈希>,即使会话 id 很长(headless 自定义 id 最长 64、owner +// 字段最长 128)也符合 WebView2 的命名规则,且不同会话互不相同、同一会话每次相同。 +TEST(AgentBrowserWindowsProfile, ProfileNamesAreValidStableAndDistinct) { + const auto shared = agent_browser_windows_profile(AgentBrowserCookieStoreKey{}); + EXPECT_TRUE(shared.name.empty()); + EXPECT_FALSE(shared.in_private); + + AgentBrowserCookieStoreKey until_quit; + until_quit.kind = AgentBrowserCookieStoreKind::UntilQuit; + const auto ephemeral = agent_browser_windows_profile(until_quit); + EXPECT_EQ(ephemeral.name, kAgentBrowserUntilQuitProfileName); + EXPECT_TRUE(ephemeral.in_private); + EXPECT_TRUE(valid_webview2_profile_name(ephemeral.name)); + + const std::string long_id(128, 'a'); + const auto per_session = agent_browser_windows_profile(session_key(long_id)); + EXPECT_FALSE(per_session.in_private); + EXPECT_TRUE(valid_webview2_profile_name(per_session.name)) << per_session.name; + EXPECT_EQ(per_session.name.size(), 26u); + EXPECT_EQ(per_session.name, agent_browser_session_profile_name(long_id)); + EXPECT_NE(agent_browser_session_profile_name("20261020_101010_abcd"), + agent_browser_session_profile_name("20261020_101010_abce")); + // 大小写不同的会话 id 也要映射到不同 Profile(WebView2 Profile 名大小写不敏感, + // 直接拿 id 当名字会让它们撞到同一份存储)。 + EXPECT_NE(agent_browser_session_profile_name("Abc"), + agent_browser_session_profile_name("abc")); + // 磁盘目录是 WebView2 运行时决定的 WV2Profile_<名字>(冒烟实测),启动删除与 + // 运行中删除都靠它定位;写错就只会删一个不存在的目录,cookie 原样留在磁盘上。 + EXPECT_EQ(agent_browser_session_profile_directory_name(long_id), + "WV2Profile_" + per_session.name); +} + +// 触发场景:macOS 宿主为按会话存储派生 dataStoreForIdentifier 的 UUID。 +// 期望行为:标准 8-4-4-4-12 大写形态,版本位 8、变体位 10xx;同一会话恒定,不同会话不同。 +TEST(AgentBrowserSessionStoreUuid, UuidIsWellFormedAndStable) { + const std::string uuid = agent_browser_session_store_uuid("20261020_101010_abcd"); + ASSERT_EQ(uuid.size(), 36u); + EXPECT_EQ(uuid[8], '-'); + EXPECT_EQ(uuid[13], '-'); + EXPECT_EQ(uuid[18], '-'); + EXPECT_EQ(uuid[23], '-'); + EXPECT_EQ(uuid[14], '8'); + EXPECT_NE(std::string("89AB").find(uuid[19]), std::string::npos); + for (const char c : uuid) { + if (c == '-') continue; + EXPECT_TRUE(std::isdigit(static_cast(c)) || (c >= 'A' && c <= 'F')) + << uuid; + } + EXPECT_EQ(uuid, agent_browser_session_store_uuid("20261020_101010_abcd")); + std::set distinct; + for (int i = 0; i < 200; ++i) { + distinct.insert(agent_browser_session_store_uuid("session-" + std::to_string(i))); + } + EXPECT_EQ(distinct.size(), 200u); +} + +// 触发场景:用户在三档之间切换。 +// 期望行为:切到退出即清 → 立即清空共享存储 + 给所有按会话存储打待清空标记; +// 共享 ↔ 按会话互切不清任何数据(规格:持久档之间切换保留登录);离开退出即清时释放它的存储。 +TEST(AgentBrowserCookieModeSwitch, OnlyUntilQuitDeletesPersistedData) { + const auto to_quit = plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode::Shared, AgentBrowserCookieMode::UntilQuit); + EXPECT_TRUE(to_quit.changed); + EXPECT_TRUE(to_quit.clear_shared_now); + EXPECT_TRUE(to_quit.mark_session_stores_for_wipe); + EXPECT_FALSE(to_quit.release_until_quit_store); + + const auto between = plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode::Shared, AgentBrowserCookieMode::PerSession); + EXPECT_TRUE(between.changed); + EXPECT_FALSE(between.clear_shared_now); + EXPECT_FALSE(between.mark_session_stores_for_wipe); + EXPECT_FALSE(between.release_until_quit_store); + + const auto from_quit = plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode::UntilQuit, AgentBrowserCookieMode::PerSession); + EXPECT_TRUE(from_quit.release_until_quit_store); + EXPECT_FALSE(from_quit.clear_shared_now); + + EXPECT_FALSE(plan_agent_browser_cookie_mode_switch( + AgentBrowserCookieMode::PerSession, + AgentBrowserCookieMode::PerSession).changed); +} + +// 触发场景:切档时有若干页面开着:A 的主页面、A 的子代理页面、无 owner 的页面。 +// 期望行为:共享 → 按会话时,有 owner 的页面要换存储而重建;无 owner 的页面在两档下都用 +// 共享存储,不重建(规格:存储不变的页面不重新加载)。 +TEST(AgentBrowserPagesToRebuild, OnlyPagesWhoseStoreChangesAreRebuilt) { + std::vector pages = { + {"p-main", owner_of("A"), AgentBrowserCookieStoreKey{}}, + {"p-child", owner_of("A-child", "A"), AgentBrowserCookieStoreKey{}}, + {"p-orphan", AgentBrowserPageOwner{}, AgentBrowserCookieStoreKey{}}, + }; + EXPECT_EQ(agent_browser_pages_to_rebuild(pages, AgentBrowserCookieMode::PerSession), + (std::vector{"p-main", "p-child"})); + EXPECT_TRUE(agent_browser_pages_to_rebuild(pages, AgentBrowserCookieMode::Shared).empty()); + EXPECT_EQ(agent_browser_pages_to_rebuild(pages, AgentBrowserCookieMode::UntilQuit).size(), 3u); + + EXPECT_EQ(agent_browser_pages_of_root_session(pages, "A"), + (std::vector{"p-main", "p-child"})); + EXPECT_TRUE(agent_browser_pages_of_root_session(pages, "").empty()); +} + +// 触发场景:分叉会话 C(父 A)第一次打开浏览器,A 已经有自己的存储。 +// 期望行为:从 A 复制、登记 C 的存储;写回后分叉记录被消费,下一次不会再复制。 +TEST(AgentBrowserSessionStorePreparation, ForkSeedsFromParentOnlyOnce) { + AgentBrowserStoreState state; + state.session_stores = {"A"}; + state.pending_seeds.push_back({"C", "A", 1}); + + const auto first = prepare_agent_browser_session_store("C", state); + EXPECT_FALSE(first.wipe_first); + EXPECT_EQ(first.seed_from, "A"); + EXPECT_TRUE(first.register_store); + + apply_agent_browser_session_store_preparation("C", state); + EXPECT_TRUE(state.pending_seeds.empty()); + EXPECT_EQ(state.session_stores, (std::vector{"A", "C"})); + + const auto again = prepare_agent_browser_session_store("C", state); + EXPECT_TRUE(again.seed_from.empty()); + EXPECT_FALSE(again.register_store); +} + +// 触发场景:父会话没有存储、父会话存储待清空 / 待删除,或子会话已经有自己的存储。 +// 期望行为:都不复制(规格:父会话没有存储时子会话从空开始;已有存储的不再复制), +// 且不能从已经被要求清空 / 删除的存储里把登录态带出来。 +TEST(AgentBrowserSessionStorePreparation, SeedSkipsUnusableParentsAndExistingStores) { + AgentBrowserStoreState state; + state.pending_seeds.push_back({"C1", "missing", 1}); + state.session_stores = {"wiped", "deleted", "C4"}; + state.pending_wipe = {"wiped"}; + state.pending_deletions = {"deleted"}; + state.pending_seeds.push_back({"C2", "wiped", 2}); + state.pending_seeds.push_back({"C3", "deleted", 3}); + state.pending_seeds.push_back({"C4", "deleted", 4}); + EXPECT_TRUE(prepare_agent_browser_session_store("C1", state).seed_from.empty()); + EXPECT_TRUE(prepare_agent_browser_session_store("C2", state).seed_from.empty()); + EXPECT_TRUE(prepare_agent_browser_session_store("C3", state).seed_from.empty()); + EXPECT_TRUE(prepare_agent_browser_session_store("C4", state).seed_from.empty()); +} + +// 触发场景:切到退出即清后又切回按会话,会话 A 的存储在待清空清单里。 +// 期望行为:先清空再用,用过之后移出待清空清单 —— 这样切回来时 A 不会带着切走前的登录。 +TEST(AgentBrowserSessionStorePreparation, PendingWipeIsClearedBeforeReuse) { + AgentBrowserStoreState state; + state.session_stores = {"A"}; + state.pending_wipe = {"A"}; + const auto preparation = prepare_agent_browser_session_store("A", state); + EXPECT_TRUE(preparation.wipe_first); + EXPECT_FALSE(preparation.register_store); + apply_agent_browser_session_store_preparation("A", state); + EXPECT_TRUE(state.pending_wipe.empty()); + EXPECT_EQ(state.session_stores, (std::vector{"A"})); +} diff --git a/tests/desktop/helpers/agent_browser_cookie_smoke.cpp b/tests/desktop/helpers/agent_browser_cookie_smoke.cpp new file mode 100644 index 000000000..fa0a0a71b --- /dev/null +++ b/tests/desktop/helpers/agent_browser_cookie_smoke.cpp @@ -0,0 +1,498 @@ +// Agent Browser「保留 Cookie」三档的 Windows 端到端冒烟(openspec +// add-agent-browser-cookie-modes)。真实 WebView2 + 真实网络(https://example.com), +// cookie 经 Desktop 代理的 CDP Network.setCookie / getCookies 读写。 +// +// 覆盖:共享 → 按会话 → 退出即清 → 共享的切档与页面重建、按会话隔离与子代理归根、 +// 退出即清在关掉所有页面后仍保留(保活页)、切到退出即清时清空已存 cookie、重启后 +// 持久 cookie 保留 / 退出即清丢失、启动时删除待清空 / 待删除的会话目录、分叉复制、 +// 清除浏览数据只影响当前存储、运行中删除会话存储、代理 drain_store_tasks 操作。 +// +// 成功输出 SMOKE_COOKIE_MODES_OK;观察项(WebView2 运行时行为)以 SMOKE_INFO 输出。 +// 只在 Windows 构建(EXCLUDE_FROM_ALL),手动运行:需要能访问 example.com。 + +#include "desktop/agent_browser_cookie_jars.hpp" +#include "desktop/agent_browser_host.hpp" +#include "ipc/agent_browser_runtime.hpp" +#include "ipc/agent_browser_store_state.hpp" +#include "tool/agent_browser/cdp_client.hpp" +#include "utils/state_file.hpp" +#include "utils/utf8_path.hpp" + +#include + +#ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +# define NOMINMAX +#endif +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +using acecode::desktop::AgentBrowserHost; +using acecode::desktop::AgentBrowserPageOwner; +using acecode::desktop::AgentBrowserStorageEvent; + +constexpr UINT kDispatchMessage = WM_APP + 72; +constexpr const char kSite[] = "https://example.com/"; + +LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == kDispatchMessage) { + std::unique_ptr> task( + reinterpret_cast*>(lparam)); + if (task && *task) (*task)(); + return 0; + } + if (message == WM_DESTROY) { + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(window, message, wparam, lparam); +} + +bool pump_until(const std::function& predicate, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + if (predicate()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return predicate(); +} + +void pump_for(std::chrono::milliseconds duration) { + pump_until([] { return false; }, duration); +} + +struct SmokeFailure : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +void require(bool condition, const std::string& message) { + if (!condition) throw SmokeFailure(message); +} + +void info(const std::string& key, const nlohmann::json& value) { + std::cout << "SMOKE_INFO " << key << " " << value.dump() << '\n'; +} + +class Smoke { +public: + explicit Smoke(HWND window) : window_(window) {} + + void start_host() { + ++generation_; + const std::string instance = "cookie-smoke-" + std::to_string(GetCurrentProcessId()) + + "-" + std::to_string(generation_); + HWND window = window_; + host_ = std::make_unique( + window_, static_cast(GetCurrentProcessId()), instance, + AgentBrowserHost::StateHandler{}, + [window](std::function task) { + auto* pending = new std::function(std::move(task)); + if (!PostMessageW(window, kDispatchMessage, 0, + reinterpret_cast(pending))) { + delete pending; + } + }); + host_->set_storage_event_handler( + [this](const AgentBrowserStorageEvent& event) { events_.push_back(event); }); + require(pump_until([this] { + const auto state = host_->state(); + return state.ready || !state.error.empty(); + }, std::chrono::seconds(30)), + "WebView2 host did not become ready"); + require(host_->state().ready, "host failed: " + host_->state().error); + } + + // 模拟 Desktop 退出:销毁宿主,等 WebView2 浏览器进程释放用户数据目录 + // (lockfile 能被独占打开或已删除)。否则下一个宿主会复用仍在运行的浏览器进程, + // 看不出「重启」的效果。 + void stop_host() { + const fs::path lockfile = user_data() / "EBWebView" / "lockfile"; + std::error_code exists_error; + require(fs::exists(lockfile, exists_error), + "WebView2 lockfile not found; cannot observe browser shutdown"); + host_.reset(); + const bool released = pump_until([&lockfile] { + std::error_code ec; + if (!fs::exists(lockfile, ec)) return true; + HANDLE handle = CreateFileW(lockfile.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) return false; + CloseHandle(handle); + return true; + }, std::chrono::seconds(30)); + require(released, "WebView2 browser process did not exit after host shutdown"); + pump_for(std::chrono::milliseconds(500)); + } + + AgentBrowserHost& host() { return *host_; } + + static fs::path user_data() { return acecode::desktop::agent_browser_user_data_path(); } + + static AgentBrowserPageOwner owner(const std::string& session, const std::string& root = {}) { + AgentBrowserPageOwner value; + value.session_id = session; + value.workspace_hash = "smoke-ws"; + value.root_session_id = root; + return value; + } + + std::string open_page(const std::string& session, const std::string& root = {}) { + std::string error; + const std::string page = host_->create_page(&error, owner(session, root)); + require(!page.empty(), "create_page failed: " + error); + require(host_->set_shared_with_agent(page, true, &error), "share failed: " + error); + require(host_->set_bounds(page, {12, 12, 860, 580, true}, &error), + "set_bounds failed: " + error); + require(pump_until([&] { return host_->state(page).ready; }, std::chrono::seconds(30)), + "page " + page + " did not become ready: " + host_->state(page).error); + require(host_->navigate(page, kSite, &error), "navigate failed: " + error); + wait_live(page); + return page; + } + + void wait_live(const std::string& page) { + require(pump_until([&] { + const auto state = host_->state(page); + return state.ready && !state.loading && + state.content_state == acecode::desktop::kAgentBrowserContentStateLive; + }, std::chrono::seconds(45)), + "page " + page + " did not load " + kSite + " (state=" + + host_->state(page).content_state + ", error=" + + host_->state(page).error + ")"); + } + + nlohmann::json cdp(const std::string& page, const std::string& method, + const nlohmann::json& params) { + std::atomic done{false}; + std::string error; + nlohmann::json result; + std::thread agent([&] { + acecode::agent_browser::AgentBrowserCdpClient client; + if (client.connect(std::chrono::seconds(10), nullptr, error) && + client.select_page(page, std::chrono::seconds(10), nullptr, error)) { + result = client.command(method, params, std::chrono::seconds(20), nullptr, error); + } + done.store(true); + }); + const bool finished = pump_until([&done] { return done.load(); }, + std::chrono::seconds(40)); + agent.join(); + require(finished, method + " timed out"); + require(error.empty(), method + " failed: " + error); + return result; + } + + void set_cookie(const std::string& page, const std::string& name, bool persistent) { + nlohmann::json params{{"name", name}, {"value", "1"}, {"url", kSite}}; + if (persistent) { + params["expires"] = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count() + 86400); + } + const auto result = cdp(page, "Network.setCookie", params); + require(result.value("success", true), "Network.setCookie rejected " + name); + } + + std::set cookies(const std::string& page) { + const auto result = cdp(page, "Network.getCookies", + {{"urls", nlohmann::json::array({kSite})}}); + std::set names; + for (const auto& cookie : result.value("cookies", nlohmann::json::array())) { + names.insert(cookie.value("name", "")); + } + return names; + } + + void switch_mode(const std::string& mode) { + std::string error; + require(host_->set_cookie_mode(mode, &error), "set_cookie_mode failed: " + error); + require(host_->cookie_mode() == mode, "cookie mode did not change to " + mode); + } + + void drain_with_proxy() { + std::atomic done{false}; + std::string error; + std::thread agent([&] { + acecode::agent_browser::AgentBrowserCdpClient client; + if (client.connect(std::chrono::seconds(10), nullptr, error)) { + client.drain_store_tasks(std::chrono::seconds(10), nullptr, error); + } + done.store(true); + }); + const bool finished = pump_until([&done] { return done.load(); }, + std::chrono::seconds(30)); + agent.join(); + require(finished && error.empty(), "drain_store_tasks failed: " + error); + } + + bool wait_event(const std::string& type, const std::string& store, + AgentBrowserStorageEvent* out = nullptr) { + const bool found = pump_until([&] { + return std::any_of(events_.begin(), events_.end(), [&](const auto& event) { + return event.type == type && (store.empty() || event.store == store); + }); + }, std::chrono::seconds(30)); + if (found && out) { + *out = *std::find_if(events_.begin(), events_.end(), [&](const auto& event) { + return event.type == type && (store.empty() || event.store == store); + }); + } + return found; + } + + static std::vector profile_directories() { + std::vector names; + std::error_code ec; + for (const auto& entry : fs::directory_iterator(user_data() / "EBWebView", ec)) { + if (entry.is_directory(ec)) names.push_back(acecode::path_to_utf8(entry.path().filename())); + } + std::sort(names.begin(), names.end()); + return names; + } + + static bool profile_exists(const std::string& session) { + std::error_code ec; + return fs::exists(user_data() / "EBWebView" / + acecode::desktop::agent_browser_session_profile_directory_name( + session), + ec); + } + +private: + HWND window_ = nullptr; + int generation_ = 0; + std::unique_ptr host_; + std::vector events_; +}; + +bool contains(const std::set& names, const std::string& name) { + return names.count(name) != 0; +} + +bool listed(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +void run(Smoke& smoke) { + using acecode::desktop::read_agent_browser_store_state; + + // ---- 宿主 1:默认共享 → 按会话 → 退出即清 → 共享 ---- + smoke.start_host(); + require(smoke.host().cookie_mode() == "shared", "fresh install must default to shared"); + const std::string p1 = smoke.open_page("A"); + smoke.set_cookie(p1, "smk_shared", true); + smoke.set_cookie(p1, "smk_shared_session", false); + + smoke.switch_mode("per_session"); + smoke.wait_live(p1); // 重建后按原 page id 回到原地址 + require(smoke.host().state(p1).url.find("example.com") != std::string::npos, + "rebuilt page did not restore its URL"); + require(!contains(smoke.cookies(p1), "smk_shared"), + "per-session store of A must not see the shared cookie"); + smoke.set_cookie(p1, "smk_A", true); + + const std::string p2 = smoke.open_page("B"); + auto names = smoke.cookies(p2); + require(!contains(names, "smk_A") && !contains(names, "smk_shared"), + "session B must not see session A's or shared cookies"); + smoke.set_cookie(p2, "smk_B", true); + + const std::string p3 = smoke.open_page("A-child", "A"); + require(contains(smoke.cookies(p3), "smk_A"), + "a sub-agent page must use its root session's store"); + + const auto directories = Smoke::profile_directories(); + info("profile_directories", directories); + require(Smoke::profile_exists("A") && Smoke::profile_exists("B"), + "named WebView2 profiles are not at /EBWebView/WV2Profile_"); + + smoke.switch_mode("until_quit"); + for (const auto& page : {p1, p2, p3}) smoke.wait_live(page); + names = smoke.cookies(p1); + require(!contains(names, "smk_A") && !contains(names, "smk_shared"), + "until-quit store must start empty"); + smoke.set_cookie(p1, "smk_quit", true); + require(pump_until([] { return !read_agent_browser_store_state().shared_wipe_pending; }, + std::chrono::seconds(30)), + "shared store was not cleared after switching to until_quit"); + { + const auto state = read_agent_browser_store_state(); + require(listed(state.pending_wipe, "A") && listed(state.pending_wipe, "B"), + "per-session stores were not marked for wipe"); + require(state.cookie_mode == "until_quit", "cookie mode was not persisted"); + } + + std::string error; + for (const auto& page : {p1, p2, p3}) { + require(smoke.host().close_page(page, &error), "close_page failed: " + error); + } + pump_for(std::chrono::seconds(3)); + const std::string p4 = smoke.open_page("A"); + const bool until_quit_survived = contains(smoke.cookies(p4), "smk_quit"); + info("until_quit_after_closing_all_pages", until_quit_survived); + require(until_quit_survived, "until-quit cookies were lost after closing all pages"); + + smoke.switch_mode("shared"); + smoke.wait_live(p4); + names = smoke.cookies(p4); + require(!contains(names, "smk_shared") && !contains(names, "smk_quit"), + "switching to until_quit must have cleared the shared store"); + smoke.set_cookie(p4, "smk_persist", true); + smoke.set_cookie(p4, "smk_session", false); + smoke.stop_host(); + + // ---- 宿主 2:启动删除待清空目录、重启后的持久 / 会话 cookie、分叉、清除、删除 ---- + smoke.start_host(); + require(!Smoke::profile_exists("A") && !Smoke::profile_exists("B"), + "startup did not remove per-session stores marked for wipe"); + require(read_agent_browser_store_state().pending_wipe.empty(), + "startup did not clear pending_wipe"); + const std::string p5 = smoke.open_page("X"); + names = smoke.cookies(p5); + require(contains(names, "smk_persist"), "persistent shared cookie was lost on restart"); + info("session_cookie_after_restart", contains(names, "smk_session")); + + smoke.switch_mode("per_session"); + const std::string p6 = smoke.open_page("A"); + smoke.set_cookie(p6, "smk_A2", true); + require(acecode::desktop::record_agent_browser_store_seed("C", "A", 1), + "failed to record fork seed"); + const std::string p7 = smoke.open_page("C"); + require(contains(smoke.cookies(p7), "smk_A2"), "forked session was not seeded from A"); + smoke.set_cookie(p7, "smk_C", true); + require(!contains(smoke.cookies(p6), "smk_C"), "fork copy must be independent"); + require(read_agent_browser_store_state().pending_seeds.empty(), + "fork seed was not consumed"); + + require(smoke.host().clear_browsing_data(p6, &error), "clear failed: " + error); + AgentBrowserStorageEvent cleared; + require(smoke.wait_event("browsing_data_cleared", "session:A", &cleared) && cleared.ok, + "browsing data clear did not complete: " + cleared.error); + smoke.wait_live(p6); + require(smoke.cookies(p6).empty(), "clearing A left cookies behind"); + require(contains(smoke.cookies(p7), "smk_C"), "clearing A must not touch C"); + + require(acecode::desktop::record_agent_browser_store_deletion("C"), + "failed to record deletion"); + smoke.host().process_store_tasks(); + AgentBrowserStorageEvent deleted; + require(smoke.wait_event("session_store_deleted", "session:C", &deleted) && deleted.ok, + "loaded session store was not deleted: " + deleted.error); + require(smoke.host().states("C").empty(), "pages of the deleted session were not closed"); + + // 本次运行没加载过的会话存储:代理提醒后直接删目录并撤掉记录。 + const fs::path never_loaded = Smoke::user_data() / "EBWebView" / + acecode::desktop::agent_browser_session_profile_directory_name("Z"); + fs::create_directories(never_loaded); + std::ofstream(never_loaded / "Cookies") << "x"; + require(acecode::desktop::record_agent_browser_store_deletion("Z"), + "failed to record deletion of Z"); + smoke.drain_with_proxy(); + require(!Smoke::profile_exists("Z"), "drain_store_tasks did not remove an unloaded store"); + require(!listed(read_agent_browser_store_state().pending_deletions, "Z"), + "drained deletion stayed pending"); + + smoke.switch_mode("until_quit"); + const std::string p8 = smoke.open_page("Q"); + smoke.set_cookie(p8, "smk_quit2", true); + smoke.stop_host(); + + // ---- 宿主 3:退出即清跨重启丢失、上次运行中删除的存储在启动时删目录 ---- + smoke.start_host(); + require(!Smoke::profile_exists("C"), "deleted session store survived restart"); + require(!listed(read_agent_browser_store_state().pending_deletions, "C"), + "deleted session store stayed pending after restart"); + require(smoke.host().cookie_mode() == "until_quit", "persisted cookie mode was not loaded"); + const std::string p9 = smoke.open_page("Q"); + require(!contains(smoke.cookies(p9), "smk_quit2"), "until-quit cookie survived a restart"); + smoke.stop_host(); +} + +} // namespace + +int main() { + if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) { + std::cerr << "SMOKE_FAILED: CoInitializeEx failed\n"; + return 1; + } + wchar_t original_user_profile[32768]{}; + const DWORD original_size = GetEnvironmentVariableW( + L"USERPROFILE", original_user_profile, + static_cast(std::size(original_user_profile))); + const fs::path isolated = fs::temp_directory_path() / + ("acecode-agent-browser-cookie-smoke-" + std::to_string(GetCurrentProcessId())); + std::error_code ec; + fs::create_directories(isolated / ".acecode", ec); + if (ec || !SetEnvironmentVariableW(L"USERPROFILE", isolated.wstring().c_str())) { + std::cerr << "SMOKE_FAILED: cannot isolate USERPROFILE\n"; + CoUninitialize(); + return 1; + } + // 状态文件显式落在隔离目录,绝不写到真实的 ~/.acecode/state.json。 + acecode::set_state_file_path_for_test( + acecode::path_to_utf8(isolated / ".acecode" / "state.json")); + + const HINSTANCE instance = GetModuleHandleW(nullptr); + WNDCLASSW window_class{}; + window_class.hInstance = instance; + window_class.lpfnWndProc = window_proc; + window_class.lpszClassName = L"ACECodeAgentBrowserCookieSmoke"; + RegisterClassW(&window_class); + HWND window = CreateWindowExW(0, window_class.lpszClassName, L"ACECode cookie smoke", + WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 900, 640, + nullptr, nullptr, instance, nullptr); + int exit_code = 0; + if (!window) { + std::cerr << "SMOKE_FAILED: CreateWindowExW failed\n"; + exit_code = 1; + } else { + ShowWindow(window, SW_SHOW); + Smoke smoke(window); + try { + run(smoke); + std::cout << "SMOKE_COOKIE_MODES_OK\n"; + } catch (const std::exception& error) { + std::cerr << "SMOKE_FAILED: " << error.what() << '\n'; + exit_code = 1; + } + DestroyWindow(window); + } + if (original_size > 0 && original_size < std::size(original_user_profile)) { + SetEnvironmentVariableW(L"USERPROFILE", original_user_profile); + } else { + SetEnvironmentVariableW(L"USERPROFILE", nullptr); + } + if (exit_code == 0) { + fs::remove_all(isolated, ec); + } else { + std::cerr << "SMOKE_PROFILE: " << isolated.string() << '\n'; + } + CoUninitialize(); + return exit_code; +} diff --git a/tests/environment/data_dir_migration_test.cpp b/tests/environment/data_dir_migration_test.cpp index 8f4c5076c..2ee2f335f 100644 --- a/tests/environment/data_dir_migration_test.cpp +++ b/tests/environment/data_dir_migration_test.cpp @@ -246,6 +246,12 @@ TEST_F(DataDirMigrationTest, ExclusionRules) { EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent-browser") / "macos-profile-id")); EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent-browser"))); EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent-browser") / "webview2" / "Default")); + // 新装环境的目录是下划线形式 agent_browser/webview2(运行数据命名规则,只有旧的 + // 连字符目录已存在时才沿用旧名),同样是尽力复制子树的根;边界规则与连字符形式一致。 + EXPECT_TRUE(migration_is_best_effort_root(fs::path("agent_browser") / "webview2")); + EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent_browser") / "macos_profile_id")); + EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent_browser"))); + EXPECT_FALSE(migration_is_best_effort_root(fs::path("agent_browser") / "webview2" / "Default")); // 可再生缓存与锁文件跳过;登录态相关的库与配置不跳过。 EXPECT_TRUE(migration_skips_rebuildable_browser_entry(fs::path("Cache"))); @@ -593,8 +599,10 @@ namespace { // 造一个最小的 WebView2 profile(Desktop 的 Agent Browser user data):配置、会话库、 // 可再生缓存与锁文件。DIPS 用非 SQLite 内容,走原始复制路径。 -fs::path populate_browser_profile(const fs::path& data_dir) { - const fs::path profile = data_dir / "agent-browser" / "webview2"; +// root_name 是数据根下 Agent Browser 目录的名字:旧装机 agent-browser,新装机 agent_browser。 +fs::path populate_browser_profile(const fs::path& data_dir, + const std::string& root_name = "agent-browser") { + const fs::path profile = data_dir / root_name / "webview2"; write_file(profile / "Local State", R"({"browser":{}})"); write_file(profile / "lockfile", ""); write_file(profile / "Default" / "Preferences", R"({"profile":{}})"); @@ -666,6 +674,41 @@ TEST_F(DataDirMigrationTest, BrowserProfileChurnDoesNotFailMigration) { EXPECT_EQ(result.copied_bytes, result.total_bytes); } +// 场景:新装环境(2026-10-16 起运行数据改用下划线命名)的 Agent Browser 目录是 +// agent_browser/webview2,Desktop 开着,profile 在迁移过程中持续变动 —— 与上一条用例相同的 +// 追加写 / 新建临时文件 / 删除已清点文件。 +// 期望:与连字符目录完全一致 —— 迁移成功并写指针,缓存与锁文件不复制,被删文件不计入 +// skipped_files。 +// bug 表现:修复前尽力复制只认 agent-browser/webview2,下划线目录被当成普通文件严格复检, +// 迁移报「source changed during migration: agent_browser/webview2/...」失败,新装用户只要 +// Desktop 开着就迁移不了数据目录。 +TEST_F(DataDirMigrationTest, UnderscoreBrowserProfileChurnDoesNotFailMigration) { + populate_source(default_dir); + const fs::path profile = populate_browser_profile(default_dir, "agent_browser"); + bool churned = false; + const fs::path target = root / "moved"; + const auto result = run_data_dir_migration(s(default_dir), s(default_dir), s(target), + [&](unsigned long long, unsigned long long) { + if (churned) return; + churned = true; + append_file(profile / "Default" / "DIPS-shm", "more shared memory"); + write_file(profile / "Default" / "new.tmp", "tmp"); + fs::remove(profile / "Default" / "Preferences"); + }); + ASSERT_EQ(result.state, "done") << result.error; + EXPECT_TRUE(churned); + EXPECT_EQ(result.skipped_files, 0u); + EXPECT_TRUE(acecode::read_data_dir_redirect(s(default_dir)).has_value()); + const fs::path moved = target / "agent_browser" / "webview2"; + EXPECT_TRUE(fs::exists(moved / "Local State")); + EXPECT_TRUE(fs::exists(moved / "Default" / "DIPS")); + EXPECT_TRUE(fs::exists(moved / "Default" / "Local Storage" / "leveldb" / "000003.log")); + EXPECT_FALSE(fs::exists(moved / "Default" / "Cache")); + EXPECT_FALSE(fs::exists(moved / "Default" / "Code Cache")); + EXPECT_FALSE(fs::exists(moved / "lockfile")); + EXPECT_EQ(result.copied_bytes, result.total_bytes); +} + // 场景:profile 里的 Cookies 库是 WAL 模式,浏览器连接一直开着,已提交的数据还在 -wal 里; // 旁边还躺着一个 Cookies-journal。 // 期望:目标库能读到那一行(走了在线 backup 的一致快照);目标里没有 -wal 和 -journal。 diff --git a/tests/ipc/agent_browser_store_state_test.cpp b/tests/ipc/agent_browser_store_state_test.cpp new file mode 100644 index 000000000..b1bfcccaf --- /dev/null +++ b/tests/ipc/agent_browser_store_state_test.cpp @@ -0,0 +1,182 @@ +// Agent Browser cookie 存储状态(state.json 的 agent_browser 段)的读写与规范化。 +// openspec add-agent-browser-cookie-modes D6:Desktop 与 daemon 共用这一份格式。 + +#include + +#include "ipc/agent_browser_store_state.hpp" +#include "test_support/utils/state_file_fixture.hpp" +#include "utils/state_file.hpp" + +#include +#include + +#include + +using namespace acecode::desktop; +using acecode::test_support::StateFileTest; +using acecode::test_support::write_raw; + +namespace { + +class AgentBrowserStoreStateTest : public StateFileTest {}; + +nlohmann::json read_file_json(const std::string& path) { + std::ifstream in(path, std::ios::binary); + return nlohmann::json::parse(in, nullptr, false); +} + +} // namespace + +// 场景:全新安装,state.json 不存在。 +// 期望:读出空状态 —— 没选过档位(cookie_mode 为空,由使用方按 shared 处理),各清单为空。 +TEST_F(AgentBrowserStoreStateTest, MissingFileReadsAsEmptyState) { + const auto state = read_agent_browser_store_state(); + EXPECT_TRUE(state.cookie_mode.empty()); + EXPECT_TRUE(state.session_stores.empty()); + EXPECT_TRUE(state.pending_wipe.empty()); + EXPECT_FALSE(state.shared_wipe_pending); + EXPECT_TRUE(state.pending_deletions.empty()); + EXPECT_TRUE(state.pending_seeds.empty()); +} + +// 场景:Desktop 写入完整状态后再读回。 +// 期望:每个字段原样往返;空字段不写进文件(state.json 写入保持稀疏)。 +TEST_F(AgentBrowserStoreStateTest, RoundTripKeepsFieldsAndOmitsEmptyOnes) { + ASSERT_TRUE(update_agent_browser_store_state([](AgentBrowserStoreState& state) { + state.cookie_mode = "per_session"; + state.session_stores = {"20261020_101010_abcd", "headless-run-1"}; + state.pending_wipe = {"20261020_101010_abcd"}; + state.shared_wipe_pending = true; + state.pending_seeds.push_back({"child", "parent", 42}); + return true; + })); + const auto state = read_agent_browser_store_state(); + EXPECT_EQ(state.cookie_mode, "per_session"); + EXPECT_EQ(state.session_stores, + (std::vector{"20261020_101010_abcd", "headless-run-1"})); + EXPECT_EQ(state.pending_wipe, (std::vector{"20261020_101010_abcd"})); + EXPECT_TRUE(state.shared_wipe_pending); + ASSERT_EQ(state.pending_seeds.size(), 1u); + EXPECT_EQ(state.pending_seeds[0].session_id, "child"); + EXPECT_EQ(state.pending_seeds[0].from, "parent"); + EXPECT_EQ(state.pending_seeds[0].at_ms, 42); + + const auto file = read_file_json(path_); + ASSERT_TRUE(file.is_object()); + const auto& section = file.at(kAgentBrowserStoreStateKey); + EXPECT_FALSE(section.contains("pending_deletions")); +} + +// 场景:state.json 里已有别的模块的字段(终端提示标记等)。 +// 期望:读-改-写 agent_browser 段时其它字段原样保留。 +TEST_F(AgentBrowserStoreStateTest, UpdateKeepsOtherStateKeys) { + write_raw(path_, R"({"legacy_terminal_hint_shown":true})"); + ASSERT_TRUE(record_agent_browser_store_deletion("s1")); + const auto file = read_file_json(path_); + EXPECT_TRUE(file.value("legacy_terminal_hint_shown", false)); + EXPECT_EQ(read_agent_browser_store_state().pending_deletions, + (std::vector{"s1"})); +} + +// 场景:状态段被手改坏 / 写入了异常数据:非法 id(带路径分隔符、空串、超长)、 +// 重复条目、非字符串条目、子会话等于父会话的分叉记录。 +// 期望:非法与重复条目被丢掉,其余按首次出现顺序保留;不抛异常。 +// 非法 id 必须挡在状态文件外 —— 它会被拼进 Profile 名 / 目录名,带 `/` 的值可能越出用户数据目录。 +TEST_F(AgentBrowserStoreStateTest, FromJsonDropsInvalidAndDuplicateEntries) { + const auto section = nlohmann::json::parse(R"({ + "cookie_mode": "shared", + "session_stores": ["a", "../escape", "", "a", 7, "b"], + "pending_deletions": ["dir\\sub", "c"], + "pending_seeds": [ + {"session_id": "x", "from": "x"}, + {"session_id": "y", "from": "p1", "at_ms": 1}, + {"session_id": "y", "from": "p2", "at_ms": 2}, + {"session_id": "z"}, + "garbage" + ] + })"); + const auto state = agent_browser_store_state_from_json(section); + EXPECT_EQ(state.session_stores, (std::vector{"a", "b"})); + EXPECT_EQ(state.pending_deletions, (std::vector{"c"})); + // 同一子会话的分叉记录以最后一条为准。 + ASSERT_EQ(state.pending_seeds.size(), 1u); + EXPECT_EQ(state.pending_seeds[0].session_id, "y"); + EXPECT_EQ(state.pending_seeds[0].from, "p2"); + + EXPECT_FALSE(valid_agent_browser_store_session_id(std::string(129, 'a'))); + EXPECT_TRUE(valid_agent_browser_store_session_id(std::string(128, 'a'))); + EXPECT_TRUE(valid_agent_browser_store_session_id("ses.~:@+_-9")); +} + +// 场景:agent_browser 段不是对象(被别的工具写成字符串)。 +// 期望:按空状态处理,下一次写入把它修成正常对象。 +TEST_F(AgentBrowserStoreStateTest, CorruptedSectionIsTreatedAsEmpty) { + write_raw(path_, R"({"agent_browser":"garbage"})"); + EXPECT_TRUE(read_agent_browser_store_state().session_stores.empty()); + ASSERT_TRUE(record_agent_browser_store_seed("child", "parent", 5)); + EXPECT_EQ(read_agent_browser_store_state().pending_seeds.size(), 1u); +} + +// 场景:长期处在 shared 档,用户分叉了几百个会话,分叉记录永远不会被消费。 +// 期望:只保留最新的 500 条,最旧的被丢掉 —— 这是防止 state.json 无限增长的上限。 +TEST_F(AgentBrowserStoreStateTest, PendingSeedsAreCappedAtFiveHundredNewest) { + ASSERT_TRUE(update_agent_browser_store_state([](AgentBrowserStoreState& state) { + for (int i = 0; i < 505; ++i) { + state.pending_seeds.push_back( + {"child" + std::to_string(i), "parent", i}); + } + return true; + })); + const auto state = read_agent_browser_store_state(); + ASSERT_EQ(state.pending_seeds.size(), kAgentBrowserMaxPendingSeeds); + EXPECT_EQ(state.pending_seeds.front().session_id, "child5"); + EXPECT_EQ(state.pending_seeds.back().session_id, "child504"); +} + +// 场景:daemon 永久删除一个分叉出来、还没打开过浏览器的会话。 +// 期望:进入待删除清单,同时撤掉它作为子会话的分叉记录(否则那条记录会一直占着名额); +// 以它为父的分叉记录保留。非法 id 不写入。 +TEST_F(AgentBrowserStoreStateTest, RecordDeletionDropsSeedsOfDeletedChild) { + ASSERT_TRUE(record_agent_browser_store_seed("forked", "main", 1)); + ASSERT_TRUE(record_agent_browser_store_seed("grandchild", "forked", 2)); + ASSERT_TRUE(record_agent_browser_store_deletion("forked")); + EXPECT_FALSE(record_agent_browser_store_deletion("bad/id")); + + const auto state = read_agent_browser_store_state(); + EXPECT_EQ(state.pending_deletions, (std::vector{"forked"})); + ASSERT_EQ(state.pending_seeds.size(), 1u); + EXPECT_EQ(state.pending_seeds[0].session_id, "grandchild"); +} + +// 场景:把会话分叉到它自己(不该发生的调用)/ 父或子 id 非法。 +// 期望:拒绝写入,状态文件不出现这条记录。 +TEST_F(AgentBrowserStoreStateTest, RecordSeedRejectsSelfAndInvalidIds) { + EXPECT_FALSE(record_agent_browser_store_seed("same", "same", 1)); + EXPECT_FALSE(record_agent_browser_store_seed("child", "", 1)); + EXPECT_FALSE(record_agent_browser_store_seed("a b", "parent", 1)); + EXPECT_TRUE(read_agent_browser_store_state().pending_seeds.empty()); +} + +// 场景:所有清单都被消费完、档位也没选过。 +// 期望:写回时整个 agent_browser 段被移除,不在 state.json 里留空对象。 +TEST_F(AgentBrowserStoreStateTest, EmptiedSectionIsRemovedFromStateFile) { + ASSERT_TRUE(record_agent_browser_store_deletion("s1")); + ASSERT_TRUE(update_agent_browser_store_state([](AgentBrowserStoreState& state) { + state.pending_deletions.clear(); + return true; + })); + const auto file = read_file_json(path_); + ASSERT_TRUE(file.is_object()); + EXPECT_FALSE(file.contains(kAgentBrowserStoreStateKey)); +} + +// 场景:未来版本写入了本版本不认识的档位名。 +// 期望:原样保留(由使用方按 shared 处理,直到用户重新选择);超长的值丢弃。 +TEST_F(AgentBrowserStoreStateTest, UnknownCookieModeIsKeptVerbatimButBounded) { + auto state = agent_browser_store_state_from_json( + nlohmann::json{{"cookie_mode", "per_workspace"}}); + EXPECT_EQ(state.cookie_mode, "per_workspace"); + state = agent_browser_store_state_from_json( + nlohmann::json{{"cookie_mode", std::string(64, 'x')}}); + EXPECT_TRUE(state.cookie_mode.empty()); +} diff --git a/tests/session/session_fork_listener_test.cpp b/tests/session/session_fork_listener_test.cpp new file mode 100644 index 000000000..3ffd9e338 --- /dev/null +++ b/tests/session/session_fork_listener_test.cpp @@ -0,0 +1,143 @@ +// 覆盖 session_fork_listeners:SessionManager::fork_session_to_new_id 成功写出新会话后 +// 发出「会话分叉」通知(Agent Browser 按会话 cookie 存储据此给子会话复制父会话的 +// cookie,openspec add-agent-browser-cookie-modes D7)。Web 分叉路由与线程服务分叉 +// 都只走 fork_session_to_new_id,所以在它身上测一次就覆盖了两个入口。 + +#include + +#include "session/session_fork_listeners.hpp" +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::ChatMessage; +using acecode::SessionManager; +using acecode::SessionStorage; + +namespace { + +class TempCwd { +public: + TempCwd() { + const auto base = fs::temp_directory_path() / "acecode_fork_listener_test"; + fs::create_directories(base); + std::ostringstream oss; + oss << "fork_listener_" + << ::testing::UnitTest::GetInstance()->current_test_info()->name(); + path_ = (base / oss.str()).string(); + fs::remove_all(path_); + fs::create_directories(path_); + } + ~TempCwd() { + std::error_code ec; + fs::remove_all(path_, ec); + } + const std::string& path() const { return path_; } + +private: + std::string path_; +}; + +ChatMessage user_message(const std::string& content, const std::string& uuid) { + ChatMessage message; + message.role = "user"; + message.content = content; + message.uuid = uuid; + return message; +} + +struct ForkRecord { + std::string project_dir; + std::string new_session_id; + std::string forked_from; +}; + +// 注册监听者并在析构时注销:监听表是进程级单例,不能把本用例的回调留给别的用例。 +class ScopedForkListener { +public: + explicit ScopedForkListener(std::vector& records) + : id_(acecode::add_session_fork_listener( + [&records](const std::string& project_dir, + const std::string& new_session_id, + const std::string& forked_from) { + records.push_back({project_dir, new_session_id, forked_from}); + })) {} + ~ScopedForkListener() { acecode::remove_session_fork_listener(id_); } + +private: + std::uint64_t id_; +}; + +} // namespace + +// 场景:Web 上把会话 src 从某条消息处分叉出一个新会话。 +// 期望:恰好通知一次,带上新会话 id、源会话 id 与新会话所在的项目目录。 +TEST(SessionForkListener, SuccessfulForkNotifiesWithNewAndSourceIds) { + TempCwd cwd; + SessionManager sm; + sm.start_session(cwd.path(), "openai", "gpt-4"); + std::vector records; + ScopedForkListener listener(records); + + const auto new_id = sm.fork_session_to_new_id( + {user_message("hi", "u-1")}, "T", "src-session", "u-1"); + ASSERT_FALSE(new_id.empty()); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0].new_session_id, new_id); + EXPECT_EQ(records[0].forked_from, "src-session"); + EXPECT_EQ(fs::path(records[0].project_dir), + fs::path(SessionStorage::get_project_dir(cwd.path()))); +} + +// 场景:会话还没启动就调用分叉(调用方出错),fork_session_to_new_id 返回空。 +// 期望:不发通知 —— 否则 Agent Browser 会为一个并不存在的会话记下 cookie 复制记录。 +TEST(SessionForkListener, FailedForkDoesNotNotify) { + SessionManager sm; + std::vector records; + ScopedForkListener listener(records); + + const auto new_id = sm.fork_session_to_new_id( + {user_message("hi", "u-1")}, "T", "src-session", "u-1"); + EXPECT_TRUE(new_id.empty()); + EXPECT_TRUE(records.empty()); +} + +// 场景:没有来源会话 id 的复制(forked_from 为空)。 +// 期望:不发通知 —— 没有父会话就没有可复制的 cookie。 +TEST(SessionForkListener, ForkWithoutSourceIdDoesNotNotify) { + TempCwd cwd; + SessionManager sm; + sm.start_session(cwd.path(), "openai", "gpt-4"); + std::vector records; + ScopedForkListener listener(records); + + const auto new_id = sm.fork_session_to_new_id( + {user_message("hi", "u-1")}, "T", "", "u-1"); + ASSERT_FALSE(new_id.empty()); + EXPECT_TRUE(records.empty()); +} + +// 场景:监听者抛异常(实现缺陷)。 +// 期望:分叉本身照常成功,后续注册的监听者仍收到通知。 +TEST(SessionForkListener, ThrowingListenerDoesNotBreakForkOrOtherListeners) { + TempCwd cwd; + SessionManager sm; + sm.start_session(cwd.path(), "openai", "gpt-4"); + const auto throwing = acecode::add_session_fork_listener( + [](const std::string&, const std::string&, const std::string&) { + throw std::runtime_error("boom"); + }); + std::vector records; + ScopedForkListener listener(records); + + const auto new_id = sm.fork_session_to_new_id( + {user_message("hi", "u-1")}, "T", "src-session", "u-1"); + acecode::remove_session_fork_listener(throwing); + ASSERT_FALSE(new_id.empty()); + EXPECT_EQ(records.size(), 1u); +} diff --git a/tests/session/sidebar_tree_test.cpp b/tests/session/sidebar_tree_test.cpp index 41fb0a38e..e3893a002 100644 --- a/tests/session/sidebar_tree_test.cpp +++ b/tests/session/sidebar_tree_test.cpp @@ -16,6 +16,55 @@ json row(const std::string& id, const std::string& loop = {}, const std::string& {"loop_execution", {{"loop_id", loop}}}}; } +TEST(SidebarTree, BatchKeepsOrderForBeforeAfterAndInsideAcrossReload) { + const auto records = json::array({row("1", "a"), row("2", "a"), row("3", "b"), row("normal")}); + for (const auto& placement : {"before", "after", "inside"}) { + json layout; + auto tree = build_sidebar_tree(records, layout); + std::string error; + ASSERT_TRUE(move_sidebar_nodes(tree, layout, {"session:2", "session:1"}, "loop:b", placement, error)); + tree = build_sidebar_tree(records, layout); + const auto& siblings = std::string(placement) == "inside" ? tree.nodes.at("loop:b").children : tree.roots; + auto first = std::find(siblings.begin(), siblings.end(), "session:2"); + ASSERT_NE(first, siblings.end()); + ASSERT_NE(first + 1, siblings.end()); + EXPECT_EQ(*(first + 1), "session:1"); + if (std::string(placement) == "after") EXPECT_EQ(*(first - 1), "loop:b"); + } +} + +TEST(SidebarTree, InvalidBatchDoesNotPartiallyChangeTreeOrLayout) { + const auto records = json::array({row("1", "a"), row("2", "a"), row("3", "b"), row("normal")}); + json layout; + auto tree = build_sidebar_tree(records, layout); + const auto roots = tree.roots; + const auto children = tree.nodes.at("loop:a").children; + std::string error; + for (const auto& sources : std::vector>{ + {}, {"session:1", "missing"}, {"session:1", "session:1"}, + {"session:1", "loop:b"}, {"session:1", "loop:a"}, + {"session:normal", "loop:a"}, + }) { + EXPECT_FALSE(move_sidebar_nodes(tree, layout, sources, "loop:b", "inside", error)); + EXPECT_TRUE(layout.is_null()); + EXPECT_EQ(tree.roots, roots); + EXPECT_EQ(tree.nodes.at("loop:a").children, children); + EXPECT_TRUE(tree.nodes.at("session:normal").parent.empty()); + } +} + +TEST(SidebarTree, BatchIntoDetachedRunCreatesOnlyOneManualGroup) { + const auto records = json::array({row("1"), row("2"), row("run", "a"), row("normal")}); + json layout; + auto tree = build_sidebar_tree(records, layout); + std::string error; + ASSERT_TRUE(move_sidebar_node(tree, layout, "session:run", "session:normal", "before", error)); + ASSERT_TRUE(move_sidebar_nodes(tree, layout, {"session:2", "session:1"}, "session:run", "inside", error)); + tree = build_sidebar_tree(records, layout); + EXPECT_EQ(tree.nodes.at("group:run").children, + (std::vector{"session:run", "session:2", "session:1"})); +} + TEST(SidebarTree, LatestCreatedRunRepresentsGroupEvenWhenOlderRunWasUpdated) { auto old = row("old", "daily", "2026-10-11"); old["updated_at"] = "2026-10-14"; diff --git a/tests/session_host/agent_browser_store_lifecycle_test.cpp b/tests/session_host/agent_browser_store_lifecycle_test.cpp new file mode 100644 index 000000000..e1a7ea116 --- /dev/null +++ b/tests/session_host/agent_browser_store_lifecycle_test.cpp @@ -0,0 +1,156 @@ +// 覆盖 session_host/agent_browser_store_lifecycle(openspec add-agent-browser-cookie-modes D7): +// daemon / TUI 在会话永久删除、分叉时把 Agent Browser cookie 存储的待办写进 state.json, +// 并在后台提醒 Desktop。用假提醒实现代替真实的 Desktop 代理,不连任何进程。 + +#include + +#include "ipc/agent_browser_store_state.hpp" +#include "session/session_fork_listeners.hpp" +#include "session/session_purge_listeners.hpp" +#include "session_host/agent_browser_store_lifecycle.hpp" +#include "test_support/utils/state_file_fixture.hpp" + +#include +#include +#include +#include +#include +#include + +using acecode::AgentBrowserStoreLifecycle; +using acecode::desktop::read_agent_browser_store_state; +using acecode::test_support::StateFileTest; + +namespace { + +class AgentBrowserStoreLifecycleTest : public StateFileTest {}; + +bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +// 可阻塞的假提醒:记录调用次数;gate 关着时卡在调用里,模拟 Desktop 处理得慢。 +struct FakeDesktop { + std::mutex mu; + std::condition_variable cv; + int calls = 0; + bool gate_open = true; + bool succeed = true; + std::string error_text; + + AgentBrowserStoreLifecycle::NotifyDesktop notifier() { + return [this](const std::atomic& abort, std::string& error) { + std::unique_lock lock(mu); + ++calls; + cv.notify_all(); + // abort 由被测对象置位,不会唤醒这里的条件变量,所以短间隔轮询。 + while (!gate_open && !abort.load()) { + cv.wait_for(lock, std::chrono::milliseconds(10)); + } + if (!succeed) error = error_text; + return succeed; + }; + } + void set_gate(bool open) { + { + std::lock_guard lock(mu); + gate_open = open; + } + cv.notify_all(); + } + bool wait_calls(int expected) { + std::unique_lock lock(mu); + return cv.wait_for(lock, std::chrono::seconds(5), [this, expected] { + return calls >= expected; + }); + } + int call_count() { + std::lock_guard lock(mu); + return calls; + } +}; + +} // namespace + +// 场景:Desktop 开着,用户在 Web 上永久删除一个会话(purge_session_files 成功后发通知)。 +// 期望:待删除清单里有这个会话,并且后台向 Desktop 发了一次 drain_store_tasks 提醒。 +TEST_F(AgentBrowserStoreLifecycleTest, PurgeRecordsDeletionAndNotifiesDesktop) { + FakeDesktop desktop; + AgentBrowserStoreLifecycle lifecycle(desktop.notifier()); + acecode::notify_session_purged("project", "20261021_101010_abcd"); + + EXPECT_TRUE(contains(read_agent_browser_store_state().pending_deletions, + "20261021_101010_abcd")); + ASSERT_TRUE(desktop.wait_calls(1)); + EXPECT_TRUE(lifecycle.wait_until_idle_for_test(std::chrono::seconds(5))); +} + +// 场景:把会话 A 分叉成 C(fork_session_to_new_id 成功后发通知)。 +// 期望:记下 C ← A 的复制记录;分叉不需要提醒 Desktop(复制发生在 C 第一次打开浏览器时)。 +TEST_F(AgentBrowserStoreLifecycleTest, ForkRecordsSeedWithoutNotifying) { + FakeDesktop desktop; + AgentBrowserStoreLifecycle lifecycle(desktop.notifier()); + acecode::notify_session_forked("project", "C", "A"); + + const auto state = read_agent_browser_store_state(); + ASSERT_EQ(state.pending_seeds.size(), 1u); + EXPECT_EQ(state.pending_seeds[0].session_id, "C"); + EXPECT_EQ(state.pending_seeds[0].from, "A"); + EXPECT_GT(state.pending_seeds[0].at_ms, 0); + EXPECT_TRUE(lifecycle.wait_until_idle_for_test(std::chrono::milliseconds(200))); + EXPECT_EQ(desktop.call_count(), 0); +} + +// 场景:Desktop 没开,或是旧版 Desktop(代理回复 operation is invalid)。 +// 期望:删除照常记录、不报错、不重试刷屏 —— 下次 Desktop 启动时从状态文件里处理。 +TEST_F(AgentBrowserStoreLifecycleTest, DesktopUnavailableIsNotAnError) { + FakeDesktop desktop; + desktop.succeed = false; + desktop.error_text = "Agent Browser proxy operation is invalid"; + AgentBrowserStoreLifecycle lifecycle(desktop.notifier()); + acecode::notify_session_purged("project", "S1"); + ASSERT_TRUE(desktop.wait_calls(1)); + EXPECT_TRUE(lifecycle.wait_until_idle_for_test(std::chrono::seconds(5))); + EXPECT_EQ(desktop.call_count(), 1); + EXPECT_TRUE(contains(read_agent_browser_store_state().pending_deletions, "S1")); +} + +// 场景:永久删除一个带子代理的会话,会话树按「子在前、主在后」连续删除好几个。 +// 期望:状态文件里每个都记下;提醒合并 —— Desktop 还在处理第一次提醒时到来的删除只再 +// 提醒一次,不会一个删除一次网络往返。 +TEST_F(AgentBrowserStoreLifecycleTest, BurstOfPurgesCoalescesNotifications) { + FakeDesktop desktop; + desktop.set_gate(false); + AgentBrowserStoreLifecycle lifecycle(desktop.notifier()); + acecode::notify_session_purged("project", "child-1"); + ASSERT_TRUE(desktop.wait_calls(1)); + acecode::notify_session_purged("project", "child-2"); + acecode::notify_session_purged("project", "main"); + desktop.set_gate(true); + EXPECT_TRUE(lifecycle.wait_until_idle_for_test(std::chrono::seconds(5))); + EXPECT_EQ(desktop.call_count(), 2); + const auto state = read_agent_browser_store_state(); + EXPECT_TRUE(contains(state.pending_deletions, "child-1")); + EXPECT_TRUE(contains(state.pending_deletions, "child-2")); + EXPECT_TRUE(contains(state.pending_deletions, "main")); +} + +// 场景:服务停止(daemon / TUI 退出)之后又有会话被删除或分叉。 +// 期望:监听已注销,不再写状态文件;停止时卡在提醒里的调用被中止,stop 不会挂住。 +TEST_F(AgentBrowserStoreLifecycleTest, StopUnregistersListenersAndAbortsPendingNotify) { + FakeDesktop desktop; + desktop.set_gate(false); + AgentBrowserStoreLifecycle lifecycle(desktop.notifier()); + acecode::notify_session_purged("project", "before-stop"); + ASSERT_TRUE(desktop.wait_calls(1)); + const auto started = std::chrono::steady_clock::now(); + lifecycle.stop(); + EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(3)); + + acecode::notify_session_purged("project", "after-stop"); + acecode::notify_session_forked("project", "C2", "A2"); + const auto state = read_agent_browser_store_state(); + EXPECT_TRUE(contains(state.pending_deletions, "before-stop")); + EXPECT_FALSE(contains(state.pending_deletions, "after-stop")); + EXPECT_TRUE(state.pending_seeds.empty()); +} diff --git a/tests/web/agent_browser_top_sites_test.cpp b/tests/web/agent_browser_top_sites_test.cpp new file mode 100644 index 000000000..006fd7794 --- /dev/null +++ b/tests/web/agent_browser_top_sites_test.cpp @@ -0,0 +1,222 @@ +// 覆盖 src/apps/web/handlers/agent_browser_top_sites.cpp:Agent Browser 新标签页 +// 「推荐」(常访问网站)的记录、排序、移除与落盘。一旦回归: +// - 同一次访问的标题 / 图标补充被当成新访问 → 刷新几次标题就把某个网站顶到第一 +// - 已移除的推荐又出现 → 用户点了 × 没有效果 +// - 损坏的文件让接口报错 → 新标签页整块空白 + +#include + +#include "web/handlers/agent_browser_top_sites.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace top_sites = acecode::web::agent_browser_top_sites; +using nlohmann::json; + +namespace { + +constexpr std::int64_t kDay = 24LL * 60 * 60 * 1000; +constexpr std::int64_t kNow = 1'800'000'000'000LL; + +top_sites::Visit visit(const std::string& url, + const std::string& title = "", + bool new_visit = true, + const std::string& favicon = "") { + top_sites::Visit v; + v.url = url; + v.title = title; + v.new_visit = new_visit; + v.favicon = favicon; + return v; +} + +std::vector displayed_urls(const json& doc, std::size_t limit = 4, + std::int64_t now = kNow) { + std::vector urls; + for (const auto& site : top_sites::display_sites(doc, limit, now)) { + urls.push_back(site.at("url").get()); + } + return urls; +} + +} // namespace + +// 场景:页面地址带 #锚点、协议大写、非 http(s) 地址。 +// 期望:锚点去掉、协议转小写;about:blank / file: / javascript: / 带空格的地址一律不记。 +TEST(AgentBrowserTopSitesTest, NormalizesOnlyHttpUrlsAndDropsFragment) { + EXPECT_EQ(top_sites::normalize_url("HTTPS://example.com/a?b=1#top"), + "https://example.com/a?b=1"); + EXPECT_EQ(top_sites::normalize_url("http://127.0.0.1:5173/"), "http://127.0.0.1:5173/"); + EXPECT_EQ(top_sites::normalize_url("about:blank"), ""); + EXPECT_EQ(top_sites::normalize_url("file:///C:/a.html"), ""); + EXPECT_EQ(top_sites::normalize_url("javascript:alert(1)"), ""); + EXPECT_EQ(top_sites::normalize_url("https://exa mple.com/"), ""); + EXPECT_EQ(top_sites::normalize_url("https://"), ""); + EXPECT_EQ(top_sites::normalize_url(std::string("https://e.com/") + std::string(3000, 'a')), ""); +} + +// 场景:Desktop 先报一次新导航(只有地址),随后标题、图标陆续到达(new_visit=false)。 +// 期望:访问次数只算 1 次;标题与图标补进同一条记录。 +// 回归:曾经每次状态事件都计数,标题变化几次就让网站排名虚高。 +TEST(AgentBrowserTopSitesTest, MetadataUpdatesDoNotCountAsVisits) { + auto doc = top_sites::empty_document(); + EXPECT_TRUE(top_sites::record_visit(doc, visit("https://example.com/"), kNow)); + EXPECT_TRUE(top_sites::record_visit(doc, visit("https://example.com/", "Example", false), kNow)); + EXPECT_TRUE(top_sites::record_visit( + doc, visit("https://example.com/", "", false, "data:image/png;base64,AAAA"), kNow)); + // 内容没变的补充不需要落盘。 + EXPECT_FALSE(top_sites::record_visit(doc, visit("https://example.com/", "Example", false), kNow)); + + ASSERT_EQ(doc["sites"].size(), 1u); + EXPECT_EQ(doc["sites"][0]["visit_count"], 1); + EXPECT_EQ(doc["sites"][0]["title"], "Example"); + EXPECT_EQ(doc["sites"][0]["favicon"], "data:image/png;base64,AAAA"); +} + +// 场景:没有记录过的地址只收到元数据补充(new_visit=false)。 +// 期望:不新建记录 —— 只有真正打开过的页面才能成为推荐。 +TEST(AgentBrowserTopSitesTest, MetadataForUnknownUrlIsIgnored) { + auto doc = top_sites::empty_document(); + EXPECT_FALSE(top_sites::record_visit(doc, visit("https://ghost.example/", "Ghost", false), kNow)); + EXPECT_TRUE(doc["sites"].empty()); +} + +// 场景:A 访问 3 次但都在 100 天前,B 今天访问 1 次。 +// 期望:B 排在 A 前(3×0.1 < 1×1.0);同为近期时访问多的在前。 +TEST(AgentBrowserTopSitesTest, RanksByVisitCountWeightedByRecency) { + auto doc = top_sites::empty_document(); + for (int i = 0; i < 3; ++i) { + top_sites::record_visit(doc, visit("https://old.example/"), kNow - 100 * kDay); + } + top_sites::record_visit(doc, visit("https://fresh.example/"), kNow); + for (int i = 0; i < 2; ++i) { + top_sites::record_visit(doc, visit("https://busy.example/"), kNow - kDay); + } + + EXPECT_EQ(displayed_urls(doc), + (std::vector{"https://busy.example/", "https://fresh.example/", + "https://old.example/"})); +} + +// 场景:记录了 6 个网站,新标签页只要 4 个;其中一个没有标题。 +// 期望:只返回前 4 个;没有标题的显示主机名(含端口,去掉用户信息)。 +TEST(AgentBrowserTopSitesTest, DisplayLimitsCountAndFallsBackToHost) { + auto doc = top_sites::empty_document(); + for (int i = 0; i < 6; ++i) { + top_sites::record_visit(doc, visit("https://site" + std::to_string(i) + ".example/"), + kNow - i); + } + top_sites::record_visit(doc, visit("http://user@127.0.0.1:8080/app"), kNow + 1); + + const auto sites = top_sites::display_sites(doc, 4, kNow + 1); + ASSERT_EQ(sites.size(), 4u); + EXPECT_EQ(sites[0]["url"], "http://user@127.0.0.1:8080/app"); + EXPECT_EQ(sites[0]["title"], "127.0.0.1:8080"); + EXPECT_FALSE(sites[0].contains("favicon")); +} + +// 场景:用户点 × 移除推荐,之后又访问该网站;随后点「撤销」。 +// 期望:移除期间无论再访问几次都不出现在推荐里;撤销后恢复,访问次数没有丢。 +TEST(AgentBrowserTopSitesTest, DismissedSiteStaysHiddenUntilRestored) { + auto doc = top_sites::empty_document(); + top_sites::record_visit(doc, visit("https://a.example/"), kNow); + top_sites::record_visit(doc, visit("https://b.example/"), kNow); + + EXPECT_TRUE(top_sites::dismiss(doc, "https://a.example/#ignored")); + EXPECT_FALSE(top_sites::dismiss(doc, "https://a.example/")); // 重复移除无变化 + top_sites::record_visit(doc, visit("https://a.example/"), kNow); + EXPECT_EQ(displayed_urls(doc), (std::vector{"https://b.example/"})); + + EXPECT_TRUE(top_sites::restore(doc, "https://a.example/")); + EXPECT_FALSE(top_sites::restore(doc, "https://a.example/")); + EXPECT_EQ(displayed_urls(doc), + (std::vector{"https://a.example/", "https://b.example/"})); +} + +// 场景:页面给出超大的 data URL 图标、javascript: 图标、超长且带换行的标题。 +// 期望:图标不存(前端退回地球图标);标题去掉控制字符并按 UTF-8 安全截断。 +TEST(AgentBrowserTopSitesTest, RejectsOversizedFaviconsAndCleansTitles) { + EXPECT_EQ(top_sites::normalize_favicon("javascript:alert(1)"), ""); + EXPECT_EQ(top_sites::normalize_favicon( + "data:image/png;base64," + std::string(top_sites::kMaxFaviconDataUrlBytes, 'A')), + ""); + EXPECT_EQ(top_sites::normalize_favicon("https://cdn.example/favicon.ico"), + "https://cdn.example/favicon.ico"); + + auto doc = top_sites::empty_document(); + std::string title = "第一行\n第二行"; + for (int i = 0; i < 200; ++i) title += "汉"; + top_sites::record_visit(doc, visit("https://t.example/", title), kNow); + const auto stored = doc["sites"][0]["title"].get(); + EXPECT_EQ(stored.find('\n'), std::string::npos); + EXPECT_LE(stored.size(), top_sites::kMaxTitleBytes); + EXPECT_EQ(stored.rfind("第一行 第二行", 0), 0u); +} + +// 场景:记录超过上限(kMaxStoredSites)。 +// 期望:丢掉分数最低(最旧)的,文件大小有界。 +TEST(AgentBrowserTopSitesTest, TrimsToStoredSiteLimit) { + auto doc = top_sites::empty_document(); + for (std::size_t i = 0; i < top_sites::kMaxStoredSites + 5; ++i) { + top_sites::record_visit(doc, visit("https://s" + std::to_string(i) + ".example/"), + kNow - 200 * kDay + static_cast(i) * kDay); + } + ASSERT_EQ(doc["sites"].size(), top_sites::kMaxStoredSites); + for (const auto& site : doc["sites"]) { + EXPECT_NE(site["url"], "https://s0.example/"); + } +} + +// 场景:手改 / 旧版本留下的文件里有重复 URL(一个带锚点)、坏条目与非字符串的移除项。 +// 期望:合并重复项的访问次数,丢弃坏条目,结果仍是合法文档。 +TEST(AgentBrowserTopSitesTest, NormalizeDocumentMergesDuplicatesAndDropsJunk) { + const json raw = { + {"sites", json::array({ + {{"url", "https://dup.example/"}, {"visit_count", 2}, {"last_visit_ms", kNow - 10}, + {"title", "Old"}}, + {{"url", "https://dup.example/#x"}, {"visit_count", 3}, {"last_visit_ms", kNow}, + {"title", "New"}}, + {{"url", "about:blank"}, {"visit_count", 9}}, + "not-an-object", + })}, + {"blocked", json::array({"https://gone.example/", 42, "ftp://x"})}, + }; + const auto doc = top_sites::normalize_document(raw); + ASSERT_EQ(doc["sites"].size(), 1u); + EXPECT_EQ(doc["sites"][0]["visit_count"], 5); + EXPECT_EQ(doc["sites"][0]["title"], "New"); + EXPECT_EQ(doc["blocked"], json::array({"https://gone.example/"})); +} + +// 场景:写盘后重新读取;文件被写坏(不是 JSON)。 +// 期望:往返内容一致;损坏时返回空文档并给出 warning,接口照常可用。 +TEST(AgentBrowserTopSitesTest, SaveLoadRoundTripAndCorruptFileFallsBack) { + std::random_device rd; + const auto dir = fs::temp_directory_path() / + ("acecode_top_sites_test_" + std::to_string(rd())); + fs::create_directories(dir); + const auto path = top_sites::file_path(dir.string()); + + auto doc = top_sites::empty_document(); + top_sites::record_visit(doc, visit("https://round.example/", "Round"), kNow); + top_sites::dismiss(doc, "https://hidden.example/"); + ASSERT_TRUE(top_sites::save(path, doc)); + + std::string warning; + EXPECT_EQ(top_sites::load(path, &warning), doc); + EXPECT_TRUE(warning.empty()); + + { + std::ofstream out(fs::path(path), std::ios::binary | std::ios::trunc); + out << "{broken"; + } + EXPECT_EQ(top_sites::load(path, &warning), top_sites::empty_document()); + EXPECT_FALSE(warning.empty()); + + std::error_code ec; + fs::remove_all(dir, ec); +} diff --git a/tests/web/session_batch_handler_test.cpp b/tests/web/session_batch_handler_test.cpp new file mode 100644 index 000000000..82f9c9629 --- /dev/null +++ b/tests/web/session_batch_handler_test.cpp @@ -0,0 +1,113 @@ +#include + +#include "web/handlers/session_batch_handler.hpp" + +#include +#include + +using acecode::web::PinnedSessionOrderItem; +using acecode::web::SessionBatchAction; +using acecode::web::SessionBatchRequest; +using acecode::web::apply_batch_pin_order_items; +using acecode::web::apply_batch_pin_session_ids; +using acecode::web::kMaxSessionBatchItems; +using acecode::web::parse_session_batch_request; +using nlohmann::json; + +namespace { + +std::vector order_keys(const std::vector& items) { + std::vector out; + for (const auto& item : items) out.push_back(item.workspace_hash + "/" + item.session_id); + return out; +} + +} // namespace + +// 场景:侧栏多选后发一次批量请求,请求体带 action 与按可见顺序排列的会话。 +// 期望:动作名映射正确;无工作区任务两种写法(no_workspace 或置顶作用域名)都归一成 +// no_workspace;重复会话只保留第一次出现;read 的游标原样保留。 +TEST(SessionBatchHandler, ParsesItemsInOrderAndNormalizesNoWorkspaceScope) { + SessionBatchRequest request; + std::string error; + ASSERT_TRUE(parse_session_batch_request(json{ + {"action", "read"}, + {"items", json::array({ + json{{"session_id", "a"}, {"workspace_hash", "ws1"}, {"cursor", 42}}, + json{{"session_id", "b"}, {"no_workspace", true}, {"workspace_hash", "ignored"}}, + json{{"session_id", "c"}, {"workspace_hash", "__no_workspace__"}}, + json{{"session_id", "a"}, {"workspace_hash", "ws1"}}, + })}, + }, request, error)) << error; + + EXPECT_EQ(request.action, SessionBatchAction::Read); + ASSERT_EQ(request.items.size(), 3u); + EXPECT_EQ(request.items[0].session_id, "a"); + EXPECT_EQ(request.items[0].pin_scope(), "ws1"); + EXPECT_EQ(request.items[0].cursor, 42u); + EXPECT_TRUE(request.items[1].no_workspace); + EXPECT_EQ(request.items[1].workspace_hash, ""); + EXPECT_EQ(request.items[1].pin_scope(), "__no_workspace__"); + EXPECT_TRUE(request.items[2].no_workspace); + EXPECT_EQ(request.items[2].pin_scope(), "__no_workspace__"); +} + +// 场景:同一个会话 id 出现在两个不同工作区(各自的作用域)。 +// 期望:不算重复,两项都保留 —— 去重键是「作用域 + id」而不是只看 id。 +TEST(SessionBatchHandler, SameIdInDifferentScopesIsNotADuplicate) { + SessionBatchRequest request; + std::string error; + ASSERT_TRUE(parse_session_batch_request(json{ + {"action", "pin"}, + {"items", json::array({ + json{{"session_id", "same"}, {"workspace_hash", "ws1"}}, + json{{"session_id", "same"}, {"workspace_hash", "ws2"}}, + })}, + }, request, error)) << error; + EXPECT_EQ(request.items.size(), 2u); +} + +// 场景:客户端发来未知动作、空列表、非法 id(含路径分隔符)或超过上限的条目数。 +// 期望:全部解析失败并给出错误文案,路由据此回 400,不碰任何磁盘状态。 +TEST(SessionBatchHandler, RejectsInvalidRequests) { + SessionBatchRequest request; + std::string error; + EXPECT_FALSE(parse_session_batch_request(json{{"action", "delete"}, {"items", json::array({json{{"session_id", "a"}}})}}, request, error)); + EXPECT_FALSE(error.empty()); + EXPECT_FALSE(parse_session_batch_request(json{{"action", "pin"}, {"items", json::array()}}, request, error)); + EXPECT_FALSE(parse_session_batch_request(json{{"action", "pin"}, {"items", json::array({json{{"session_id", "../x"}}})}}, request, error)); + EXPECT_FALSE(parse_session_batch_request(json{{"action", "pin"}, {"items", json::array({json{{"session_id", ""}}})}}, request, error)); + EXPECT_FALSE(parse_session_batch_request(json::array(), request, error)); + + json too_many = json::array(); + for (std::size_t i = 0; i <= kMaxSessionBatchItems; ++i) { + too_many.push_back(json{{"session_id", "s" + std::to_string(i)}, {"workspace_hash", "ws"}}); + } + EXPECT_FALSE(parse_session_batch_request(json{{"action", "pin"}, {"items", too_many}}, request, error)); +} + +// 场景:作用域里已置顶 [x, y],多选 [a, x, b] 后批量置顶。 +// 期望:新置顶的 a、b 按选择顺序插到最前(a 在最上面),已置顶的 x 不被挪动, +// 结果 [a, b, x, y] —— 与逐个点置顶时「已置顶的保持原位」一致。 +TEST(SessionBatchHandler, BatchPinPrependsNewIdsInRequestOrderAndKeepsExistingInPlace) { + EXPECT_EQ(apply_batch_pin_session_ids({"x", "y"}, {"a", "x", "b", "a"}, true), + (std::vector{"a", "b", "x", "y"})); +} + +// 场景:作用域里已置顶 [a, b, c],批量取消置顶 [c, a, 不存在的 z]。 +// 期望:只移除命中的 a、c,剩余顺序不变。 +TEST(SessionBatchHandler, BatchUnpinRemovesOnlySelectedIds) { + EXPECT_EQ(apply_batch_pin_session_ids({"a", "b", "c"}, {"c", "a", "z"}, false), + (std::vector{"b"})); +} + +// 场景:全局置顶视觉顺序跨工作区,多选里有一个已经在顺序里的项。 +// 期望:置顶时只把不在顺序里的项按请求顺序插到最前;取消置顶时按「作用域 + id」 +// 精确移除,不会误删另一个工作区里同 id 的项。 +TEST(SessionBatchHandler, BatchOrderUsesScopeAndIdAsIdentity) { + const std::vector current{{"ws2", "s"}, {"ws1", "x"}}; + EXPECT_EQ(order_keys(apply_batch_pin_order_items(current, {{"ws1", "a"}, {"ws1", "x"}, {"ws3", "b"}}, true)), + (std::vector{"ws1/a", "ws3/b", "ws2/s", "ws1/x"})); + EXPECT_EQ(order_keys(apply_batch_pin_order_items(current, {{"ws1", "s"}, {"ws1", "x"}}, false)), + (std::vector{"ws2/s"})); +} diff --git a/tests/web/sidebar_sessions_test.cpp b/tests/web/sidebar_sessions_test.cpp index 23ea5a31a..98c6ba96e 100644 --- a/tests/web/sidebar_sessions_test.cpp +++ b/tests/web/sidebar_sessions_test.cpp @@ -100,6 +100,28 @@ class SidebarSessionsHttp : public testing::Test { } }; +TEST_F(SidebarSessionsHttp, BatchMovePersistsOrderAndRejectsPartialOrMalformedRequests) { + record("run-1", "a"); record("run-2", "a"); record("receiver", "b"); + const auto post = [&](const json& sources) { + return cpr::Post(cpr::Url{url(endpoint())}, cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"sources", sources}, {"target", "loop:b"}, {"placement", "inside"}}.dump()}, + cpr::Timeout{3000}); + }; + auto response = post(json::array({"session:run-2", "session:run-1"})); + ASSERT_EQ(response.status_code, 200) << response.text; + const auto page = get({{"expanded", "[\"loop:b\"]"}}); + ASSERT_EQ(page["sessions"].size(), 4u); + EXPECT_EQ(page["sessions"][2]["id"], "run-2"); + EXPECT_EQ(page["sessions"][3]["id"], "run-1"); + const auto layout_path = std::filesystem::path(SessionStorage::get_project_dir(cwd)) / "sidebar_sessions.json"; + const auto saved = read_sidebar_layout(layout_path); + for (const auto& sources : {json::array({"session:run-1", "missing"}), json::array(), + json::array({"session:run-1", 42}), json("wrong")}) { + EXPECT_EQ(post(sources).status_code, 409); + EXPECT_EQ(read_sidebar_layout(layout_path), saved); + } +} + TEST_F(SidebarSessionsHttp, PagesGroupsAndReturnsAllExpandedHistory) { for (int i = 10; i < 23; ++i) record("run-" + std::to_string(i), "daily"); for (int i = 0; i < 6; ++i) record("normal-" + std::to_string(i)); diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index e0bd81863..03bdd9cce 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -5565,6 +5565,169 @@ TEST(WebServerHttp, PinnedSessionVisualOrderIncludesNoWorkspaceTasks) { EXPECT_EQ(get_body["items"][0]["session_id"], workspace_session_id); } +namespace { + +std::string create_batch_test_session(WebServerFixture& fx, const std::string& hash) { + auto create = hash.empty() + ? cpr::Post(cpr::Url{fx.url("/api/sessions")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{R"({"no_workspace":true})"}) + : cpr::Post(cpr::Url{fx.url("/api/workspaces/" + hash + "/sessions")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{R"({})"}); + EXPECT_EQ(create.status_code, 201) << create.text; + return json::parse(create.text).value("session_id", std::string{}); +} + +cpr::Response post_session_batch(WebServerFixture& fx, const std::string& action, const json& items) { + return cpr::Post(cpr::Url{fx.url("/api/session-batch")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"action", action}, {"items", items}}.dump()}); +} + +json batch_ws_item(const std::string& hash, const std::string& id) { + return json{{"session_id", id}, {"workspace_hash", hash}}; +} + +json batch_task_item(const std::string& id) { + return json{{"session_id", id}, {"no_workspace", true}}; +} + +} // namespace + +// 场景:侧栏多选了两个工作区会话、一个无工作区任务和一个已不存在的会话,一次请求批量置顶。 +// 期望:三个真实会话成功、不存在的那项单独失败(整体仍 200);各作用域的置顶文件与 +// 全局视觉顺序一次写好,顺序与选择顺序一致(第一个在最上面)。随后批量取消置顶其中两个, +// 只剩第一个。回归:批量操作以前逐个发请求,选 20 个就是 40 个往返。 +TEST(WebServerHttp, SessionBatchPinsAndUnpinsAcrossScopesInOneRequest) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + const auto s1 = create_batch_test_session(fx, hash); + const auto task = create_batch_test_session(fx, ""); + const auto s2 = create_batch_test_session(fx, hash); + ASSERT_FALSE(s1.empty()); + ASSERT_FALSE(task.empty()); + ASSERT_FALSE(s2.empty()); + + auto pin = post_session_batch(fx, "pin", json::array({ + batch_ws_item(hash, s1), batch_task_item(task), batch_ws_item(hash, s2), + batch_ws_item(hash, "missing-session"), + })); + ASSERT_EQ(pin.status_code, 200) << pin.text; + const auto pin_body = json::parse(pin.text); + EXPECT_EQ(pin_body.value("succeeded", 0), 3); + EXPECT_EQ(pin_body.value("failed", 0), 1); + EXPECT_FALSE(pin_body["results"][3].value("ok", true)); + + auto workspace_pins = cpr::Get(cpr::Url{fx.url("/api/workspaces/" + hash + "/pinned-sessions")}); + ASSERT_EQ(workspace_pins.status_code, 200) << workspace_pins.text; + EXPECT_EQ(json::parse(workspace_pins.text)["session_ids"], json::array({s1, s2})); + const auto task_pins = acecode::session_pins::read_pinned_sessions_state( + fx.no_workspace_cache_root / "pinned_sessions.json"); + EXPECT_EQ(task_pins.session_ids, (std::vector{task})); + + auto order = cpr::Get(cpr::Url{fx.url("/api/pinned-sessions/order")}); + ASSERT_EQ(order.status_code, 200) << order.text; + const auto items = json::parse(order.text)["items"]; + ASSERT_EQ(items.size(), 3u); + EXPECT_EQ(items[0]["session_id"], s1); + EXPECT_EQ(items[1]["session_id"], task); + EXPECT_EQ(items[1]["workspace_hash"], "__no_workspace__"); + EXPECT_EQ(items[2]["session_id"], s2); + EXPECT_EQ(pin_body["pinned_order"]["items"], items); + + auto unpin = post_session_batch(fx, "unpin", json::array({ + batch_ws_item(hash, s2), batch_task_item(task), + })); + ASSERT_EQ(unpin.status_code, 200) << unpin.text; + const auto unpin_body = json::parse(unpin.text); + EXPECT_EQ(unpin_body.value("failed", -1), 0); + ASSERT_EQ(unpin_body["pinned_order"]["items"].size(), 1u); + EXPECT_EQ(unpin_body["pinned_order"]["items"][0]["session_id"], s1); +} + +// 场景:批量归档两个已置顶的工作区会话,再批量取消归档。 +// 期望:归档结果带回会话 meta(archived=true),默认列表不再出现它们,置顶文件与全局 +// 顺序里的这两项在同一次请求里被移除;取消归档后两者回到默认列表。 +TEST(WebServerHttp, SessionBatchArchiveUnpinsAndUnarchiveRestores) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + const auto s1 = create_batch_test_session(fx, hash); + const auto s2 = create_batch_test_session(fx, hash); + ASSERT_EQ(post_session_batch(fx, "pin", json::array({ + batch_ws_item(hash, s1), batch_ws_item(hash, s2), + })).status_code, 200); + + auto archive = post_session_batch(fx, "archive", json::array({ + batch_ws_item(hash, s1), batch_ws_item(hash, s2), + })); + ASSERT_EQ(archive.status_code, 200) << archive.text; + const auto archive_body = json::parse(archive.text); + EXPECT_EQ(archive_body.value("succeeded", 0), 2); + EXPECT_TRUE(archive_body["results"][0]["session"].value("archived", false)); + ASSERT_TRUE(archive_body.contains("pinned_scopes")); + EXPECT_TRUE(archive_body["pinned_scopes"][0]["session_ids"].empty()); + EXPECT_TRUE(archive_body["pinned_order"]["items"].empty()); + + const auto has_session = [&](const std::string& id) { + auto list = cpr::Get(cpr::Url{fx.url("/api/workspaces/" + hash + "/sessions")}); + EXPECT_EQ(list.status_code, 200) << list.text; + const auto sessions = json::parse(list.text); + return std::any_of(sessions.begin(), sessions.end(), + [&](const json& s) { return s.value("id", std::string{}) == id; }); + }; + EXPECT_FALSE(has_session(s1)); + EXPECT_FALSE(has_session(s2)); + + auto unarchive = post_session_batch(fx, "unarchive", json::array({ + batch_ws_item(hash, s1), batch_ws_item(hash, s2), + })); + ASSERT_EQ(unarchive.status_code, 200) << unarchive.text; + EXPECT_EQ(json::parse(unarchive.text).value("succeeded", 0), 2); + EXPECT_TRUE(has_session(s1)); + EXPECT_TRUE(has_session(s2)); +} + +// 场景:批量标记未读,再批量标记已读(工作区会话与无工作区任务混合)。 +// 期望:每项结果都带回最新状态载荷,未读后 attention_state=unread,已读后恢复 read。 +TEST(WebServerHttp, SessionBatchMarksReadAndUnreadWithStatuses) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + const auto s1 = create_batch_test_session(fx, hash); + const auto task = create_batch_test_session(fx, ""); + const auto items = json::array({batch_ws_item(hash, s1), batch_task_item(task)}); + + auto unread = post_session_batch(fx, "unread", items); + ASSERT_EQ(unread.status_code, 200) << unread.text; + const auto unread_body = json::parse(unread.text); + ASSERT_EQ(unread_body["results"].size(), 2u); + for (const auto& result : unread_body["results"]) { + EXPECT_TRUE(result.value("ok", false)); + EXPECT_EQ(result["status"].value("attention_state", std::string{}), "unread"); + } + + auto read = post_session_batch(fx, "read", items); + ASSERT_EQ(read.status_code, 200) << read.text; + const auto read_body = json::parse(read.text); + ASSERT_EQ(read_body["results"].size(), 2u); + for (const auto& result : read_body["results"]) { + EXPECT_EQ(result["status"].value("attention_state", std::string{}), "read"); + } +} + +// 场景:批量请求的动作名未知、条目为空或会话 id 含路径字符。 +// 期望:400 且不改动任何状态。 +TEST(WebServerHttp, SessionBatchRejectsMalformedRequests) { + WebServerFixture fx; + EXPECT_EQ(post_session_batch(fx, "delete", json::array({batch_task_item("a")})).status_code, 400); + EXPECT_EQ(post_session_batch(fx, "pin", json::array()).status_code, 400); + EXPECT_EQ(post_session_batch(fx, "pin", json::array({batch_task_item("../a")})).status_code, 400); + auto bad_json = cpr::Post(cpr::Url{fx.url("/api/session-batch")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{"}); + EXPECT_EQ(bad_json.status_code, 400); +} + // 场景:归档 workspace 会话后,默认列表隐藏它;专用 archived 查询能看到, // 取消归档后恢复到默认列表。 TEST(WebServerHttp, WorkspaceArchiveSessionHidesFromDefaultList) { @@ -11933,6 +12096,67 @@ TEST(WebServerHttp, ToolRewritesRoundTripPersistsJsonAndAppliesToProcess) { EXPECT_EQ(json::parse(after.text)["rewrites"]["file_read"], "peek"); } +// 场景:Agent Browser 新标签页「推荐」的 REST 往返。前端上报两次访问(其中一次只是 +// 标题补充)→ 读推荐 → 移除 → 恢复;另有非法地址与远程代理来源(127.0.0.2)。 +// 期望:标题补充不计访问次数;记录落在 config.json 同目录的 agent_browser_top_sites.json; +// 移除后读不到、恢复后回来;非 http(s) 地址 400;浏览记录不对远程来源开放(403)。 +TEST(WebServerHttp, AgentBrowserTopSitesRoundTripAndStayLocalOnly) { + WebServerFixture fx; + const cpr::Header headers{{"Content-Type", "application/json"}}; + const auto post = [&](const std::string& path, const json& body) { + return cpr::Post(cpr::Url{fx.url(path)}, headers, cpr::Body{body.dump()}); + }; + + ASSERT_EQ(post("/api/agent-browser/visits", + {{"url", "https://a.example/#x"}, {"new_visit", true}}).status_code, 200); + ASSERT_EQ(post("/api/agent-browser/visits", + {{"url", "https://a.example/"}, {"title", "Alpha"}, {"new_visit", false}}) + .status_code, 200); + ASSERT_EQ(post("/api/agent-browser/visits", {{"url", "http://127.0.0.1:5173/"}}).status_code, 200); + EXPECT_EQ(post("/api/agent-browser/visits", {{"url", "about:blank"}}).status_code, 400); + EXPECT_EQ(cpr::Post(cpr::Url{fx.url("/api/agent-browser/visits")}, headers, + cpr::Body{"{oops"}).status_code, 400); + + const auto listed = cpr::Get(cpr::Url{fx.url("/api/agent-browser/top-sites")}, + cpr::Parameters{{"limit", "4"}}); + ASSERT_EQ(listed.status_code, 200) << listed.text; + const auto sites = json::parse(listed.text)["sites"]; + ASSERT_EQ(sites.size(), 2u); + bool saw_alpha = false; + for (const auto& site : sites) { + if (site["url"] == "https://a.example/") { + saw_alpha = true; + EXPECT_EQ(site["title"], "Alpha"); + } else { + EXPECT_EQ(site["title"], "127.0.0.1:5173"); + } + } + EXPECT_TRUE(saw_alpha); + { + std::ifstream ifs(fx.tmp_dir / "agent_browser_top_sites.json"); + ASSERT_TRUE(ifs.is_open()); + const auto saved = json::parse(ifs); + for (const auto& site : saved["sites"]) { + if (site["url"] == "https://a.example/") EXPECT_EQ(site["visit_count"], 1); + } + } + + ASSERT_EQ(post("/api/agent-browser/top-sites/dismiss", {{"url", "https://a.example/"}}).status_code, 200); + auto after_dismiss = json::parse( + cpr::Get(cpr::Url{fx.url("/api/agent-browser/top-sites")}).text)["sites"]; + ASSERT_EQ(after_dismiss.size(), 1u); + EXPECT_EQ(after_dismiss[0]["url"], "http://127.0.0.1:5173/"); + ASSERT_EQ(post("/api/agent-browser/top-sites/restore", {{"url", "https://a.example/"}}).status_code, 200); + EXPECT_EQ(json::parse(cpr::Get(cpr::Url{fx.url("/api/agent-browser/top-sites")}).text)["sites"].size(), 2u); + + const auto remote = cpr::Get(cpr::Url{fx.url("/api/agent-browser/top-sites")}, + cpr::Header{{"X-ACECode-Token", "smoke-token"}}, + cpr::Interface{"127.0.0.2"}, cpr::Timeout{3000}); + if (remote.status_code == 0) GTEST_SKIP() << "本机不能以 127.0.0.2 为源地址发起连接: " << remote.error.message; + EXPECT_EQ(remote.status_code, 403) << remote.text; + EXPECT_EQ(remote.text.find("a.example"), std::string::npos); +} + TEST(WebServerHttp, SavedModelReasoningSyncStartupSaveAndRefreshAreAsynchronous) { std::atomic requests{0}; diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index a0d9f63c1..fd6453c09 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,58 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '在文件列表展示': 'Show in file list', + '定位当前工作区内的活动文件并高亮': 'Locate and highlight the active file in the current workspace', + // Agent Browser New Tab page (mirrors Codex Desktop's Suggested / Recents). + '推荐': 'Suggested', + '最近': 'Recents', + '最近文件': 'Recent files', + '移除推荐': 'Dismiss suggestion', + '移除推荐:{{p0}}': 'Dismiss {{p0}}', + '已移除推荐': 'Suggestion dismissed', + '无法更新推荐': 'Couldn\'t update suggestion', + '无法更新推荐:': 'Couldn\'t update suggestion: ', + '向上滚动': 'Scroll up', + '向下滚动': 'Scroll down', + '向上滚动最近文件': 'Scroll recents up', + '向下滚动最近文件': 'Scroll recents down', + '文档': 'Document', + '图片': 'Image', + '演示文稿': 'Presentation', + '电子表格': 'Spreadsheet', + '网页': 'Website', + // Agent Browser cookie storage modes (mirrors Claude Desktop's Keep cookies menu). + '保留 Cookie': 'Keep cookies', + '退出时清除': 'Until quit', + '共享': 'Shared', + '按会话': 'Per session', + '退出 ACECode 时清除 Cookie': 'Cookies are cleared when ACECode quits', + '所有会话共用同一份 Cookie': 'All chats share the same cookies', + '每个会话各自保存 Cookie': 'Each chat keeps its own cookies', + '更多': 'More', + '浏览器更多操作': 'More browser actions', + '清除浏览数据': 'Clear browsing data', + '清除浏览数据?': 'Clear browsing data?', + '将删除所有会话共用的 Cookie、登录状态和网站数据。之后需要重新登录网站。': 'This deletes the cookies, sign-ins and site data shared by all chats. You will need to sign in to sites again.', + '将删除当前会话浏览器里保存的 Cookie、登录状态和网站数据,其它会话不受影响。之后需要重新登录网站。': 'This deletes the cookies, sign-ins and site data saved for this chat\'s browser. Other chats are not affected. You will need to sign in to sites again.', + '将删除本次运行中保存的 Cookie、登录状态和网站数据(退出 ACECode 时本来也会清除)。之后需要重新登录网站。': 'This deletes the cookies, sign-ins and site data saved during this run (they are cleared when ACECode quits anyway). You will need to sign in to sites again.', + '正在清除…': 'Clearing…', + '浏览数据已清除': 'Browsing data cleared', + '清除浏览数据超时': 'Clearing browsing data timed out', + '无法清除浏览数据': 'Could not clear browsing data', + '无法保存 Cookie 设置': 'Could not save the cookie setting', + '无法读取 Cookie 设置': 'Could not read the cookie setting', + '未知的 Cookie 档位:{{p0}}': 'Unknown cookie setting: {{p0}}', + 'Cookie 已改为「{{p0}}」': 'Cookies set to "{{p0}}"', + 'Cookie 已改为「{{p0}}」,已保存的 Cookie 已清除': 'Cookies set to "{{p0}}"; saved cookies were cleared', + 'Agent 浏览器里登录网站后,登录状态保存多久、各会话之间是否共用。切到「退出时清除」会删除已保存的 Cookie。': 'How long sign-ins in the Agent Browser are kept, and whether chats share them. Switching to "Until quit" deletes saved cookies.', + 'agent browser cookie cookies keep sign in login session 登录 登录状态 记住登录 浏览器 网站数据 清除浏览数据 共享 按会话 退出时清除 until quit shared per session': 'agent browser cookie cookies keep sign in login session signed in remember login browser site data clear browsing data shared per session until quit', + '批量操作:成功 {{success}} 项,失败 {{failed}} 项': 'Batch operation: {{success}} succeeded, {{failed}} failed', + '已处理 {{count}} 个会话': 'Processed {{count}} chats', + '已归档 {{count}} 个会话': 'Archived {{count}} chats', + '批量操作失败:': 'Batch operation failed: ', + '松开以引用 {{count}} 个会话': 'Release to reference {{count}} chats', + '拖入的会话已引用过,或是当前会话': 'These chats are already referenced or are the current chat', '右键配置麦克风': 'Right-click to configure microphone', '麦克风配置': 'Microphone settings', '麦克风': 'Microphone', diff --git a/web/scripts/test-preview-file-locate.mjs b/web/scripts/test-preview-file-locate.mjs new file mode 100644 index 000000000..052490f1d --- /dev/null +++ b/web/scripts/test-preview-file-locate.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Exercise the production App and its real menu/shortcut/list owners. The API +// fixtures never connect to a user daemon or reuse a browser profile. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await fs.mkdtemp(path.join(os.tmpdir(), 'ace-preview-file-locate-')); +const server = await createServer({ root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0, open: false, hmr: false } }); +const session = { id: 'locate-session', session_id: 'locate-session', title: 'Locate fixture', + cwd: 'C:/fixture', workspace_hash: 'fixture', active: true, updated_at: '2026-10-22T10:00:00Z' }; +const messages = [ + { id: 'user-1', role: 'user', content: '定位测试' }, + { id: 'assistant-1', role: 'assistant', content: '[target.js](src/deep/target.js)\n\n[other.js](other.js)\n\n[outside.js](D:/outside/outside.js)' }, +]; +const errors = [], directories = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); + page.setDefaultTimeout(15000); + page.on('pageerror', e => errors.push(e.message)); + await page.addInitScript(() => { + class FixtureWebSocket extends EventTarget { + static OPEN = 1; static CLOSED = 3; + constructor() { super(); this.readyState = 1; setTimeout(() => { this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); } + send() {} close() { this.readyState = 3; } + } + window.WebSocket = FixtureWebSocket; + if (location.search.includes('desktop')) window.__ACECODE_DESKTOP_SHELL__ = true; + localStorage.setItem('acecode.uiPrefs.v1', JSON.stringify({ view: 'single', sidebarCollapsed: false, + sidePanelCollapsed: false, sidePanelListCollapsed: false, fontSize: 'medium' })); + window.locateAnimations = []; + document.addEventListener('animationstart', e => { + if (e.animationName.startsWith('ace-file-locate')) window.locateAnimations.push({ name: e.animationName, + path: e.target.closest('[data-desktop-file-path]')?.getAttribute('data-desktop-file-path') }); + }); + }); + await page.route('**/api/**', async route => { + const { pathname, searchParams } = new URL(route.request().url()); + let data = {}; + if (pathname === '/api/health') data = { status: 'ok', version: '1.0.0', cwd: session.cwd }; + else if (pathname.includes('onboarding')) data = { dismissed: true }; + else if (pathname === '/api/config/ui-preferences') data = { theme: 'light', color_theme: 'blue', font_size: 'medium' }; + else if (pathname === '/api/workspaces') data = [{ hash: 'fixture', cwd: session.cwd, name: 'Locate project' }]; + else if (pathname.endsWith('/messages')) data = { messages, events: [], busy: false, title: session.title, cwd: session.cwd }; + else if (pathname.endsWith('/sessions') || pathname.endsWith('/session-sidebar')) data = pathname === '/api/session-sidebar' ? [] : [session]; + else if (pathname.endsWith('/resume') || /^\/api\/sessions\/[^/]+$/.test(pathname)) data = session; + else if (pathname.endsWith('/model')) data = { name: 'fixture', provider: 'openai', model: 'test' }; + else if (pathname.includes('models')) data = { models: [], profiles: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + else if (pathname.includes('loops') || pathname.includes('/pty') || pathname.includes('/themes')) data = []; + else if (pathname === '/api/files') { + const dir = searchParams.get('path') || ''; directories.push(dir); + const entry = (name, kind = 'file') => ({ name, path: dir ? `${dir}/${name}` : name, kind }); + if (!dir) data = [entry('src', 'dir'), entry('other.js')]; + else if (dir === 'src') data = [entry('deep', 'dir')]; + else if (dir === 'src/deep') { + await new Promise(resolve => setTimeout(resolve, 150)); + data = [...Array.from({ length: 70 }, (_, i) => entry(`a${String(i).padStart(2, '0')}.js`)), entry('target.js')]; + } else throw new Error(`Unexpected directory request: ${dir}`); + } else if (pathname.includes('/files/content')) { + await route.fulfill({ status: 200, contentType: 'text/plain', body: 'export const answer = 42;\n' }); return; + } else if (pathname.includes('/files/edit')) data = { text: 'export const answer = 42;\n', read_id: 'fixture-read' }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + const count = () => page.evaluate(() => window.locateAnimations.filter(a => a.name === 'ace-file-locate-highlight').length); + const waitPulse = async before => { + try { + await page.waitForFunction(n => window.locateAnimations.filter(a => a.name === 'ace-file-locate-highlight').length > n, before); + } catch (error) { + console.error('Locate state', await page.evaluate(async () => { + const { sessionWorkbench } = await import('/src/lib/sessionWorkbench.js'); + return { request: sessionWorkbench.get('session:locate-session', 'fileLocate'), + animations: window.locateAnimations, dialogs: [...document.querySelectorAll('[role="dialog"], [role="menu"]')].map(e => e.textContent), + selected: document.querySelector('[data-desktop-file-selected="true"]')?.outerHTML, + tree: document.querySelector('.ace-file-tree')?.getBoundingClientRect().toJSON() }; + }), errors); + throw error; + } + }; + const row = page.locator('.ace-file-tree [data-desktop-file-path="src/deep/target.js"]'); + const activePath = () => page.locator('.ace-preview-details-tab[aria-selected="true"]').getAttribute('data-desktop-preview-tab-path'); + const assertVisibleSelected = async () => { + assert.equal(await row.getAttribute('data-desktop-file-selected'), 'true'); + assert.equal(await row.evaluate(e => { + const r = e.getBoundingClientRect(), p = e.closest('.ace-file-tree').getBoundingClientRect(); + return r.top >= p.top && r.bottom <= p.bottom && p.width > 0; + }), true); + }; + const menuLocate = async target => { + await target.click({ button: 'right' }); + await page.getByRole('menuitem', { name: /在文件列表展示/ }).click(); + }; + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/${process.argv.includes('--desktop') ? '?desktop' : ''}`, { waitUntil: 'commit' }); + await page.locator('.ace-sidebar-session-title-button').filter({ hasText: session.title }).first().click(); + const transcript = page.locator('[data-conversation-find-root]'); + await transcript.getByText('target.js', { exact: true }).click(); + await page.locator('[data-desktop-preview-path="src/deep/target.js"]').waitFor(); + if (process.argv.includes('--desktop')) { + const editor = page.locator('[data-ace-editable-preview-text]'); + await editor.fill('export const draft = 99;\n'); + await page.keyboard.press('Control+BracketLeft'); + await waitPulse(0); + assert.equal(await editor.inputValue(), 'export const draft = 99;\n'); + await editor.fill('export const answer = 42;\n'); + } + // Start from a different list tab and a collapsed list, with no loaded parents. + await page.locator('.ace-side-panel').getByRole('tab', { name: '变更', exact: true }).click(); + await page.locator('.ace-side-panel').getByRole('button', { name: '收起列表面板', exact: true }).click(); + const initialPulseCount = await count(); + await page.keyboard.press('Control+BracketLeft'); + await waitPulse(initialPulseCount); + await assertVisibleSelected(); + assert.equal(await activePath(), 'src/deep/target.js'); + assert.ok(directories.includes('src') && directories.includes('src/deep')); + assert.equal(directories.includes('src/deep/target.js'), false); + const motion = await row.locator('.ace-file-leaf-content').evaluate(e => { + const animation = e.getAnimations()[0]; animation.pause(); animation.currentTime = 210; + return getComputedStyle(e).transform; + }); + assert.notEqual(motion, 'none'); assert.notEqual(motion, 'matrix(1, 0, 0, 1, 0, 0)'); + await page.screenshot({ path: path.join(output, 'locate-highlight.png') }); + let before = await count(); + await page.keyboard.press('Control+BracketLeft'); await waitPulse(before); await assertVisibleSelected(); + console.log('[pass] shortcut opens list, lazily expands parents, scrolls/selects and restarts visible scale highlight'); + + // Right-click a background tab without activating it. + await transcript.getByText('other.js', { exact: true }).click(); + const targetTab = page.locator('.ace-preview-details-tab[data-desktop-preview-tab-path="src/deep/target.js"]'); + before = await count(); await menuLocate(targetTab); await waitPulse(before); + await assertVisibleSelected(); assert.equal(await activePath(), 'other.js'); + console.log('[pass] background tab context menu locates its own file and preserves the active preview'); + + // Content menu, repeated requests, and reduced-motion feedback. + await targetTab.click(); + before = await count(); + await menuLocate(page.locator('[data-desktop-preview-path="src/deep/target.js"]').first()); + await waitPulse(before); await assertVisibleSelected(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + before = await count(); await page.keyboard.press('Control+BracketLeft'); await waitPulse(before); + assert.equal(await row.locator('.ace-file-leaf-content').evaluate(e => getComputedStyle(e).animationName), 'none'); + await page.waitForFunction(() => !document.querySelector('.ace-file-locate-highlight')); + assert.equal(await row.getAttribute('data-desktop-file-selected'), 'true'); + // Ordinary selection changes must not replay a consumed explicit reveal. + before = await count(); + await page.locator('.ace-file-tree [data-desktop-file-path="other.js"]').click(); + await row.click(); + await page.waitForTimeout(100); + assert.equal(await count(), before); + console.log('[pass] content menu and reduced motion retain feedback and final selection'); + + // External files must not change the collapsed list, selection, or scroll. + await transcript.getByText('outside.js', { exact: true }).click(); + await page.locator('.ace-side-panel').getByRole('button', { name: '收起列表面板', exact: true }).click(); + const state = () => page.evaluate(() => ({ collapsed: document.querySelector('.ace-side-panel-shell')?.dataset.collapsed, + selected: document.querySelector('.ace-file-tree [data-desktop-file-selected="true"]')?.dataset.desktopFilePath, + scroll: document.querySelector('.ace-file-tree')?.scrollTop, animations: window.locateAnimations.length })); + const prior = await state(); + await page.keyboard.press('Control+BracketLeft'); + await menuLocate(page.locator('.ace-preview-details-tab[aria-selected="true"]')); + await page.waitForTimeout(150); + assert.deepEqual(await state(), prior); + assert.equal(await page.getByText('操作不可用', { exact: true }).count(), 0); + console.log('[pass] outside-workspace shortcut and menu have no side effects'); + + // Narrow layout uses the existing right popup and can reveal its list. + await page.setViewportSize({ width: 600, height: 900 }); + await page.waitForTimeout(350); + const rightToggle = page.locator('[data-sidebar-toggle="right"]'); + if (await rightToggle.getAttribute('aria-expanded') !== 'true') await rightToggle.click(); + await targetTab.click(); + before = await count(); await page.keyboard.press('Control+BracketLeft'); await waitPulse(before); + await assertVisibleSelected(); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.screenshot({ path: path.join(output, 'locate-narrow.png') }); + console.log('[pass] narrow layout reveals the selected row without page overflow'); + assert.deepEqual(errors, []); + console.log(`Screenshots: ${output}`); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-responsive-sidebars.mjs b/web/scripts/test-responsive-sidebars.mjs index ecc4fba64..05093b89b 100644 --- a/web/scripts/test-responsive-sidebars.mjs +++ b/web/scripts/test-responsive-sidebars.mjs @@ -106,115 +106,103 @@ try { g = await resize(906); assert.equal(g.left.visible, true); assert.equal(g.right.visible, true); assert.equal(g.transcript.width, 350); g = await resize(905); - assert.equal(g.left.visible, true); assert.equal(g.right.visible, false); - g = await resize(969); assert.equal(g.right.visible, false); - g = await resize(970); assert.equal(g.right.visible, true); + assert.equal(g.left.visible, false); assert.equal(g.right.visible, true); assert.equal(g.popup, null); + g = await resize(969); assert.equal(g.left.visible, false); assert.equal(g.right.visible, true); + g = await resize(970); assert.equal(g.left.visible, true); + await right.click(); await settle(); g = await resize(626); assert.equal(g.left.visible, true); assert.equal(g.right.visible, false); assert.equal(g.transcript.width, 350); g = await resize(625); assert.equal(g.left.visible, false); g = await resize(689); assert.equal(g.left.visible, false); g = await resize(690); assert.equal(g.left.visible, true); g = await resize(600); - assert.equal(g.left.visible, false); assert.equal(g.right.visible, false); assert.ok(g.transcript.width >= 350, JSON.stringify(g)); - await page.screenshot({ path: path.join(output, '600-transcript.png') }); - for (const side of ['left', 'right']) { - const button = side === 'left' ? left : right; - const before = await geometry(); await button.click(); await settle(); g = await geometry(); - assert.equal(g.popup, side); assert.equal(g.transcript.width, before.transcript.width); - await page.locator(`[data-sidebar-popup="${side}"]`).click({ position: { x: 30, y: 10 } }); - assert.equal((await geometry()).popup, side); - await page.locator('[data-conversation-find-root]').click({ position: { x: side === 'left' ? 450 : 20, y: 180 } }); - await settle(); assert.equal((await geometry()).popup, null); - await button.click(); await settle(); await page.keyboard.press('Escape'); await settle(); - assert.equal((await geometry()).popup, null); assert.equal(await button.evaluate(e => e === document.activeElement), true); - if (side === 'right') { - await right.click(); await settle(); - await page.locator('[data-sidebar-popup="right"]').getByRole('button', { name: '收起列表面板', exact: true }).click(); - await settle(); assert.equal((await geometry()).popup, null); - assert.equal(await right.getAttribute('aria-expanded'), 'false'); - } - } - await left.click(); await settle(); await right.click(); await settle(); assert.equal((await geometry()).popup, 'right'); - await right.click(); await settle(); assert.equal((await geometry()).popup, null); + assert.equal(g.left.visible, false); assert.equal(g.right.visible, false); + const beforePopup = await geometry(); + await left.click(); await settle(); g = await geometry(); + assert.equal(g.popup, 'left'); assert.equal(g.transcript.width, beforePopup.transcript.width); + await page.locator('[data-sidebar-popup="left"]').click({ position: { x: 30, y: 10 } }); + assert.equal((await geometry()).popup, 'left'); + await page.locator('[data-conversation-find-root]').click({ position: { x: 450, y: 180 } }); + await settle(); assert.equal((await geometry()).popup, null); + await left.click(); await settle(); await page.keyboard.press('Escape'); await settle(); + assert.equal((await geometry()).popup, null); assert.equal(await left.evaluate(e => e === document.activeElement), true); + await left.click(); await settle(); await right.click(); await settle(); g = await geometry(); + assert.equal(g.popup, null); assert.equal(g.right.visible, true); assert.equal(g.left.visible, false); assert.deepEqual(await prefs(), initial); + g = await resize(480); assert.equal(g.right.visible, true); assert.equal(g.popup, null); + await page.screenshot({ path: path.join(output, '480-right-manual.png') }); g = await resize(1920); assert.equal(g.left.visible, true); assert.equal(g.right.visible, true); - console.log('[pass] production App collapses right then left, popup does not squeeze transcript, outside/Escape/toggle/mutual exclusion preserve preferences'); + console.log('[pass] only left collapses at 350px; right stays docked and narrow-window toggles persist without a popup'); for (const leftClosed of [false, true]) for (const rightClosed of [false, true]) { g = await geometry(); if (g.left.visible === leftClosed) await left.click(); if (g.right.visible === rightClosed) await right.click(); await settle(); const saved = await prefs(); - await resize(600); await left.click(); await settle(); await right.click(); await settle(); + await resize(600); await left.click(); await settle(); + assert.equal((await geometry()).popup, 'left'); + assert.equal((await geometry()).right.visible, !rightClosed); await resize(1920); g = await geometry(); - assert.equal(g.left.visible, !leftClosed, JSON.stringify({ g, prefs: await prefs(), saved })); - assert.equal(g.right.visible, !rightClosed, JSON.stringify({ g, prefs: await prefs(), saved })); + assert.equal(g.left.visible, !leftClosed); assert.equal(g.right.visible, !rightClosed); assert.deepEqual(await prefs(), saved); await resize(600); await page.evaluate(() => history.replaceState(null, '', '/?open=resize-session&workspace=fixture')); await page.reload({ waitUntil: 'commit' }); await page.locator('[data-conversation-find-root]').waitFor(); await settle(); + assert.equal((await geometry()).right.visible, !rightClosed); await resize(1920); g = await geometry(); assert.equal(g.left.visible, !leftClosed); assert.equal(g.right.visible, !rightClosed); assert.deepEqual(await prefs(), saved); } - console.log('[pass] all four open/closed combinations survive popup, resize and reload'); + console.log('[pass] all four manual states survive left popup, resize and reload'); - // Opening a real file changes the right-hand width requirement without resize. await left.click(); await right.click(); await settle(); - // Exercise the shared Desktop editor in Chromium without starting a native shell. await page.evaluate(() => { window.__ACECODE_DESKTOP_SHELL__ = true; }); await page.locator('[data-file-path="source.js"]').first().click(); - await page.locator('.ace-preview-details-shell').waitFor(); await settle(); const editor = page.locator('.ace-file-text-editor'); await editor.waitFor(); await editor.fill('export const edited = 123;\n'); - await editor.evaluate(e => { e.setSelectionRange(5, 10); window.responsiveSavedEditor = e; }); + await editor.evaluate(e => { window.responsiveSavedEditor = e; }); const beforePreviewResize = await prefs(); - g = await resize(1300); assert.equal(g.right.visible, false); assert.ok(g.transcript.width >= 350); - await right.click(); await settle(); - assert.equal((await geometry()).popup, 'right'); - assert.equal(await editor.inputValue(), 'export const edited = 123;\n'); - assert.equal(await editor.evaluate(e => e === window.responsiveSavedEditor), true); - await page.screenshot({ path: path.join(output, '1300-preview-popup.png') }); - g = await resize(800); assert.equal(g.popup, 'right'); assert.ok(g.right.right <= 800); - await resize(600); await editor.waitFor(); - const expandList = page.locator('.ace-preview-details-panel').getByRole('button', { name: '展开列表面板', exact: true }); - await expandList.click(); await settle(); - assert.equal(await page.locator('.ace-popup-navigation').isVisible(), true); - await page.locator('.ace-popup-navigation').getByRole('button', { name: '收起列表面板', exact: true }).click(); - await settle(); assert.equal((await geometry()).popup, 'right'); - assert.equal(await editor.isVisible(), true); - await page.locator('[data-conversation-find-root]').click({ position: { x: 5, y: 150 } }); await settle(); - assert.equal((await geometry()).popup, null); - g = await resize(1920); assert.equal(g.right.visible, true); + g = await resize(1300); + assert.equal(g.left.visible, false); assert.equal(g.right.visible, true); assert.ok(g.transcript.width >= 350); + for (const width of [1100, 800, 480, 1300]) { + g = await resize(width); + assert.equal(g.left.visible, false); assert.equal(g.right.visible, true); assert.equal(g.popup, null); + assert.equal(await editor.evaluate(e => e === window.responsiveSavedEditor), true); + assert.equal(await editor.inputValue(), 'export const edited = 123;\n'); + } + assert.deepEqual(await prefs(), beforePreviewResize); + await page.screenshot({ path: path.join(output, '1300-preview-docked.png') }); + await resize(800); await right.click(); await settle(); + const unsavedDialog = page.getByRole('dialog'); + await unsavedDialog.getByRole('button', { name: '取消', exact: true }).click(); await settle(); + assert.equal((await geometry()).right.visible, true); assert.equal(await editor.inputValue(), 'export const edited = 123;\n'); - assert.equal(await editor.evaluate(e => e === window.responsiveSavedEditor), true); + await editor.fill('export const answer = 42;\n'); await settle(); + await right.click(); await settle(); + assert.equal((await geometry()).right.visible, false); + assert.equal((await geometry()).left.visible, true); + await right.click(); await settle(); + assert.equal((await geometry()).right.visible, true); assert.equal((await geometry()).left.visible, false); + assert.equal((await geometry()).popup, null); assert.equal(await editor.inputValue(), 'export const answer = 42;\n'); assert.deepEqual(await prefs(), beforePreviewResize); - console.log('[pass] real preview width, compact popup navigation, editor DOM and unsaved draft survive responsive transitions'); + console.log('[pass] right details remain docked during resize; manual close releases space for left; editor and draft survive'); - // A left-sidebar menu is a React portal outside the popup's DOM subtree. - await resize(600); await left.click(); await settle(); + await left.click(); await settle(); await page.getByRole('button', { name: 'ACECode 快捷菜单', exact: true }).click(); const menu = page.locator('[data-shortcut-menu="true"]'); await menu.waitFor(); await menu.dispatchEvent('pointerdown', { bubbles: true, pointerId: 1 }); assert.equal((await geometry()).popup, 'left'); await page.keyboard.press('Escape'); await settle(); assert.equal((await geometry()).popup, 'left'); await page.keyboard.press('Escape'); await settle(); assert.equal((await geometry()).popup, null); - console.log('[pass] portal menu interaction and nested Escape keep the sidebar alive until the outer dismissal'); + console.log('[pass] portal menu and nested Escape preserve the left popup'); for (const theme of ['light', 'dark']) { await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); - for (const width of [1920, 1100, 800, 320]) { - g = await resize(width); assert.equal(g.overflow, false, JSON.stringify(g)); - assert.ok(g.transcript.width >= Math.min(350, width - 6) - 1, JSON.stringify(g)); - if (width === 320) { - await right.click(); await settle(); g = await geometry(); - assert.ok(g.right.right <= width && g.right.x >= 0, JSON.stringify(g)); - } + for (const width of [1920, 1100, 800]) { + g = await resize(width); assert.equal(g.right.visible, true); assert.equal(g.popup, null); + assert.equal(g.overflow, false, JSON.stringify(g)); await page.screenshot({ path: path.join(output, `${theme}-${width}.png`) }); - if (g.popup) { await page.keyboard.press('Escape'); await settle(); } } } - console.log('[pass] light/dark at 1920, 1100, 800 and 320 CSS pixels keep transcript and popup inside the viewport'); - await resize(1920); await page.getByRole('button', { name: '最大化预览面板', exact: true }).click(); await settle(); assert.equal((await geometry()).transcript.visible, false); @@ -223,21 +211,23 @@ try { assert.equal((await geometry()).transcript.visible, true); await resize(1920); await editor.fill('export const answer = 42;\n'); await settle(); await page.getByRole('button', { name: '隐藏详情面板', exact: true }).click(); await settle(); - await resize(1000); + await resize(1200); await page.getByRole('button', { name: '后台任务', exact: true }).click(); await settle(); - g = await geometry(); assert.equal(g.right.visible, false); assert.equal(g.left.visible, false); + g = await geometry(); assert.equal(g.right.visible, true); assert.equal(g.left.visible, false); assert.ok(g.transcript.width >= 350, JSON.stringify(g)); await page.getByRole('button', { name: '后台任务', exact: true }).click(); await settle(); - console.log('[pass] explicit preview maximization remains usable and child transcript pressure collapses sidebars'); + assert.equal((await geometry()).left.visible, true); + console.log('[pass] explicit maximization is unchanged; child transcript pressure collapses only left'); const originalOwnerPrefs = await prefs(); - await resize(600); await right.click(); await settle(); + await resize(600); await left.click(); await settle(); await page.evaluate(() => window.aceDesktop_focusSessionFromBridge('resize-other', 'fixture')); await settle(); assert.equal((await geometry()).popup, null); assert.deepEqual(await prefs(), originalOwnerPrefs); await page.evaluate(() => window.aceDesktop_focusSessionFromBridge('resize-session', 'fixture')); await settle(); assert.equal((await geometry()).popup, null); - console.log('[pass] changing workbench owners discards temporary popup without changing the original owner preferences'); + assert.equal((await geometry()).right.visible, true); + console.log('[pass] workbench changes clear only temporary popup state'); } // Exercise the actual popup edges; floating preferences must never change @@ -281,61 +271,35 @@ try { await left.click(); await settle(); console.log('[pass] left popup supports drag/keyboard, remains floating even at 160px, and starts clamped drags without jumping'); - await right.click(); await settle(); - const initialRightWidth = (await geometry()).right.width; - g = await dragPopup('right', -120); assert.equal(g.right.width, initialRightWidth + 120); assert.equal(g.right.right, 600); - assert.equal(await page.locator('.ace-side-panel-shell').evaluate(e => e.getBoundingClientRect().width), g.right.width); - await page.locator('[data-popup-resize="right"]').focus(); await page.keyboard.press('ArrowLeft'); await settle(); - const filePopupWidth = (await geometry()).right.width; - assert.equal(filePopupWidth, initialRightWidth + 132); - await right.click(); await right.click(); await settle(); assert.equal((await geometry()).right.width, filePopupWidth); - console.log('[pass] navigation-only popup expands its content with the outer frame and remembers the selected width'); - - await page.evaluate(() => { window.__ACECODE_DESKTOP_SHELL__ = true; }); - await page.locator('[data-file-path="source.js"]').first().click(); - const resizeEditor = page.locator('.ace-file-text-editor'); await resizeEditor.waitFor(); - await resizeEditor.fill('export const resizedDraft = 321;\n'); - await resizeEditor.evaluate(e => { window.popupResizeEditor = e; }); - g = await resize(1300); - const detailStartWidth = g.right.width; - g = await dragPopup('right', -160); assert.equal(g.right.width, detailStartWidth + 160); assert.equal(g.right.right, 1300); - assert.equal(await resizeEditor.evaluate(e => e === window.popupResizeEditor), true); - assert.equal(await resizeEditor.inputValue(), 'export const resizedDraft = 321;\n'); - await page.screenshot({ path: path.join(output, 'detail-popup-resized.png') }); - assert.deepEqual(withoutPopupWidths(await prefs()), withoutPopupWidths(resizeBase)); - - // Closing or cancelling in the middle of a captured drag releases all state. - let handle = page.locator('[data-popup-resize="right"]'); + // Cancelling a captured left drag must not leak resize listeners or preferences. + await left.click(); await settle(); + let handle = page.locator('[data-popup-resize="left"]'); let box = await handle.boundingBox(); await page.mouse.move(box.x + 4, box.y + 160); await page.mouse.down(); await page.keyboard.press('Escape'); await settle(); assert.equal((await geometry()).popup, null); assert.equal(await page.evaluate(() => document.body.classList.contains('ace-resizing')), false); - const afterClose = await prefs(); await page.mouse.move(box.x - 80, box.y + 160); await page.mouse.up(); await settle(); + const afterClose = await prefs(); await page.mouse.move(box.x + 80, box.y + 160); await page.mouse.up(); await settle(); assert.deepEqual(await prefs(), afterClose); - await right.click(); await settle(); - handle = page.locator('[data-popup-resize="right"]'); box = await handle.boundingBox(); + await left.click(); await settle(); + handle = page.locator('[data-popup-resize="left"]'); box = await handle.boundingBox(); await handle.evaluate(e => e.addEventListener('pointerdown', event => { window.popupDragPointer = event.pointerId; }, { once: true })); await page.mouse.move(box.x + 4, box.y + 160); await page.mouse.down(); await handle.dispatchEvent('pointercancel', { bubbles: true, pointerId: await page.evaluate(() => window.popupDragPointer) }); assert.equal(await page.evaluate(() => document.body.classList.contains('ace-resizing')), false); - const afterCancel = await prefs(); await page.mouse.move(box.x - 80, box.y + 160); await page.mouse.up(); await settle(); - assert.deepEqual(await prefs(), afterCancel); assert.equal((await geometry()).popup, 'right'); - console.log('[pass] preview drag preserves transcript and draft; Escape and pointercancel release capture and listeners'); + const afterCancel = await prefs(); await page.mouse.move(box.x + 80, box.y + 160); await page.mouse.up(); await settle(); + assert.deepEqual(await prefs(), afterCancel); assert.equal((await geometry()).popup, 'left'); + console.log('[pass] Escape and pointercancel release left-popup capture and listeners'); const savedPopupPrefs = await prefs(); - const savedPopupWidth = (await geometry()).right.width; - g = await resize(480); assert.equal(g.right.width, 456); assert.deepEqual(await prefs(), savedPopupPrefs); - g = await resize(900); assert.equal(g.right.width, savedPopupWidth); await page.evaluate(() => history.replaceState(null, '', '/?open=resize-session&workspace=fixture')); await page.reload({ waitUntil: 'commit' }); await page.locator('[data-conversation-find-root]').waitFor(); await settle(); - await right.click(); await settle(); assert.equal((await geometry()).right.width, savedPopupWidth); - assert.deepEqual(await prefs(), savedPopupPrefs); - await resize(600); + assert.equal((await geometry()).right.visible, true); await left.click(); await settle(); assert.equal((await geometry()).left.width, 436); - g = await resize(1920); assert.equal(g.popup, null); assert.equal(g.left.width, 270); + assert.deepEqual(await prefs(), savedPopupPrefs); + g = await resize(1920); assert.equal(g.popup, null); assert.equal(g.left.width, 270); assert.equal(g.right.visible, true); assert.deepEqual(withoutPopupWidths(await prefs()), withoutPopupWidths(resizeBase)); - console.log('[pass] popup widths persist across reload and temporary viewport clamps without changing docked widths or open/closed preferences'); + console.log('[pass] left popup width persists without changing docked widths or either manual open/closed preference'); assert.deepEqual(errors, []); console.log(JSON.stringify({ output, status: 'passed' })); } catch (error) { diff --git a/web/scripts/test-sidebar-session-tree.mjs b/web/scripts/test-sidebar-session-tree.mjs index c010fb1a7..99e05254b 100644 --- a/web/scripts/test-sidebar-session-tree.mjs +++ b/web/scripts/test-sidebar-session-tree.mjs @@ -14,6 +14,8 @@ const fixture = String.raw` import React,{useState} from 'react'; import {createRoot} from 'react-dom/client'; import {Sidebar} from '/src/components/Sidebar.jsx'; +import {DesktopContextMenu} from '/src/components/DesktopContextMenu.jsx'; +import {Toaster} from '/src/components/Toast.jsx'; import {api} from '/src/lib/api.js'; import {connection} from '/src/lib/connection.js'; import {SESSION_LIST_CHANGED_EVENT} from '/src/lib/sessionListEvents.js'; @@ -32,17 +34,22 @@ const initial = [ ...Array.from({length:3},(_,i)=>({key:'session:n'+(i+3),id:'n'+(i+3),title:'普通会话 '+(i+3),children:[]})) ]; let roots=JSON.parse(localStorage.getItem('tree-fixture')||'null')||initial; +let pins=JSON.parse(localStorage.getItem('tree-pins')||'[]'),pinOrder=[]; const persist=()=>localStorage.setItem('tree-fixture',JSON.stringify(roots)); const locate=key=>{ for(const root of roots){if(root.key===key)return {node:root,parent:null,siblings:roots}; const node=root.children.find(child=>child.key===key);if(node)return {node,parent:root,siblings:root.children};} }; -window.calls=[];window.moves=[];window.reads=[];window.retained=[]; +window.calls=[];window.moves=[];window.reads=[];window.unreads=[];window.retained=[];window.archived=[]; +window.failArchive='';window.failMove=false;window.failPin=false;window.pinDelay=0;window.pinWrites=0; const payload=(opts={})=>{ + const tree=roots.filter(root=>!pins.includes(root.id)) + .map(root=>({...root,children:root.children.filter(child=>!pins.includes(child.id))})) + .filter(root=>!root.group||root.children.length); const expanded=new Set(opts.expanded||[]); - let limit=opts.limit||roots.length; - const reveal=roots.findIndex(root=>root.id===opts.reveal||root.children.some(child=>child.id===opts.reveal)); - if(reveal>=0){limit=Math.max(limit,reveal+1);if(roots[reveal].id!==opts.reveal)expanded.add(roots[reveal].key);} + let limit=opts.limit||tree.length; + const reveal=tree.findIndex(root=>root.id===opts.reveal||root.children.some(child=>child.id===opts.reveal)); + if(reveal>=0){limit=Math.max(limit,reveal+1);if(tree[reveal].id!==opts.reveal)expanded.add(tree[reveal].key);} const rows=[]; function emit(node,parent,rank){const latest=node.group?node.children.reduce((a,b)=>(b.created||'')>(a.created||'')?b:a):node; rows.push({id:node.id,title:node.title,workspace_hash:'w',cwd:'/fixture',active:!node.group,created_at:latest.created||'2026-10-13T09:00:00Z',updated_at:latest.created||'2026-10-13T09:00:00Z', @@ -52,14 +59,37 @@ const payload=(opts={})=>{ sidebar_members:node.children.map(child=>({id:child.id,state:child.state||'read',busy:child.state==='in_progress',workspace_hash:'w',cwd:'/fixture'}))}:{}), sidebar_key:node.key,sidebar_parent_key:parent,sidebar_child_count:node.children.length,sidebar_rank:rank,sidebar_expanded:expanded.has(node.key), ...(node.loopId?{loop_execution:{loop_id:node.loopId}}:{})});} - roots.slice(0,limit).forEach((root,rank)=>{emit(root,'',rank);if(expanded.has(root.key))root.children.forEach((child,index)=>emit(child,root.key,index));}); - return {sidebar_tree:true,sessions:rows,total:roots.length,total_exact:true,has_more:limit{emit(root,'',rank);if(expanded.has(root.key))root.children.forEach((child,index)=>emit(child,root.key,index));}); + pins.forEach((id,index)=>{const found=locate('session:'+id);if(found)emit(found.node,'',index);}); + return {sidebar_tree:true,sessions:rows,total:tree.length,total_exact:true,has_more:limit[{hash:'w',cwd:'/fixture',name:'acecode',active:true}], - getPinnedSessions:async()=>({session_ids:[]}),getNoWorkspacePinnedSessions:async()=>({session_ids:[]}),getPinnedSessionOrder:async()=>({items:[]}), + getPinnedSessions:async()=>({session_ids:pins}),getNoWorkspacePinnedSessions:async()=>({session_ids:[]}),getPinnedSessionOrder:async()=>({items:pinOrder}), + setPinnedSessions:async(hash,ids)=>{ + window.pinWrites++; + if(window.pinDelay)await new Promise(resolve=>setTimeout(resolve,window.pinDelay)); + if(window.failPin)throw Error('fixture pin failure'); + pins=ids;localStorage.setItem('tree-pins',JSON.stringify(pins));return {session_ids:pins}; + }, + setPinnedSessionOrder:async(items)=>{pinOrder=items;return {items};}, + archiveWorkspaceSession:async(hash,id)=>{ + if(id===window.failArchive)throw Error('fixture archive failure'); + const found=locate('session:'+id);found.siblings.splice(found.siblings.indexOf(found.node),1); + window.archived.push(id);persist(); + }, getOpencodeImportPreview:async()=>({available:false,count:0}), moveSidebarSession:async(hash,move,opts)=>{ + if(window.failMove)throw Error('fixture save failure'); + if(move.sources){ + let result,receiver=move.target; + const ordered=move.placement==='after'?[...move.sources].reverse():move.sources; + for(const source of ordered){ + result=await api.moveSidebarSession(hash,{source,target:receiver,placement:move.placement},opts); + if(move.placement==='inside')receiver=locate(source).parent.key; + } + window.moves.push(move);return result; + } const source=locate(move.source),target=locate(move.target); if(!validSidebarTreeDrop({key:source.node.key,parent:source.parent?.key,childCount:source.node.children.length,group:source.node.group}, {key:target.node.key,parent:target.parent?.key,scheduled:Boolean(target.node.loopId)},move.placement, @@ -81,12 +111,36 @@ Object.assign(api,{ }); for(const name of ['reconfigure','retainSession','releaseSession','subscribeWorkspaceStatus','unsubscribeWorkspaceStatus'])connection[name]=()=>{}; connection.markSessionRead=payload=>window.reads.push(payload); +connection.markSessionUnread=payload=>{window.unreads.push(payload);return true;}; connection.retainSession=id=>window.retained.push(id); // Exercise the production URL builder; mocking listSidebarSessions itself hides // missing query parameters on old WebViews (notably URLSearchParams.size). -window.fetch=async(input)=>{ +window.fetch=async(input,options={})=>{ const url=new URL(input,location.href); let body={}; + if(url.pathname==='/api/session-batch'){ + const {action,items}=JSON.parse(options.body); + const results=items.map(item=>({...item,ok:true})); + window.batchCalls=(window.batchCalls||0)+1; + if(action==='pin'||action==='unpin'){ + const ids=items.map(item=>item.session_id); + const next=action==='pin'?[...ids.filter(id=>!pins.includes(id)),...pins]:pins.filter(id=>!ids.includes(id)); + try{await api.setPinnedSessions('w',next);}catch(error){results.forEach(item=>{item.ok=false;item.error=error.message;});} + }else if(action==='archive'){ + for(const result of results){ + try{await api.archiveWorkspaceSession('w',result.session_id);}catch(error){result.ok=false;result.error=error.message;} + } + const archived=new Set(results.filter(item=>item.ok).map(item=>item.session_id)); + pins=pins.filter(id=>!archived.has(id));localStorage.setItem('tree-pins',JSON.stringify(pins)); + }else if(action==='read'||action==='unread'){ + for(const result of results){ + const status={session_id:result.session_id,workspace_hash:'w',cwd:'/fixture',state:action==='read'?'read':'unread',timestamp_ms:Date.now()}; + result.status=status; + (action==='read'?window.reads:window.unreads).push(status); + } + } + body={action,results,pinned_scopes:[{pin_scope:'w',session_ids:pins}],pinned_order:{items:pinOrder}}; + } if(url.pathname.endsWith('/session-sidebar')){ const hash=url.pathname.startsWith('/api/workspaces/')?'w':''; const opts={limit:Number(url.searchParams.get('limit'))||undefined, @@ -101,7 +155,7 @@ function App(){ window.fixture={roots:()=>roots,select:(id)=>setActive({sessionId:id,workspaceHash:'w',active:true}), attention(id,state){const found=locate('session:'+id);if(found)found.node.state=state;connection.dispatchEvent(new CustomEvent('message',{detail:{type:'session_status',payload:{session_id:id,workspace_hash:'w',cwd:'/fixture',state,timestamp_ms:Date.now()}}}));}, addRun(){const root=roots.find(item=>item.key==='loop:daily');root.children.unshift({id:'run-next',key:'session:run-next',loopId:root.loopId,title:'每日简报:新执行',created:'2026-10-14T09:00:00Z',children:[]});persist();window.dispatchEvent(new CustomEvent(SESSION_LIST_CHANGED_EVENT,{detail:{workspaceHash:'w'}}));}}; - return
{active?.sessionId||'选择会话'}
; + return
setActive(null)}/>
{active?.sessionId||'选择会话'}
; } createRoot(document.getElementById('root')).render(); `; @@ -262,7 +316,7 @@ try { await page.mouse.move(950,750); await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-sidebar-tree-key="session:n1"]')).backgroundColor==='rgba(0, 0, 0, 0)',null,{timeout:2000}); assert.equal(await row('old-0').evaluate(el=>getComputedStyle(el).backgroundColor),'rgba(0, 0, 0, 0)', - 'the group supplies its background; ordinary children remain transparent'); + 'unselected children remain transparent'); assert.equal(await row('old-0').evaluate(el=>getComputedStyle(el).backgroundColor), await row('n1').evaluate(el=>getComputedStyle(el).backgroundColor)); await assertParentIcons('loop:daily',true); @@ -274,8 +328,8 @@ try { padding:getComputedStyle(children).paddingLeft,border:getComputedStyle(children).borderLeftWidth, group:getComputedStyle(header.parentElement).backgroundColor,selected:getComputedStyle(child).backgroundColor}; }); - assert.equal(appearance.headerX,appearance.childX);assert.equal(appearance.padding,'0px');assert.equal(appearance.border,'0px'); - assert.notEqual(appearance.group,'rgba(0, 0, 0, 0)');assert.notEqual(appearance.selected,appearance.group); + assert.equal(appearance.childX-appearance.headerX,18);assert.equal(appearance.padding,'0px');assert.equal(appearance.border,'0px'); + assert.equal(appearance.group,'rgba(0, 0, 0, 0)');assert.notEqual(appearance.selected,appearance.group); await row('loop:daily').hover(); await assertParentIcons('loop:daily',true); await page.screenshot({path:path.join(output,theme+'.png'),animations:'disabled'}); @@ -316,5 +370,129 @@ try { assert.deepEqual(errors,[]); assert.equal(await page.evaluate(()=>window.retained.some(id=>id.startsWith('sidebar-group:'))),false); assert.equal(await page.evaluate(()=>window.reads.some(item=>item.sessionId?.startsWith('sidebar-group:'))),false); - console.log(JSON.stringify({result:'PASS',browser:browser.version(),output,scenarios:28})); + await page.evaluate(()=>localStorage.clear()); + await page.setViewportSize({width:1100,height:1000}); + await page.reload();await row('loop:daily').waitFor(); + await select('loop:daily');await row('old-7').waitFor(); + const selected=()=>page.locator('[data-sidebar-selected="true"]'); + const expectCount=async count=>page.waitForFunction(n=>document.querySelectorAll('[data-sidebar-selected="true"]').length===n,count); + const clickWith=async(id,modifier)=>row(id).locator('.ace-sidebar-session-title-button').click({modifiers:[modifier]}); + await select('run-new');await clickWith('old-2','Shift');await expectCount(4); + assert.equal(await page.locator('[data-selected]').textContent(),'run-new','Shift does not navigate'); + await clickWith('old-0','Shift');await expectCount(2); + await clickWith('n1','Control');await expectCount(3); + await clickWith('n1','Meta');await expectCount(2); + await row('n2').click({button:'right'});await expectCount(1); + assert.equal(await selected().getAttribute('data-desktop-session-id'),'n2','right click outside selection replaces it'); + await page.keyboard.press('Escape');await page.keyboard.press('Escape');await expectCount(0); + await select('run-new');await clickWith('old-2','Shift');await expectCount(4); + const readCount=await page.evaluate(()=>window.reads.length); + await row('old-1').click({button:'right'}); + await page.getByRole('menuitem',{name:'标记为未读 (4)',exact:true}).click(); + await page.waitForFunction(()=>window.unreads.length===4); + assert.equal(await row('run-new').getAttribute('data-desktop-session-unread'),'true'); + assert.equal(await page.evaluate(()=>window.reads.length),readCount,'active manually unread session stays unread'); + await row('old-1').click({button:'right'}); + await page.getByRole('menuitem',{name:'标记为已读 (4)',exact:true}).click(); + await page.waitForFunction(n=>window.reads.length===n+4,readCount); + for(const theme of ['light','dark']){ + await page.waitForTimeout(3600); + await page.evaluate(mode=>document.documentElement.dataset.theme=mode,theme); + await row('old-1').click({button:'right'}); + await page.screenshot({path:path.join(output,theme+'-multiselect-menu.png'),animations:'disabled'}); + await page.keyboard.press('Escape'); + } + // Real pointer events preserve the batch until release and render a capped preview. + const from=await row('old-0').boundingBox(),to=await row('loop:check').boundingBox(); + await page.mouse.move(from.x+100,from.y+from.height/2);await page.mouse.down(); + await page.mouse.move(to.x+100,to.y+to.height/2,{steps:12}); + assert.equal(await page.locator('.ace-sidebar-session-drag-count').textContent(),'4'); + assert.equal(await page.locator('.ace-sidebar-session-drag-ghost').count(),3); + const badgeVisible=await page.locator('.ace-sidebar-session-drag-count').evaluate(el=>{ + const rect=el.getBoundingClientRect(),style=getComputedStyle(el); + return Number(style.zIndex)>Number(getComputedStyle(el.nextElementSibling).zIndex||0) + || getComputedStyle(el.nextElementSibling).zIndex==='auto'; + }); + assert.equal(badgeVisible,true,'drag count sits above the preview rows'); + await page.screenshot({path:path.join(output,'batch-drag.png'),animations:'disabled'}); + await page.mouse.up(); + await page.waitForFunction(()=>window.moves.some(move=>move.sources?.length===4)); + assert.deepEqual(await page.evaluate(()=>window.moves.find(move=>move.sources)?.sources), + ['session:run-new','session:old-0','session:old-1','session:old-2']); + await page.waitForFunction(()=>document.querySelector('[data-sidebar-tree-key="session:old-2"]')?.dataset.sidebarTreeParent==='loop:check'); + assert.equal(await page.locator('[data-selected]').textContent(),'run-new','release does not navigate'); + const beforeInvalid=await page.evaluate(()=>window.moves.length); + await drag('old-1','n1','inside',false); + assert.equal(await page.evaluate(()=>window.moves.length),beforeInvalid); + const cancelFrom=await row('old-1').boundingBox(); + await page.mouse.move(cancelFrom.x+80,cancelFrom.y+12);await page.mouse.down(); + await page.mouse.move(cancelFrom.x+100,cancelFrom.y+30,{steps:3}); + await page.keyboard.press('Escape');await page.mouse.up(); + assert.equal(await page.locator('.ace-sidebar-session-drag-ghost').count(),0); + assert.equal(await page.evaluate(()=>window.moves.length),beforeInvalid); + await page.reload();await row('loop:check').waitFor(); + await select('loop:check');await row('old-2').waitFor(); + assert.equal(await row('old-2').getAttribute('data-sidebar-tree-parent'),'loop:check','batch survives reload'); + await select('run-new');await clickWith('old-2','Shift');await expectCount(4); + // A failed save keeps the entire selection and original tree. + await page.evaluate(()=>window.failMove=true); + await drag('old-1','loop:daily'); + await page.getByText('保存会话分组失败,请重试',{exact:true}).waitFor(); + assert.equal(await row('old-2').getAttribute('data-sidebar-tree-parent'),'loop:check'); + await expectCount(4); + await page.evaluate(()=>window.failMove=false); + await page.evaluate(()=>window.failPin=true); + await row('old-1').click({button:'right'}); + await page.getByRole('menuitem',{name:'置顶 (4)',exact:true}).click(); + await page.getByText('批量操作:成功 0 项,失败 4 项',{exact:true}).waitFor(); + await expectCount(4); + assert.equal(await page.locator('[data-sidebar-pinned-key]').count(),0); + await page.evaluate(()=>{window.failPin=false;window.pinDelay=180;}); + await row('old-1').click({button:'right'}); + await page.getByRole('menuitem',{name:'置顶 (4)',exact:true}).click(); + await page.locator('[data-desktop-session-id="old-1"]').click({button:'right'}); + assert.equal(await page.getByRole('menuitem').evaluateAll(items=>items.length>0&&items.every(item=>item.disabled)),true, + 'batch commands cannot reenter while the first batch is saving'); + await page.keyboard.press('Escape'); + await page.waitForFunction(()=>document.querySelectorAll('[data-sidebar-pinned-key]').length===4); + await page.waitForFunction(()=>window.pinWrites===2); + await page.waitForTimeout(200); + await page.evaluate(()=>window.pinDelay=0); + const pinnedRow=id=>page.locator('[data-sidebar-pinned-key][data-desktop-session-id="'+id+'"]'); + await pinnedRow('run-new').locator('.ace-sidebar-session-title-button').click(); + await pinnedRow('old-0').locator('.ace-sidebar-session-title-button').click({modifiers:['Shift']}); + await expectCount(2); + const pa=await pinnedRow('run-new').boundingBox(),pb=await pinnedRow('old-2').boundingBox(); + await page.mouse.move(pa.x+90,pa.y+12);await page.mouse.down(); + await page.mouse.move(pb.x+100,pb.y+pb.height-2,{steps:12});await page.mouse.up(); + await page.waitForFunction(()=>Array.from(document.querySelectorAll('[data-sidebar-pinned-key]')).map(el=>el.dataset.desktopSessionId).join(',')==='old-1,old-2,run-new,old-0'); + await pinnedRow('old-0').click({button:'right'}); + await page.getByRole('menuitem',{name:'取消置顶 (2)',exact:true}).click(); + await page.waitForFunction(()=>document.querySelectorAll('[data-sidebar-pinned-key]').length===2); + await row('run-new').waitFor();await row('old-0').waitFor(); + await select('run-new');await clickWith('old-0','Shift');await expectCount(2); + await page.evaluate(()=>window.failArchive='old-0'); + await row('old-0').click({button:'right'}); + await page.getByRole('menuitem',{name:'归档 (2)',exact:true}).click(); + await page.waitForFunction(()=>window.archived.includes('run-new')); + await page.getByText('批量操作:成功 1 项,失败 1 项',{exact:true}).waitFor(); + await expectCount(1); + assert.equal(await selected().getAttribute('data-desktop-session-id'),'old-0'); + assert.equal(await row('run-new').count(),0); + // Collapse prunes selected children; subsequent Shift starts a fresh range. + await select('loop:check');await expectCount(0); + await clickWith('n1','Shift');await expectCount(1); + assert.equal(await page.locator('[data-selected]').textContent(),'选择会话'); + await page.keyboard.press('Escape');await expectCount(0); + await page.setViewportSize({width:390,height:844}); + await select('loop:daily');await row('old-7').waitFor(); + await select('old-3');await clickWith('old-5','Shift');await expectCount(3); + await row('old-4').click({button:'right'}); + const menuBox=await page.getByRole('menu').boundingBox(); + assert.ok(menuBox.x>=0&&menuBox.x+menuBox.width<=390); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + await page.waitForTimeout(3600); + await page.screenshot({path:path.join(output,'narrow-multiselect.png'),animations:'disabled'}); + assert.deepEqual(errors,[]); + console.log(JSON.stringify({result:'PASS',browser:browser.version(),output,scenarios:46})); } finally { await browser?.close(); await server.close(); } diff --git a/web/scripts/test-tool-transcript.mjs b/web/scripts/test-tool-transcript.mjs new file mode 100644 index 000000000..4a52f6b7c --- /dev/null +++ b/web/scripts/test-tool-transcript.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await mkdtemp(path.join(os.tmpdir(), 'ace-tool-transcript-')); +const entry = String.raw` +import React, {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {ToolBlock} from '/src/components/ToolBlock.jsx'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import {builtinTranscriptToolNames} from '/src/i18n/catalogs/toolTranscript.js'; +import '/src/styles/globals.css'; +const longCommand = 'Get-Content -LiteralPath "C:/project/a-very-long-directory-name/components/LeftSideBar/index.vue"\nWrite-Output complete'; +const file = 'M:/AIGC/shz_test/web/src/components/LeftSideBar/index.vue'; +const rows = [ + ['read', {tool:'file_read',args:{file_path:file},summary:{verb:'Read',object:file,metrics:[{label:'lines',value:200},{label:'size',value:'8.5KB'},{label:'enc',value:'utf-8'},{label:'eol',value:'lf'}]},output:'1: '}], + ['grep', {tool:'grep',args:{pattern:'权限下发',path:'web/src',include_pattern:'*.vue',head_limit:50},output:'LeftSideBar/index.vue:86\n // 权限下发'}], + ['glob', {tool:'glob',args:{pattern:'**/*.vue',path:'web/src'},output:'LeftSideBar/index.vue\nHome.vue'}], + ['bash', {tool:'bash',args:{command:'pnpm test'},summary:{verb:'Ran',object:'pnpm test',metrics:[{label:'time',value:'2.1s'},{label:'exit',value:0}]},output:'All tests passed\n'}], + ['long', {tool:'bash',args:{command:longCommand},output:'complete'}], + ['failed', {tool:'file_read',args:{file_path:file,max_bytes:15000},success:false,output:'[Error] Invalid parameter max_bytes'}], + ['running', {tool:'grep',args:{pattern:'Permission',head_limit:80},isDone:false,totalLines:3,totalBytes:48,elapsed:1,tailLines:['permission.ts:3','menu.vue:14'],currentPartial:'checking...'}], + ['metrics', {tool:'file_read',summary:{verb:'Read',metrics:[{label:'cache',value:'unchanged'}]}}], + ['created', {tool:'file_write',args:{file_path:'/tmp/new.js',content:'const created = true;'},summary:{verb:'Created',object:'/tmp/new.js',metrics:[{label:'+',value:1}]},hunks:[{old_start:1,old_count:0,new_start:1,new_count:1,lines:[{kind:'added',text:'const created = true;',new_line_no:1}]}]}], + ['edit', {tool:'file_edit',args:{file_path:'/tmp/edit.js',old_string:'old',new_string:'new'},summary:{verb:'Edited',object:'/tmp/edit.js',metrics:[{label:'+',value:1},{label:'-',value:1}]},hunks:[{old_start:1,old_count:1,new_start:1,new_count:1,lines:[{kind:'removed',text:'old',old_line_no:1},{kind:'added',text:'new',new_line_no:1}]}]}], + ['question', {tool:'AskUserQuestion',askUserQuestionResult:{items:[{question:'使用哪种语言?',answer:'中文'}]}}], +]; +function App() { + const [inventory,setInventory] = useState(false); + window.fixture = {locale:applyLocalePreference,inventory:setInventory,longCommand,file}; + const displayed = inventory ? builtinTranscriptToolNames.map(tool=>[tool,{tool}]) : rows; + return
+ {displayed.map(([id,entry])=>
+ +
)} +
; +} +Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied=text;}}}); +createRoot(document.getElementById('root')).render(); +`; +const html = '
'; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'tool-transcript-fixture', + resolveId(id) { if (id === 'virtual:tool-transcript') return '\0virtual:tool-transcript'; }, + async load(id) { if (id === '\0virtual:tool-transcript') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx' })).code; }, + configureServer(vite) { + vite.middlewares.use('/tool-transcript', async (_req,res) => { + res.setHeader('Content-Type','text/html'); + res.end(await vite.transformIndexHtml('/tool-transcript',html)); + }); + }, + }], +}); +const errors = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless:true, executablePath:process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + for (const [width,theme] of [[1280,'light'],[390,'dark']]) { + const page = await browser.newPage({viewport:{width,height:1000}}); + page.on('pageerror',error=>errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/tool-transcript`); + await page.waitForFunction(()=>!!window.fixture); + await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme); + const row = id=>page.locator(`[data-case="${id}"]`); + const head = id=>row(id).locator('[data-unified-activity-line] > [data-activity-title-anchor]'); + for (const locale of ['zh-CN','en-US']) { + await page.evaluate(locale=>window.fixture.locale(locale),locale); + await page.waitForFunction(locale=>document.querySelector('[data-case="read"] .ace-activity-line-label').textContent.includes(locale==='zh-CN'?'读取文件':'Read File'),locale); + assert.match(await head('read').innerText(),locale==='zh-CN'?/读取文件:\s*index.vue/:/Read File:\s*index.vue/); + assert.doesNotMatch(await head('read').innerText(),/M:|lines=|utf-8|200/); + assert.doesNotMatch(await head('grep').innerText(),/50|\.vue/); + assert.doesNotMatch(await head('running').innerText(),/48|80|秒|1s/); + await page.evaluate(()=>document.activeElement?.blur()); await page.mouse.move(0,0); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-case="read"] .ace-activity-line-chevron')).opacity==='0'); + await head('read').hover(); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-case="read"] .ace-activity-line-chevron')).opacity==='1'); + for (const id of ['read','grep','glob','bash','long','failed','running','metrics','created','edit']) { + if (await head(id).getAttribute('aria-expanded') !== 'true') await head(id).click(); + } + assert.match(await row('read').innerText(),/M:\/AIGC/); + assert.match(await row('read').innerText(),locale==='zh-CN'?/读取行数: 200/:/Lines Read: 200/); + assert.match(await row('grep').innerText(),locale==='zh-CN'?/最多 50 条/:/Up to 50 results/); + assert.equal((await row('bash').innerText()).match(/pnpm test/g)?.length,1); + assert.equal(await row('bash').locator('.ace-shell-card-header').count(),0); + assert.equal(await row('bash').locator('.ace-shell-card-command').count(),0); + assert.equal(await row('long').locator('.ace-shell-card-command').count(),1); + assert.equal(await row('long').locator('.ace-shell-card-command').innerText(),'$ '+await page.evaluate(()=>window.fixture.longCommand)); + await row('bash').locator('[data-code-copy-button]').click(); + assert.equal(await page.evaluate(()=>window.copied),'$ pnpm test\n\nAll tests passed\n'); + await row('long').locator('[data-code-copy-button]').click(); + assert.equal(await page.evaluate(()=>window.copied),await page.evaluate(()=>'$ '+window.fixture.longCommand+'\n\ncomplete')); + assert.match(await row('failed').innerText(),/Invalid parameter max_bytes/); + assert.match(await row('metrics').innerText(),locale==='zh-CN'?/未变化/:/Unchanged/); + assert.equal(await row('created').locator('[data-created-file-source]').count(),1); + assert.equal(await row('edit').locator('.d2h-wrapper').count(),1); + assert.equal(await row('edit').locator('[data-tool-detail-metadata]').count(),0); + assert.equal(await row('question').locator('.ace-qa-answer').innerText(),'中文'); + await page.evaluate(()=>document.activeElement?.blur()); await page.mouse.move(0,0); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-case="read"] .ace-activity-line-chevron')).opacity==='0'); + await head('read').focus(); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-case="read"] .ace-activity-line-chevron')).opacity==='1'); + await page.keyboard.press('Enter'); assert.equal(await head('read').getAttribute('aria-expanded'),'false'); + await page.keyboard.press('Space'); assert.equal(await head('read').getAttribute('aria-expanded'),'true'); + await page.evaluate(()=>document.activeElement?.blur()); await page.mouse.move(0,0); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-case="read"] .ace-activity-line-chevron')).opacity==='0'); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false); + await page.screenshot({path:path.join(output,`${width}-${theme}-${locale}.png`),fullPage:true}); + } + // Resizing must reveal the full command once when the actual row clips it. + await page.setViewportSize({width:230,height:1000}); + await page.waitForFunction(()=>!!document.querySelector('[data-case="bash"] .ace-shell-card-command')); + await page.setViewportSize({width:1280,height:1000}); + await page.waitForFunction(()=>!document.querySelector('[data-case="bash"] .ace-shell-card-command')); + await page.evaluate(()=>window.fixture.inventory(true)); + await page.waitForFunction(()=>document.querySelector('main').dataset.inventory==='true'); + assert.ok(await page.locator('.ace-activity-line-label').count()>=81); + assert.doesNotMatch(await page.locator('main').innerText(),/toolTranscript\./); + await page.close(); + } + assert.deepEqual(errors,[]); + console.log(JSON.stringify({passed:4,output,checks:['native-inventory','locale-switch','basename','metadata','shell-no-duplicates','copy','history-unit-tests','hover-focus','narrow-layout','resize']})); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/src/App.jsx b/web/src/App.jsx index a442f86cd..6c1feb715 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,6 +24,7 @@ import { installAgentBrowserPageListener, reconcileAgentBrowserPageStore, } from './lib/agentBrowserPages.js'; +import { installAgentBrowserVisitRecorder } from './lib/agentBrowserTopSites.js'; import { loadUiLocale } from './lib/uiLocale.js'; import { installNativeFileDropRouter } from './lib/macNativeFileDrag.js'; import { @@ -479,6 +480,10 @@ export function App() { void reconcileAgentBrowserPageStore(); return dispose; }, []); + // 新标签页「推荐」的访问记录同样在 App 级上报,不依赖当前会话。 + useEffect(() => installAgentBrowserVisitRecorder( + (visit) => api.recordAgentBrowserVisit(visit), + ), []); // WS 心跳超时被迫重连时,连接层把现场交给这里报到 daemon 日志([transcript-stall])。 // 连接层自己不发 HTTP;base 带着连接当前指向的端口与 token。 useEffect(() => { diff --git a/web/src/components/ActivityLine.jsx b/web/src/components/ActivityLine.jsx index 6ecc6f247..06d070147 100644 --- a/web/src/components/ActivityLine.jsx +++ b/web/src/components/ActivityLine.jsx @@ -1,3 +1,4 @@ +import { useLayoutEffect, useRef } from 'react'; import { VsIcon } from './Icon.jsx'; import { clsx } from '../lib/format.js'; @@ -10,6 +11,8 @@ import { clsx } from '../lib/format.js'; export function ActivityLine({ label = '', detail = '', + detailSeparator = null, + onDetailOverflowChange, icon = null, running = false, spinnerStatic = false, @@ -24,6 +27,17 @@ export function ActivityLine({ preserveLabel = false, className = '', }) { + const detailRef = useRef(null); + useLayoutEffect(() => { + if (!onDetailOverflowChange) return undefined; + const element = detailRef.current; + const measure = () => onDetailOverflowChange(!!element && element.scrollWidth > element.clientWidth + 1); + measure(); + if (!element || typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [detail, label, onDetailOverflowChange]); const interactive = expandable && typeof onToggle === 'function'; const activityLabel = label || '正在处理'; const liveCopyClassName = live ? 'ace-activity-line-live-copy' : ''; @@ -48,7 +62,7 @@ export function ActivityLine({ preserveLabel ? 'shrink-0' : 'min-w-0 max-w-[62%] truncate', liveCopyClassName, )}> - {activityLabel} + {activityLabel}{detail && detailSeparator !== null ? detailSeparator : ''} {live && ( {detail && ( - - · {detail} + + {detailSeparator === null ? '· ' : ''}{detail} )} {expandable && ( diff --git a/web/src/components/AgentBrowserMoreMenu.jsx b/web/src/components/AgentBrowserMoreMenu.jsx new file mode 100644 index 000000000..cf8c0628d --- /dev/null +++ b/web/src/components/AgentBrowserMoreMenu.jsx @@ -0,0 +1,270 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { clsx } from '../lib/format.js'; +import { + AGENT_BROWSER_COOKIE_MODES, + agentBrowserClearBrowsingDataDialog, + agentBrowserCookieModeOption, + clearAgentBrowserBrowsingData, + setAgentBrowserCookieMode, + waitForAgentBrowserStorageEvent, +} from '../lib/agentBrowserCookieMode.js'; +import { notifyNativeSurfaceOverlayChange } from '../lib/agentBrowserSurfaceCoordinator.js'; +import { submenuPosition } from '../lib/submenuPosition.js'; +import { useAgentBrowserCookieMode } from '../lib/useAgentBrowserCookieMode.js'; +import { AnchoredMenu } from './AnchoredMenu.jsx'; +import { VsIcon } from './Icon.jsx'; +import { Modal } from './Modal.jsx'; +import { toast } from './Toast.jsx'; + +const MENU_ITEM_CLASS = 'w-full h-8 px-2 rounded-md flex items-center gap-2 text-[13px] text-fg-2 hover:bg-surface-hi hover:text-fg transition text-left disabled:opacity-50 disabled:cursor-default'; + +// Agent Browser 工具栏最右侧的「⋮」菜单(仿 Claude 桌面版内置浏览器的设置菜单): +// 控制台日志进聊天、「保留 Cookie」三档(子菜单)、清除浏览数据。cookie 两项只在 +// Desktop 提供对应 bridge 时出现。 +export function AgentBrowserMoreMenu({ pageId, ready, onAddConsoleLogs }) { + const anchorRef = useRef(null); + const submenuTriggerRef = useRef(null); + const submenuRef = useRef(null); + const [open, setOpen] = useState(false); + const [submenuOpen, setSubmenuOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [clearing, setClearing] = useState(false); + const cookie = useAgentBrowserCookieMode(); + const currentMode = cookie.pending || cookie.mode; + const currentOption = agentBrowserCookieModeOption(currentMode); + const dialog = agentBrowserClearBrowsingDataDialog(cookie.mode); + + const closeMenu = () => { + setSubmenuOpen(false); + setOpen(false); + }; + + // 子菜单是主菜单里的 fixed 浮层(role="menu",原生页面遮挡登记会把它算进去), + // 每次渲染按主菜单与触发项的实际位置重新摆放。 + useLayoutEffect(() => { + if (!submenuOpen) return; + const trigger = submenuTriggerRef.current; + const submenu = submenuRef.current; + const menu = trigger?.closest('.ace-anchored-menu'); + if (!trigger || !submenu || !menu) return; + const submenuRect = submenu.getBoundingClientRect(); + const position = submenuPosition({ + menuRect: menu.getBoundingClientRect(), + itemRect: trigger.getBoundingClientRect(), + submenuWidth: submenuRect.width, + submenuHeight: submenuRect.height, + viewportWidth: document.documentElement.clientWidth, + viewportHeight: window.innerHeight, + }); + Object.assign(submenu.style, { + left: `${position.left}px`, + top: `${position.top}px`, + visibility: 'visible', + }); + }); + + useLayoutEffect(() => { + if (!submenuOpen) return undefined; + notifyNativeSurfaceOverlayChange(); + return () => notifyNativeSurfaceOverlayChange(); + }, [submenuOpen]); + + const chooseMode = async (mode) => { + closeMenu(); + if (mode === cookie.mode) return; + const option = agentBrowserCookieModeOption(mode); + const result = await setAgentBrowserCookieMode(mode); + if (result?.ok !== true) { + toast({ kind: 'err', text: result?.error || '无法保存 Cookie 设置' }); + return; + } + toast({ + kind: 'ok', + text: mode === 'until_quit' + ? `Cookie 已改为「${option.label}」,已保存的 Cookie 已清除` + : `Cookie 已改为「${option.label}」`, + }); + }; + + const addConsoleLogs = () => { + closeMenu(); + onAddConsoleLogs?.(); + }; + + const confirmClear = async () => { + setClearing(true); + const finished = waitForAgentBrowserStorageEvent( + (detail) => detail.type === 'browsing_data_cleared' && detail.page_id === pageId, + ); + const started = await clearAgentBrowserBrowsingData(pageId); + const result = started?.ok === true + ? await finished + : { ok: false, error: started?.error }; + setClearing(false); + setConfirmOpen(false); + toast(result?.ok === true + ? { kind: 'ok', text: '浏览数据已清除' } + : { kind: 'err', text: result?.error || '无法清除浏览数据' }); + }; + + return ( + <> + + {open && ( + + + {cookie.supported && ( + <> +
+
Cookie
+ + + {submenuOpen && ( +
{ + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + setSubmenuOpen(false); + submenuTriggerRef.current?.focus(); + } + }} + > + {AGENT_BROWSER_COOKIE_MODES.map((option) => { + const checked = option.value === currentMode; + return ( + + ); + })} +
+ )} + + )} + + )} + {confirmOpen && ( + setConfirmOpen(false)} + width={420} + dismissOnBackdrop={!clearing} + dismissOnEscape={!clearing} + layerClassName="z-[400]" + labelledBy="agent-browser-clear-title" + > +
+
+ {dialog.title} +
+
{dialog.body}
+
+ + +
+
+
+ )} + + ); +} diff --git a/web/src/components/AgentBrowserNewTab.jsx b/web/src/components/AgentBrowserNewTab.jsx new file mode 100644 index 000000000..98d5ded55 --- /dev/null +++ b/web/src/components/AgentBrowserNewTab.jsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { api } from '../lib/api.js'; +import { formatDateTime } from '../lib/format.js'; +import { + AGENT_BROWSER_TOP_SITES_LIMIT, + cachedAgentBrowserTopSites, + normalizeTopSitesResponse, + rememberAgentBrowserTopSites, + withoutTopSite, +} from '../lib/agentBrowserTopSites.js'; +import { + recentFileTimeLabel, + recentFileTypeLabel, +} from '../lib/agentBrowserRecentFiles.js'; +import { FileTypeIcon, VsIcon } from './Icon.jsx'; +import { toast } from './Toast.jsx'; + +// Agent Browser 空白页(新标签页),版式照 Codex 桌面版的 New Tab: +// 推荐 —— 常访问网站,一行最多 4 个,悬停出现 × 移除(可撤销); +// 最近 —— 本会话改过的文件,点开后用文件预览替换这个标签页。 +// Codex 页面最上方的「工具」区(变更 / 终端 / 文件 / 侧边聊天…)不做。 +// 两块都没有内容时退回原来的「浏览器」提示。 + +function useTopSites() { + const [sites, setSites] = useState(() => cachedAgentBrowserTopSites() || []); + const [loaded, setLoaded] = useState(() => cachedAgentBrowserTopSites() !== null); + const mountedRef = useRef(true); + + const refresh = useCallback(async () => { + try { + const next = normalizeTopSitesResponse( + await api.getAgentBrowserTopSites(AGENT_BROWSER_TOP_SITES_LIMIT), + ); + rememberAgentBrowserTopSites(next); + if (mountedRef.current) setSites(next); + } catch { + // daemon 不可用时保留缓存;推荐只是便利功能,不打扰用户。 + } finally { + if (mountedRef.current) setLoaded(true); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + void refresh(); + return () => { mountedRef.current = false; }; + }, [refresh]); + + const dismiss = useCallback(async (site) => { + setSites((previous) => { + const next = withoutTopSite(previous, site.url); + rememberAgentBrowserTopSites(next); + return next; + }); + try { + await api.dismissAgentBrowserTopSite(site.url); + } catch { + toast({ kind: 'err', text: '无法更新推荐' }); + void refresh(); + return; + } + void refresh(); + toast({ + kind: 'info', + text: '已移除推荐', + actions: [{ + label: '撤销', + errorText: '无法更新推荐:', + onClick: async () => { + await api.restoreAgentBrowserTopSite(site.url); + await refresh(); + }, + }], + }); + }, [refresh]); + + return { sites, loaded, dismiss }; +} + +function SiteFavicon({ favicon }) { + const [failed, setFailed] = useState(false); + useEffect(() => { setFailed(false); }, [favicon]); + return ( + + ); +} + +function SuggestedSites({ sites, onNavigate, onDismiss }) { + if (sites.length === 0) return null; + return ( +
+

推荐

+
    + {sites.map((site) => ( +
  • + + +
  • + ))} +
+
+ ); +} + +// 列表高度按可用空间取整行(最多 6 行),再多露出半行提示还能滚动;上下箭头按整页 +// 翻,与 Codex 的 Recents 一致。 +function RecentFiles({ files, onOpen }) { + const listRef = useRef(null); + const [edges, setEdges] = useState({ top: true, bottom: true }); + const [interacted, setInteracted] = useState(false); + const [now, setNow] = useState(() => new Date()); + + const updateEdges = useCallback(() => { + const list = listRef.current; + if (!list) return; + const top = list.scrollTop < 1; + const bottom = list.scrollTop + list.clientHeight >= list.scrollHeight - 1; + setEdges((previous) => ( + previous.top === top && previous.bottom === bottom ? previous : { top, bottom } + )); + }, []); + + useLayoutEffect(() => { + const list = listRef.current; + const root = list?.closest('[data-new-tab-scroll-root]'); + if (!list || !root || typeof ResizeObserver !== 'function') { + updateEdges(); + return undefined; + } + const measure = () => { + const row = list.firstElementChild; + if (!(row instanceof HTMLElement) || row.offsetHeight === 0) return; + const rowHeight = row.offsetHeight; + const rootRect = root.getBoundingClientRect(); + const offsetTop = list.getBoundingClientRect().top - rootRect.top + root.scrollTop; + const paddingBottom = parseFloat(getComputedStyle(root).paddingBottom) || 0; + const rows = Math.max(2, Math.floor( + (root.clientHeight - offsetTop - paddingBottom - rowHeight / 2) / rowHeight, + )); + list.style.maxHeight = `${Math.min(6, rows + 0.5) * rowHeight}px`; + list.style.setProperty('--recent-row-height', `${rowHeight}px`); + updateEdges(); + }; + const observer = new ResizeObserver(measure); + observer.observe(root); + observer.observe(list); + measure(); + return () => observer.disconnect(); + }, [files.length, updateEdges]); + + // 时间标签里「今天」的判断跨过午夜要更新。 + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(timer); + }, []); + + const scrollPage = (direction) => { + setInteracted(true); + const list = listRef.current; + const row = list?.firstElementChild; + if (!list || !(row instanceof HTMLElement)) return; + const rowHeight = row.offsetHeight || list.clientHeight; + const rows = Math.max(1, Math.floor((list.clientHeight - rowHeight / 2) / rowHeight)); + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + list.scrollBy({ top: direction * rows * rowHeight, behavior: reduceMotion ? 'instant' : 'smooth' }); + }; + + if (files.length === 0) return null; + return ( +
+
+

最近

+
+ + +
+
+
    setInteracted(true)} + onTouchStart={() => setInteracted(true)} + onKeyDown={() => setInteracted(true)} + > + {files.map((file) => { + const time = recentFileTimeLabel(file.ts, now); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export function AgentBrowserNewTab({ recentFiles = [], onNavigate, onOpenRecentFile, fallback }) { + const { sites, loaded, dismiss } = useTopSites(); + const files = Array.isArray(recentFiles) ? recentFiles : []; + const canOpenFiles = typeof onOpenRecentFile === 'function'; + const visibleFiles = canOpenFiles ? files : []; + if (sites.length === 0 && visibleFiles.length === 0) { + // 首次读推荐还没返回时先留白,避免「浏览器」提示一闪而过。 + return loaded ? fallback :
; + } + return ( +
+
+ + +
+
+ ); +} diff --git a/web/src/components/AgentBrowserPanel.jsx b/web/src/components/AgentBrowserPanel.jsx index f8116a702..1f69a1eb5 100644 --- a/web/src/components/AgentBrowserPanel.jsx +++ b/web/src/components/AgentBrowserPanel.jsx @@ -33,6 +33,8 @@ import { nativeSurfaceViewportRect, nextAgentBrowserLayoutRequest, } from '../lib/agentBrowserSurfaceCoordinator.js'; +import { AgentBrowserMoreMenu } from './AgentBrowserMoreMenu.jsx'; +import { AgentBrowserNewTab } from './AgentBrowserNewTab.jsx'; import { NavigationArrowIcon, RefreshIcon, VsIcon } from './Icon.jsx'; import { toast } from './Toast.jsx'; @@ -60,6 +62,8 @@ export function AgentBrowserPanel({ agentActive = false, surfaceEnabled = true, onAddContext, + recentFiles = [], + onOpenRecentFile, }) { const viewportRef = useRef(null); const addressFocusedRef = useRef(false); @@ -307,6 +311,16 @@ export function AgentBrowserPanel({ return result; }, [pageId]); + const navigateTo = useCallback(async (url) => { + const result = await runAgentBrowserBridgeAction( + 'aceDesktop_agentBrowserNavigate', + { page_id: pageId, url }, + ); + if (result?.ok === false) { + toast({ kind: 'err', text: result.error || '无法打开该地址' }); + } + }, [pageId]); + const submitAddress = useCallback(async (event) => { event.preventDefault(); const normalized = normalizeAgentBrowserAddress(address); @@ -314,14 +328,8 @@ export function AgentBrowserPanel({ toast({ kind: 'err', text: '请输入 http 或 https 地址' }); return; } - const result = await runAgentBrowserBridgeAction( - 'aceDesktop_agentBrowserNavigate', - { page_id: pageId, url: normalized }, - ); - if (result?.ok === false) { - toast({ kind: 'err', text: result.error || '无法打开该地址' }); - } - }, [address, pageId]); + await navigateTo(normalized); + }, [address, navigateTo]); const toggleSharing = useCallback(async () => { const shared = !state.shared_with_agent; @@ -361,6 +369,14 @@ export function AgentBrowserPanel({ } }, [pageId]); + const statusSurface = ( + runAction('aceDesktop_agentBrowserReload')} + onGoBack={() => runAction('aceDesktop_agentBrowserGoBack')} + /> + ); + return (
- +
- {!nativePageVisible && ( -
- -
- {surface.title} -
-
- {surface.detail} -
- {surface.diagnostic && ( -
-                  {surface.diagnostic}
-                
- )} - {surface.canRetry && ( -
- - {surface.canGoBack && ( - - )} -
- )} -
+ {surface.kind === 'empty' && ( + )} + {!nativePageVisible && surface.kind !== 'empty' && statusSurface}
); } + +function AgentBrowserStatusSurface({ surface, onRetry, onGoBack }) { + return ( +
+ +
+ {surface.title} +
+
+ {surface.detail} +
+ {surface.diagnostic && ( +
+          {surface.diagnostic}
+        
+ )} + {surface.canRetry && ( +
+ + {surface.canGoBack && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 1bf363170..08408bf0d 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -7,6 +7,7 @@ // // 没有 sessionId 时显示 Codex 风格新任务主页(首条消息提交时才创建 session)。 +import { previewFileLocatePlan } from '../lib/fileTreeLocate.js'; import { Component, Suspense, @@ -105,6 +106,10 @@ import { latestTurnSuccessfulChangedFiles, summarizeChangeGroups, } from '../lib/sessionChanges.js'; +import { + collectAgentBrowserRecentFiles, + recentFilesSignature, +} from '../lib/agentBrowserRecentFiles.js'; import { forkRestoredPrompt } from '../lib/sessionFork.js'; import { stableBySignature } from '../lib/changeReviewStability.js'; import { @@ -5136,6 +5141,15 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses { signature: changeSignature, value: changeMessages }, ); const stableChangeMessages = changeMessagesStableRef.current.value; + // Agent Browser 新标签页的「最近」:本会话改过的文件。同样按签名稳定引用, + // 流式期间不让预览面板每帧重建。 + const rawBrowserRecentFiles = useMemo(() => collectAgentBrowserRecentFiles(items), [items]); + const browserRecentFilesStableRef = useRef(null); + browserRecentFilesStableRef.current = stableBySignature( + browserRecentFilesStableRef.current, + { signature: recentFilesSignature(rawBrowserRecentFiles), value: rawBrowserRecentFiles }, + ); + const browserRecentFiles = browserRecentFilesStableRef.current.value; const changeDockDismissalKey = useMemo( () => dockDismissalKey(ref, sid), [ref?.workspaceHash, sid], @@ -5231,7 +5245,19 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const locateInFileTree = useStableCallback((path) => { if (!showSidePanel || !path) return; if (sidePanelCollapsed || sidePanelListCollapsed) onRevealSidePanelList?.(); - setFileLocateRequest((prev) => ({ path, token: (prev.token || 0) + 1 })); + setFileLocateRequest((prev) => ({ path, includeSelf: true, token: (prev.token || 0) + 1 })); + }); + + const locatePreviewFile = useStableCallback(({ cwd, path }) => { + if (!showSidePanel || sessionIsNoWorkspace) return; + const plan = previewFileLocatePlan({ workspaceCwd: sidePanelCwd, cwd, path }); + if (!plan) return; + onRevealSidePanelList?.(); + setPopupNavigationCollapsed(false); + setFileLocateRequest((prev) => ({ + path: plan.selectedPath, includeSelf: false, emphasize: true, + token: (prev.token || 0) + 1, + })); }); const dismissChangeDock = useStableCallback(() => { @@ -5810,6 +5836,13 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses requestPreviewClose('one', tabKey); }); + // 新标签页「最近」里点文件:用文件预览替换这个空白浏览器页签(Codex 同款), + // 文件页签打开成功后才关掉浏览器页,不会留下空白页。 + const replaceBrowserTabWithFile = useStableCallback(async (tabKey, path) => { + const opened = await openFilePreview(path); + if (opened && tabKey) closePreview(tabKey); + }); + const closeAllPreviews = useStableCallback(() => { requestPreviewClose('all'); }); @@ -6585,6 +6618,9 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses onSelectChangeFile={openSessionChangePreview} onSelectGitChangeFile={openGitChangePreview} onOpenFilePreview={openFilePreview} + browserRecentFiles={browserRecentFiles} + onLocateFile={locatePreviewFile} + onReplaceBrowserTabWithFile={sidePanelCwd ? replaceBrowserTabWithFile : undefined} /> )} diff --git a/web/src/components/DesktopContextMenu.jsx b/web/src/components/DesktopContextMenu.jsx index 15221b8a6..a89c0367b 100644 --- a/web/src/components/DesktopContextMenu.jsx +++ b/web/src/components/DesktopContextMenu.jsx @@ -86,6 +86,7 @@ const ACTION_LABELS = { [DESKTOP_CONTEXT_ACTIONS.COPY_FILE_DIFF]: '复制此文件 diff', [DESKTOP_CONTEXT_ACTIONS.COPY_ALL_DIFFS]: '复制全部 diff', [DESKTOP_CONTEXT_ACTIONS.LOCATE_IN_FILE_TREE]: '在文件树中定位', + [DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST]: '在文件列表展示', [DESKTOP_CONTEXT_ACTIONS.EXPAND_ALL_DIFFS]: '展开全部 diff', [DESKTOP_CONTEXT_ACTIONS.COLLAPSE_ALL_DIFFS]: '折叠全部 diff', [DESKTOP_CONTEXT_ACTIONS.COPY_MESSAGE_TEXT]: '复制消息', @@ -730,6 +731,9 @@ export function DesktopContextMenu() { {action.id === DESKTOP_CONTEXT_ACTIONS.TOGGLE_PREVIEW_PRESENTATION && ( )} + {action.id === DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST && ( + + )} ))} diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index c950d575b..9a84a1500 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -83,6 +83,10 @@ import { replaceQueryWithSessionReference, searchSessionReferenceData, } from '../lib/sessionReference.js'; +import { + SESSION_REFERENCE_DROP_EVENT, + droppableSessionReferences, +} from '../lib/sessionReferenceDrop.js'; import { hasNativeContextPicker, parseNativeContextPickerResult, @@ -809,6 +813,34 @@ export const InputBar = forwardRef(function InputBar({ const activePathDropdown = composerSelectionCollapsed ? pathMention : null; + // 侧栏会话拖到本输入框:在落点插入 @会话 引用,效果同 @ 菜单里选会话。 + const sessionReferenceDropEnabled = !disabled && canLoadSessionReferences(pathReferenceApi); + useEffect(() => { + if (!sessionReferenceDropEnabled) return undefined; + const onDrop = (event) => { + const detail = event.detail; + const root = rootRef.current; + if (!detail || detail.handled || !root?.contains(detail.target)) return; + detail.handled = true; + const references = droppableSessionReferences(detail.references, { + currentSessionId, + text: ta.current?.value ?? '', + }); + if (!references.length) { + toast({ text: t('拖入的会话已引用过,或是当前会话') }); + return; + } + if (ta.current?.insertSessionReferences?.(references, { + clientX: detail.clientX, + clientY: detail.clientY, + })) { + setEditedSinceHistory(true); + } + }; + window.addEventListener(SESSION_REFERENCE_DROP_EVENT, onDrop); + return () => window.removeEventListener(SESSION_REFERENCE_DROP_EVENT, onDrop); + }, [currentSessionId, sessionReferenceDropEnabled, t]); + const handleFileIntake = useCallback((payload, transfer) => { if (disabled || !transfer?.isActive()) return Promise.resolve(true); setCapabilityOpen(false); @@ -1614,6 +1646,7 @@ export const InputBar = forwardRef(function InputBar({ data-speech-composer="true" data-main-composer={mainComposer ? 'true' : undefined} data-native-file-drop-disabled={disabled ? 'true' : undefined} + data-session-reference-drop={sessionReferenceDropEnabled ? 'true' : undefined} onPointerDownCapture={(event) => preserveComposerFocusOnPointerDown(event, rootRef.current)} onDragEnter={fileDropManagedExternally ? undefined : handleDragEnter} onDragOver={fileDropManagedExternally ? undefined : handleDragOver} diff --git a/web/src/components/PreviewDetailsPanel.jsx b/web/src/components/PreviewDetailsPanel.jsx index 2c9a35cb5..3e55b2b91 100644 --- a/web/src/components/PreviewDetailsPanel.jsx +++ b/web/src/components/PreviewDetailsPanel.jsx @@ -4,6 +4,7 @@ import { createPortal } from 'react-dom'; import { useWorkbenchState } from '../lib/useWorkbenchState.js'; import { useWorkbenchScroll } from '../lib/useWorkbenchScroll.js'; import { usePreviewTextZoom } from '../lib/usePreviewTextZoom.js'; +import { useAppShortcuts } from '../lib/useAppShortcuts.js'; import { clsx } from '../lib/format.js'; import { PREVIEW_TAB_TYPES, @@ -342,6 +343,9 @@ export function PreviewDetailsPanel({ onSelectChangeFile, onSelectGitChangeFile, onOpenFilePreview, + onLocateFile, + browserRecentFiles = [], + onReplaceBrowserTabWithFile, }) { const tabListRef = useRef(null); const { panelRef, zoom: textZoom } = usePreviewTextZoom(owner); @@ -357,12 +361,31 @@ export function PreviewDetailsPanel({ useWorkbenchScroll(owner, 'previewTabScroll', tabListRef, tabs.length); const active = activeTab || tabs[0] || null; + useAppShortcuts({ + revealPreviewFile: () => { + if (active?.type !== PREVIEW_TAB_TYPES.FILE || !onLocateFile + || !panelRef.current?.getClientRects().length) return false; + onLocateFile({ cwd: active.cwd || cwd, path: active.path }); + }, + }); + // 预览标签右键菜单复用全局 DesktopContextMenu:菜单项命中 close_* action 后, // 由 DesktopContextMenu 通过 DESKTOP_CONTEXT_ACTION_EVENT 派发回来,这里执行关闭逻辑。 useEffect(() => { const handler = (event) => { const detail = event.detail || {}; const { action, target } = detail; + if (action === DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST) { + if (detail.handled || !target?.element || !panelRef.current?.contains(target.element)) return; + detail.handled = true; // Outside-workspace files are intentionally silent. + if (target.type === 'preview-tab') { + const tab = tabs.find((item) => item.key === target.key); + if (tab?.type === PREVIEW_TAB_TYPES.FILE) onLocateFile?.({ cwd: tab.cwd || cwd, path: tab.path }); + } else if (target.type === 'preview') { + onLocateFile?.({ cwd: target.cwd || cwd, path: target.path }); + } + return; + } if (action === DESKTOP_CONTEXT_ACTIONS.TOGGLE_PREVIEW_PRESENTATION) { if (target?.element !== panelRef.current || !onTogglePresentation) return; detail.handled = true; @@ -388,7 +411,7 @@ export function PreviewDetailsPanel({ }; window.addEventListener(DESKTOP_CONTEXT_ACTION_EVENT, handler); return () => window.removeEventListener(DESKTOP_CONTEXT_ACTION_EVENT, handler); - }, [onCloseAll, onCloseOthers, onCloseTab, onCloseToRight, onTogglePresentation, panelRef]); + }, [cwd, tabs, onLocateFile, onCloseAll, onCloseOthers, onCloseTab, onCloseToRight, onTogglePresentation, panelRef]); const renderedBody = useMemo(() => { if (!active) return null; @@ -400,6 +423,10 @@ export function PreviewDetailsPanel({ agentActive={agentBrowserActive && active.pageId === agentBrowserActive} surfaceEnabled={nativeSurfacesVisible} onAddContext={onAddBrowserContext} + recentFiles={browserRecentFiles} + onOpenRecentFile={onReplaceBrowserTabWithFile + ? (path) => onReplaceBrowserTabWithFile(active.key, path) + : undefined} /> ); } @@ -480,7 +507,7 @@ export function PreviewDetailsPanel({ onRefresh={() => onRefreshTab?.(active.key)} /> ); - }, [active, agentBrowserActive, api, busy, changeGroups, changeSummary, cwd, nativeSurfacesVisible, onAddBrowserContext, onEditFileTab, onOpenBrowser, onOpenFilePreview, onRefreshTab, onRetargetSimulatorTab, onSelectChangeFile, onSelectGitChangeFile, owner, selectionContexts, sessionChangesReady, setWrapPreview, textZoom, turnChangeSets, wrapPreview]); + }, [active, agentBrowserActive, api, browserRecentFiles, busy, changeGroups, changeSummary, cwd, nativeSurfacesVisible, onAddBrowserContext, onEditFileTab, onOpenBrowser, onOpenFilePreview, onRefreshTab, onReplaceBrowserTabWithFile, onRetargetSimulatorTab, onSelectChangeFile, onSelectGitChangeFile, owner, selectionContexts, sessionChangesReady, setWrapPreview, textZoom, turnChangeSets, wrapPreview]); const handleTabWheel = useCallback((event) => { const el = tabListRef.current; diff --git a/web/src/components/RichComposer.jsx b/web/src/components/RichComposer.jsx index c52e87c28..935fe6762 100644 --- a/web/src/components/RichComposer.jsx +++ b/web/src/components/RichComposer.jsx @@ -44,6 +44,7 @@ import { composerDocumentFromContent, composerSkillTag, composerPathTag, + composerSessionTagForReference, composerDocumentFromText, composerInlineTagRanges, composerPlainTextRangeFromSelection, @@ -1153,6 +1154,54 @@ function RichComposerShell({ useImperativeHandle(ref, () => ({ beginFileTransfer, + // 侧栏会话拖入:在松手位置插入会话标签(落在标签上时插在它后面),落点不在 + // 编辑区内时插在当前光标处,没有光标时插在末尾。每个标签后跟一个空格,与 @ 菜单一致。 + insertSessionReferences(references = [], { clientX, clientY } = {}) { + if (disabledRef.current) return false; + const tags = references.map(composerSessionTagForReference).filter(Boolean); + if (!tags.length) return false; + // 不调 ensureLegalEditorDocument:它会把选区重置到末尾,用户原来的光标就丢了。 + let point = null; + const editable = editableRef.current; + if (editable && Number.isFinite(clientX) && Number.isFinite(clientY)) { + const hit = editable.ownerDocument.elementFromPoint(clientX, clientY); + if (hit && editable.contains(hit)) { + try { + const tagElement = hit.closest('[data-composer-inline-tag]'); + point = tagElement + ? composerTagSelection(editor, ReactEditor.findPath(editor, ReactEditor.toSlateNode(editor, tagElement))).focus + : ReactEditor.findEventRange(editor, { target: hit, clientX, clientY }).focus; + } catch { + point = null; + } + } + } + const end = latestTextRef.current.length; + const atEnd = () => composerSelectionFromPlainTextRange(editor.children, end, end); + try { + Transforms.select(editor, point ? { anchor: point, focus: point } : editor.selection || atEnd()); + } catch { + // 外部草稿同步后残留的旧选区可能已失效。 + Transforms.select(editor, atEnd()); + } + const start = currentPlainSelection(editor.children, editor.selection).start; + const before = composerTextFromDocument(editor.children).slice(0, start); + HistoryEditor.withNewBatch(editor, () => { + if (Range.isExpanded(editor.selection)) Transforms.delete(editor); + if (before && !/\s$/.test(before)) editor.insertText(' '); + for (const tag of tags) { + Transforms.insertNodes(editor, tag); + Transforms.move(editor); + editor.insertText(' '); + } + }); + try { + ReactEditor.focus(editor); + } catch { + // 插入后 Slate 的 DOM 映射可能还差一帧;焦点不是必需的。 + } + return true; + }, focus({ canFocus } = {}) { const focusEditor = () => { if (canFocus && !canFocus()) return true; diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index 5ea48a83d..d32898dd5 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -28,6 +28,12 @@ import { SettingsConfigSection } from './SettingsConfigSection.jsx'; import { FeedbackForm } from './FeedbackForm.jsx'; import { SettingsSearch, SettingsSearchResults } from './SettingsSearch.jsx'; import { settingsSearchEntries, searchSettings, locateSetting, settingsSearchResultIndex } from '../lib/settingsSearch.js'; +import { + AGENT_BROWSER_COOKIE_MODES, + agentBrowserCookieModeOption, + setAgentBrowserCookieMode, +} from '../lib/agentBrowserCookieMode.js'; +import { useAgentBrowserCookieMode } from '../lib/useAgentBrowserCookieMode.js'; import { openExternalUrl } from '../lib/externalUrl.js'; import { copyTextToSystemClipboard } from '../lib/systemClipboard.js'; import { @@ -2553,6 +2559,7 @@ function SectionTools({ onCheckUpdates, onModelProfileUpdated }) { {nativeBrowserAvailable ? '可用' : '仅 Windows Desktop'}
+ @@ -2565,6 +2572,70 @@ function SectionTools({ onCheckUpdates, onModelProfileUpdated }) { ); } +// Agent 浏览器「保留 Cookie」三档,与浏览器工具栏「⋮」菜单共用同一个 store。 +// 只在 Desktop 提供 cookie bridge 时显示。 +function AgentBrowserCookieSettings() { + const cookie = useAgentBrowserCookieMode(); + if (!cookie.supported) return null; + const currentMode = cookie.pending || cookie.mode; + + const choose = async (mode) => { + if (mode === cookie.mode || cookie.pending) return; + const option = agentBrowserCookieModeOption(mode); + const result = await setAgentBrowserCookieMode(mode); + if (result?.ok !== true) { + toast({ kind: 'err', text: result?.error || '无法保存 Cookie 设置' }); + return; + } + toast({ + kind: 'ok', + text: mode === 'until_quit' + ? `Cookie 已改为「${option.label}」,已保存的 Cookie 已清除` + : `Cookie 已改为「${option.label}」`, + }); + }; + + return ( +
+
保留 Cookie
+

+ Agent 浏览器里登录网站后,登录状态保存多久、各会话之间是否共用。切到「退出时清除」会删除已保存的 Cookie。 +

+
+ {AGENT_BROWSER_COOKIE_MODES.map((option) => { + const selected = option.value === currentMode; + return ( +
void choose(option.value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + void choose(option.value); + } + }} + className={clsx( + 'flex items-center justify-between px-3.5 py-2.5 rounded-md border mb-2 cursor-pointer hover:bg-surface-hi transition', + selected ? 'border-accent bg-accent-bg' : 'border-border bg-surface', + )} + > +
+
{option.label}
+
{option.hint}
+
+ {selected && } +
+ ); + })} +
+
+ ); +} + // ─── 钩子 ────────────────────────────────────────────────────────────────── function SectionHooks() { diff --git a/web/src/components/ShellCommandCard.jsx b/web/src/components/ShellCommandCard.jsx index 072c61446..100ea19cd 100644 --- a/web/src/components/ShellCommandCard.jsx +++ b/web/src/components/ShellCommandCard.jsx @@ -5,7 +5,7 @@ import { CopyableCodeFrame } from './CopyableCodeFrame.jsx'; import { VsIcon } from './Icon.jsx'; import '../styles/shell-command-card.css'; -export function ShellCommandCard({ presentation, expanded, onToggle, running, failed, status, contextAttrs }) { +export function ShellCommandCard({ presentation, expanded, onToggle, running, failed, status, contextAttrs, transcript = false, showCommand = true }) { const { command, output, copyText } = presentation; const commandRef = useRef(null); const outputRef = useRef(null); @@ -33,9 +33,10 @@ export function ShellCommandCard({ presentation, expanded, onToggle, running, fa className="ace-shell-command-card" data-shell-command-card="true" data-expanded={expanded ? 'true' : 'false'} + data-transcript={transcript ? 'true' : undefined} style={{ '--ace-shell-preview-lines': SHELL_PREVIEW_LINES }} > - + } -
+ {transcript && status} + {showCommand &&
             {command ? <>$ {command} : 命令未记录}
           
-
+
} {output && (
{output}
diff --git a/web/src/components/SidePanel.jsx b/web/src/components/SidePanel.jsx index 43717a01d..00c1063d3 100644 --- a/web/src/components/SidePanel.jsx +++ b/web/src/components/SidePanel.jsx @@ -123,10 +123,15 @@ function TreeArrowIcon({ open }) { // effect 竞争(loadDir('') 守卫读到旧 treeCache 直接 bail,父级 setTreeCache(new Map()) // 又跑得更晚把刚拉的根清掉)在这个数据结构下不复存在。 function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setExpandedDirs, - selectedPath, selectedPathRevision = 0, onPickFile, onRefreshTree, refreshToken, reviewStatusByPath }) { + selectedPath, selectedPathRevision = 0, highlightPath = '', visible = true, + onPickFile, onRefreshTree, refreshToken, reviewStatusByPath }) { const [loading, setLoading] = useState(new Set()); // cwd/path request key 集合 const [errors, setErrors] = useState(new Map()); // cwd/path request key → 错误文案 const treeRef = useRef(null); + const highlightedRowRef = useRef(null); + useEffect(() => () => { + highlightedRowRef.current?.classList.remove('ace-file-locate-highlight'); + }, []); useWorkbenchScroll(owner, `treeScroll:${cwd}`, treeRef, treeCache.size > 0); const treeCacheRef = useRef(treeCache); const inFlightRequestsRef = useRef(new Set()); @@ -135,6 +140,7 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE const selectedNormalizedPath = normalizeTreePath(selectedPath); const revealKey = JSON.stringify([selectedNormalizedPath, selectedPathRevision]); const revealedSelectionRef = useRef(sessionWorkbench.get(owner, `treeReveal:${cwd}`, '')); + const highlightedRequestRef = useRef(sessionWorkbench.get(owner, `treeHighlight:${cwd}`, 0)); const selectedParentPath = selectedNormalizedPath ? treeParentPath(selectedNormalizedPath) : ''; const selectedParentGuideIndex = selectedParentPath ? selectedParentPath.split('/').filter(Boolean).length - 1 @@ -269,24 +275,40 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE }, [loadDir, onPickFile, onRefreshTree, setExpandedDirs]); useLayoutEffect(() => { - if (!selectedNormalizedPath) return; + if (!selectedNormalizedPath || !visible) return; if (revealedSelectionRef.current === revealKey) return; - const tree = treeRef.current; - if (!tree) return; - const row = Array.from(tree.querySelectorAll('[data-desktop-file-path]')) - .find((node) => normalizeTreePath(node.getAttribute('data-desktop-file-path')) === selectedNormalizedPath); - if (!row) return; - revealedSelectionRef.current = revealKey; - sessionWorkbench.set(owner, `treeReveal:${cwd}`, revealKey); - const treeRect = tree.getBoundingClientRect(); - const rowRect = row.getBoundingClientRect(); - const margin = 8; - if (rowRect.top < treeRect.top) { - tree.scrollTop = Math.max(0, tree.scrollTop + rowRect.top - treeRect.top - margin); - } else if (rowRect.bottom > treeRect.bottom) { - tree.scrollTop = Math.max(0, tree.scrollTop + rowRect.bottom - treeRect.bottom + margin); - } - }, [selectedNormalizedPath, revealKey, treeCache, expandedDirs, owner, cwd]); + // Wait for the list to finish mounting/showing. Do not consume the request + // during a cancelled mount (including React's StrictMode effect replay). + const frame = requestAnimationFrame(() => { + const tree = treeRef.current; + if (!tree) return; + const row = Array.from(tree.querySelectorAll('[data-desktop-file-path]')) + .find((node) => normalizeTreePath(node.getAttribute('data-desktop-file-path')) === selectedNormalizedPath); + if (!row) return; + const treeRect = tree.getBoundingClientRect(); + if (!treeRect.width || !treeRect.height) return; + highlightedRowRef.current?.classList.remove('ace-file-locate-highlight'); + const rowRect = row.getBoundingClientRect(); + const margin = 20; + if (rowRect.top < treeRect.top + margin) { + tree.scrollTop = Math.max(0, tree.scrollTop + rowRect.top - treeRect.top - margin); + } else if (rowRect.bottom > treeRect.bottom - margin) { + tree.scrollTop = Math.max(0, tree.scrollTop + rowRect.bottom - treeRect.bottom + margin); + } + revealedSelectionRef.current = revealKey; + sessionWorkbench.set(owner, `treeReveal:${cwd}`, revealKey); + if (highlightPath && normalizeTreePath(highlightPath) === selectedNormalizedPath + && highlightedRequestRef.current !== selectedPathRevision) { + // Commit removal before restarting, including repeated requests for one row. + void row.offsetWidth; + row.classList.add('ace-file-locate-highlight'); + highlightedRowRef.current = row; + highlightedRequestRef.current = selectedPathRevision; + sessionWorkbench.set(owner, `treeHighlight:${cwd}`, selectedPathRevision); + } + }); + return () => cancelAnimationFrame(frame); + }, [selectedNormalizedPath, selectedPathRevision, revealKey, treeCache, expandedDirs, owner, cwd, highlightPath, visible]); // 递归渲染 — 每个目录的子项展开时插入到该目录节点之后 const renderEntries = (parentPath, depth) => { @@ -360,6 +382,9 @@ function FileTree({ owner, api, cwd, treeCache, setTreeCache, expandedDirs, setE isActive && 'bg-accent-soft text-accent', )} onClick={() => isDir ? toggleDir(e.path) : onPickFile(e)} + onAnimationEnd={(event) => { + if (event.target === event.currentTarget) event.currentTarget.classList.remove('ace-file-locate-highlight'); + }} title={reviewStatus ? `${e.path} - ${statusTitle}` : e.path} > @@ -587,14 +612,15 @@ export function SidePanel({ setActiveTab('changes'); }, [reviewRequest]); - const lastFileLocateToken = useRef(fileLocateRequest?.token || 0); + const lastFileLocateToken = useRef(sessionWorkbench.get(owner, `fileLocateApplied:${cwd}`, 0)); useEffect(() => { const token = Number(fileLocateRequest?.token || 0); const path = fileLocateRequest?.path || ''; if (!token || token === lastFileLocateToken.current || !path || !filesEnabled) return; - lastFileLocateToken.current = token; - const plan = fileTreeLocatePlan(path, cwd, { includeSelf: true }); + const plan = fileTreeLocatePlan(path, cwd, { includeSelf: fileLocateRequest.includeSelf !== false }); if (!plan) return; + lastFileLocateToken.current = token; + sessionWorkbench.set(owner, `fileLocateApplied:${cwd}`, token); setActiveTab('files'); setSelectedPath(plan.selectedPath); setExpandedDirs((prev) => { @@ -602,7 +628,7 @@ export function SidePanel({ for (const dir of plan.expandedDirs) next.add(dir); return next; }); - }, [cwd, fileLocateRequest, filesEnabled, setExpandedDirs]); + }, [owner, cwd, fileLocateRequest, filesEnabled, setExpandedDirs]); const lastSelectedChange = useRef([selectedChangeFile, selectedChangeFileRevision]); useEffect(() => { @@ -737,6 +763,8 @@ export function SidePanel({ setExpandedDirs={setExpandedDirs} selectedPath={selectedPath} selectedPathRevision={fileLocateRequest?.token || 0} + highlightPath={fileLocateRequest?.emphasize ? fileLocateRequest.path : ''} + visible={!collapsed} onPickFile={onPickFile} onRefreshTree={refreshFileTree} refreshToken={fileRefreshToken} diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 6568d3154..7a1f348bf 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -35,6 +35,8 @@ import { DESKTOP_CONTEXT_ACTION_EVENT, DESKTOP_CONTEXT_ACTIONS, SESSION_PIN_TOGGLE_EVENT, + CONTEXT_MENU_DELEGATE_EVENT, + openDesktopContextMenu, } from '../lib/desktopContextMenu.js'; import { relativeTime, clsx, formatCount } from '../lib/format.js'; import { preserveComposerFocusOnPointerDown } from '../lib/composerCaretRestore.js'; @@ -48,8 +50,6 @@ import { pinSessionId, pinPinnedOrderItem, pinnedSessionsForList, - reorderPinnedOrderItems, - reorderPinnedSessionId, sessionPinScope, unpinPinnedOrderItem, unpinSessionId, @@ -60,7 +60,16 @@ import { SessionTitleText } from './SessionTitleText.jsx'; import { pushTrayMenu } from '../lib/desktopTrayMenu.js'; import { desktopTaskbarBadge, desktopTaskbarBadgeAvailable } from '../lib/desktopTaskbarBadge.js'; import { usePreference } from '../lib/usePreference.js'; -import { applySidebarSessionStatuses, mergeSidebarSessionStatuses, sidebarNodeKey, sidebarSessionRoots, sidebarSessionStatusRows, sidebarTreeDropTarget } from '../lib/sidebarSessionTree.js'; +import { applySidebarSessionStatuses, mergeSidebarSessionStatuses, sidebarNodeKey, sidebarSessionRoots, sidebarSessionStatusRows, sidebarTreeDropTarget, validSidebarTreeDrop } from '../lib/sidebarSessionTree.js'; +import { reorderSidebarSelection, sidebarSelectionKey } from '../lib/sidebarSessionSelection.js'; +import { sessionBatchItems, sessionBatchOutcome, sessionBatchScopes } from '../lib/sidebarSessionBatch.js'; +import { + SESSION_REFERENCE_DROP_ACTIVE_CLASS, + dispatchSessionReferenceDrop, + sessionReferenceDropTargetAt, + sessionReferencesFromEntries, +} from '../lib/sessionReferenceDrop.js'; +import { SidebarSessionSelectionContext, useSidebarSessionSelection } from '../lib/useSidebarSessionSelection.js'; import { createWorkspaceFolderOrderController } from '../lib/workspaceFolderOrder.js'; import { useWorkspaceFolderReorder } from '../lib/useWorkspaceFolderReorder.js'; import '../styles/workspaceFolderReorder.css'; @@ -332,9 +341,13 @@ function sidebarDropTargetForPointer(clientY, rows, targetForRow) { return null; } -function pinnedDropTargetForPointer(clientY) { +function pinnedDropTargetForPointer(clientY, clientX) { if (typeof document === 'undefined') return null; const rows = Array.from(document.querySelectorAll('[data-sidebar-pinned-key]')); + if (rows.length && (clientX < rows[0].getBoundingClientRect().left - 10 + || clientX > rows[0].getBoundingClientRect().right + 10)) return null; + const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-sidebar-workspace-session-key], [data-desktop-workspace-id]'); + if (hit) return null; return sidebarDropTargetForPointer(clientY, rows, (row) => ({ targetWorkspaceHash: row.dataset.sidebarPinnedWorkspace || '', targetId: row.dataset.sidebarPinnedId || '', @@ -342,9 +355,13 @@ function pinnedDropTargetForPointer(clientY) { })); } -function workspaceDropTargetForPointer(clientY, workspaceHash, sourceId, clientX) { +function workspaceDropTargetForPointer(clientY, workspaceHash, sourceId, clientX, sources = []) { if (typeof document === 'undefined') return null; const workspace = String(workspaceHash || ''); + const hit = document.elementFromPoint(clientX, clientY)?.closest('[data-sidebar-pinned-key], [data-sidebar-workspace-session-key], [data-desktop-workspace-id]'); + if (hit?.hasAttribute('data-sidebar-pinned-key') + || (hit?.dataset.sidebarWorkspaceSessionWorkspace && hit.dataset.sidebarWorkspaceSessionWorkspace !== workspace) + || (hit?.dataset.desktopWorkspaceId && hit.dataset.desktopWorkspaceId !== workspace)) return null; const rows = Array.from(document.querySelectorAll('[data-sidebar-workspace-session-key]')) .filter((row) => row.dataset.sidebarWorkspaceSessionWorkspace === workspace); const entries = rows.map((row) => { @@ -360,8 +377,15 @@ function workspaceDropTargetForPointer(clientY, workspaceHash, sourceId, clientX top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, }; }); - if (entries.length && clientX > Math.max(...entries.map(row => row.right)) + 10) return null; + if (entries.length && (clientX < Math.min(...entries.map(row => row.left)) - 10 + || clientX > Math.max(...entries.map(row => row.right)) + 10)) return null; const target = sidebarTreeDropTarget(entries, entries.find(row => row.id === sourceId), clientX, clientY); + if (target && sources.length) { + const parent = entries.find(row => row.key === target.parent); + if (sources.some(source => !validSidebarTreeDrop( + entries.find(row => row.id === source.id), target, target.placement, parent, + ))) return null; + } return target ? { ...target, targetWorkspaceHash: workspace, targetId: target.id, targetKey: target.dragKey } : null; } @@ -377,6 +401,8 @@ function startSidebarSessionPointerDrag({ event, session, title, + entries = [{ session }], + sessionReferences = [], showSessionTime = true, dragRef, conflictingDragRef, @@ -386,7 +412,7 @@ function startSidebarSessionPointerDrag({ updateDragScroll, updateDragTarget, }) { - if (event.button !== 0 || dragRef.current || conflictingDragRef?.current) return; + if (event.button !== 0 || event.shiftKey || event.ctrlKey || event.metaKey || dragRef.current || conflictingDragRef?.current) return; if (event.target?.closest?.('[data-sidebar-row-control="true"], input, textarea, select')) return; const workspaceHash = sessionPinScope(session); @@ -403,10 +429,22 @@ function startSidebarSessionPointerDrag({ // Best effort; window-level listeners below still own cleanup. } + // 拖到输入框上方时切换成「引用」:高亮输入框、不再计算侧栏内的排序落点。 + const setComposerTarget = (drag, target) => { + if (drag.composerTarget === target) return; + drag.composerTarget?.classList.remove(SESSION_REFERENCE_DROP_ACTIVE_CLASS); + drag.composerTarget = target; + target?.classList.add(SESSION_REFERENCE_DROP_ACTIVE_CLASS); + }; + const cleanup = () => { + if (dragRef.current) setComposerTarget(dragRef.current, null); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onCancel); + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('blur', onBlur); + pointerTarget.removeEventListener('lostpointercapture', onCancel); try { pointerTarget.releasePointerCapture?.(pointerId); } catch { @@ -424,6 +462,7 @@ function startSidebarSessionPointerDrag({ document.body.classList.add('ace-sidebar-session-reordering'); setDragState({ sourceKey: drag.sourceKey, + sourceKeys: drag.sourceKeys, targetKey: drag.sourceKey, placement: 'before', }); @@ -435,13 +474,26 @@ function startSidebarSessionPointerDrag({ if (!drag || moveEvent.pointerId !== pointerId) return; if (!beginDragIfNeeded(moveEvent)) return; moveEvent.preventDefault(); + const composer = drag.sessionReferences.length + ? sessionReferenceDropTargetAt(document, moveEvent.clientX, moveEvent.clientY) : null; + setComposerTarget(drag, composer); setDragGhost({ - left: drag.rect.left, + left: moveEvent.clientX - drag.offsetX, top: moveEvent.clientY - drag.offsetY, width: drag.rect.width, title: drag.title, timeText: drag.timeText, + titles: drag.titles, + count: drag.entries.length, + referenceCount: composer ? drag.sessionReferences.length : 0, }); + if (composer) { + drag.targetId = ''; + drag.targetKey = ''; + drag.targetTreeKey = ''; + setDragState({ sourceKey: drag.sourceKey, sourceKeys: drag.sourceKeys, targetKey: '', placement: '' }); + return; + } updateDragScroll(moveEvent.clientY); updateDragTarget(moveEvent.clientY, moveEvent.clientX); } @@ -450,6 +502,13 @@ function startSidebarSessionPointerDrag({ const drag = dragRef.current; if (!drag || upEvent.pointerId !== pointerId) return; if (drag.dragging) upEvent.preventDefault(); + const composer = drag.dragging ? drag.composerTarget : null; + if (composer) { + const references = drag.sessionReferences; + finishDrag(false); + dispatchSessionReferenceDrop(composer, references, { clientX: upEvent.clientX, clientY: upEvent.clientY }); + return; + } finishDrag(true); } @@ -458,11 +517,21 @@ function startSidebarSessionPointerDrag({ if (!drag || cancelEvent.pointerId !== pointerId) return; finishDrag(false); } + function onKeyDown(keyEvent) { + if (keyEvent.key !== 'Escape') return; + keyEvent.preventDefault(); + finishDrag(false); + } + function onBlur() { finishDrag(false); } dragRef.current = { workspaceHash, sourceId, sourceKey, + entries, + sessionReferences, + composerTarget: null, + sourceKeys: entries.map(entry => sidebarSessionDragKey(sessionPinScope(entry.session), entry.session.id)), targetWorkspaceHash: workspaceHash, targetId: sourceId, targetKey: sourceKey, @@ -470,8 +539,11 @@ function startSidebarSessionPointerDrag({ startX: event.clientX, startY: event.clientY, offsetY: event.clientY - rect.top, + offsetX: event.clientX - rect.left, rect, title, + titles: entries.slice(0, 3).map(entry => entry.session.sidebar_group + ? entry.session.sidebar_group_name || title : sessionDisplayTitle(entry.session, entry.session.name || '')), timeText: showSessionTime ? relativeTime(session.updated_at || session.created_at) : '', @@ -481,6 +553,9 @@ function startSidebarSessionPointerDrag({ window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onCancel); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('blur', onBlur); + pointerTarget.addEventListener('lostpointercapture', onCancel); } function hasDesktopBridge() { @@ -808,6 +883,9 @@ function SessionRow({ onToggleTree, }) { const isGroup = s.sidebar_group === true; + const selection = useContext(SidebarSessionSelectionContext); + const selectionKey = sidebarSelectionKey(s); + const selected = !!selection?.keys.has(selectionKey); const attention = s.attention_state || s.read_state || 'read'; const meta = attentionMeta(attention); const workspaceHash = s.workspace_hash || s.workspaceHash || ''; @@ -826,7 +904,8 @@ function SessionRow({ const archiveMarkerIcon = sessionMarker === 'loop' ? 'alarm' : sessionMarker === 'worktree' ? 'worktree' : null; const hasTreeChildren = !pinned && Number(s.sidebar_child_count) > 0; const showTreeArrow = hasTreeChildren && attention !== 'in_progress' && attention !== 'unread'; - const selectRow = () => { + const selectRow = (event) => { + if (selection && !selection.click(event, s)) return; if (isGroup) { onToggleTree?.(s); return; } onSelect?.(s); if (hasTreeChildren) onToggleTree?.(s); @@ -1008,6 +1087,9 @@ function SessionRow({ return (
{ if (event.defaultPrevented) return; if (event.target?.closest?.('[data-sidebar-row-control="true"], input, textarea, select')) return; - selectRow(); + selectRow(event); }} > {remoteControlSurging && ( @@ -1141,7 +1224,8 @@ function SessionRow({ ) : (
- {sessionDragGhost && ( + {sessionDragGhost && createPortal( + {sessionDragGhost.count > 1 && {sessionDragGhost.count}} + {sessionDragGhost.titles.map((title, index) => ( +
+ + {title} + {sessionDragGhost.count === 1 && {sessionDragGhost.timeText}} +
+ ))} + {sessionDragGhost.referenceCount > 0 && ( +
+ {tr('松开以引用 {{count}} 个会话', { count: sessionDragGhost.referenceCount })} +
+ )} +
, document.body )}
@@ -4188,6 +4506,7 @@ export function Sidebar({
+ {folderReorder.drag && createPortal( diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index 3dfc4861d..79c97c5e4 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -1,15 +1,9 @@ -// 工具调用块:统一保留工具执行行,Shell 仅在展开详情时使用命令/输出卡片。 -// - 进度模式: tool_start 之后,显示 5-line tail + 状态行(行数/字节/已耗时) -// - summary 模式: tool_end 后,绿/红 chip(icon · verb · object · metrics) -// - 失败折叠: success=false 时 summary 行下显示前 3 行 stderr,可展开看完整 output -// 用户点 chip 可切换"展开/收起"。task_complete 用 Done: 渲染。 -// -// hunks 字段(file_edit / file_write):新建文件直接展示可复制源码,覆盖和编辑 -// 继续走 diff2html。其余普通文本展开内容统一套 ToolTextFrame。 +// 工具行只展示本地化动作与精简对象。参数、统计和结果位于展开详情, +// 新文件源码、diff、问答和 Shell 保留各自内容渲染器;复制使用完整原文。 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { clsx, formatBytes, formatCount, formatElapsed } from '../lib/format.js'; +import { clsx } from '../lib/format.js'; import { hunksToUnifiedDiff } from '../lib/diff.js'; import { compactOneLinePreview } from '../lib/compactMessagePreview.js'; import { createdFileSource } from '../lib/createdFileSource.js'; @@ -17,6 +11,7 @@ import { normalizeAttachmentList } from '../lib/messageAttachments.js'; import { renderMarkdown } from '../lib/markdown.js'; import { highlightSourceForFile } from '../lib/sourceCodeHighlight.js'; import { fallbackToolSummary } from '../lib/toolSummaryFallback.js'; +import { canonicalTranscriptTool, presentTool } from '../lib/toolPresentation.js'; import { codeTextFromCopyButtonTarget, copyTextToClipboard } from '../lib/codeBlockCopy.js'; import { normalizeTaskCompleteMarkdown } from '../lib/taskCompleteSummary.js'; import { questionFeedbackForTool } from '../lib/questionFeedback.js'; @@ -30,20 +25,27 @@ import { ActivityLine } from './ActivityLine.jsx'; import { CopyableCodeFrame } from './CopyableCodeFrame.jsx'; import { QuestionFeedbackCard } from './QuestionFeedbackCard.jsx'; import { ShellCommandCard } from './ShellCommandCard.jsx'; +import { ToolDetailMetadata } from './ToolDetailMetadata.jsx'; import { ToolSummaryIcon, VsIcon } from './Icon.jsx'; import { toast } from './Toast.jsx'; import * as Diff2Html from 'diff2html'; -function ToolTextFrame({ text = '', children = null }) { +function ToolTextFrame({ text = '', children = null, metadata = null }) { const copyText = String(text ?? ''); - if (!copyText && !children) return null; + if (!copyText && !children && !metadata) return null; + if (!copyText && !children) return ( +
+ {metadata} +
+ ); return ( - {children || ( + {metadata} + {children || (copyText && (
           {copyText}
         
- )} + ))}
); } -function CreatedFileFrame({ source }) { +function CreatedFileFrame({ source, metadata }) { const highlighted = useMemo( () => highlightSourceForFile(source.path, source.content), [source.content, source.path], @@ -72,6 +74,7 @@ function CreatedFileFrame({ source }) { data-created-file-source="true" data-code-lang={highlighted.language || undefined} > + {metadata}
         
-      {metrics.map((m, i) => {
-        // file_edit / file_write 的加删行数(label 恒为 "+" / "-",见
-        // src/tool/file_edit_tool.cpp)按 diff 惯例渲染成 +12 / -3 并着红绿;
-        // 其余 metrics 保持 label=value 的通用格式。
-        const label = String(m.label ?? '');
-        if (label === '+' || label === '-') {
-          return (
-            
-              {' '}
-              
-                {label}{m.value}
-              
-            
-          );
-        }
-        return (
-          
-            {' · '}
-            {m.label}={m.value}
-          
-        );
-      })}
-    
-  );
-}
-
 function askUserQuestionText(result) {
   const items = Array.isArray(result?.items) ? result.items : [];
   return items
@@ -139,6 +112,7 @@ function askUserQuestionItems(result) {
 }
 
 function AskUserQuestionDetails({ items }) {
+  const { t } = useTranslation();
   return (
     
{items.map((item, index) => ( @@ -147,7 +121,7 @@ function AskUserQuestionDetails({ items }) { {index + 1}. {item.question}
- {item.unanswered ? 未作答 : item.answer} + {item.unanswered ? {t('toolTranscript.unanswered')} : item.answer}
))} @@ -155,14 +129,6 @@ function AskUserQuestionDetails({ items }) { ); } -function joinTooltipParts(...parts) { - const text = parts - .map((part) => String(part || '').trim()) - .filter(Boolean) - .join('\n\n'); - return text || undefined; -} - function metricText(metrics, label) { if (!Array.isArray(metrics)) return ''; const wanted = String(label || '').toLowerCase(); @@ -218,6 +184,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio && success !== false && askUserQuestionItems(askUserQuestionResult).length > 0; const [expanded, setExpanded] = useState(shouldExpandAskResult); + const [detailClipped, setDetailClipped] = useState(false); useEffect(() => { if (shouldExpandAskResult) setExpanded(true); }, [shouldExpandAskResult]); @@ -250,7 +217,8 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio const liveElapsed = liveProgress && startedAtMs ? Math.max(elapsedSeconds, computedElapsed) : (elapsedSeconds || (wasLiveProgressRef.current ? computedElapsed : 0)); - const shellPresentation = useMemo(() => shellCommandPresentation(entry), [entry]); + const presentation = presentTool(entry, translate, { elapsed: liveElapsed }); + const shellPresentation = useMemo(() => shellCommandPresentation({ ...entry, tool: canonicalTranscriptTool(entry) }), [entry]); const bashCommand = shellPresentation?.command || ''; const outputPreview = useMemo( () => compactOneLinePreview(output || askUserQuestionOutput || currentPartial || tailLines.join('\n') || bashCommand || title || displayOverride || tool), @@ -259,12 +227,12 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio const createdFile = useMemo(() => createdFileSource({ success, - tool, + tool: presentation.tool, args, summary, hunks, displayOverride, - }), [args, displayOverride, hunks, success, summary, tool]); + }), [args, displayOverride, hunks, success, summary, presentation.tool]); // diff2html 渲染:先把 hunks 转 unified diff,再交给 diff2html。空 hunks 时 // 不构造,避免每次 render 浪费。 @@ -287,7 +255,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio } }, [createdFile, diffText]); - const fullOutput = output || askUserQuestionOutput || diffText || tailLines.join('\n') || currentPartial || ''; + const fullOutput = presentation.output || askUserQuestionOutput || diffText || tailLines.join('\n') || currentPartial || ''; const fullToolOutput = shellPresentation?.copyText ?? fullOutput; const visibleOutput = expanded ? fullToolOutput : (shellPresentation?.command ?? (outputPreview || currentPartial || tailLines.join('\n') || '')); const taskCompleteText = isTaskComplete @@ -297,13 +265,12 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio () => ({ __html: renderMarkdown(taskCompleteText) }), [taskCompleteText], ); - const toolName = bashCommand - ? [summary?.verb || title || tool || 'bash', bashCommand].filter(Boolean).join(' · ') - : (title || displayOverride || tool || summary?.object || 'tool'); - const summaryLabel = summary - ? [summary.verb, bashCommand || summary.object].filter(Boolean).join(' · ') - : ''; - const buttonTooltip = joinTooltipParts(summaryLabel || toolName, fullToolOutput); + const toolName = presentation.label + (presentation.detail ? presentation.separator + presentation.detail : ''); + const buttonTooltip = presentation.fullObject || toolName; + const toggleLabel = translate(expanded ? 'toolTranscript.collapse' : 'toolTranscript.expand'); + // Diff headers already own the complete file path and addition/deletion counts. + const detailFields = diffHtml ? presentation.fields.filter(field => !['path', '+', '-'].includes(field.key)) : presentation.fields; + const detailMetadata = detailFields.length ? : null; const toolContextAttrs = { 'data-desktop-tool-id': contextIdRef.current, 'data-desktop-tool-name': toolName, @@ -354,13 +321,12 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio return (
} - label="Done" - detail={compactOneLinePreview(taskCompleteText)} + label={translate('toolTranscript.names.task_complete')} preserveLabel />
- : {formatElapsed(liveElapsed)}} + transcript + showCommand={detailClipped || bashCommand !== presentation.detail} + status={detailMetadata} /> ); @@ -407,19 +373,19 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio > } - label={completedSummary.verb || title || tool || 'AskUserQuestion'} - detail={completedSummary.object || ''} - trailing={liveElapsed > 0 ? {formatElapsed(liveElapsed)} : null} + label={presentation.label} + detail={presentation.detail} + detailSeparator={presentation.separator} preserveLabel expandable={!askCancelled && askItems.length > 0} expanded={askExpanded} onToggle={!askCancelled && askItems.length > 0 ? toggleExpanded : undefined} - title={askCancelled ? undefined : (askExpanded ? '收起详情' : '展开详情')} - ariaLabel={askCancelled ? undefined : (askExpanded ? '收起详情' : '展开详情')} + title={askCancelled ? undefined : toggleLabel} + ariaLabel={askCancelled ? undefined : toggleLabel} /> {askCancelled ? (
{translate('用户已取消回答')}
- ) : askExpanded ? : null} + ) : askExpanded ? <>{detailMetadata} : null}
); } @@ -439,33 +405,29 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio > } - label={completedSummary.verb || title || tool || '工具完成'} - detail={bashCommand || completedSummary.object || ''} - trailing={( - <> - - {!ok && output && · {outputPreview}} - {liveElapsed > 0 && {formatElapsed(liveElapsed)}} - - )} + label={presentation.label} + detail={presentation.detail} + detailSeparator={presentation.separator} + onDetailOverflowChange={shellPresentation ? setDetailClipped : undefined} preserveLabel + trailing={!ok ? {translate('toolTranscript.failed')} : null} expandable expanded={expanded} onToggle={toggleExpanded} - title={buttonTooltip || (expanded ? '收起' : '展开')} - ariaLabel={expanded ? '收起' : '展开'} + title={buttonTooltip || toggleLabel} + ariaLabel={toggleLabel} /> - {expanded && (shellDetails || createdFile || diffHtml || fullToolOutput) && ( + {expanded && (shellDetails || createdFile || diffHtml || fullToolOutput || detailMetadata) && (
{shellDetails || (createdFile ? ( - + ) : diffHtml ? ( -
+
+ {detailMetadata} +
+
) : ( - + ))}
)} @@ -481,15 +443,13 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio // 进度模式 const hidden = Math.max(0, totalLines - tailLines.length); - // 工具刚启动、还没有任何可展示内容(无 bash 命令 / tail 输出 / partial)时, - // 展开区渲染出来只有一圈 padding 的空框(高度就几个像素),观感像 bug。 - // 此时干脆不提供展开:箭头不显示、点击不响应,等有内容后恢复展开能力。 + // 参数、统计或输出至少有一项时才提供展开,避免出现空框。 const progressFrameText = [ - hidden > 0 ? `... +${hidden} 行已折叠` : '', + hidden > 0 ? translate('toolTranscript.foldedLines', { count: hidden }) : '', tailLines.join('\n'), currentPartial, ].filter(Boolean).join('\n'); - const hasExpandableContent = !!(shellDetails || progressFrameText); + const hasExpandableContent = !!(shellDetails || progressFrameText || detailMetadata); return (
} spinnerStatic={!liveProgress} - label={genericSummary.verb || title || displayOverride || tool || '正在执行工具'} - detail={bashCommand || genericSummary.object || ''} - trailing={( - <> - {totalLines} 行 - {formatBytes(totalBytes)} - {formatElapsed(liveElapsed)} - - )} + label={presentation.label} + detail={presentation.detail} + detailSeparator={presentation.separator} + onDetailOverflowChange={shellPresentation ? setDetailClipped : undefined} preserveLabel expandable={hasExpandableContent} expanded={expanded} onToggle={hasExpandableContent ? toggleExpanded : undefined} - title={buttonTooltip || outputPreview || (hasExpandableContent ? (expanded ? '收起' : '展开') : undefined)} - ariaLabel={hasExpandableContent ? (expanded ? '收起' : '展开') : undefined} + title={buttonTooltip || (hasExpandableContent ? toggleLabel : undefined)} + ariaLabel={hasExpandableContent ? toggleLabel : undefined} /> {expanded && hasExpandableContent && (
- {shellDetails || } + {shellDetails || }
)}
diff --git a/web/src/components/ToolDetailMetadata.jsx b/web/src/components/ToolDetailMetadata.jsx new file mode 100644 index 000000000..6c90e8c17 --- /dev/null +++ b/web/src/components/ToolDetailMetadata.jsx @@ -0,0 +1,17 @@ +import '../styles/tool-details.css'; + +export function ToolDetailMetadata({ fields = [] }) { + if (!fields.length) return null; + return ( +
+ {fields.map(({ key, label, value, standalone }) => ( + + {key === '+' || key === '-' ? ( + {key}{value} + ) : standalone ? value : <>{label}: {value}} + + ))} +
+ ); +} diff --git a/web/src/i18n/catalogs.js b/web/src/i18n/catalogs.js index 90dfb8259..d3370b475 100644 --- a/web/src/i18n/catalogs.js +++ b/web/src/i18n/catalogs.js @@ -2,8 +2,9 @@ import { enUS } from './catalogs/en-US.js'; import { zhCN } from './catalogs/zh-CN.js'; import { sourceCatalogs } from './sourceCatalog.generated.js'; import { systemNoticeEnUS, systemNoticeZhCN } from './catalogs/systemNotice.js'; +import { toolTranscriptEnUS, toolTranscriptZhCN } from './catalogs/toolTranscript.js'; export const translationCatalogs = Object.freeze({ - 'zh-CN': { ...zhCN, systemNotice: systemNoticeZhCN, source: sourceCatalogs['zh-CN'] }, - 'en-US': { ...enUS, systemNotice: systemNoticeEnUS, source: sourceCatalogs['en-US'] }, + 'zh-CN': { ...zhCN, toolTranscript: toolTranscriptZhCN, systemNotice: systemNoticeZhCN, source: sourceCatalogs['zh-CN'] }, + 'en-US': { ...enUS, toolTranscript: toolTranscriptEnUS, systemNotice: systemNoticeEnUS, source: sourceCatalogs['en-US'] }, }); diff --git a/web/src/i18n/catalogs/toolTranscript.js b/web/src/i18n/catalogs/toolTranscript.js new file mode 100644 index 000000000..b92293eb5 --- /dev/null +++ b/web/src/i18n/catalogs/toolTranscript.js @@ -0,0 +1,121 @@ +// Native tool identities stay stable; only presentation is localized. +const names = { + file_read: ['读取文件', 'Read File'], file_write: ['写入文件', 'Write File'], + file_edit: ['编辑文件', 'Edit File'], apply_patch: ['应用补丁', 'Apply Patch'], + grep: ['搜索内容', 'Search Content'], glob: ['查找文件', 'Find Files'], + bash: ['执行命令', 'Run Command'], askuserquestion: ['询问用户', 'Ask User'], + todowrite: ['更新待办', 'Update Todos'], task_complete: ['完成任务', 'Complete Task'], + skills_list: ['查看技能', 'List Skills'], skill_view: ['读取技能', 'Read Skill'], + memory_read: ['读取记忆', 'Read Memory'], memory_write: ['更新记忆', 'Update Memory'], + spawn_subagent: ['创建子代理', 'Create Subagent'], wait_subagent: ['等待子代理', 'Wait for Subagent'], + agent_spawn: ['创建代理', 'Create Agent'], agent_list: ['查看代理', 'List Agents'], + agent_send_message: ['发送消息', 'Send Message'], agent_followup_task: ['追加任务', 'Assign Follow-up'], + agent_wait: ['等待代理', 'Wait for Agent'], agent_interrupt: ['停止代理', 'Stop Agent'], + create_thread: ['创建会话', 'Create Chat'], fork_thread: ['分叉会话', 'Fork Chat'], + list_threads: ['查看会话列表', 'List Chats'], read_thread: ['读取会话', 'Read Chat'], + send_message_to_thread: ['发送会话消息', 'Send Chat Message'], wait_threads: ['等待会话', 'Wait for Chats'], + set_thread_title: ['重命名会话', 'Rename Chat'], set_thread_pinned: ['设置会话置顶', 'Set Chat Pin'], + set_thread_archived: ['设置会话归档', 'Set Chat Archive'], delete_thread: ['删除会话', 'Delete Chat'], + repair_thread: ['修复会话', 'Repair Chat'], create_workspace: ['创建工作区', 'Create Workspace'], + enterworktree: ['进入工作树', 'Enter Worktree'], exitworktree: ['退出工作树', 'Exit Worktree'], + enterplanmode: ['进入计划模式', 'Enter Plan Mode'], exitplanmode: ['退出计划模式', 'Exit Plan Mode'], + get_goal: ['查看目标', 'Read Goal'], create_goal: ['创建目标', 'Create Goal'], update_goal: ['更新目标', 'Update Goal'], + create_scheduled_task: ['创建定时任务', 'Create Scheduled Task'], suggest_task: ['建议任务', 'Suggest Task'], + dismiss_task_suggestion: ['忽略任务建议', 'Dismiss Task Suggestion'], theme_create: ['创建主题', 'Create Theme'], + show_image: ['显示图片', 'Show Image'], vision_analyze: ['分析图片', 'Analyze Image'], + image_generate: ['生成图片', 'Generate Image'], web_search: ['搜索网页', 'Search Web'], + lsp: ['分析代码', 'Analyze Code'], ios_simulator: ['操作模拟器', 'Use Simulator'], + ios_simulator_build: ['构建模拟器应用', 'Build Simulator App'], + browser_open: ['打开浏览器', 'Open Browser'], browser_navigate: ['访问网页', 'Navigate Page'], + browser_read_page: ['读取网页', 'Read Page'], browser_click: ['点击网页元素', 'Click Page Element'], + browser_fill: ['填写网页字段', 'Fill Page Field'], browser_type: ['输入网页文本', 'Type on Page'], + browser_press: ['按下网页按键', 'Press Page Key'], browser_hover: ['悬停网页元素', 'Hover Page Element'], + browser_drag: ['拖动网页元素', 'Drag Page Element'], browser_scroll: ['滚动网页', 'Scroll Page'], + browser_wait: ['等待网页', 'Wait for Page'], browser_screenshot: ['截取网页', 'Capture Page'], + browser_handle_dialog: ['处理网页对话框', 'Handle Page Dialog'], browser_evaluate: ['运行网页脚本', 'Run Page Script'], + browser_close: ['关闭网页', 'Close Page'], + computer_list_windows: ['查看窗口列表', 'List Windows'], computer_get_window: ['查看窗口', 'Read Window'], + computer_list_apps: ['查看应用列表', 'List Apps'], computer_get_window_state: ['观察窗口', 'Inspect Window'], + computer_activate_window: ['激活窗口', 'Activate Window'], computer_click: ['点击窗口元素', 'Click Window Element'], + computer_type_text: ['输入文本', 'Type Text'], computer_press_key: ['按下按键', 'Press Key'], + computer_scroll: ['滚动窗口', 'Scroll Window'], computer_drag: ['拖动窗口元素', 'Drag Window Element'], + computer_set_value: ['设置控件值', 'Set Control Value'], + computer_perform_secondary_action: ['操作控件', 'Use Control'], + computer_launch_app: ['启动应用', 'Launch App'], computer_release: ['释放桌面控制', 'Release Desktop Control'], +}; +const actions = { + goToDefinition: ['跳转定义', 'Go to Definition'], findReferences: ['查找引用', 'Find References'], + hover: ['查看符号信息', 'Inspect Symbol'], documentSymbol: ['查看文件符号', 'List File Symbols'], + workspaceSymbol: ['搜索项目符号', 'Search Workspace Symbols'], goToImplementation: ['查找实现', 'Find Implementations'], + prepareCallHierarchy: ['查看调用层级', 'Inspect Call Hierarchy'], incomingCalls: ['查找调用方', 'Find Callers'], + outgoingCalls: ['查找被调用方', 'Find Callees'], + list_devices: ['查看模拟器列表', 'List Simulators'], boot: ['启动模拟器', 'Boot Simulator'], + shutdown: ['关闭模拟器', 'Shut Down Simulator'], attach: ['连接模拟器', 'Attach Simulator'], + detach: ['断开模拟器', 'Detach Simulator'], screenshot: ['截取模拟器画面', 'Capture Simulator'], + tap: ['点击模拟器', 'Tap Simulator'], swipe: ['滑动模拟器', 'Swipe Simulator'], + touch_path: ['拖动模拟器触点', 'Drag Simulator Touch'], touch2_path: ['模拟双指手势', 'Perform Two-finger Gesture'], + text: ['输入模拟器文本', 'Type in Simulator'], key: ['按下模拟器按键', 'Press Simulator Key'], + button: ['按下设备按钮', 'Press Device Button'], open_url: ['打开模拟器链接', 'Open Simulator URL'], + launch: ['启动模拟器应用', 'Launch Simulator App'], terminate: ['停止模拟器应用', 'Stop Simulator App'], + list_apps: ['查看模拟器应用', 'List Simulator Apps'], inspect: ['检查模拟器界面', 'Inspect Simulator UI'], + logs: ['读取模拟器日志', 'Read Simulator Logs'], hardware_keyboard: ['设置模拟器键盘', 'Set Simulator Keyboard'], +}; +const fields = { + path: ['路径', 'Path'], scope: ['范围', 'Scope'], files: ['文件', 'Files'], limit: ['返回上限', 'Result Limit'], + range: ['行范围', 'Line Range'], lines: ['读取行数', 'Lines Read'], outputLines: ['输出行数', 'Output Lines'], + size: ['大小', 'Size'], enc: ['编码', 'Encoding'], eol: ['换行符', 'Line Endings'], + elapsed: ['耗时', 'Elapsed'], exit: ['退出码', 'Exit Code'], byte_offset: ['字节偏移', 'Byte Offset'], + max_bytes: ['字节上限', 'Byte Limit'], cache: ['缓存', 'Cache'], partial: ['截断', 'Truncated'], + truncated: ['输出截断', 'Output Truncated'], mode: ['模式', 'Mode'], hint: ['提示', 'Hint'], + sandbox: ['沙箱', 'Sandbox'], timeout: ['超时', 'Timeout'], aborted: ['已中止', 'Aborted'], + branch: ['分支', 'Branch'], quality: ['质量', 'Quality'], model: ['模型', 'Model'], + total: ['总数', 'Total'], done: ['完成数', 'Completed'], count: ['数量', 'Count'], + results: ['结果数', 'Results'], sources: ['来源数', 'Sources'], images: ['图片数', 'Images'], + width: ['宽度', 'Width'], height: ['高度', 'Height'], format: ['格式', 'Format'], + cwd: ['工作目录', 'Working Directory'], line: ['行', 'Line'], character: ['列', 'Column'], + query: ['查询', 'Query'], target: ['目标', 'Target'], text: ['文本', 'Text'], value: ['值', 'Value'], + url: ['地址', 'URL'], device: ['设备', 'Device'], project: ['项目', 'Project'], workspace: ['工作区', 'Workspace'], + scheme: ['构建方案', 'Scheme'], configuration: ['构建配置', 'Configuration'], + window: ['窗口', 'Window'], key: ['按键', 'Key'], button: ['按钮', 'Button'], + selector: ['选择器', 'Selector'], page_id: ['页面', 'Page'], role: ['角色', 'Role'], + timeout_ms: ['超时毫秒数', 'Timeout (ms)'], duration: ['时长', 'Duration'], seconds: ['秒数', 'Seconds'], + x: ['横坐标', 'X'], y: ['纵坐标', 'Y'], x2: ['结束横坐标', 'End X'], y2: ['结束纵坐标', 'End Y'], + scrollX: ['横向滚动', 'Horizontal Scroll'], scrollY: ['纵向滚动', 'Vertical Scroll'], + element_index: ['元素', 'Element'], prompt: ['任务描述', 'Prompt'], message: ['消息', 'Message'], + title: ['标题', 'Title'], objective: ['目标', 'Objective'], status: ['状态', 'Status'], + token_budget: ['令牌预算', 'Token Budget'], name: ['名称', 'Name'], action: ['操作', 'Action'], + enabled: ['启用', 'Enabled'], pinned: ['置顶', 'Pinned'], archived: ['归档', 'Archived'], + schedule: ['计划', 'Schedule'], rrule: ['重复规则', 'Recurrence'], expression: ['脚本', 'Script'], + reason: ['原因', 'Reason'], agent_type: ['代理类型', 'Agent Type'], process: ['进程', 'Process'], + memory_type: ['记忆类型', 'Memory Type'], namespace: ['命名空间', 'Namespace'], + from_x: ['起点横坐标', 'Start X'], from_y: ['起点纵坐标', 'Start Y'], + to_x: ['终点横坐标', 'End X'], to_y: ['终点纵坐标', 'End Y'], + kept: ['保留位置', 'Retained Path'], backend: ['搜索服务', 'Search Service'], + failed: ['失败原因', 'Failure'], err: ['错误', 'Error'], remaining: ['剩余', 'Remaining'], + lossy: ['替换字符数', 'Replaced Characters'], + includeArchived: ['包含归档', 'Include Archived'], includeOutputs: ['包含输出', 'Include Outputs'], + noWorkspace: ['无工作区', 'No Workspace'], +}; +const values = { + true: ['是', 'Yes'], false: ['否', 'No'], unchanged: ['未变化', 'Unchanged'], + truncated: ['已截断', 'Truncated'], bytes: ['字节读取', 'Byte Range'], large_file: ['大文件', 'Large File'], + complete: ['已完成', 'Complete'], completed: ['已完成', 'Completed'], paused: ['已暂停', 'Paused'], + blocked: ['受阻', 'Blocked'], active: ['进行中', 'Active'], high: ['高', 'High'], medium: ['中', 'Medium'], + low: ['低', 'Low'], auto: ['自动', 'Auto'], +}; +const localize = (pairs, index) => Object.fromEntries(Object.entries(pairs).map(([key, value]) => [key, value[index]])); +export const builtinTranscriptToolNames = Object.freeze(Object.keys(names)); +export const toolTranscriptZhCN = { + names: localize(names, 0), actions: localize(actions, 0), fields: localize(fields, 0), values: localize(values, 0), + separator: ':', failed: '失败', running: '执行中', unknown: '调用工具', + expand: '展开详情', collapse: '收起详情', fromLine: '第 {{line}} 行起', + lineRange: '第 {{start}}–{{end}} 行', maxResults: '最多 {{count}} 条', + fileCount: '{{count}} 个文件', foldedLines: '… 已折叠 {{count}} 行', unanswered: '未作答', +}; +export const toolTranscriptEnUS = { + names: localize(names, 1), actions: localize(actions, 1), fields: localize(fields, 1), values: localize(values, 1), + separator: ': ', failed: 'Failed', running: 'Running', unknown: 'Use Tool', + expand: 'Expand details', collapse: 'Collapse details', fromLine: 'From line {{line}}', + lineRange: 'Lines {{start}}–{{end}}', maxResults: 'Up to {{count}} results', + fileCount: '{{count}} files', foldedLines: '… {{count}} lines folded', unanswered: 'Unanswered', +}; diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 5e76ae5aa..ac65ac8b9 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -4,6 +4,7 @@ export const sourceCatalogs = { "zh-CN": { "s_0006d696d8e1ec28": "新增", "s_004b4ea56503f522": "加载图像生成配置失败", + "s_005074b65962188c": "网页", "s_005a671c0955c0d6": "浏览器只允许 HTTPS 或本机地址使用麦克风。内网 HTTP 页面可在 Firefox 打开 about:config,把 media.devices.insecure.enabled 和 media.getusermedia.insecure.enabled 设为 true,重启浏览器后生效。", "s_006c2a1d1f2455cc": "暂无会话内容", "s_006df11eb7a802c9": "复制ID", @@ -53,6 +54,7 @@ export const sourceCatalogs = { "s_054b73a972372bdb": "未找到可用终端", "s_05564f10bcba6ae8": "同一专家可属于多个 Tag", "s_055761c5f8f17c52": "无法保存快捷键,请检查本地存储后重试。", + "s_0571a5f5f8ec4c83": "正在清除…", "s_057dc352ed0a0a29": "更换机器人", "s_0580fcee37e02012": "删除失败:", "s_05a531a083e68807": "复制 {{p0}} 验证码 {{p1}}", @@ -88,6 +90,7 @@ export const sourceCatalogs = { "s_07ee23a0924d3d0b": "由于你中断了当前响应,队列已暂停", "s_080f87c1ee39a9f5": "主题资源尚未下载或已损坏,请重新下载", "s_081da169dcc59cf5": "附件仍在上传,请稍后发送", + "s_0828cafb738794ba": "无法保存 Cookie 设置", "s_082a738ae620ae34": "模型设置", "s_0831592a902fb3a3": "将永久清空当前工作区的 {{p0}} 条记忆,以及索引、待整合观察和归档。全局记忆不受影响。", "s_084ae4a64fb06560": "待办事项 ({{p0}}/{{p1}})", @@ -121,6 +124,7 @@ export const sourceCatalogs = { "s_0a98a48eaaac4175": "测试连接", "s_0a9a4724ab124b4b": "双列", "s_0aaf1f62f7a179c2": "国庆节", + "s_0aaf66fcd093fec2": "Agent 浏览器里登录网站后,登录状态保存多久、各会话之间是否共用。切到「退出时清除」会删除已保存的 Cookie。", "s_0ab653adadfadb42": "读取 {{p0}}", "s_0abba0a69b08e888": "更早的消息加载失败", "s_0acb29894da7490b": "启用摘要生成", @@ -249,6 +253,7 @@ export const sourceCatalogs = { "s_14381a6ef00a1253": "定时测试与问题修复", "s_143dab9db74c3131": "连接完成", "s_145219e726b87790": "隐藏", + "s_1452deafc6d96546": "推荐", "s_145eb48a326b5d27": "工具重写", "s_14749c39dc5360cd": "尚未配置此范围的 Skill 扫描路径", "s_148d195e21b05db5": "配置", @@ -326,6 +331,7 @@ export const sourceCatalogs = { "s_1ae4451e51587ed6": "主 checkout 在运行期间出现 {{p0}} 处 worktree 之外的改动", "s_1ae6b0a0f8266382": "关闭窗口", "s_1ae7a34c1452785e": "Markdown 所见即所得编辑器", + "s_1ae81c8cd0d5e977": "将删除所有会话共用的 Cookie、登录状态和网站数据。之后需要重新登录网站。", "s_1afed6a81a2512d2": "选择模型", "s_1b14bd33bab15a6d": "{{p0}} 继承全局", "s_1b442420620b3bb4": "现在可以在飞书里私聊机器人了。把机器人拉进群后 @ 它,第一次需要在设置页批准。", @@ -413,6 +419,7 @@ export const sourceCatalogs = { "s_21cab4f2e52fbfef": "未命名对话", "s_21cc0ba839811c78": "新路径与当前路径相同", "s_21dde55cd99a68a2": "当前后台服务不支持工具重写,请更新 ACECode 并重启后重试", + "s_21e2d4c9bc70557d": "浏览数据已清除", "s_2217610d8e605cdc": "打开全局 Skill 目录", "s_222c9c161b8907d6": "当前会话已多次压缩,建议在新会话中接续当前工作。", "s_222eac7780eaef4d": "模型配置已检查", @@ -460,6 +467,7 @@ export const sourceCatalogs = { "s_264333fb5458a573": "准备调用工具", "s_2657124b1481b789": "项目提供", "s_267c4a7a86dd1849": "全部类型", + "s_2687ccdbb1d2288a": "文档", "s_26ab0eee67e8c707": "已调用工具", "s_26c8aefe6fbfa744": "沙箱、文件与命令策略,以及每一次判定的审计记录。", "s_26da3cee0bb25151": "等待用户回复 AskUserQuestion", @@ -472,6 +480,7 @@ export const sourceCatalogs = { "s_273f40d0251a8261": "打开钉钉开放平台", "s_2745ccb884fcf6ab": "意见反馈", "s_27586f180db1c812": "已重命名", + "s_27e6b9d0620b4a71": "无法更新推荐:", "s_2812f6c1263c96a9": "已删除“{{p0}}”", "s_281b0924bb9c5a16": "用微信扫一扫并在手机上确认;扫码的微信号就是机主,也是唯一能使用它的人。", "s_28200a87c40ced80": "导入并应用", @@ -479,6 +488,7 @@ export const sourceCatalogs = { "s_2834cf755087f50d": "摘要生成", "s_28384d7afd2e4fa6": "失败", "s_2851a9e9734926ad": "沙箱拦截", + "s_28583f55198226bf": "所有会话共用同一份 Cookie", "s_2874151bd36be9a3": "搜索设置", "s_28751566873e4520": "{{p0}} 屏幕", "s_28786d90697520ac": "删除预设", @@ -528,6 +538,7 @@ export const sourceCatalogs = { "s_2ce5c86917eca527": "页面内存不足", "s_2cffe851c7765400": "展开整个右侧面板", "s_2d1d8c1e38956bea": "后退", + "s_2d2f36b1f8d7b687": "已移除推荐", "s_2d5492fa38ef2f75": "正在建立连接", "s_2d55d67a4923e9fa": "不支持的目标操作", "s_2d8ff2bb909846ec": "继承模型", @@ -542,7 +553,9 @@ export const sourceCatalogs = { "s_2e659c0c617332d7": "读隔离生效", "s_2e6bfec616ad6945": "团队成员", "s_2e7e1c747a435c14": "(多选)", + "s_2e7ec51b79fe4721": "移除推荐:{{p0}}", "s_2e7eebdf89461c27": "正在扫描", + "s_2e9c35dded6b1b71": "退出 ACECode 时清除 Cookie", "s_2ec809a15744b733": "外观配置保存失败,主题未删除", "s_2ed9c6fabac2013a": "紫色", "s_2efad4f47ae7804b": "工具完成", @@ -560,6 +573,7 @@ export const sourceCatalogs = { "s_305378ddd2b86b60": "链接", "s_3055a035f0eb7a8b": "更新", "s_307253e3d1f704a5": "已打开文件夹", + "s_30ad3d68cbb14095": "浏览器更多操作", "s_30bed62ace8872f7": "新路径", "s_30c731f953a5122d": "高级开发工程师", "s_30c83dc25b12c32c": "绿色", @@ -618,6 +632,7 @@ export const sourceCatalogs = { "s_35c52c32418a2678": "将删除全部 {{p0}} 条记录,无法恢复。需要留档请先导出。", "s_35cda8a7052dec20": "迁移失败", "s_35d9e1259e698690": "模拟器键盘输入", + "s_35dccd6dcfaae1d4": "无法读取 Cookie 设置", "s_3615b021b8af5e82": "收尾", "s_3620d3d986f14584": "关闭模型弹窗", "s_3625f9fa2f9c629a": "拒绝执行命令", @@ -640,6 +655,7 @@ export const sourceCatalogs = { "s_37c427d9f5dc817d": "默认模型已设为 ", "s_37d36b087f49c6d2": "选择专家组件", "s_37e4e42c314102da": "图像", + "s_3806c706f5b553b1": "清除浏览数据?", "s_381117baedb79e1a": "选择 Provider 和模型后,只显示当前连接真正支持的配置。", "s_381796352dc0301d": "配置已保存但未能生效,请重试", "s_3819d5e4aa5dba68": "钢笔", @@ -647,6 +663,7 @@ export const sourceCatalogs = { "s_383ebd59e0d42c3e": "需要你确认", "s_38725be8afc17556": "录入期间不会执行快捷键,点击保存后立即生效。", "s_38744132b501a1eb": "提问", + "s_38844b135cf70dfc": "更多", "s_38c043e08502e710": "个性化", "s_38cd88ed8b3ddbf1": "复制完整输出", "s_38d456bbdb81317c": "按 Tag 筛选", @@ -678,6 +695,7 @@ export const sourceCatalogs = { "s_3bc19f21617f0a86": "根据描述生成图片,或编辑已有图片", "s_3bceaa431f3153d5": "正在读取状态", "s_3be2c9604a690f97": "旁路聊天连接超时,请重试。", + "s_3c2d4d2fcc58fb6d": "向下滚动最近文件", "s_3c39e9bde120ddb5": "定时任务", "s_3c446e94a996b7aa": "旁路聊天连接失败,请重试。", "s_3c763f6769fda365": "主题下载或应用失败,请重试", @@ -707,6 +725,7 @@ export const sourceCatalogs = { "s_3e6221be3922f850": "用微信扫一扫,并在手机上确认", "s_3e65bba57b173f30": "粘贴的文本太长,超出 /{{p0}} 的上限({{p1}} 字节)。去掉命令可作为普通消息发送,或缩短内容", "s_3e7d75b94f3a1f26": "加载工具重写配置失败", + "s_3e8c634dc4f3cdbc": "agent browser cookie cookies keep sign in login session 登录 登录状态 记住登录 浏览器 网站数据 清除浏览数据 共享 按会话 退出时清除 until quit shared per session", "s_3ea0e6e41518ea24": "空目录", "s_3ec957bd9b13024f": "搜索已归档会话", "s_3ed8de8d3a0924ea": "Grok Coding Plan 已连接", @@ -746,6 +765,7 @@ export const sourceCatalogs = { "s_412c8bdb63028865": "点击“工作区”栏右侧的添加按钮,选择已有的本地代码目录,它就会出现在左侧工作区列表中。引导期间只做说明,不会真的打开目录选择器。", "s_4136ed8160d39565": "使用 Skills", "s_41623c6b19ff5c83": "升级服务", + "s_416deb8c2fd2287e": "拖入的会话已引用过,或是当前会话", "s_41abc174fa444bb5": "已保存。需重启 daemon 才能生效", "s_41b2ac5db0b6e64d": "近 365 天", "s_41f5cada11edbc9a": "最近活动", @@ -874,7 +894,6 @@ export const sourceCatalogs = { "s_4c7f436250902124": "拒绝调用工具", "s_4c8a4e3da39e5c2a": "打开文件", "s_4cc0f0608edf5ef1": "选择模型后自动填入模型 ID", - "s_4cc7b00050cb1c21": "行", "s_4cca8a4406aea952": "需要确认", "s_4ce87e7fe2b6e95d": "文件或文件夹", "s_4ceae06adf5b9199": "撤销后立即停止处理其消息,并中止其会话正在进行的回合。", @@ -882,6 +901,7 @@ export const sourceCatalogs = { "s_4d2b19065afe4b07": "LSP 已开启:{{p0}}", "s_4d3f6a081ea86097": "彻底删除失败:", "s_4d5193ca941bf930": "下载中 {{p0}}%", + "s_4d6a4bc105dd3f07": "共享", "s_4d6b5440965402ae": "通过右上角操作开始创建。", "s_4d7611fc1530dbaa": "不可用警告", "s_4d7ebdff0f79b59a": "上下文 {{p0}}", @@ -898,6 +918,7 @@ export const sourceCatalogs = { "s_4e8c405c2b796248": "此组合键由系统、浏览器或文本编辑保留,请换一组按键。", "s_4eafa9e925b30bcd": "自定义", "s_4eb2c65beb7f5731": "使用已下载版本", + "s_4ec5b26015f27485": "电子表格", "s_4ef515abfdb8690d": "安装包", "s_4ef6221d6213436e": "已启动", "s_4f01af9093fc9e59": "无法连接到此网站", @@ -966,6 +987,7 @@ export const sourceCatalogs = { "s_548f1cbcdbee6f4e": "项目与自定义规则", "s_549134d7633ae974": "恢复终端失败", "s_54ee672a9610bc5d": "操作失败:{{p0}}", + "s_54f9826310400f00": "演示文稿", "s_550a9f4636e4131b": "仅本机访问 · daemon {{p0}}:{{p1}}", "s_5539b36ee6a7a23e": "权限请求", "s_553f952d1b612c11": ")。保存后会永久记住。", @@ -1066,6 +1088,7 @@ export const sourceCatalogs = { "s_5d7fd21f1e3ca6b4": "复制消息", "s_5d817dbb5da09259": "导入主题返回了无效结果,请刷新后重试", "s_5d937ed111134879": "模型能力", + "s_5d941448f1bea257": "向上滚动最近文件", "s_5ddcb16ab6fcb808": "查看成员", "s_5ddffd77ea7fedae": "正在加载已保存模型…", "s_5e3009e7cde76251": "最长录音秒数", @@ -1088,6 +1111,7 @@ export const sourceCatalogs = { "s_5f7780324a4b55f1": "会话续接建议", "s_5fba64a2b5976bd4": "上传失败,点击重试", "s_5fcad9600a50f834": "关闭右侧标签页", + "s_5fd5ea73912afe29": "Cookie 已改为「{{p0}}」", "s_5fe46d0c2faeacf7": "选择模型连接", "s_5ffdb67a4142cc22": "当前环境无法保存截图", "s_605a534da98e42ec": "Token 活动视图", @@ -1173,6 +1197,7 @@ export const sourceCatalogs = { "s_68891ab35cab9663": "重新连接", "s_68a09a528d4cb4a6": "已取消归档", "s_68b455b6cdbf83a4": "导出 Mermaid 源码", + "s_68c0106fafb13332": "清除浏览数据", "s_68c0c2cab6899682": "此快捷键已用于“{{p0}}”,请换一组按键。", "s_68ec0a1ef12b104e": "此页面需要身份验证", "s_68f22858a36d6ac9": "模型列表已刷新", @@ -1205,6 +1230,7 @@ export const sourceCatalogs = { "s_6aecec7bb2ae1d74": "公网地址(可选,留空则自动建立隧道)", "s_6b01fce4dbeaeeb9": "更改", "s_6b7b01114a2db31f": "接口协议", + "s_6b9ec50c55d6b2e3": "无法清除浏览数据", "s_6bbaf705e8c64c2b": "目录内容", "s_6bdf7013183b1835": "与模拟器的连接已断开", "s_6bea77acefb364ac": "标准", @@ -1288,6 +1314,8 @@ export const sourceCatalogs = { "s_72969c8ff0ed9876": "右键配置麦克风", "s_72ccb1274a093efb": "填写 QQ 开放平台里机器人的 AppID 与 AppSecret,保存前会先验证。", "s_72dde8dfef88d37d": "确认添加", + "s_72e546e30e85f92c": "将删除当前会话浏览器里保存的 Cookie、登录状态和网站数据,其它会话不受影响。之后需要重新登录网站。", + "s_72e9b59aef42c6bd": "定位当前工作区内的活动文件并高亮", "s_72ec61d7855c86a1": "请求超时(秒)", "s_7310f2c21a738bd0": "(上一条)", "s_737c725c1321d260": "已保存 {{p0}},留空则不修改", @@ -1652,6 +1680,7 @@ export const sourceCatalogs = { "s_8fa4088e982d0fc9": "盆栽", "s_8fd867ae44e0f4bf": "主题工坊地址无效,请检查更新源设置", "s_8ff756c4feb2a890": "work mode coding daily 日常工作 用于编程 进度提示 工具前言 loading", + "s_9003b29bcd11bb17": "批量操作:成功 {{success}} 项,失败 {{failed}} 项", "s_901a6d728b8cb75b": "尚未配置机器人", "s_90253fc5741dc402": "配置 JSON(直接编辑 mcp_servers 部分)", "s_906502a6a28e4eaa": "导入后立即应用", @@ -1755,6 +1784,7 @@ export const sourceCatalogs = { "s_9873be1ed93630a0": "请稍候,网页正在加载。", "s_9884e90a773101b9": "全局记忆", "s_988c12f1398e3d9d": "AI 执行的命令运行在隔离沙箱里,文件、命令、网络策略按下面的规则生效。", + "s_988f77b34abbcca2": "Cookie 已改为「{{p0}}」,已保存的 Cookie 已清除", "s_98c4b439f2506ab2": "在消息输入框中生效", "s_98cbc356f0e61a08": "新增一个 Provider 配置后即可在这里管理。", "s_98fc221b10e87c21": "创建应用", @@ -1763,6 +1793,7 @@ export const sourceCatalogs = { "s_99587cf2ca773409": "按住录音", "s_9966d6b375133f89": "路径必须是绝对路径:{{p0}}", "s_99731fe916c6b3bf": "现在可以在钉钉里私聊机器人了。把机器人拉进群后 @ 它,第一次需要在设置页批准。", + "s_997a5e6e513f2a90": "最近", "s_99845832ed7f16f6": "稍后再说", "s_998568217a58f2fc": "无匹配的全局技能", "s_999bf43be3657ea3": "非交互嵌入默认放行", @@ -1778,6 +1809,7 @@ export const sourceCatalogs = { "s_9ae14f5b00594c4c": "可读白名单", "s_9afc6a4c62aea23f": "申请额外权限", "s_9b7824cefa1e8b16": "放弃修改", + "s_9b7cbee7afe5aee4": "已处理 {{count}} 个会话", "s_9b8cf7af48da71e0": "第 {{p0}} / {{p1}} 步", "s_9badcf14ef9a8d08": "新建会话默认使用此模式", "s_9bcbd690c4a55f31": "导出主题失败,请重试", @@ -1830,11 +1862,13 @@ export const sourceCatalogs = { "s_9fcad048086de1f8": "${ACECODE_DATA_DIR} 为数据目录;${XDG_CONFIG_HOME}、${XDG_CACHE_HOME} 默认分别为 ~/.config、~/.cache。未设置的其他变量会跳过。", "s_9fd56f7aae1ca61d": "显示隐藏项目", "s_9fef8ee282a01ab9": "本地模型目录格式不完整,请更新 ACECode 或刷新目录。", + "s_9ff28eb7a7975dc2": "已归档 {{count}} 个会话", "s_9ff545329109ea65": "机主:{{p0}}", "s_a00235e8c624f16d": "扩展 / 专家组件", "s_a011d73ec679db1a": "启动升级失败:", "s_a071de661efe95fb": "删除这条记忆?", "s_a0a0e5f6072c8213": "weixin wechat 微信 扫码 ilink 机器人 im channel 消息通道 连接 取消连接", + "s_a0c4f4ed67f1871d": "移除推荐", "s_a0dd1a666530c173": "默认权限模式已更新,当前会话同步失败:", "s_a0f73bcccc33cb7b": "关闭预览面板会关闭当前预览区域的全部 {{p0}} 个标签页。是否继续?", "s_a1315bcfc62a19f8": "原生桥返回了无效结果", @@ -1844,6 +1878,7 @@ export const sourceCatalogs = { "s_a1a5b179235d0009": "当前使用 {{p0}} · {{p1}}", "s_a1a9da9ee6ed0401": "未知原因", "s_a1beb78ea67743ca": "展开/收起控制台", + "s_a1c2931850241896": "向上滚动", "s_a1dfbbbaa9f2fef9": "机器人设置了 webhook,需要处理", "s_a203f86cf6a6a841": "MCP 服务器", "s_a22774c08bc9187e": "加入可写", @@ -1897,6 +1932,7 @@ export const sourceCatalogs = { "s_a63280253f17d440": "会话", "s_a655bcaa91a25426": "取消派遣:{{p0}}", "s_a67fa4bcb68094b4": "没有符合筛选条件的专家组件", + "s_a686e64364525272": "最近文件", "s_a6a4761cfcca4484": "当前路径", "s_a6a4d8fa7f9120be": "开启后,真正退出桌面应用时,后台任务和待处理交互仍会继续运行", "s_a6dfb80406396953": "当前平台不拦截读取,此清单不生效(仅 macOS / Linux)", @@ -1982,6 +2018,7 @@ export const sourceCatalogs = { "s_ae30451fc65b75de": "没有匹配的 Provider", "s_ae5090199fb03c14": "缓存读", "s_ae5358b57d3a7ea5": "找不到路径:{{p0}}", + "s_ae5c3dba292d734a": "批量操作失败:", "s_ae5ead4d89074727": "连接超时", "s_ae6742618467eea1": "能力:{{p0}}", "s_ae6d1c297557333c": "语音识别使用代理", @@ -2070,6 +2107,7 @@ export const sourceCatalogs = { "s_b79ce792c1a1287a": "正在校验并安装…", "s_b7da1d6716999707": "浏览器只允许 HTTPS 或本机地址使用麦克风。内网 HTTP 页面请改用 Chrome、Edge 或 Firefox,并把 {{p0}} 设为安全来源。", "s_b7f4557b8b746a5e": "请输入完整的文件夹路径", + "s_b8058f5d63f321f7": "未知的 Cookie 档位:{{p0}}", "s_b806e39b8e3c4403": "用手机相机扫码(Telegram 自带的扫码扫不了这个),在 Telegram 里点「开始」就成为机主。", "s_b817c99bb4f24dd5": "展开列表面板", "s_b8199c9199128706": "计划与任务", @@ -2080,6 +2118,7 @@ export const sourceCatalogs = { "s_b8954daeace3fcef": "指定 Git Bash 路径", "s_b8ab0fa06986bd29": "权限模式已变更", "s_b8f88aeead1520c8": "辅助功能", + "s_b90af4ac2e8fc874": "无法更新推荐", "s_b913e107ad8b0d83": "结束时间必须晚于开始时间", "s_b91472f6f25b1041": "输入消息或 / 命令…", "s_b9238eb0c9178901": "主题操作失败,请重试", @@ -2148,6 +2187,7 @@ export const sourceCatalogs = { "s_be7bbdf1213e0555": "QQ 机器人", "s_be98c07b13ac0281": "静态", "s_bee1bfab91f18284": "无人值守自动放行", + "s_bf0f3c75608fee18": "在文件列表展示", "s_bf1617e297b85f92": "已从桌面工作区列表移除", "s_bf24e735d802644f": "一行一个;显示在卡片与详情中", "s_bf2a78216b764bb1": "qq bot 机器人 扫码 appid appsecret im channel 消息通道 连接 取消连接 owner approve revoke 机主 授权 审批 撤销", @@ -2251,6 +2291,7 @@ export const sourceCatalogs = { "s_c8934dd8eab25062": "选择主题 ZIP", "s_c8ac23e2ea8d01b4": "下一张幻灯片", "s_c8c14507b2d37395": "推理强度", + "s_c8e2a670188d2917": "按会话", "s_c8fb9e13e776596a": "目标使用受限", "s_c9136488e2f44217": "图像生成测试结果", "s_c92179b74af61689": "取消置顶", @@ -2282,6 +2323,7 @@ export const sourceCatalogs = { "s_ca949d1eb3377a79": "ACE 标记", "s_cadd75ef8275de01": "已查看 {{p0}} 张图像", "s_caecb5d342cc889a": "恢复目标", + "s_caf00e1d2410243b": "每个会话各自保存 Cookie", "s_cafc8a4f6acda2cb": "Tag 可多选;同一专家会出现在每个已选 Tag 下", "s_cb16eec3c3bd3c51": "增加更多宠物,丰富您快乐工作的每一天", "s_cb2fcb9099668d50": "暂无回答", @@ -2359,6 +2401,7 @@ export const sourceCatalogs = { "s_d1eeaa83a7bc0e11": "向用户提问或等待确认时使用", "s_d1faecf810be0d87": "主题压缩进度", "s_d21bc3d1a4a3e196": "不使用工作区", + "s_d24c10d37db0feea": "图片", "s_d2671739eec0a619": "会话运行在 worktree:{{p0}}", "s_d26e5c70a8422042": "管理定时提醒和计划执行的任务,查看最近运行记录。", "s_d276202550a2206f": "关闭窗口操作失败:", @@ -2483,6 +2526,7 @@ export const sourceCatalogs = { "s_dbcac8ae7d65ae39": "二维码已过期", "s_dbcb25260e3df31a": "每一次命令执行、文件修改、沙箱拦截与规则变更的判定记录;最多保留 {{p0}} 条。", "s_dbd379b39f0d3537": "粉色", + "s_dbe66822e12d3b28": "向下滚动", "s_dc2ba467fc7ac962": "描述", "s_dc3cabe68bcc3046": "打开钉钉开放平台,创建「企业内部应用」,在「应用能力」里添加「机器人」。", "s_dc47f62bd3f882f0": "组建专家团", @@ -2501,6 +2545,7 @@ export const sourceCatalogs = { "s_dd15d4fdc9a08a5a": "按 Provider 要求配置认证", "s_dd42f689b17393c2": "正在加载更早的消息…", "s_dd7a9008edbca109": "已取消 {{p0}} 个会话的归档,{{p1}} 个失败", + "s_dd9803437a8cb4e3": "保留 Cookie", "s_dda206707b809157": "连接已中断", "s_ddcf6e77b0ee1000": "准备", "s_ddde08946298195d": "还原", @@ -2601,6 +2646,7 @@ export const sourceCatalogs = { "s_e60a1b37a8c217ac": "内置保护规则", "s_e6139bb641de6052": "EVA 初号机", "s_e638abde8f537e3b": "目录内有变更: {{p0}}", + "s_e641a78d36beded8": "退出时清除", "s_e6571559056e87e1": "请填写这个专家的工作方式", "s_e66883ffd17e6b11": "编码", "s_e6750cdfb5003ef4": "连接网络", @@ -2683,7 +2729,6 @@ export const sourceCatalogs = { "s_ed403b6483e73a63": "查看子会话", "s_eda73a5daf8f734f": "当前问题:{{p0}}", "s_edb18f09d0ff4c83": "编辑模型 {{p0}}", - "s_edbd4f79f1b7919a": "... +{{p0}} 行已折叠", "s_ede83cc65dad0522": "使用已保存的机器人,或者重新扫码换一个。", "s_edecf0ae6e5144f9": "发送", "s_ee00e57c07195431": "当前运行模式不支持自动重启,请完全退出后重新启动 ACECode", @@ -2712,6 +2757,8 @@ export const sourceCatalogs = { "s_f00066652a6a3b69": "日志({{p0}} 条)", "s_f006455e3baf2b0b": "已停止", "s_f00ef1be4493739b": "每次询问", + "s_f00f116b3af758aa": "清除浏览数据超时", + "s_f028961b1aec7c70": "将删除本次运行中保存的 Cookie、登录状态和网站数据(退出 ACECode 时本来也会清除)。之后需要重新登录网站。", "s_f0352e9758fbff2d": "禁止执行命令", "s_f035aab05364d2c5": "选择「{{p0}}」", "s_f03b1d0aa80f24ea": "在 LINE 私聊或群里 @机器人 下达指令,结果回到聊天窗口。", @@ -2883,6 +2930,7 @@ export const sourceCatalogs = { "s_fd5fd3c4567938c7": "询问", "s_fd60890af789ad92": "模拟器未启动", "s_fd6529f1e944f8c7": "与当前会话共享文件;有任务运行时会排队。", + "s_fd6bd46367cbbf35": "松开以引用 {{count}} 个会话", "s_fd6d4b5099959168": "插话", "s_fd7b67c923cd4b3e": "每日", "s_fdd200f5dbdb26fc": "选择 Agent 显示多少技术细节", @@ -2913,6 +2961,7 @@ export const sourceCatalogs = { "en-US": { "s_0006d696d8e1ec28": "New", "s_004b4ea56503f522": "Could not load image generation settings", + "s_005074b65962188c": "Website", "s_005a671c0955c0d6": "Browsers allow the microphone only on HTTPS or local pages. For an intranet HTTP page, open about:config in Firefox, set media.devices.insecure.enabled and media.getusermedia.insecure.enabled to true, and restart the browser.", "s_006c2a1d1f2455cc": "No conversation content yet", "s_006df11eb7a802c9": "Copy ID", @@ -2962,6 +3011,7 @@ export const sourceCatalogs = { "s_054b73a972372bdb": "No usable terminal found", "s_05564f10bcba6ae8": "The same expert can belong to multiple Tags", "s_055761c5f8f17c52": "Could not save the shortcut. Check local storage and try again.", + "s_0571a5f5f8ec4c83": "Clearing…", "s_057dc352ed0a0a29": "Change bot", "s_0580fcee37e02012": "Delete failed:", "s_05a531a083e68807": "Copy {{p0}} verification code {{p1}}", @@ -2997,6 +3047,7 @@ export const sourceCatalogs = { "s_07ee23a0924d3d0b": "Queue paused because you interrupted the current response", "s_080f87c1ee39a9f5": "Theme files are missing or damaged. Download them again.", "s_081da169dcc59cf5": "The attachment is still being uploaded, please send it later", + "s_0828cafb738794ba": "Could not save the cookie setting", "s_082a738ae620ae34": "Model settings", "s_0831592a902fb3a3": "This permanently clears {{p0}} memories in the current workspace, along with the index, pending observations and archive. Global memories are not affected.", "s_084ae4a64fb06560": "To-Do List ({{p0}}/{{p1}})", @@ -3030,6 +3081,7 @@ export const sourceCatalogs = { "s_0a98a48eaaac4175": "Test connection", "s_0a9a4724ab124b4b": "Side by side", "s_0aaf1f62f7a179c2": "National Day", + "s_0aaf66fcd093fec2": "How long sign-ins in the Agent Browser are kept, and whether chats share them. Switching to \"Until quit\" deletes saved cookies.", "s_0ab653adadfadb42": "Read {{p0}}", "s_0abba0a69b08e888": "Could not load earlier messages", "s_0acb29894da7490b": "Enable summary generation", @@ -3158,6 +3210,7 @@ export const sourceCatalogs = { "s_14381a6ef00a1253": "Regular testing and problem fixing", "s_143dab9db74c3131": "All set", "s_145219e726b87790": "Hidden", + "s_1452deafc6d96546": "Suggested", "s_145eb48a326b5d27": "Tool rewrite", "s_14749c39dc5360cd": "No Skill scan path is configured for this scope", "s_148d195e21b05db5": "Configuration", @@ -3235,6 +3288,7 @@ export const sourceCatalogs = { "s_1ae4451e51587ed6": "The main checkout gained {{p0}} change(s) outside the worktree while this run executed", "s_1ae6b0a0f8266382": "Close window", "s_1ae7a34c1452785e": "Markdown WYSIWYG editor", + "s_1ae81c8cd0d5e977": "This deletes the cookies, sign-ins and site data shared by all chats. You will need to sign in to sites again.", "s_1afed6a81a2512d2": "Select model", "s_1b14bd33bab15a6d": "Inherit global settings for {{p0}}", "s_1b442420620b3bb4": "You can now message the bot privately in Feishu. Add it to a group and @mention it there; the first time needs approval in Settings.", @@ -3322,6 +3376,7 @@ export const sourceCatalogs = { "s_21cab4f2e52fbfef": "Unnamed conversation", "s_21cc0ba839811c78": "The new path is the same as the current path", "s_21dde55cd99a68a2": "The current backend does not support tool rewrite. Update ACECode, restart, and try again", + "s_21e2d4c9bc70557d": "Browsing data cleared", "s_2217610d8e605cdc": "Open the global Skill Catalog", "s_222c9c161b8907d6": "This conversation has been compacted several times. Continue the current work in a new conversation.", "s_222eac7780eaef4d": "Model configuration checked", @@ -3369,6 +3424,7 @@ export const sourceCatalogs = { "s_264333fb5458a573": "Preparing a tool call", "s_2657124b1481b789": "Provided by project", "s_267c4a7a86dd1849": "All types", + "s_2687ccdbb1d2288a": "Document", "s_26ab0eee67e8c707": "Tool called", "s_26c8aefe6fbfa744": "Sandbox, file and command policies, plus an audit record of every decision.", "s_26da3cee0bb25151": "Wait for user to reply to AskUserQuestion", @@ -3381,6 +3437,7 @@ export const sourceCatalogs = { "s_273f40d0251a8261": "Open DingTalk Open Platform", "s_2745ccb884fcf6ab": "Feedback", "s_27586f180db1c812": "renamed", + "s_27e6b9d0620b4a71": "Couldn't update suggestion:", "s_2812f6c1263c96a9": "Deleted “{{p0}}”", "s_281b0924bb9c5a16": "Scan with WeChat and confirm on your phone. The WeChat account that scans becomes the owner and is the only one who can use the bot.", "s_28200a87c40ced80": "Import and apply", @@ -3388,6 +3445,7 @@ export const sourceCatalogs = { "s_2834cf755087f50d": "Summary generation", "s_28384d7afd2e4fa6": "fail", "s_2851a9e9734926ad": "Sandbox block", + "s_28583f55198226bf": "All chats share the same cookies", "s_2874151bd36be9a3": "Search settings", "s_28751566873e4520": "{{p0}} screen", "s_28786d90697520ac": "Delete profile", @@ -3437,6 +3495,7 @@ export const sourceCatalogs = { "s_2ce5c86917eca527": "Page ran out of memory", "s_2cffe851c7765400": "Expand the entire right panel", "s_2d1d8c1e38956bea": "Back", + "s_2d2f36b1f8d7b687": "Suggestion dismissed", "s_2d5492fa38ef2f75": "Establishing connection", "s_2d55d67a4923e9fa": "Unsupported goal action", "s_2d8ff2bb909846ec": "Inherit model", @@ -3451,7 +3510,9 @@ export const sourceCatalogs = { "s_2e659c0c617332d7": "read isolation active", "s_2e6bfec616ad6945": "Team members", "s_2e7e1c747a435c14": "(Multi)", + "s_2e7ec51b79fe4721": "Dismiss {{p0}}", "s_2e7eebdf89461c27": "Scanning", + "s_2e9c35dded6b1b71": "Cookies are cleared when ACECode quits", "s_2ec809a15744b733": "Appearance preferences could not be saved. The theme was not deleted.", "s_2ed9c6fabac2013a": "Purple", "s_2efad4f47ae7804b": "Tool complete", @@ -3469,6 +3530,7 @@ export const sourceCatalogs = { "s_305378ddd2b86b60": "Link", "s_3055a035f0eb7a8b": "renew", "s_307253e3d1f704a5": "Folder opened", + "s_30ad3d68cbb14095": "More browser actions", "s_30bed62ace8872f7": "New path", "s_30c731f953a5122d": "Senior Development Engineer", "s_30c83dc25b12c32c": "Green", @@ -3527,6 +3589,7 @@ export const sourceCatalogs = { "s_35c52c32418a2678": "All {{p0}} entries will be deleted and cannot be recovered. Export first if you need a copy.", "s_35cda8a7052dec20": "Migration failed", "s_35d9e1259e698690": "Simulator keyboard input", + "s_35dccd6dcfaae1d4": "Could not read the cookie setting", "s_3615b021b8af5e82": "Wrapping up", "s_3620d3d986f14584": "Close model dialog", "s_3625f9fa2f9c629a": "Command denied", @@ -3549,6 +3612,7 @@ export const sourceCatalogs = { "s_37c427d9f5dc817d": "The default model has been set to", "s_37d36b087f49c6d2": "Select expert component", "s_37e4e42c314102da": "image", + "s_3806c706f5b553b1": "Clear browsing data?", "s_381117baedb79e1a": "After choosing a provider and model, only settings supported by that connection are shown.", "s_381796352dc0301d": "Settings were saved but could not be applied. Please try again", "s_3819d5e4aa5dba68": "Pen", @@ -3556,6 +3620,7 @@ export const sourceCatalogs = { "s_383ebd59e0d42c3e": "Needs your confirmation", "s_38725be8afc17556": "Shortcuts do not run while recording. Changes take effect when saved.", "s_38744132b501a1eb": "Question", + "s_38844b135cf70dfc": "More", "s_38c043e08502e710": "Personalization", "s_38cd88ed8b3ddbf1": "Copy full output", "s_38d456bbdb81317c": "Filter by Tag", @@ -3587,6 +3652,7 @@ export const sourceCatalogs = { "s_3bc19f21617f0a86": "Generate images from descriptions or edit existing images", "s_3bceaa431f3153d5": "Loading status", "s_3be2c9604a690f97": "The side chat connection timed out. Try again.", + "s_3c2d4d2fcc58fb6d": "Scroll recents down", "s_3c39e9bde120ddb5": "Scheduled tasks", "s_3c446e94a996b7aa": "Could not connect to side chat. Try again.", "s_3c763f6769fda365": "Could not download or apply the theme. Try again.", @@ -3616,6 +3682,7 @@ export const sourceCatalogs = { "s_3e6221be3922f850": "Scan with WeChat and confirm on your phone", "s_3e65bba57b173f30": "The pasted text is too long for the /{{p0}} limit ({{p1}} bytes). Remove the command to send it as a normal message, or shorten it", "s_3e7d75b94f3a1f26": "Failed to load tool rewrite settings", + "s_3e8c634dc4f3cdbc": "agent browser cookie cookies keep sign in login session signed in remember login browser site data clear browsing data shared per session until quit", "s_3ea0e6e41518ea24": "Empty directory", "s_3ec957bd9b13024f": "Search archived sessions", "s_3ed8de8d3a0924ea": "Grok Coding Plan connected", @@ -3655,6 +3722,7 @@ export const sourceCatalogs = { "s_412c8bdb63028865": "Click the Add button on the right of the Workspaces heading and select an existing local code folder to add it to the sidebar. The tour only explains this action; it does not open the folder picker.", "s_4136ed8160d39565": "Use Skills", "s_41623c6b19ff5c83": "Upgrade service", + "s_416deb8c2fd2287e": "These chats are already referenced or are the current chat", "s_41abc174fa444bb5": "Saved. The daemon needs to be restarted to take effect.", "s_41b2ac5db0b6e64d": "Last 365 days", "s_41f5cada11edbc9a": "Last active", @@ -3783,7 +3851,6 @@ export const sourceCatalogs = { "s_4c7f436250902124": "Tool call denied", "s_4c8a4e3da39e5c2a": "Open file", "s_4cc0f0608edf5ef1": "Filled with the model ID once a model is selected", - "s_4cc7b00050cb1c21": "OK", "s_4cca8a4406aea952": "Confirmation required", "s_4ce87e7fe2b6e95d": "File or folder", "s_4ceae06adf5b9199": "Their messages stop being processed immediately, and any running turn in their sessions is stopped.", @@ -3791,6 +3858,7 @@ export const sourceCatalogs = { "s_4d2b19065afe4b07": "LSP is on:{{p0}}", "s_4d3f6a081ea86097": "Complete deletion failed:", "s_4d5193ca941bf930": "Downloading {{p0}}%", + "s_4d6a4bc105dd3f07": "Shared", "s_4d6b5440965402ae": "Use the actions in the upper-right corner to get started.", "s_4d7611fc1530dbaa": "Availability warning", "s_4d7ebdff0f79b59a": "Context {{p0}}", @@ -3807,6 +3875,7 @@ export const sourceCatalogs = { "s_4e8c405c2b796248": "This combination is reserved by the system, browser or text editor. Choose another combination.", "s_4eafa9e925b30bcd": "Custom", "s_4eb2c65beb7f5731": "Use downloaded version", + "s_4ec5b26015f27485": "Spreadsheet", "s_4ef515abfdb8690d": "Installation package", "s_4ef6221d6213436e": "Booted", "s_4f01af9093fc9e59": "Cannot connect to this site", @@ -3875,6 +3944,7 @@ export const sourceCatalogs = { "s_548f1cbcdbee6f4e": "Project and custom rules", "s_549134d7633ae974": "Failed to restore terminals", "s_54ee672a9610bc5d": "Operation failed:{{p0}}", + "s_54f9826310400f00": "Presentation", "s_550a9f4636e4131b": "Local access only · daemon {{p0}}:{{p1}}", "s_5539b36ee6a7a23e": "permission request", "s_553f952d1b612c11": "). Once saved, it will be remembered forever.", @@ -3975,6 +4045,7 @@ export const sourceCatalogs = { "s_5d7fd21f1e3ca6b4": "Copy message", "s_5d817dbb5da09259": "The theme import returned an invalid result. Refresh and try again", "s_5d937ed111134879": "Model capabilities", + "s_5d941448f1bea257": "Scroll recents up", "s_5ddcb16ab6fcb808": "Checking members", "s_5ddffd77ea7fedae": "Loading saved models…", "s_5e3009e7cde76251": "Maximum recording seconds", @@ -3997,6 +4068,7 @@ export const sourceCatalogs = { "s_5f7780324a4b55f1": "Conversation continuation suggestion", "s_5fba64a2b5976bd4": "Upload failed, click to retry", "s_5fcad9600a50f834": "Close the tab on the right", + "s_5fd5ea73912afe29": "Cookies set to \"{{p0}}\"", "s_5fe46d0c2faeacf7": "Choose a model connection", "s_5ffdb67a4142cc22": "Screenshots cannot be saved in this environment", "s_605a534da98e42ec": "Token activity view", @@ -4082,6 +4154,7 @@ export const sourceCatalogs = { "s_68891ab35cab9663": "Reconnect", "s_68a09a528d4cb4a6": "Unarchived", "s_68b455b6cdbf83a4": "Export Mermaid source", + "s_68c0106fafb13332": "Clear browsing data", "s_68c0c2cab6899682": "This shortcut is already used by “{{p0}}”. Choose another combination.", "s_68ec0a1ef12b104e": "This page requires authentication", "s_68f22858a36d6ac9": "Model list has been refreshed", @@ -4114,6 +4187,7 @@ export const sourceCatalogs = { "s_6aecec7bb2ae1d74": "Public address (optional; leave empty for an automatic tunnel)", "s_6b01fce4dbeaeeb9": "Change", "s_6b7b01114a2db31f": "API protocol", + "s_6b9ec50c55d6b2e3": "Could not clear browsing data", "s_6bbaf705e8c64c2b": "Folder contents", "s_6bdf7013183b1835": "The connection to the simulator was lost", "s_6bea77acefb364ac": "Standard", @@ -4197,6 +4271,8 @@ export const sourceCatalogs = { "s_72969c8ff0ed9876": "Right-click to configure microphone", "s_72ccb1274a093efb": "Enter the bot's AppID and AppSecret from QQ Open Platform. They are verified before saving.", "s_72dde8dfef88d37d": "Confirm to add", + "s_72e546e30e85f92c": "This deletes the cookies, sign-ins and site data saved for this chat's browser. Other chats are not affected. You will need to sign in to sites again.", + "s_72e9b59aef42c6bd": "Locate and highlight the active file in the current workspace", "s_72ec61d7855c86a1": "Request timeout (seconds)", "s_7310f2c21a738bd0": "(previous)", "s_737c725c1321d260": "Saved {{p0}}. Leave blank to keep it", @@ -4561,6 +4637,7 @@ export const sourceCatalogs = { "s_8fa4088e982d0fc9": "Potted plant", "s_8fd867ae44e0f4bf": "Invalid workshop address. Check the update source settings", "s_8ff756c4feb2a890": "work mode coding daily progress status loading", + "s_9003b29bcd11bb17": "Batch operation: {{success}} succeeded, {{failed}} failed", "s_901a6d728b8cb75b": "No bot configured", "s_90253fc5741dc402": "Configure JSON (edit the mcp_servers section directly)", "s_906502a6a28e4eaa": "Apply after importing", @@ -4664,6 +4741,7 @@ export const sourceCatalogs = { "s_9873be1ed93630a0": "Please wait while the page loads.", "s_9884e90a773101b9": "Global memory", "s_988c12f1398e3d9d": "Commands run by the AI execute inside an isolated sandbox; file, command and network policies follow the rules below.", + "s_988f77b34abbcca2": "Cookies set to \"{{p0}}\"; saved cookies were cleared", "s_98c4b439f2506ab2": "Available in the message input", "s_98cbc356f0e61a08": "Add a provider configuration to manage it here.", "s_98fc221b10e87c21": "Create app", @@ -4672,6 +4750,7 @@ export const sourceCatalogs = { "s_99587cf2ca773409": "Hold to record", "s_9966d6b375133f89": "Paths must be absolute: {{p0}}", "s_99731fe916c6b3bf": "You can now message the robot privately in DingTalk. Add it to a group and @mention it there; the first time needs approval in Settings.", + "s_997a5e6e513f2a90": "Recents", "s_99845832ed7f16f6": "Later", "s_998568217a58f2fc": "No matching global skills", "s_999bf43be3657ea3": "Allowed by default in a non-interactive embedding", @@ -4687,6 +4766,7 @@ export const sourceCatalogs = { "s_9ae14f5b00594c4c": "Readable allow list", "s_9afc6a4c62aea23f": "Requested additional permissions", "s_9b7824cefa1e8b16": "Discard changes", + "s_9b7cbee7afe5aee4": "Processed {{count}} chats", "s_9b8cf7af48da71e0": "Step {{p0}} of {{p1}}", "s_9badcf14ef9a8d08": "New sessions use this mode by default", "s_9bcbd690c4a55f31": "Could not export the theme. Try again.", @@ -4739,11 +4819,13 @@ export const sourceCatalogs = { "s_9fcad048086de1f8": "${ACECODE_DATA_DIR} is the data directory; ${XDG_CONFIG_HOME} and ${XDG_CACHE_HOME} default to ~/.config and ~/.cache. Paths with other unset variables are skipped.", "s_9fd56f7aae1ca61d": "Show hidden items", "s_9fef8ee282a01ab9": "The local model catalog is incomplete. Update ACECode or refresh the catalog.", + "s_9ff28eb7a7975dc2": "Archived {{count}} chats", "s_9ff545329109ea65": "Owner: {{p0}}", "s_a00235e8c624f16d": "Extension/expert components", "s_a011d73ec679db1a": "Failed to start upgrade:", "s_a071de661efe95fb": "Delete this memory?", "s_a0a0e5f6072c8213": "weixin wechat qr ilink bot im channel messaging connect disconnect", + "s_a0c4f4ed67f1871d": "Dismiss suggestion", "s_a0dd1a666530c173": "The default permission mode has been updated and the current session synchronization failed:", "s_a0f73bcccc33cb7b": "Closing the preview panel will close all {{p0}} tabs in the current preview area. Continue?", "s_a1315bcfc62a19f8": "Native bridge returned invalid results", @@ -4753,6 +4835,7 @@ export const sourceCatalogs = { "s_a1a5b179235d0009": "Currently using {{p0}} · {{p1}}", "s_a1a9da9ee6ed0401": "unknown reason", "s_a1beb78ea67743ca": "Toggle console", + "s_a1c2931850241896": "Scroll up", "s_a1dfbbbaa9f2fef9": "The bot has a webhook set; action needed", "s_a203f86cf6a6a841": "MCP servers", "s_a22774c08bc9187e": "Allow writes", @@ -4806,6 +4889,7 @@ export const sourceCatalogs = { "s_a63280253f17d440": "session", "s_a655bcaa91a25426": "Cancel dispatch: {{p0}}", "s_a67fa4bcb68094b4": "There are no expert components matching the filter criteria", + "s_a686e64364525272": "Recent files", "s_a6a4761cfcca4484": "Current path", "s_a6a4d8fa7f9120be": "Once enabled, background tasks and pending interactions will continue to run when you actually exit the desktop application.", "s_a6dfb80406396953": "The current platform does not block reads, so this list has no effect (macOS / Linux only)", @@ -4891,6 +4975,7 @@ export const sourceCatalogs = { "s_ae30451fc65b75de": "No matching providers", "s_ae5090199fb03c14": "cache read", "s_ae5358b57d3a7ea5": "Path not found: {{p0}}", + "s_ae5c3dba292d734a": "Batch operation failed:", "s_ae5ead4d89074727": "Connection timed out", "s_ae6742618467eea1": "Capability: {{p0}}", "s_ae6d1c297557333c": "Use proxy for speech recognition", @@ -4979,6 +5064,7 @@ export const sourceCatalogs = { "s_b79ce792c1a1287a": "Verifying and installing…", "s_b7da1d6716999707": "Browsers allow the microphone only on HTTPS or local pages. For an intranet HTTP page, use Chrome, Edge, or Firefox and mark {{p0}} as a secure origin.", "s_b7f4557b8b746a5e": "Enter an absolute directory path", + "s_b8058f5d63f321f7": "Unknown cookie setting: {{p0}}", "s_b806e39b8e3c4403": "Scan it with your phone's camera (Telegram's own scanner can't read it), then tap Start in Telegram to become the owner.", "s_b817c99bb4f24dd5": "Expand list panel", "s_b8199c9199128706": "Planning and tasks", @@ -4989,6 +5075,7 @@ export const sourceCatalogs = { "s_b8954daeace3fcef": "Specify Git Bash path", "s_b8ab0fa06986bd29": "Permissions mode changed", "s_b8f88aeead1520c8": "Accessibility", + "s_b90af4ac2e8fc874": "Couldn't update suggestion", "s_b913e107ad8b0d83": "End time must be later than start time", "s_b91472f6f25b1041": "Enter message or /command…", "s_b9238eb0c9178901": "The theme action failed. Try again.", @@ -5057,6 +5144,7 @@ export const sourceCatalogs = { "s_be7bbdf1213e0555": "QQ bot", "s_be98c07b13ac0281": "static", "s_bee1bfab91f18284": "Auto-approved while unattended", + "s_bf0f3c75608fee18": "Show in file list", "s_bf1617e297b85f92": "Removed from desktop workspace list", "s_bf24e735d802644f": "One per line; shown in cards and details", "s_bf2a78216b764bb1": "qq bot qr appid appsecret im channel messaging connect disconnect owner approve revoke", @@ -5160,6 +5248,7 @@ export const sourceCatalogs = { "s_c8934dd8eab25062": "Choose theme ZIP", "s_c8ac23e2ea8d01b4": "Next slide", "s_c8c14507b2d37395": "Reasoning effort", + "s_c8e2a670188d2917": "Per session", "s_c8fb9e13e776596a": "Goal usage limited", "s_c9136488e2f44217": "Image generation test result", "s_c92179b74af61689": "Unpin", @@ -5191,6 +5280,7 @@ export const sourceCatalogs = { "s_ca949d1eb3377a79": "ACE badge", "s_cadd75ef8275de01": "Viewed {{p0}} images", "s_caecb5d342cc889a": "Resume goal", + "s_caf00e1d2410243b": "Each chat keeps its own cookies", "s_cafc8a4f6acda2cb": "Tags can be selected multiple times; the same expert will appear under each selected Tag", "s_cb16eec3c3bd3c51": "More pets to brighten every working day", "s_cb2fcb9099668d50": "No answer yet", @@ -5268,6 +5358,7 @@ export const sourceCatalogs = { "s_d1eeaa83a7bc0e11": "Used while asking the user or waiting for confirmation", "s_d1faecf810be0d87": "Theme compression progress", "s_d21bc3d1a4a3e196": "Not using workspace", + "s_d24c10d37db0feea": "Image", "s_d2671739eec0a619": "The session runs in worktree:{{p0}}", "s_d26e5c70a8422042": "Manage reminders and scheduled tasks, and view recent runs.", "s_d276202550a2206f": "Close-window action failed:", @@ -5392,6 +5483,7 @@ export const sourceCatalogs = { "s_dbcac8ae7d65ae39": "The QR code expired", "s_dbcb25260e3df31a": "A decision record for every command run, file change, sandbox block and rule change; the newest {{p0}} entries are kept.", "s_dbd379b39f0d3537": "Pink", + "s_dbe66822e12d3b28": "Scroll down", "s_dc2ba467fc7ac962": "Description", "s_dc3cabe68bcc3046": "Open the DingTalk Open Platform, create an internal app, and add the Robot capability.", "s_dc47f62bd3f882f0": "Build expert team", @@ -5410,6 +5502,7 @@ export const sourceCatalogs = { "s_dd15d4fdc9a08a5a": "Configure authentication as required by the provider", "s_dd42f689b17393c2": "Loading earlier messages…", "s_dd7a9008edbca109": "Unarchived {{p0}} sessions; {{p1}} failed", + "s_dd9803437a8cb4e3": "Keep cookies", "s_dda206707b809157": "Connection interrupted", "s_ddcf6e77b0ee1000": "Preparing to", "s_ddde08946298195d": "reduction", @@ -5510,6 +5603,7 @@ export const sourceCatalogs = { "s_e60a1b37a8c217ac": "Built-in protection rule", "s_e6139bb641de6052": "EVA Unit-01", "s_e638abde8f537e3b": "There are changes in the directory:{{p0}}", + "s_e641a78d36beded8": "Until quit", "s_e6571559056e87e1": "Describe how this expert should work", "s_e66883ffd17e6b11": "Coding", "s_e6750cdfb5003ef4": "Network address", @@ -5592,7 +5686,6 @@ export const sourceCatalogs = { "s_ed403b6483e73a63": "View subsessions", "s_eda73a5daf8f734f": "Current issue:{{p0}}", "s_edb18f09d0ff4c83": "Edit model {{p0}}", - "s_edbd4f79f1b7919a": "... +{{p0}} lines collapsed", "s_ede83cc65dad0522": "Use the saved bot, or scan again to switch to another one.", "s_edecf0ae6e5144f9": "send", "s_ee00e57c07195431": "The current running mode does not support automatic restart. Please exit completely and then restart ACECode.", @@ -5621,6 +5714,8 @@ export const sourceCatalogs = { "s_f00066652a6a3b69": "Log ({{p0}} entries)", "s_f006455e3baf2b0b": "Stopped", "s_f00ef1be4493739b": "Ask every time", + "s_f00f116b3af758aa": "Clearing browsing data timed out", + "s_f028961b1aec7c70": "This deletes the cookies, sign-ins and site data saved during this run (they are cleared when ACECode quits anyway). You will need to sign in to sites again.", "s_f0352e9758fbff2d": "Command forbidden", "s_f035aab05364d2c5": "Select \"{{p0}}\"", "s_f03b1d0aa80f24ea": "Give instructions in a LINE private chat or by @mentioning the bot in a group. Results come back to the chat.", @@ -5792,6 +5887,7 @@ export const sourceCatalogs = { "s_fd5fd3c4567938c7": "Ask", "s_fd60890af789ad92": "Simulator is not booted", "s_fd6529f1e944f8c7": "Share files with this conversation. The task will queue while other work is running.", + "s_fd6bd46367cbbf35": "Release to reference {{count}} chats", "s_fd6d4b5099959168": "Interject", "s_fd7b67c923cd4b3e": "Daily", "s_fdd200f5dbdb26fc": "Choose how much technical detail Agent shows", diff --git a/web/src/lib/agentBrowserArchitecture.test.js b/web/src/lib/agentBrowserArchitecture.test.js index 25b6903f2..2b6235fdc 100644 --- a/web/src/lib/agentBrowserArchitecture.test.js +++ b/web/src/lib/agentBrowserArchitecture.test.js @@ -1,4 +1,4 @@ -import { readCppSource, cppSourcePath } from './cppSourcePaths.testHelper.js'; +import { readCppSource as readSingleCppSource, cppSourcePath } from './cppSourcePaths.testHelper.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -7,6 +7,16 @@ import { fileURLToPath } from 'node:url'; const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(srcRoot, '..', '..'); + +function readCppSource(relativePath) { + const helpers = { + 'desktop/main.cpp': ['desktop/agent_browser_cookie_bridge.cpp'], + 'desktop/agent_browser_host.cpp': ['desktop/agent_browser_windows_helpers.cpp', 'desktop/agent_browser_windows_helpers.hpp'], + 'desktop/agent_browser_host_mac.mm': ['desktop/agent_browser_macos_helpers.mm', 'desktop/agent_browser_macos_helpers.hpp'], + }; + return [relativePath, ...(helpers[relativePath] || [])].map(readSingleCppSource).join('\n'); +} + function source(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); } @@ -139,11 +149,16 @@ run('Agent Browser collaboration chrome mirrors the VS Code page actions', () => const host = readCppSource('desktop/agent_browser_host.cpp'); const desktop = readCppSource('desktop/main.cpp'); + const moreMenu = source('web/src/components/AgentBrowserMoreMenu.jsx'); + assert.match(panel, /ace-agent-browser-share-toggle/); assert.match(panel, /正在与智能体共享/); assert.match(panel, /将元素添加到聊天/); - assert.match(panel, /将控制台日志添加到聊天/); assert.match(panel, /切换开发者工具/); + // 控制台日志进聊天从工具栏移进「⋮」菜单;工具栏不再有单独按钮。 + assert.match(panel, / assert.match(desktop, /aceDesktop_agentBrowserToggleDevTools/); }); +run('Agent Browser cookie storage modes reach the toolbar menu and settings', () => { + const moreMenu = source('web/src/components/AgentBrowserMoreMenu.jsx'); + const settings = source('web/src/components/SettingsPage.jsx'); + const cookieMode = source('web/src/lib/agentBrowserCookieMode.js'); + const header = readCppSource('desktop/agent_browser_host.hpp'); + const host = readCppSource('desktop/agent_browser_host.cpp'); + const desktop = readCppSource('desktop/main.cpp'); + + // 三个 bridge 与存储事件:Desktop 注册、前端封装同名调用。 + for (const name of [ + 'aceDesktop_agentBrowserGetCookieMode', + 'aceDesktop_agentBrowserSetCookieMode', + 'aceDesktop_agentBrowserClearBrowsingData', + ]) { + assert.match(desktop, new RegExp(`"${name}"`), name); + assert.match(cookieMode, new RegExp(name), name); + } + assert.match(desktop, /acecode:agent-browser-storage/); + assert.match(cookieMode, /acecode:agent-browser-storage/); + assert.match(header, /set_storage_event_handler/); + + // 菜单走 AnchoredMenu(自带原生页面遮挡登记);子菜单是 role="menu" 的浮层, + // 清除确认走共享 Modal 且「清除」是默认操作。 + assert.match(moreMenu, /CreateCoreWebView2Controller/); + assert.match(host, /CreateCoreWebView2ControllerWithOptions/); + // 退出即清档的保活页(实测不保活时关掉所有页面就丢 cookie)。 + assert.match(host, /ensure_until_quit_keepalive\(\)/); +}); + run('Agent Browser mouse-producing tools show one non-blocking AI pointer at exact input coordinates', () => { const header = readCppSource('tool/agent_browser/browser_tools.hpp'); const tools = readCppSource('tool/agent_browser/browser_tools.cpp'); diff --git a/web/src/lib/agentBrowserCookieMode.js b/web/src/lib/agentBrowserCookieMode.js new file mode 100644 index 000000000..43c521367 --- /dev/null +++ b/web/src/lib/agentBrowserCookieMode.js @@ -0,0 +1,225 @@ +// Agent Browser「保留 Cookie」三档与清除浏览数据(openspec add-agent-browser-cookie-modes)。 +// +// 档位由 Desktop 持有(写在 state.json,决定页面用哪份 WebView2 Profile / WebKit 存储), +// 这里只做:三档的文案、清除确认框的文案、一个工具栏菜单与设置页共用的单写者 store, +// 以及 bridge 封装。非 Desktop 环境(普通浏览器)没有这些 bridge,界面不显示任何 +// cookie 控件。 + +import { createSingleWriterStore } from './singleWriterStore.js'; +import { parseAgentBrowserBridgeResult } from './agentBrowser.js'; + +export const AGENT_BROWSER_STORAGE_EVENT = 'acecode:agent-browser-storage'; +export const DEFAULT_AGENT_BROWSER_COOKIE_MODE = 'shared'; + +export const AGENT_BROWSER_COOKIE_MODES = Object.freeze([ + Object.freeze({ + value: 'until_quit', + label: '退出时清除', + hint: '退出 ACECode 时清除 Cookie', + }), + Object.freeze({ + value: 'shared', + label: '共享', + hint: '所有会话共用同一份 Cookie', + }), + Object.freeze({ + value: 'per_session', + label: '按会话', + hint: '每个会话各自保存 Cookie', + }), +]); + +const MODE_VALUES = new Set(AGENT_BROWSER_COOKIE_MODES.map((option) => option.value)); + +export function isAgentBrowserCookieMode(value) { + return MODE_VALUES.has(value); +} + +// Desktop 回来的档位名 → 界面档位;空与未知值按共享(与 native 的默认一致)。 +export function normalizeAgentBrowserCookieMode(value) { + return isAgentBrowserCookieMode(value) ? value : DEFAULT_AGENT_BROWSER_COOKIE_MODE; +} + +export function agentBrowserCookieModeOption(value) { + const mode = normalizeAgentBrowserCookieMode(value); + return AGENT_BROWSER_COOKIE_MODES.find((option) => option.value === mode); +} + +// 「清除浏览数据」确认框:清除的是当前页面所用的那份存储,范围随档位变化, +// 文案必须说清楚会影响谁。 +export function agentBrowserClearBrowsingDataDialog(mode) { + const normalized = normalizeAgentBrowserCookieMode(mode); + const common = { + title: '清除浏览数据?', + confirmLabel: '清除', + cancelLabel: '取消', + }; + if (normalized === 'per_session') { + return { + ...common, + scope: 'session', + body: '将删除当前会话浏览器里保存的 Cookie、登录状态和网站数据,其它会话不受影响。之后需要重新登录网站。', + }; + } + if (normalized === 'until_quit') { + return { + ...common, + scope: 'until_quit', + body: '将删除本次运行中保存的 Cookie、登录状态和网站数据(退出 ACECode 时本来也会清除)。之后需要重新登录网站。', + }; + } + return { + ...common, + scope: 'shared', + body: '将删除所有会话共用的 Cookie、登录状态和网站数据。之后需要重新登录网站。', + }; +} + +export function hasAgentBrowserCookieBridge(win = globalThis.window) { + return typeof win?.aceDesktop_agentBrowserGetCookieMode === 'function' + && typeof win?.aceDesktop_agentBrowserSetCookieMode === 'function' + && typeof win?.aceDesktop_agentBrowserClearBrowsingData === 'function'; +} + +const INITIAL_STATE = Object.freeze({ + supported: false, + loaded: false, + mode: DEFAULT_AGENT_BROWSER_COOKIE_MODE, + // 正在切换到的档位(bridge 还没返回);空 = 没有在途的切换。 + pending: '', + error: '', +}); + +export function createAgentBrowserCookieModeStore() { + return createSingleWriterStore(INITIAL_STATE); +} + +// 工具栏菜单与设置页共用这一份。 +export const agentBrowserCookieModeStore = createAgentBrowserCookieModeStore(); + +function sameState(a, b) { + return a.supported === b.supported && a.loaded === b.loaded && a.mode === b.mode + && a.pending === b.pending && a.error === b.error; +} + +function commitPatch(store, patch) { + return store.commit((state) => { + const next = { ...state, ...patch }; + return sameState(state, next) ? state : next; + }); +} + +export async function loadAgentBrowserCookieMode( + win = globalThis.window, + store = agentBrowserCookieModeStore, +) { + if (!hasAgentBrowserCookieBridge(win)) { + return commitPatch(store, { supported: false, loaded: true }); + } + let result; + try { + result = parseAgentBrowserBridgeResult(await win.aceDesktop_agentBrowserGetCookieMode()); + } catch (error) { + result = { ok: false, error: String(error?.message || error || '') }; + } + if (result.ok !== true) { + return commitPatch(store, { + loaded: true, + error: result.error || '无法读取 Cookie 设置', + }); + } + return commitPatch(store, { + supported: result.supported !== false, + loaded: true, + mode: normalizeAgentBrowserCookieMode(result.mode), + error: '', + }); +} + +// 切档。失败时档位保持原值、error 带原因;成功以 Desktop 返回的档位为准。 +export async function setAgentBrowserCookieMode( + mode, + win = globalThis.window, + store = agentBrowserCookieModeStore, +) { + if (!isAgentBrowserCookieMode(mode)) { + return { ok: false, error: `未知的 Cookie 档位:${mode}` }; + } + if (!hasAgentBrowserCookieBridge(win)) { + return { ok: false, error: 'Agent Browser 桌面桥不可用' }; + } + commitPatch(store, { pending: mode, error: '' }); + let result; + try { + result = parseAgentBrowserBridgeResult(await win.aceDesktop_agentBrowserSetCookieMode(mode)); + } catch (error) { + result = { ok: false, error: String(error?.message || error || '') }; + } + if (result.ok === true) { + commitPatch(store, { + mode: normalizeAgentBrowserCookieMode(result.mode || mode), + pending: '', + error: '', + }); + } else { + commitPatch(store, { pending: '', error: result.error || '无法保存 Cookie 设置' }); + } + return result; +} + +// 只发起清除;完成由 AGENT_BROWSER_STORAGE_EVENT(type=browsing_data_cleared)回报。 +export async function clearAgentBrowserBrowsingData(pageId, win = globalThis.window) { + const fn = win?.aceDesktop_agentBrowserClearBrowsingData; + if (typeof fn !== 'function') return { ok: false, error: 'Agent Browser 桌面桥不可用' }; + try { + return parseAgentBrowserBridgeResult(await fn(pageId)); + } catch (error) { + return { ok: false, error: String(error?.message || error || '') }; + } +} + +// 等一条满足条件的存储事件;超时返回 { ok:false, timedOut:true }。 +export function waitForAgentBrowserStorageEvent( + predicate, + { timeoutMs = 30000 } = {}, + win = globalThis.window, +) { + return new Promise((resolve) => { + if (typeof win?.addEventListener !== 'function') { + resolve({ ok: false, error: 'Agent Browser 桌面桥不可用' }); + return; + } + let timer = null; + const onEvent = (event) => { + const detail = event?.detail; + if (!detail || typeof detail !== 'object' || !predicate(detail)) return; + cleanup(); + resolve(detail); + }; + const cleanup = () => { + win.removeEventListener(AGENT_BROWSER_STORAGE_EVENT, onEvent); + if (timer) clearTimeout(timer); + }; + win.addEventListener(AGENT_BROWSER_STORAGE_EVENT, onEvent); + timer = setTimeout(() => { + cleanup(); + resolve({ ok: false, timedOut: true, error: '清除浏览数据超时' }); + }, timeoutMs); + }); +} + +// Desktop 侧档位变化(切档成功事件)同步进 store;返回注销函数。 +export function installAgentBrowserCookieModeListener( + win = globalThis.window, + store = agentBrowserCookieModeStore, +) { + if (typeof win?.addEventListener !== 'function') return () => {}; + const onEvent = (event) => { + const detail = event?.detail; + if (!detail || detail.type !== 'cookie_mode_changed' || detail.ok === false) return; + if (!isAgentBrowserCookieMode(detail.cookie_mode)) return; + commitPatch(store, { mode: detail.cookie_mode }); + }; + win.addEventListener(AGENT_BROWSER_STORAGE_EVENT, onEvent); + return () => win.removeEventListener(AGENT_BROWSER_STORAGE_EVENT, onEvent); +} diff --git a/web/src/lib/agentBrowserCookieMode.test.js b/web/src/lib/agentBrowserCookieMode.test.js new file mode 100644 index 000000000..d83bb3bf7 --- /dev/null +++ b/web/src/lib/agentBrowserCookieMode.test.js @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { + AGENT_BROWSER_COOKIE_MODES, + AGENT_BROWSER_STORAGE_EVENT, + agentBrowserClearBrowsingDataDialog, + agentBrowserCookieModeOption, + clearAgentBrowserBrowsingData, + createAgentBrowserCookieModeStore, + hasAgentBrowserCookieBridge, + installAgentBrowserCookieModeListener, + loadAgentBrowserCookieMode, + normalizeAgentBrowserCookieMode, + setAgentBrowserCookieMode, + waitForAgentBrowserStorageEvent, +} from './agentBrowserCookieMode.js'; + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// 最小的 window 替身:三个 cookie bridge + 事件派发。 +function fakeDesktop({ mode = 'shared', setResult, throwOnSet = false } = {}) { + const listeners = new Map(); + const calls = []; + return { + calls, + aceDesktop_agentBrowserGetCookieMode() { + calls.push(['get']); + return JSON.stringify({ ok: true, supported: true, mode }); + }, + aceDesktop_agentBrowserSetCookieMode(next) { + calls.push(['set', next]); + if (throwOnSet) throw new Error('bridge crashed'); + return JSON.stringify(setResult ?? { ok: true, mode: next }); + }, + aceDesktop_agentBrowserClearBrowsingData(pageId) { + calls.push(['clear', pageId]); + return JSON.stringify({ ok: true, started: true, page_id: pageId }); + }, + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type).add(fn); + }, + removeEventListener(type, fn) { + listeners.get(type)?.delete(fn); + }, + dispatch(detail) { + for (const fn of [...(listeners.get(AGENT_BROWSER_STORAGE_EVENT) || [])]) fn({ detail }); + }, + listenerCount() { + return listeners.get(AGENT_BROWSER_STORAGE_EVENT)?.size || 0; + }, + }; +} + +// 场景:三档与 Claude 的 Until quit / Shared / Per session 一一对应,默认共享。 +// 期望:未知 / 空的档位名一律按共享显示,与 native 的默认一致。 +await run('cookie modes map unknown values to the shared default', () => { + assert.deepEqual(AGENT_BROWSER_COOKIE_MODES.map((option) => option.value), + ['until_quit', 'shared', 'per_session']); + assert.equal(normalizeAgentBrowserCookieMode('per_session'), 'per_session'); + assert.equal(normalizeAgentBrowserCookieMode(''), 'shared'); + assert.equal(normalizeAgentBrowserCookieMode('per_workspace'), 'shared'); + assert.equal(agentBrowserCookieModeOption('until_quit').label, '退出时清除'); + for (const option of AGENT_BROWSER_COOKIE_MODES) { + assert.ok(option.label && option.hint, option.value); + } +}); + +// 场景:用户点「清除浏览数据」,确认框必须说清楚这次会清掉谁的数据。 +// 期望:共享档说「所有会话」;按会话档说「当前会话」且其它会话不受影响;退出即清档说「本次运行」。 +await run('clear browsing data dialog states the scope of each mode', () => { + const shared = agentBrowserClearBrowsingDataDialog('shared'); + assert.equal(shared.scope, 'shared'); + assert.match(shared.body, /所有会话/); + const session = agentBrowserClearBrowsingDataDialog('per_session'); + assert.equal(session.scope, 'session'); + assert.match(session.body, /当前会话/); + assert.match(session.body, /其它会话不受影响/); + const ephemeral = agentBrowserClearBrowsingDataDialog('until_quit'); + assert.equal(ephemeral.scope, 'until_quit'); + assert.match(ephemeral.body, /本次运行/); + assert.equal(shared.confirmLabel, '清除'); + assert.equal(agentBrowserClearBrowsingDataDialog('bogus').scope, 'shared'); +}); + +// 场景:Web UI 在普通浏览器里打开(没有 Desktop bridge)。 +// 期望:标记为不支持(界面据此不显示 cookie 控件),切档直接失败且不抛异常。 +await run('without the Desktop bridge cookie controls are unsupported', async () => { + const store = createAgentBrowserCookieModeStore(); + const state = await loadAgentBrowserCookieMode({}, store); + assert.equal(state.supported, false); + assert.equal(state.loaded, true); + assert.equal(hasAgentBrowserCookieBridge({}), false); + const result = await setAgentBrowserCookieMode('per_session', {}, store); + assert.equal(result.ok, false); + assert.equal((await clearAgentBrowserBrowsingData('p1', {})).ok, false); +}); + +// 场景:Desktop 里打开,之前选过「按会话」。 +// 期望:读到的档位进入共享 store,工具栏与设置页看到的是同一个值。 +await run('loading reads the persisted mode from Desktop', async () => { + const win = fakeDesktop({ mode: 'per_session' }); + const store = createAgentBrowserCookieModeStore(); + await loadAgentBrowserCookieMode(win, store); + assert.deepEqual(store.getState(), { + supported: true, loaded: true, mode: 'per_session', pending: '', error: '', + }); +}); + +// 场景:用户在菜单里改档位,Desktop 接受。 +// 期望:bridge 收到规范名;切换期间 pending 指向新档位,完成后 mode 更新、pending 清空。 +await run('setting a mode updates the shared store on success', async () => { + const win = fakeDesktop(); + const store = createAgentBrowserCookieModeStore(); + await loadAgentBrowserCookieMode(win, store); + const seen = []; + store.subscribe(() => seen.push(store.getState().pending)); + const result = await setAgentBrowserCookieMode('until_quit', win, store); + assert.equal(result.ok, true); + assert.deepEqual(win.calls.at(-1), ['set', 'until_quit']); + assert.equal(store.getState().mode, 'until_quit'); + assert.equal(store.getState().pending, ''); + assert.ok(seen.includes('until_quit'), 'pending was published while switching'); +}); + +// 场景:Desktop 拒绝(状态文件写不进去)或 bridge 直接抛异常。 +// 期望:档位保持原值,error 带原因 —— 界面不能显示一个其实没生效的档位。 +await run('a rejected or crashing switch keeps the previous mode', async () => { + const rejecting = fakeDesktop({ setResult: { ok: false, error: 'disk full' } }); + const store = createAgentBrowserCookieModeStore(); + await loadAgentBrowserCookieMode(rejecting, store); + const rejected = await setAgentBrowserCookieMode('per_session', rejecting, store); + assert.equal(rejected.ok, false); + assert.equal(store.getState().mode, 'shared'); + assert.equal(store.getState().error, 'disk full'); + + const crashing = fakeDesktop({ throwOnSet: true }); + const crashed = await setAgentBrowserCookieMode('per_session', crashing, store); + assert.equal(crashed.ok, false); + assert.equal(store.getState().mode, 'shared'); + assert.match(store.getState().error, /bridge crashed/); + assert.equal((await setAgentBrowserCookieMode('bogus', crashing, store)).ok, false); +}); + +// 场景:清除浏览数据是异步的,bridge 只返回「已开始」,结果靠存储事件回报。 +// 期望:等待只认匹配的事件(别的页面 / 别的类型不算),等到后注销监听;超时有明确结果。 +await run('waiting for the clear result matches the right storage event', async () => { + const win = fakeDesktop(); + const started = await clearAgentBrowserBrowsingData('page-7', win); + assert.equal(started.started, true); + const waiting = waitForAgentBrowserStorageEvent( + (detail) => detail.type === 'browsing_data_cleared' && detail.page_id === 'page-7', + { timeoutMs: 1000 }, + win, + ); + win.dispatch({ type: 'browsing_data_cleared', page_id: 'other', ok: true }); + win.dispatch({ type: 'cookie_mode_changed', page_id: 'page-7', ok: true }); + win.dispatch({ type: 'browsing_data_cleared', page_id: 'page-7', ok: true, store: 'shared' }); + const detail = await waiting; + assert.equal(detail.store, 'shared'); + assert.equal(win.listenerCount(), 0); + + const timedOut = await waitForAgentBrowserStorageEvent(() => true, { timeoutMs: 5 }, win); + assert.equal(timedOut.timedOut, true); + assert.equal(win.listenerCount(), 0); +}); + +// 场景:Desktop 侧档位变了(切档成功事件)。 +// 期望:合法档位同步进 store;失败事件与未知档位名忽略;注销后不再更新。 +await run('cookie mode events from Desktop update the store', async () => { + const win = fakeDesktop(); + const store = createAgentBrowserCookieModeStore(); + const uninstall = installAgentBrowserCookieModeListener(win, store); + win.dispatch({ type: 'cookie_mode_changed', ok: true, cookie_mode: 'per_session' }); + assert.equal(store.getState().mode, 'per_session'); + win.dispatch({ type: 'cookie_mode_changed', ok: false, cookie_mode: 'until_quit' }); + win.dispatch({ type: 'cookie_mode_changed', ok: true, cookie_mode: 'bogus' }); + assert.equal(store.getState().mode, 'per_session'); + uninstall(); + win.dispatch({ type: 'cookie_mode_changed', ok: true, cookie_mode: 'until_quit' }); + assert.equal(store.getState().mode, 'per_session'); +}); diff --git a/web/src/lib/agentBrowserRecentFiles.js b/web/src/lib/agentBrowserRecentFiles.js new file mode 100644 index 000000000..8df3a87ad --- /dev/null +++ b/web/src/lib/agentBrowserRecentFiles.js @@ -0,0 +1,144 @@ +// Agent Browser 新标签页「最近」:本会话产出 / 修改过的文件。 +// +// 对照 Codex 桌面版(thread-side-panel-new-tab 的 Recents 区):它列当前对话的输出 +// 产物(文档、图片、PDF、网站…),每行「图标 + 名称 + 类型 + 时间」,点开后用该产物 +// 替换这个新标签页。ACECode 对应的数据是 transcript 里带 hunks 的工具调用(与「会话 +// 变更」面板同一来源):每个文件取最后一次改动的时间,新的在前。 +import { formatDateTime } from './format.js'; + +export const AGENT_BROWSER_RECENT_FILES_LIMIT = 50; + +const KIND_BY_EXTENSION = new Map([ + ...['md', 'markdown', 'mdx', 'txt', 'rst', 'adoc', 'doc', 'docx', 'rtf', 'odt'] + .map((ext) => [ext, 'document']), + ...['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif', 'tif', 'tiff'] + .map((ext) => [ext, 'image']), + ['pdf', 'pdf'], + ...['ppt', 'pptx', 'key', 'odp'].map((ext) => [ext, 'presentation']), + ...['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'ods'].map((ext) => [ext, 'spreadsheet']), + ...['html', 'htm'].map((ext) => [ext, 'website']), +]); + +const KIND_LABELS = Object.freeze({ + document: '文档', + image: '图片', + pdf: 'PDF', + presentation: '演示文稿', + spreadsheet: '电子表格', + website: '网页', + other: '文件', +}); + +function text(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function recentFileName(path) { + const parts = text(path).split(/[\\/]/).filter(Boolean); + return parts.length ? parts[parts.length - 1] : text(path); +} + +export function recentFileKind(path) { + const name = recentFileName(path); + const dot = name.lastIndexOf('.'); + if (dot <= 0) return 'other'; + return KIND_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) || 'other'; +} + +export function recentFileTypeLabel(kind) { + return KIND_LABELS[kind] || KIND_LABELS.other; +} + +// 同一文件的不同写法(反斜杠 / ./ 前缀 / Windows 盘符大小写)视为同一项。 +function pathKey(path) { + let key = text(path) + .replace(/\\/g, '/') + .replace(/^\/\/\?\//, '') + .replace(/^\.\/+/, '') + .replace(/\/{2,}/g, '/'); + if (/^[A-Za-z]:\//.test(key)) key = key.toLowerCase(); + return key; +} + +function positiveNumber(value) { + const n = Number(value || 0); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +// 工具项改动文件的时间。持久化的工具消息不带时间戳,sessionTranscript 给历史工具项 +// 填的是加载时刻(兜底),拿来显示会让所有旧改动都显示成「刚刚」;历史项(带 messageId +// 字段,实时事件建的工具项没有)改用所在回合用户消息的时间 —— 用户消息落盘时带时间戳。 +function toolChangeTimestamp(item, turnStartTs) { + const fromHistory = Object.prototype.hasOwnProperty.call(item, 'messageId'); + const ts = fromHistory ? 0 : positiveNumber(item.ts || item.tool?.startedAtMs); + return Math.max(ts, turnStartTs); +} + +function changedFilesOfTool(tool) { + const files = []; + const fallback = text(tool?.summary?.object); + for (const hunk of Array.isArray(tool?.hunks) ? tool.hunks : []) { + const file = text(hunk?.file) || fallback; + if (file && !files.includes(file)) files.push(file); + } + return files; +} + +// transcript items → [{ key, path, name, kind, ts }],按最后改动时间倒序。 +// 失败的工具调用不算(文件没有真正写入)。 +export function collectAgentBrowserRecentFiles(items, { limit = AGENT_BROWSER_RECENT_FILES_LIMIT } = {}) { + if (!Array.isArray(items)) return []; + const byKey = new Map(); + let order = 0; + let turnStartTs = 0; + for (const item of items) { + if (item?.kind === 'msg' && item.role === 'user') { + turnStartTs = positiveNumber(item.ts) || turnStartTs; + continue; + } + if (item?.kind !== 'tool' || item.tool?.success === false) continue; + const files = changedFilesOfTool(item.tool); + if (files.length === 0) continue; + const ts = toolChangeTimestamp(item, turnStartTs); + for (const path of files) { + const key = pathKey(path); + if (!key) continue; + order += 1; + const previous = byKey.get(key); + if (previous && previous.ts > ts) continue; + byKey.set(key, { + key, + path, + name: recentFileName(path), + kind: recentFileKind(path), + ts: ts || previous?.ts || 0, + order, + }); + } + } + return [...byKey.values()] + .sort((a, b) => (b.ts - a.ts) || (b.order - a.order)) + .slice(0, Math.max(0, limit)) + .map(({ order: _order, ...file }) => file); +} + +export function recentFilesSignature(files) { + return (Array.isArray(files) ? files : []) + .map((file) => `${file.key}\u0000${file.ts}`) + .join('\u0001'); +} + +// 当天只显示时刻,其它日期显示月日(跨年再带年份),与 Codex 一致。 +export function recentFileTimeLabel(ts, now = new Date(), format = formatDateTime) { + const value = Number(ts); + if (!Number.isFinite(value) || value <= 0) return ''; + const date = new Date(value); + if (date.toDateString() === now.toDateString()) { + return format(date, { hour: 'numeric', minute: '2-digit' }); + } + return format(date, { + month: 'short', + day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }); +} diff --git a/web/src/lib/agentBrowserRecentFiles.test.js b/web/src/lib/agentBrowserRecentFiles.test.js new file mode 100644 index 000000000..5bf31929f --- /dev/null +++ b/web/src/lib/agentBrowserRecentFiles.test.js @@ -0,0 +1,158 @@ +// Agent Browser 新标签页「最近」(lib/agentBrowserRecentFiles.js)的纯逻辑测试。 +// +// 场景: +// 1. 从 transcript 的工具项收集改过的文件:同一文件取最后一次改动时间,新的在前; +// 多文件补丁(apply_patch)每个文件各一项;失败的工具调用与没有 hunks 的工具不算。 +// 2. 同一文件的不同写法(反斜杠 / ./ 前缀 / 盘符大小写)合并成一项。 +// 3. 类型标签按扩展名(文档 / 图片 / PDF / 演示文稿 / 电子表格 / 网页 / 文件)。 +// 4. 时间标签:当天只显示时刻,其它日期显示月日,跨年带年份。 +// 5. 签名只随内容变化,供 ChatView 稳定引用。 +import assert from 'node:assert/strict'; +import { + collectAgentBrowserRecentFiles, + recentFileKind, + recentFileName, + recentFileTimeLabel, + recentFileTypeLabel, + recentFilesSignature, +} from './agentBrowserRecentFiles.js'; + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +function toolItem(ts, hunks, overrides = {}) { + return { + kind: 'tool', + id: `t${ts}`, + ts, + tool: { + success: true, + startedAtMs: ts, + summary: { object: hunks[0]?.file || '' }, + hunks, + ...overrides, + }, + }; +} + +await run('collects changed files newest first, one entry per file', () => { + const items = [ + { kind: 'msg', role: 'user', id: 'u1', ts: 1 }, + toolItem(100, [{ file: 'docs/plan.md' }]), + toolItem(200, [{ file: 'src/app.js' }]), + toolItem(300, [{ file: 'docs/plan.md' }, { file: 'docs/plan.md' }]), + toolItem(400, [{ file: 'a.png' }, { file: 'b.pdf' }]), + toolItem(500, [{ file: 'broken.txt' }], { success: false }), + { kind: 'tool', id: 'bash', ts: 600, tool: { success: true, hunks: [] } }, + ]; + const files = collectAgentBrowserRecentFiles(items); + assert.deepEqual(files.map((f) => [f.path, f.ts]), [ + ['b.pdf', 400], + ['a.png', 400], + ['docs/plan.md', 300], + ['src/app.js', 200], + ], '同一时刻的多文件补丁按出现顺序倒排,失败的写入与无 hunks 的工具不算'); + assert.equal(files[2].name, 'plan.md'); + assert.equal(files[2].kind, 'document'); + assert.deepEqual(collectAgentBrowserRecentFiles(null), []); + assert.equal(collectAgentBrowserRecentFiles(items, { limit: 2 }).length, 2); +}); + +await run('different spellings of the same path merge into one entry', () => { + const files = collectAgentBrowserRecentFiles([ + toolItem(10, [{ file: 'C:\\Work\\Repo\\README.md' }]), + toolItem(20, [{ file: 'c:/work/repo/README.md' }]), + toolItem(30, [{ file: './src/x.js' }]), + toolItem(40, [{ file: 'src/x.js' }]), + ]); + assert.deepEqual(files.map((f) => [f.path, f.ts]), [ + ['src/x.js', 40], + ['c:/work/repo/README.md', 20], + ]); +}); + +await run('history tool items use their turn start time instead of the load-time fallback', () => { + // 回归:持久化的工具消息没有时间戳,历史工具项的 ts 是加载时刻;直接拿来显示, + // 打开三天前的会话「最近」里每个文件都显示成刚刚改的。 + const loadedAt = 9_000_000; + const history = (file) => ({ ...toolItem(loadedAt, [{ file }]), messageId: `m-${file}` }); + const files = collectAgentBrowserRecentFiles([ + { kind: 'msg', role: 'user', id: 'u1', ts: 1000 }, + history('first.md'), + { kind: 'msg', role: 'user', id: 'u2', ts: 2000 }, + history('second.md'), + history('third.md'), + // 实时事件建的工具项(没有 messageId)用事件时间。 + toolItem(2500, [{ file: 'live.md' }]), + ]); + assert.deepEqual(files.map((f) => [f.path, f.ts]), [ + ['live.md', 2500], + ['third.md', 2000], + ['second.md', 2000], + ['first.md', 1000], + ], '同一回合内后改的排前面'); + const orphan = collectAgentBrowserRecentFiles([history('orphan.md')]); + assert.equal(orphan[0].ts, 0, '历史页从回合中间开始、找不到用户消息时不编造时间'); +}); + +await run('hunks without their own file fall back to the tool summary path', () => { + const files = collectAgentBrowserRecentFiles([ + toolItem(10, [{ old_start: 1, lines: [] }], { summary: { object: 'notes/todo.txt' } }), + ]); + assert.deepEqual(files.map((f) => f.path), ['notes/todo.txt']); +}); + +await run('type labels follow the extension like Codex recents', () => { + const cases = { + 'a/b/report.MD': '文档', + 'pic.webp': '图片', + 'paper.pdf': 'PDF', + 'deck.pptx': '演示文稿', + 'sheet.csv': '电子表格', + 'site/index.html': '网页', + 'main.cpp': '文件', + Makefile: '文件', + '.env': '文件', + }; + for (const [path, label] of Object.entries(cases)) { + assert.equal(recentFileTypeLabel(recentFileKind(path)), label, path); + } + assert.equal(recentFileName('C:\\dir\\file.txt'), 'file.txt'); +}); + +await run('time label shows the clock today and month/day otherwise', () => { + const now = new Date(2026, 9, 21, 18, 30); + const calls = []; + const format = (date, options) => { + calls.push(options); + return `${date.getMonth() + 1}/${date.getDate()} ${JSON.stringify(options)}`; + }; + recentFileTimeLabel(new Date(2026, 9, 21, 12, 40).getTime(), now, format); + recentFileTimeLabel(new Date(2026, 9, 20, 9, 0).getTime(), now, format); + recentFileTimeLabel(new Date(2025, 11, 31, 9, 0).getTime(), now, format); + assert.deepEqual(calls, [ + { hour: 'numeric', minute: '2-digit' }, + { month: 'short', day: 'numeric' }, + { month: 'short', day: 'numeric', year: 'numeric' }, + ]); + assert.equal(recentFileTimeLabel(0, now, format), '', '没有时间戳时不显示'); +}); + +await run('signature changes only with content', () => { + const items = [toolItem(1, [{ file: 'a.md' }])]; + const a = collectAgentBrowserRecentFiles(items); + const b = collectAgentBrowserRecentFiles([...items]); + assert.notEqual(a, b); + assert.equal(recentFilesSignature(a), recentFilesSignature(b)); + assert.notEqual( + recentFilesSignature(a), + recentFilesSignature(collectAgentBrowserRecentFiles([...items, toolItem(2, [{ file: 'a.md' }])])), + ); +}); diff --git a/web/src/lib/agentBrowserTopSites.js b/web/src/lib/agentBrowserTopSites.js new file mode 100644 index 000000000..b1b03d3f3 --- /dev/null +++ b/web/src/lib/agentBrowserTopSites.js @@ -0,0 +1,192 @@ +// Agent Browser 新标签页「推荐」:常访问网站。 +// +// 对照 Codex 桌面版(thread-app-shell-chrome 的 Suggested 区):它读 Chromium 内核的 +// TopSites,取前 4 个排成一行,悬停出现 × 可移除并能撤销。ACECode 的 Agent Browser +// 是 WebView2 / WKWebView,没有浏览历史接口,所以由这里在页面加载完成时把访问 +// 上报给 daemon(`POST /api/agent-browser/visits`),daemon 存在数据目录里排序。 +// Desktop 每次启动 daemon 端口会变,localStorage 跨启动保不住,不能存在前端。 +import { + AGENT_BROWSER_STATE_EVENT, + hasNativeAgentBrowser, + listAgentBrowserPages, +} from './agentBrowser.js'; + +export const AGENT_BROWSER_TOP_SITES_LIMIT = 4; +// 与 daemon 的 kMaxFaviconDataUrlBytes 一致:更大的图标不上传,推荐退回地球图标。 +export const AGENT_BROWSER_TOP_SITE_FAVICON_MAX_LENGTH = 64 * 1024; +const MAX_URL_LENGTH = 2048; + +function text(value) { + return value == null ? '' : String(value); +} + +// 只认 http / https,去掉 #fragment;其它返回空串(about:blank、file:、data: 都不记)。 +export function topSiteUrlKey(url) { + let value = text(url).trim(); + const hash = value.indexOf('#'); + if (hash >= 0) value = value.slice(0, hash); + if (!value || value.length > MAX_URL_LENGTH || /[\s\u0000-\u001f\u007f]/.test(value)) return ''; + const match = /^(https?):\/\/([^/?#]*)/i.exec(value); + if (!match) return ''; + const authority = match[2].slice(match[2].lastIndexOf('@') + 1); + if (!authority) return ''; + return match[1].toLowerCase() + value.slice(match[1].length); +} + +export function topSiteTitle(title) { + const value = text(title).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); + // Desktop 在页面没有标题时填的默认标题(kAgentBrowserDefaultTitle),不是网站标题。 + // 必须是比较里的字面量:i18n 编译插件会把模块级常量里的中文换成译文。 + return value === '新标签页' ? '' : value; +} + +export function topSiteFavicon(favicon) { + const value = text(favicon); + if (!value || value.length > AGENT_BROWSER_TOP_SITE_FAVICON_MAX_LENGTH) return ''; + return /^(https?:\/\/|data:image\/)/i.test(value) ? value : ''; +} + +// 推荐没有标题时显示主机名(去掉用户信息,保留端口)。 +export function topSiteHost(url) { + const match = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(text(url)); + if (!match) return text(url); + return match[1].slice(match[1].lastIndexOf('@') + 1); +} + +export function normalizeTopSitesResponse(response) { + const sites = Array.isArray(response?.sites) ? response.sites : []; + const seen = new Set(); + const out = []; + for (const site of sites) { + const url = topSiteUrlKey(site?.url); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push({ + url, + title: topSiteTitle(site?.title) || topSiteHost(url), + favicon: topSiteFavicon(site?.favicon), + }); + } + return out; +} + +// 新标签页每次挂载都会重新读推荐;先用上一次的结果渲染,避免切页签时整块闪一下。 +let cachedTopSites = null; + +export function cachedAgentBrowserTopSites() { + return cachedTopSites; +} + +export function rememberAgentBrowserTopSites(sites) { + cachedTopSites = Array.isArray(sites) ? sites : null; +} + +export function withoutTopSite(sites, url) { + const key = topSiteUrlKey(url); + const list = Array.isArray(sites) ? sites : []; + const next = list.filter((site) => site?.url !== key); + return next.length === list.length ? list : next; +} + +// 把 native 页面状态事件翻译成「要不要上报、上报什么」。规则: +// - 只有加载完成(content_state=live)的 http(s) 页面才算访问; +// - 地址变了才算一次新访问(new_visit=true),刷新同一地址不计数; +// - 同一次访问后到的标题 / 图标只做补充(new_visit=false); +// - 前端加载前就已经打开的页面(第一次见到就已是 live,且不在启动时还没 +// 对账完的空窗期之后新出现)只记下,不计数 —— 否则每次刷新 / 切工作区都会 +// 把所有已打开页面多算一次。 +export function createAgentBrowserVisitTracker() { + const pages = new Map(); + let seeded = false; + + const remember = (pageId, url, title, favicon) => { + pages.set(pageId, { url, title, favicon }); + }; + + return { + // 启动时用 Desktop 的完整页面列表打底;之后第一次见到的 live 页面都是新页面。 + seed(list = []) { + for (const page of Array.isArray(list) ? list : []) { + const pageId = text(page?.page_id || page?.pageId).trim(); + if (!pageId || pages.has(pageId)) continue; + const live = text(page?.content_state) === 'live'; + remember( + pageId, + live ? topSiteUrlKey(page?.url) : '', + live ? topSiteTitle(page?.title) : '', + live ? topSiteFavicon(page?.favicon) : '', + ); + } + seeded = true; + }, + observe(detail) { + if (!detail || typeof detail !== 'object') return null; + const pageId = text(detail.page_id || detail.pageId).trim(); + if (!pageId) return null; + if (detail.closed === true) { + pages.delete(pageId); + return null; + } + const previous = pages.get(pageId); + if (text(detail.content_state) !== 'live') { + // 导航中 / 失败 / 空白:记一个「不在任何地址上」的占位,下一次 live 必然计数。 + if (!previous) remember(pageId, '', '', ''); + return null; + } + const url = topSiteUrlKey(detail.url); + const title = topSiteTitle(detail.title); + const favicon = topSiteFavicon(detail.favicon); + if (!previous) { + remember(pageId, url, title, favicon); + if (!seeded || !url) return null; + return { url, title, favicon, new_visit: true }; + } + if (previous.url !== url) { + remember(pageId, url, title, favicon); + return url ? { url, title, favicon, new_visit: true } : null; + } + if (!url) return null; + const nextTitle = title || previous.title; + const nextFavicon = favicon || previous.favicon; + if (nextTitle === previous.title && nextFavicon === previous.favicon) return null; + remember(pageId, url, nextTitle, nextFavicon); + return { + url, + title: nextTitle !== previous.title ? nextTitle : '', + favicon: nextFavicon !== previous.favicon ? nextFavicon : '', + new_visit: false, + }; + }, + }; +} + +let installedRecorder = null; + +// App 挂载时装一次:所有 Agent Browser 页面的访问都上报,不依赖用户正在看哪个会话。 +// record 失败(daemon 未就绪、数据目录迁移中)静默丢弃 —— 推荐只是便利功能。 +export function installAgentBrowserVisitRecorder( + record, + win = globalThis.window, + { listPages = listAgentBrowserPages } = {}, +) { + if (!win?.addEventListener || installedRecorder || !hasNativeAgentBrowser(win)) return () => {}; + const tracker = createAgentBrowserVisitTracker(); + const handler = (event) => { + const visit = tracker.observe(event?.detail); + if (!visit) return; + Promise.resolve() + .then(() => record(visit)) + .catch(() => {}); + }; + win.addEventListener(AGENT_BROWSER_STATE_EVENT, handler); + installedRecorder = { win, handler }; + Promise.resolve() + .then(() => listPages('', win)) + .then((result) => tracker.seed(result?.ok === true ? result.pages : [])) + .catch(() => tracker.seed([])); + return () => { + if (installedRecorder?.handler !== handler) return; + win.removeEventListener(AGENT_BROWSER_STATE_EVENT, handler); + installedRecorder = null; + }; +} diff --git a/web/src/lib/agentBrowserTopSites.test.js b/web/src/lib/agentBrowserTopSites.test.js new file mode 100644 index 000000000..a0b3a2526 --- /dev/null +++ b/web/src/lib/agentBrowserTopSites.test.js @@ -0,0 +1,197 @@ +// Agent Browser 新标签页「推荐」(lib/agentBrowserTopSites.js)的纯逻辑测试。 +// +// 场景: +// 1. 地址归一:只认 http(s),去掉 #锚点;标题去掉 Desktop 的默认「新标签页」; +// 超大 / 非法图标不上报。 +// 2. 访问跟踪器:地址变化才算新访问,标题 / 图标后到只做补充,刷新同一地址不计数。 +// 3. 前端加载前就已打开的页面(启动时第一次见到就已 live)不重复计数; +// 对账完成后新出现的页面照常计数。 +// 4. 推荐接口响应归一、移除单项。 +// 5. App 级记录器:只装一次、用 Desktop 页面列表打底、上报失败不抛出。 +import assert from 'node:assert/strict'; +import { + AGENT_BROWSER_TOP_SITE_FAVICON_MAX_LENGTH, + createAgentBrowserVisitTracker, + installAgentBrowserVisitRecorder, + normalizeTopSitesResponse, + topSiteFavicon, + topSiteHost, + topSiteTitle, + topSiteUrlKey, + withoutTopSite, +} from './agentBrowserTopSites.js'; +import { AGENT_BROWSER_STATE_EVENT } from './agentBrowser.js'; + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +function state(pageId, overrides = {}) { + return { + page_id: pageId, + url: 'about:blank', + title: '新标签页', + favicon: '', + content_state: 'empty', + ...overrides, + }; +} + +function live(pageId, url, overrides = {}) { + return state(pageId, { url, title: '', content_state: 'live', ...overrides }); +} + +await run('top site url key keeps http(s) only and drops the fragment', () => { + assert.equal(topSiteUrlKey('HTTPS://Example.com/a?b=1#top'), 'https://Example.com/a?b=1'); + assert.equal(topSiteUrlKey('http://127.0.0.1:5173/'), 'http://127.0.0.1:5173/'); + for (const bad of ['about:blank', 'file:///C:/a.html', 'data:text/html,hi', 'https://', 'https://a b/', '']) { + assert.equal(topSiteUrlKey(bad), '', `${bad} 不能成为推荐`); + } + assert.equal(topSiteHost('https://user@host.example:8443/p'), 'host.example:8443'); +}); + +await run('titles drop the desktop default and control characters; favicons are bounded', () => { + assert.equal(topSiteTitle('新标签页'), '', 'Desktop 在页面没有标题时填的默认标题不是网站标题'); + assert.equal(topSiteTitle(' 第一行\n第二行 '), '第一行 第二行'); + assert.equal(topSiteFavicon('javascript:alert(1)'), ''); + assert.equal(topSiteFavicon('https://cdn.example/f.ico'), 'https://cdn.example/f.ico'); + assert.equal(topSiteFavicon(`data:image/png;base64,${'A'.repeat(AGENT_BROWSER_TOP_SITE_FAVICON_MAX_LENGTH)}`), ''); +}); + +await run('tracker counts a navigation once and sends later title/favicon as metadata', () => { + const tracker = createAgentBrowserVisitTracker(); + tracker.seed([]); + assert.equal(tracker.observe(state('p1')), null, '空白页不是访问'); + assert.equal(tracker.observe(state('p1', { url: 'https://a.example/', content_state: 'loading' })), null); + assert.deepEqual(tracker.observe(live('p1', 'https://a.example/')), { + url: 'https://a.example/', title: '', favicon: '', new_visit: true, + }); + assert.deepEqual(tracker.observe(live('p1', 'https://a.example/', { title: 'A' })), { + url: 'https://a.example/', title: 'A', favicon: '', new_visit: false, + }); + assert.deepEqual( + tracker.observe(live('p1', 'https://a.example/', { title: 'A', favicon: 'https://a.example/f.ico' })), + { url: 'https://a.example/', title: '', favicon: 'https://a.example/f.ico', new_visit: false }, + '只带变化的字段,未变的标题不重发', + ); + assert.equal(tracker.observe(live('p1', 'https://a.example/', { title: 'A' })), null, + '标题没变、图标暂时为空(刷新中)不上报'); +}); + +await run('reloading the same address does not count; a new address or SPA route does', () => { + const tracker = createAgentBrowserVisitTracker(); + tracker.seed([]); + tracker.observe(state('p1', { content_state: 'loading', url: 'https://a.example/' })); + assert.equal(tracker.observe(live('p1', 'https://a.example/'))?.new_visit, true); + tracker.observe(state('p1', { content_state: 'loading', url: 'https://a.example/' })); + assert.equal(tracker.observe(live('p1', 'https://a.example/')), null, '刷新不计数'); + assert.equal(tracker.observe(live('p1', 'https://a.example/#section')), null, '只改锚点不计数'); + assert.equal(tracker.observe(live('p1', 'https://a.example/docs'))?.new_visit, true, + 'pushState 换路由没有 loading 也要计数'); +}); + +await run('pages already open before the frontend loaded are seeded, not counted', () => { + const tracker = createAgentBrowserVisitTracker(); + // 对账返回前:第一次见到就已 live 的页面视为早已打开。 + assert.equal(tracker.observe(live('early', 'https://early.example/', { title: 'Early' })), null); + tracker.seed([ + { page_id: 'listed', url: 'https://listed.example/', content_state: 'live', title: 'Listed' }, + { page_id: 'early', url: 'https://other.example/', content_state: 'live' }, + ]); + assert.equal(tracker.observe(live('listed', 'https://listed.example/', { title: 'Listed' })), null, + '刷新 / 切工作区后已打开页面的状态事件不能再计一次'); + assert.equal(tracker.observe(live('early', 'https://early.example/', { title: 'Early' })), null, + '打底不覆盖已经见过的页面'); + // 对账之后才出现的页面,即使第一次见到就已 live 也是新打开的。 + assert.equal(tracker.observe(live('fresh', 'https://fresh.example/'))?.new_visit, true); + // 关闭后同一 page id 重新出现按新页面处理。 + tracker.observe(state('listed', { closed: true })); + assert.equal(tracker.observe(live('listed', 'https://listed.example/'))?.new_visit, true); +}); + +await run('failed navigations are not visits but the retry is', () => { + const tracker = createAgentBrowserVisitTracker(); + tracker.seed([]); + assert.equal(tracker.observe(state('p1', { + url: 'https://down.example/', content_state: 'navigation_error', + })), null); + assert.equal(tracker.observe(live('p1', 'https://down.example/'))?.new_visit, true); +}); + +await run('top sites response is normalized and dismissing removes one entry', () => { + const sites = normalizeTopSitesResponse({ + sites: [ + { url: 'https://a.example/', title: 'A', favicon: 'data:image/png;base64,AA' }, + { url: 'https://a.example/#dup', title: 'dup' }, + { url: 'http://127.0.0.1:5173/', title: '' }, + { url: 'javascript:alert(1)', title: 'bad' }, + ], + }); + assert.deepEqual(sites, [ + { url: 'https://a.example/', title: 'A', favicon: 'data:image/png;base64,AA' }, + { url: 'http://127.0.0.1:5173/', title: '127.0.0.1:5173', favicon: '' }, + ]); + assert.deepEqual(normalizeTopSitesResponse(null), []); + assert.deepEqual(withoutTopSite(sites, 'https://a.example/#x').map((s) => s.url), ['http://127.0.0.1:5173/']); + assert.equal(withoutTopSite(sites, 'https://none.example/'), sites, '没有可移除的项时保持引用'); +}); + +function fakeDesktopWindow() { + const listeners = new Map(); + return { + listeners, + __ACECODE_DESKTOP_SHELL__: true, + __ACECODE_OS__: 'windows', + aceDesktop_agentBrowserGetState: () => '{}', + aceDesktop_agentBrowserSetLayout: () => '{}', + aceDesktop_agentBrowserCreatePage: () => '{}', + addEventListener(type, fn) { listeners.set(type, fn); }, + removeEventListener(type, fn) { if (listeners.get(type) === fn) listeners.delete(type); }, + }; +} + +await run('app recorder installs once, seeds from the page list and swallows report failures', async () => { + assert.equal(typeof installAgentBrowserVisitRecorder(() => {}, {}), 'function', + '非 Desktop 环境返回空 dispose'); + + const win = fakeDesktopWindow(); + const reported = []; + const dispose = installAgentBrowserVisitRecorder( + async (visit) => { + reported.push(visit); + if (visit.url.includes('fail')) throw new Error('daemon down'); + }, + win, + { + listPages: async () => ({ + ok: true, + pages: [{ page_id: 'old', url: 'https://old.example/', content_state: 'live' }], + }), + }, + ); + const second = installAgentBrowserVisitRecorder(() => {}, win); + assert.equal(win.listeners.size, 1, '重复安装不挂第二个监听器'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const emit = (detail) => win.listeners.get(AGENT_BROWSER_STATE_EVENT)({ detail }); + emit(live('old', 'https://old.example/', { title: 'Old' })); + emit(live('new', 'https://fail.example/')); + emit(live('new2', 'https://ok.example/')); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(reported.map((v) => [v.url, v.new_visit]), [ + ['https://old.example/', false], + ['https://fail.example/', true], + ['https://ok.example/', true], + ], '已打开的页面只补标题不计数;上报失败不影响后续上报'); + + second(); + assert.equal(win.listeners.size, 1); + dispose(); + assert.equal(win.listeners.size, 0); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 8c450822b..6cde05087 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -459,6 +459,11 @@ export function createApi(base = null) { request('GET', '/api/pinned-sessions/order', undefined, base), setPinnedSessionOrder: (items=[]) => request('PUT', '/api/pinned-sessions/order', { items }, base), + // 侧栏多选批量操作:action = pin | unpin | archive | unarchive | read | unread, + // items = [{ session_id, workspace_hash, no_workspace, cursor? }]。一次请求完成, + // 逐项结果在 results 里;置顶类动作还带回 pinned_scopes / pinned_order。 + batchSessions: (action, items=[]) => + request('POST', '/api/session-batch', { action, items }, base), destroySession: (id) => request('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, base), getSessionDraft: (id, workspaceHash = '') => request('GET', sessionDraftPath(id, workspaceHash), undefined, base), @@ -749,6 +754,12 @@ export function createApi(base = null) { { config, confirm_cost: true }, base, { timeoutMs: 615000 }), getToolRewrites: () => request('GET', '/api/config/tool-rewrites', undefined, base), setToolRewrites: (settings) => request('PUT', '/api/config/tool-rewrites', settings, base, { keepalive: true }), + // Agent Browser 新标签页「推荐」(常访问网站):访问由 App 级记录器上报。 + getAgentBrowserTopSites: (limit = 4) => request('GET', + `/api/agent-browser/top-sites?limit=${encodeURIComponent(limit)}`, undefined, base), + recordAgentBrowserVisit: (visit) => request('POST', '/api/agent-browser/visits', visit, base), + dismissAgentBrowserTopSite: (url) => request('POST', '/api/agent-browser/top-sites/dismiss', { url }, base), + restoreAgentBrowserTopSite: (url) => request('POST', '/api/agent-browser/top-sites/restore', { url }, base), // 安全中心(Settings > 编码 > 安全中心,openspec add-security-center)。 getSandboxSettings: () => request('GET', '/api/config/sandbox', undefined, base), setSandboxSettings: (settings) => request('PUT', '/api/config/sandbox', settings, base), diff --git a/web/src/lib/appShortcuts.js b/web/src/lib/appShortcuts.js index 36dd700fa..1ff5c693c 100644 --- a/web/src/lib/appShortcuts.js +++ b/web/src/lib/appShortcuts.js @@ -52,6 +52,7 @@ export function shortcutCatalog(mac = isMacShortcutHost()) { ['toggleSidebar', '界面导航', '展开/收起左侧项目栏', '切换项目和会话列表的显示'], ['toggleRightPanel', '界面导航', '展开/收起右侧面板', '切换文件及预览面板的显示'], ['previewPresentation', '界面导航', '全屏演示', '展开详情并隐藏两侧列表,再按一次还原'], + ['revealPreviewFile', '界面导航', '在文件列表展示', '定位当前工作区内的活动文件并高亮'], ['console', '界面导航', '展开/收起控制台', '控制台可用时生效'], ['settings', '界面导航', '打开设置', '打开设置窗口'], ['shortcuts', '界面导航', '打开快捷键页签', '进入快捷键并聚焦搜索栏'], diff --git a/web/src/lib/appShortcuts.test.js b/web/src/lib/appShortcuts.test.js index 7902f60b5..9d0a4b431 100644 --- a/web/src/lib/appShortcuts.test.js +++ b/web/src/lib/appShortcuts.test.js @@ -3,6 +3,10 @@ import { APP_SHORTCUTS, appShortcutContextAllows, filterShortcuts, matchAppShort import { nextReasoningEffort } from './modelReasoning.js'; const key = (code, extra = {}) => ({ code, ctrlKey: true, ...extra }); +assert.equal(matchAppShortcut(key('BracketLeft')), 'revealPreviewFile'); +assert.equal(matchAppShortcut(key('BracketLeft', { shiftKey: true })), null); +assert.equal(shortcutLabel('revealPreviewFile', false), 'Ctrl+['); +assert.equal(shortcutLabel('revealPreviewFile', true), 'Ctrl+['); assert.equal(matchAppShortcut(key('Minus')), 'forward'); assert.equal(matchAppShortcut(key('Minus', { key: '_', shiftKey: true })), 'back'); assert.equal(matchAppShortcut(key('Minus', { altKey: true })), null); @@ -38,6 +42,8 @@ assert.deepEqual(filterShortcuts(shortcutCatalog(false), '思维 深度').map((x assert.equal(filterShortcuts(shortcutCatalog(false), '不存在的功能').length, 0); const overlay = (selector, show = true) => ({ matches: (s) => s.includes(selector), getClientRects: () => show ? [{}] : [] }); const doc = (...overlays) => ({ querySelectorAll: () => overlays }); +assert.equal(appShortcutContextAllows('revealPreviewFile', null, doc(overlay('dialog'))), false); +assert.equal(appShortcutContextAllows('revealPreviewFile', { closest: () => ({}) }, doc()), true); assert.equal(appShortcutContextAllows('newSession', null, doc(overlay('dialog'))), false); assert.equal(appShortcutContextAllows('newSession', null, doc(overlay('dialog', false))), true); assert.equal(appShortcutContextAllows('shortcuts', null, doc(overlay('[data-settings-mask]'))), true); diff --git a/web/src/lib/connection.js b/web/src/lib/connection.js index e4eb94b9c..481e4f261 100644 --- a/web/src/lib/connection.js +++ b/web/src/lib/connection.js @@ -451,15 +451,15 @@ export class AceConnection extends EventTarget { sendQuestionAnswer(payload) { this._send({ type: 'question_answer', payload }); } sendAbort(sessionId=this.sessionId) { return this._send({ type: 'abort', payload: { session_id: sessionId } }); } markSessionRead({ sessionId=this.sessionId, workspaceHash='', cursor=0 } = {}) { - if (!sessionId) return; - this._send({ + if (!sessionId) return false; + return this._send({ type: 'mark_session_read', payload: { session_id: sessionId, workspace_hash: workspaceHash || '', cursor: cursor || 0 }, }); } markSessionUnread({ sessionId=this.sessionId, workspaceHash='' } = {}) { - if (!sessionId) return; - this._send({ + if (!sessionId) return false; + return this._send({ type: 'mark_session_unread', payload: { session_id: sessionId, workspace_hash: workspaceHash || '' }, }); diff --git a/web/src/lib/desktopContextMenu.js b/web/src/lib/desktopContextMenu.js index 94a62b460..4edadadf3 100644 --- a/web/src/lib/desktopContextMenu.js +++ b/web/src/lib/desktopContextMenu.js @@ -7,6 +7,7 @@ import { export const DESKTOP_CONTEXT_ACTIONS = Object.freeze({ OPEN_IN_EXPLORER: 'open_in_explorer', LOCATE_FILE: 'locate_file', + SHOW_IN_FILE_LIST: 'show_in_file_list', PIN_SESSION: 'pin_session', UNPIN_SESSION: 'unpin_session', MARK_SESSION_READ: 'mark_session_read', @@ -291,6 +292,9 @@ export function buildDesktopContextMenuItems({ if (previewPresentationTarget) { addAction(items, DESKTOP_CONTEXT_ACTIONS.TOGGLE_PREVIEW_PRESENTATION, previewPresentationTarget, { group: GROUPS.SELECTION }); } + if (previewTarget) { + addAction(items, DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, previewTarget, { group: GROUPS.CONTENT }); + } return finalizeContextMenuItems(items, allowNativeActions); } @@ -303,6 +307,7 @@ export function buildDesktopContextMenuItems({ addAction(items, DESKTOP_CONTEXT_ACTIONS.TOGGLE_PREVIEW_PRESENTATION, previewPresentationTarget, { group: GROUPS.SELECTION }); } if (previewTabTarget.tabType === 'file') { + addAction(items, DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, previewTabTarget, { group: GROUPS.FILE }); addAction(items, DESKTOP_CONTEXT_ACTIONS.COPY_ABSOLUTE_PATH, previewTabTarget, { group: GROUPS.FILE, enabled: !!previewTabTarget.absolutePath }); addAction(items, DESKTOP_CONTEXT_ACTIONS.COPY_RELATIVE_PATH, previewTabTarget, { group: GROUPS.FILE, enabled: !!previewTabTarget.relativePath }); addAction(items, DESKTOP_CONTEXT_ACTIONS.OPEN_IN_EXPLORER, { path: previewTabTarget.absolutePath, kind: 'file' }, { group: GROUPS.CONTENT, enabled: !!previewTabTarget.absolutePath }); @@ -318,6 +323,11 @@ export function buildDesktopContextMenuItems({ addAction(items, DESKTOP_CONTEXT_ACTIONS.TOGGLE_PREVIEW_PRESENTATION, previewPresentationTarget, { group: GROUPS.SELECTION }); } + // File navigation remains available while editing the displayed file. + if (previewTarget) { + addAction(items, DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, previewTarget, { group: GROUPS.CONTENT }); + } + if (!editable) { if (sessionTarget) { addAction(items, sessionTarget.pinned ? DESKTOP_CONTEXT_ACTIONS.UNPIN_SESSION : DESKTOP_CONTEXT_ACTIONS.PIN_SESSION, sessionTarget); @@ -585,6 +595,8 @@ export function previewTargetFromElement(target) { if (!path) return null; return { type: 'preview', + element: el, + cwd: getAttr(el, 'data-desktop-preview-cwd', 'desktopPreviewCwd'), path, kind: getAttr(el, 'data-desktop-preview-kind', 'desktopPreviewKind') || 'text', size: getAttr(el, 'data-desktop-preview-size', 'desktopPreviewSize'), @@ -600,6 +612,7 @@ export function previewTabTargetFromElement(target) { if (!key) return null; return { type: 'preview-tab', + element: el, key, tabType: getAttr(el, 'data-desktop-preview-tab-type', 'desktopPreviewTabType') || 'file', relativePath: getAttr(el, 'data-desktop-preview-tab-path', 'desktopPreviewTabPath'), diff --git a/web/src/lib/desktopContextMenu.test.js b/web/src/lib/desktopContextMenu.test.js index 9d2bfefde..1ab8eaf70 100644 --- a/web/src/lib/desktopContextMenu.test.js +++ b/web/src/lib/desktopContextMenu.test.js @@ -149,8 +149,9 @@ test('预览选区把引用到聊天放在第一行', () => { hasSelection: true, previewTarget: { type: 'preview', path: 'README.md', kind: 'markdown' }, }); - assert.deepEqual(ids(items).slice(0, 4), [ + assert.deepEqual(ids(items).slice(0, 5), [ DESKTOP_CONTEXT_ACTIONS.ADD_SELECTION_CONTEXT, + DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, DESKTOP_CONTEXT_ACTIONS.REFRESH_DETAILS, DESKTOP_CONTEXT_ACTIONS.COPY_PREVIEW_TEXT, DESKTOP_CONTEXT_ACTIONS.COPY_PREVIEW_METADATA, @@ -166,6 +167,7 @@ test('可编辑预览仍保留引用到聊天并同时提供文本编辑动作', }); assert.deepEqual(ids(items), [ DESKTOP_CONTEXT_ACTIONS.ADD_SELECTION_CONTEXT, + DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, DESKTOP_CONTEXT_ACTIONS.SELECT_ALL, DESKTOP_CONTEXT_ACTIONS.COPY, DESKTOP_CONTEXT_ACTIONS.PASTE, @@ -184,6 +186,7 @@ test('图片预览显示复制图片动作且非图片预览不显示', () => { }, }); assert.deepEqual(ids(imageItems), [ + DESKTOP_CONTEXT_ACTIONS.SHOW_IN_FILE_LIST, DESKTOP_CONTEXT_ACTIONS.REFRESH_DETAILS, DESKTOP_CONTEXT_ACTIONS.COPY_PREVIEW_IMAGE, DESKTOP_CONTEXT_ACTIONS.COPY_PREVIEW_TEXT, @@ -809,6 +812,7 @@ test('contextTargetsFromElement 提取各类目标', () => { assert.equal(contextTargetsFromElement(file).fileTarget.canAddContext, true); const preview = elementFor(PREVIEW_TARGET_SELECTOR, { + 'data-desktop-preview-cwd': 'C:/repo', 'data-desktop-preview-path': 'shots/result.png', 'data-desktop-preview-kind': 'image', 'data-desktop-preview-content-type': 'image/png', @@ -816,6 +820,8 @@ test('contextTargetsFromElement 提取各类目标', () => { }); assert.deepEqual(contextTargetsFromElement(preview).previewTarget, { type: 'preview', + element: preview, + cwd: 'C:/repo', path: 'shots/result.png', kind: 'image', size: '', diff --git a/web/src/lib/fileTreeLocate.js b/web/src/lib/fileTreeLocate.js index 07c9814e0..16c0b6e0b 100644 --- a/web/src/lib/fileTreeLocate.js +++ b/web/src/lib/fileTreeLocate.js @@ -1,4 +1,12 @@ import { normalizeTreePath, normalizeWorkspaceRelativePath } from './fileTreeChangeStatus.js'; +import { fileBreadcrumbs } from './fileBreadcrumbs.js'; + +// Preview cwd may be an external file's parent; only the actual workspace +// decides whether revealing it may change the file list. +export function previewFileLocatePlan({ workspaceCwd = '', cwd = workspaceCwd, path = '' } = {}) { + const location = fileBreadcrumbs({ workspaceCwd, cwd, path }); + return location ? fileTreeLocatePlan(location.relativePath) : null; +} export function pathAncestors(path) { const parts = normalizeTreePath(path).split('/').filter(Boolean); diff --git a/web/src/lib/fileTreeLocate.test.js b/web/src/lib/fileTreeLocate.test.js index b2571a4bb..b388ace9e 100644 --- a/web/src/lib/fileTreeLocate.test.js +++ b/web/src/lib/fileTreeLocate.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { fileTreeLocatePlan, pathAncestors } from './fileTreeLocate.js'; +import { fileTreeLocatePlan, pathAncestors, previewFileLocatePlan } from './fileTreeLocate.js'; function run(name, fn) { try { @@ -46,3 +46,29 @@ run('fileTreeLocatePlan returns null for empty paths', () => { assert.equal(fileTreeLocatePlan(''), null); assert.equal(fileTreeLocatePlan('C:/repo', 'C:/repo', { includeSelf: true }), null); }); + +run('preview file locate uses the actual workspace and expands only ancestors', () => { + assert.deepEqual(previewFileLocatePlan({ workspaceCwd: 'C:/worktree', cwd: 'c:/WORKTREE/src', path: './lib/../app.js' }), { + selectedPath: 'src/app.js', expandedDirs: ['src'], + }); + assert.deepEqual(previewFileLocatePlan({ workspaceCwd: 'C:/repo', path: 'README.md' }), { + selectedPath: 'README.md', expandedDirs: [], + }); + assert.deepEqual(previewFileLocatePlan({ workspaceCwd: '//server/share/repo', path: '\\\\?\\UNC\\SERVER\\share\\repo\\src\\a.js' }), { + selectedPath: 'src/a.js', expandedDirs: ['src'], + }); +}); + +run('preview file locate rejects outside, sibling-prefix and missing workspaces', () => { + for (const input of [ + { workspaceCwd: 'C:/repo', path: 'C:/repo-other/a.js' }, + { workspaceCwd: 'C:/repo', path: 'src/../../a.js' }, + { workspaceCwd: 'C:/repo', cwd: 'D:/outside', path: 'a.js' }, + { workspaceCwd: 'C:/worktree', cwd: 'C:/repo', path: 'a.js' }, + { workspaceCwd: '', cwd: 'C:/repo', path: 'a.js' }, + { workspaceCwd: '/repo', path: '/REPO/a.js' }, + { workspaceCwd: '//server/share/repo', path: '//server/other/repo/a.js' }, + { workspaceCwd: 'C:/repo', path: '' }, + { workspaceCwd: 'C:/repo', path: 'C:/repo' }, + ]) assert.equal(previewFileLocatePlan(input), null, JSON.stringify(input)); +}); diff --git a/web/src/lib/questionFeedback.test.js b/web/src/lib/questionFeedback.test.js index 8e26b7b9f..5ea4aecaa 100644 --- a/web/src/lib/questionFeedback.test.js +++ b/web/src/lib/questionFeedback.test.js @@ -16,6 +16,7 @@ import { createdFileSource } from './createdFileSource.js'; import { normalizeAttachmentList } from './messageAttachments.js'; import { fallbackToolSummary } from './toolSummaryFallback.js'; import { shellCommandPresentation } from './shellCommandPresentation.js'; +import { canonicalTranscriptTool, presentTool } from './toolPresentation.js'; import { questionFeedbackForItem, questionFeedbackForTool, @@ -64,7 +65,8 @@ const { ToolBlock } = vm.runInNewContext(`${toolTransformed.code}; ({ ToolBlock React, ...React, ...format, compactOneLinePreview, createdFileSource, normalizeAttachmentList, fallbackToolSummary, questionFeedbackForTool, QuestionFeedbackCard, - shellCommandPresentation, + shellCommandPresentation, canonicalTranscriptTool, presentTool, + ToolDetailMetadata: () => null, useTranslation: () => ({ t: (text) => text }), renderMarkdown: () => '', VsIcon: () => null, diff --git a/web/src/lib/responsiveSidebars.js b/web/src/lib/responsiveSidebars.js index a17a96199..9d3c22738 100644 --- a/web/src/lib/responsiveSidebars.js +++ b/web/src/lib/responsiveSidebars.js @@ -34,33 +34,28 @@ export function solveResponsiveSidebars({ const active = enabled && shellWidth > 0 && !(preferences.sidePanelMaximized && previewAvailable && rightWanted); const leftWidth = widths.sidebar || DEFAULT_SINGLE_LAYOUT.sidebar; - // A manually closed workspace opens with its navigation visible. Predict - // that space too, otherwise expand could squeeze the transcript immediately. - const listCollapsed = rightWanted && !!preferences.sidePanelListCollapsed; - const fitsRight = (available, required) => solveSingleContentLayout({ + // Only the left sidebar responds to transcript pressure. Account for the + // right workspace's manual visibility when predicting the expanded layout. + const fitsWithLeft = (available, required) => solveSingleContentLayout({ contentWidth: available, sidePanelWidth: widths.sidePanel, previewPanelWidth: widths.previewPanel, - sidePanelCollapsed: listCollapsed, - previewPanelVisible: previewAvailable, + sidePanelVisible: rightWanted, + sidePanelCollapsed: !!preferences.sidePanelListCollapsed, + previewPanelVisible: rightWanted && previewAvailable, previewPanelAutoFit: previewAutoFit, minimumChatWidth: required, }).chatWidth >= required; - const leftAutoCollapsed = active && shellWidth - leftWidth - < minimumChatWidth + (previous.leftAutoCollapsed ? SIDEBAR_RESTORE_MARGIN : 0); + const leftAutoCollapsed = active && !fitsWithLeft( + shellWidth - leftWidth, + minimumChatWidth + (previous.leftAutoCollapsed ? SIDEBAR_RESTORE_MARGIN : 0), + ); const leftDocked = leftWanted && !leftAutoCollapsed; - const rightAutoCollapsed = active && ((leftWanted && leftAutoCollapsed) || !fitsRight( - shellWidth - (leftDocked ? leftWidth : 0), - minimumChatWidth + (previous.rightAutoCollapsed ? SIDEBAR_RESTORE_MARGIN : 0), - )); - const rightDocked = rightWanted && !rightAutoCollapsed; - const canDockLeft = !active || (!leftAutoCollapsed && (!rightDocked || leftWanted - || fitsRight(shellWidth - leftWidth, minimumChatWidth))); return { active, transcriptMinimum, minimumChatWidth, - leftAutoCollapsed, rightAutoCollapsed, leftDocked, rightDocked, - canDockLeft, canDockRight: !rightAutoCollapsed, + leftAutoCollapsed, leftDocked, canDockLeft: !leftAutoCollapsed, + rightAutoCollapsed: false, rightDocked: rightWanted, canDockRight: true, }; } diff --git a/web/src/lib/responsiveSidebars.test.js b/web/src/lib/responsiveSidebars.test.js index 4ffd190f7..23ba7d4f0 100644 --- a/web/src/lib/responsiveSidebars.test.js +++ b/web/src/lib/responsiveSidebars.test.js @@ -32,50 +32,48 @@ run('resizing either popup never changes the docked layout decision', () => { } }); -run('transcript pressure collapses right first then left, including a wide custom left bar', () => { +run('transcript pressure only collapses the left sidebar, including a wide custom left bar', () => { for (const sidebar of [160, 270, 360]) { const widths = { ...DEFAULT_SINGLE_LAYOUT, sidebar }; let previous = solve(1920, { widths }); assert.equal(previous.leftDocked, true); assert.equal(previous.rightDocked, true); - let rightCollapsed = false; for (let width = 1920; width >= 320; width -= 1) { previous = solve(width, { widths, previous }); - if (previous.rightAutoCollapsed) rightCollapsed = true; - if (previous.leftAutoCollapsed) { - assert.ok(rightCollapsed); - assert.equal(previous.rightDocked, false); - } + assert.equal(previous.rightAutoCollapsed, false); + assert.equal(previous.rightDocked, true); + assert.equal(previous.canDockRight, true); const transcript = width - (previous.leftDocked ? sidebar : 0) - - (previous.rightDocked ? widths.sidePanel : 0) - 6; - assert.ok(transcript >= Math.min(350, width - 6)); + - widths.sidePanel - 6; + assert.ok(transcript >= Math.min(350, width - widths.sidePanel - 6)); } } }); -run('every font size keeps docked sidebars at 350px of transcript and collapses below it', () => { +run('left collapse uses 350px of transcript with either manual right-panel state for every font size', () => { for (const fontSize of ['small', 'medium', 'large']) { - const rightThreshold = 270 + 280 + 350 + 6; - assert.equal(solve(rightThreshold, { fontSize }).rightDocked, true); - assert.equal(solve(rightThreshold - 1, { fontSize }).rightDocked, false); - const leftThreshold = 270 + 350 + 6; - assert.equal(solve(leftThreshold, { fontSize }).leftDocked, true); - assert.equal(solve(leftThreshold - 1, { fontSize }).leftDocked, false); + for (const sidePanelCollapsed of [false, true]) { + const options = { fontSize, preferences: { ...defaults.preferences, sidePanelCollapsed } }; + const threshold = 270 + (sidePanelCollapsed ? 0 : 280) + 350 + 6; + assert.equal(solve(threshold, options).leftDocked, true); + assert.equal(solve(threshold - 1, options).leftDocked, false); + assert.equal(solve(threshold - 1, options).rightDocked, !sidePanelCollapsed); + } } }); run('restore uses predicted expanded geometry plus hysteresis, without oscillation', () => { const threshold = 270 + 280 + 356; let state = solve(threshold - 1); - assert.equal(state.rightDocked, false); + assert.equal(state.leftDocked, false); for (let i = 0; i < 30; i++) { state = solve(threshold + (i % 2 ? 1 : -1), { previous: state }); - assert.equal(state.rightDocked, false); + assert.equal(state.leftDocked, false); } state = solve(threshold + SIDEBAR_RESTORE_MARGIN, { previous: state }); - assert.equal(state.rightDocked, true); + assert.equal(state.leftDocked, true); state = solve(threshold, { previous: state }); - assert.equal(state.rightDocked, true); + assert.equal(state.leftDocked, true); }); run('all persisted open/closed combinations survive shrink, grow and a fresh load', () => { @@ -86,7 +84,7 @@ run('all persisted open/closed combinations survive shrink, grow and a fresh loa for (const width of [1100, 800, 500, 320, 500, 800, 1100, 1920]) { previous = solve(width, { ...options, previous }); if (sidebarCollapsed) assert.equal(previous.leftDocked, false); - if (sidePanelCollapsed) assert.equal(previous.rightDocked, false); + assert.equal(previous.rightDocked, !sidePanelCollapsed); } assert.equal(previous.leftDocked, !sidebarCollapsed); assert.equal(previous.rightDocked, !sidePanelCollapsed); @@ -95,9 +93,11 @@ run('all persisted open/closed combinations survive shrink, grow and a fresh loa }); run('previews and subagent transcripts contribute real width, while auto-fit can retain readable chat', () => { - assert.equal(solve(1300).rightDocked, true); - assert.equal(solve(1300, { previewAvailable: true }).rightDocked, false); - assert.equal(solve(1200, { subagentOpen: true }).rightDocked, false); + assert.equal(solve(1300).leftDocked, true); + assert.equal(solve(1300, { previewAvailable: true }).leftDocked, false); + assert.equal(solve(1300, { previewAvailable: true }).rightDocked, true); + assert.equal(solve(1200, { subagentOpen: true }).leftDocked, false); + assert.equal(solve(1200, { subagentOpen: true }).rightDocked, true); const state = solve(1920, { previewAvailable: true, previewAutoFit: true }); assert.equal(state.rightDocked, true); const layout = solveSingleContentLayout({ contentWidth: 1920 - 270, previewPanelVisible: true, @@ -105,15 +105,27 @@ run('previews and subagent transcripts contribute real width, while auto-fit can assert.ok(layout.chatWidth - 6 >= 350); }); -run('a closed panel cannot expand in place unless its predicted width fits', () => { +run('only the left panel requires enough width to expand in place', () => { const preferences = { sidebarCollapsed: true, sidePanelCollapsed: true }; assert.equal(solve(600, { preferences }).canDockLeft, false); - assert.equal(solve(600, { preferences }).canDockRight, false); + assert.equal(solve(600, { preferences }).canDockRight, true); assert.equal(solve(1920, { preferences }).canDockLeft, true); assert.equal(solve(1920, { preferences }).canDockRight, true); assert.equal(solve(900, { preferences: { ...preferences, sidePanelCollapsed: false } }).canDockLeft, false); }); +run('manually hidden right content reserves no width, while visible details still count', () => { + const closed = { ...defaults.preferences, sidePanelCollapsed: true }; + assert.equal(solve(700, { preferences: closed, previewAvailable: true }).leftDocked, true); + const listClosed = { ...defaults.preferences, sidePanelListCollapsed: true }; + assert.equal(solve(700, { preferences: listClosed }).leftDocked, true); + assert.equal(solve(1000, { preferences: listClosed, previewAvailable: true }).leftDocked, false); + assert.equal(solve(1100, { preferences: listClosed, previewAvailable: true }).leftDocked, true); + const stale = solve(480, { previous: { rightAutoCollapsed: true } }); + assert.equal(stale.rightAutoCollapsed, false); + assert.equal(stale.rightDocked, true); +}); + run('explicit maximized preview and disabled layout retain their existing behavior', () => { const maximized = solve(800, { previewAvailable: true, preferences: { ...defaults.preferences, sidePanelMaximized: true } }); @@ -127,7 +139,8 @@ run('the native window minimum allows reaching a transcript-only layout for ever const minimum = Number(header.match(/kMinimumDesktopWindowWidth\s*=\s*(\d+)/)?.[1]); assert.ok(minimum > 0, 'read the production native window minimum'); for (const sidebar of [160, 270, 360]) { - const state = solve(minimum, { widths: { ...DEFAULT_SINGLE_LAYOUT, sidebar } }); + const state = solve(minimum, { widths: { ...DEFAULT_SINGLE_LAYOUT, sidebar }, + preferences: { ...defaults.preferences, sidePanelCollapsed: true } }); assert.equal(state.leftDocked, false, `native minimum ${minimum} must allow the ${sidebar}px left sidebar to collapse`); assert.equal(state.rightDocked, false, 'native resizing must reach a transcript-only layout'); } diff --git a/web/src/lib/richComposerModel.js b/web/src/lib/richComposerModel.js index 2e0a13080..79bbe8f27 100644 --- a/web/src/lib/richComposerModel.js +++ b/web/src/lib/richComposerModel.js @@ -1,6 +1,10 @@ import { resolveLeadingSlashCommand } from './slashCommands.js'; import { normalizeReferencePath } from './pathReference.js'; -import { sessionReferenceTokenAt } from './sessionReference.js'; +import { + formatSessionReferenceToken, + parseSessionReferenceToken, + sessionReferenceTokenAt, +} from './sessionReference.js'; import { normalizeComposerContent } from './composerContent.js'; export const COMPOSER_PARAGRAPH = 'paragraph'; @@ -156,6 +160,14 @@ function composerSessionTag(token, reference) { }; } +// 由会话(@ 菜单候选或从侧栏拖入的会话)构造输入框里的会话标签,序列化结果与 +// @ 菜单插入的 @session: 令牌逐字节一致;无效会话返回 null。 +export function composerSessionTagForReference(session) { + const token = formatSessionReferenceToken(session, { trailingSpace: false }); + const reference = token ? parseSessionReferenceToken(token) : null; + return reference ? composerSessionTag(token, reference) : null; +} + export function composerAttachmentTag(attachment, index = 0) { const attachmentKey = String( attachment?.local_id || attachment?.key || attachment?.id || attachment?.name || index, diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 452ce787e..ed12787fc 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -28,6 +28,9 @@ import './stickyUserContext.test.js'; import './desktopContextMenu.test.js'; import './sessionHeaderContextMenu.test.js'; import './sidebarContextMenuIcons.test.js'; +import './sidebarSessionSelection.test.js'; +import './sidebarSessionBatch.test.js'; +import './sessionReferenceDrop.test.js'; import './sessionExplorerArchitecture.test.js'; import './topBarQuickActions.test.js'; import './themePackages.test.js'; @@ -51,6 +54,7 @@ import './sessionTitleAnimation.test.js'; import './codeBlockCopy.test.js'; import './createdFileSource.test.js'; import './shellCommandPresentation.test.js'; +import './toolPresentation.test.js'; import './sourceCodeHighlight.test.js'; import './markdownBlocks.test.js'; import './markdownCjkEmphasis.test.js'; @@ -223,6 +227,10 @@ import './agentBrowserSurface.test.js'; import './agentBrowserSurfaceCoordinator.test.js'; import './agentBrowserArchitecture.test.js'; import './agentBrowserPages.test.js'; +import './agentBrowserCookieMode.test.js'; +import './agentBrowserTopSites.test.js'; +import './agentBrowserRecentFiles.test.js'; +import './submenuPosition.test.js'; import './iosSimulator.test.js'; import './iosSimulatorNativeView.test.js'; import './previewTabScroll.test.js'; diff --git a/web/src/lib/sessionReferenceDrop.js b/web/src/lib/sessionReferenceDrop.js new file mode 100644 index 000000000..f2b0a91aa --- /dev/null +++ b/web/src/lib/sessionReferenceDrop.js @@ -0,0 +1,80 @@ +// 把侧栏会话拖进输入框 = 在落点插入 @会话 引用,与输入框里 @ 选择会话的效果相同; +// 多选拖动时一次插入多个。侧栏拖动是自绘的指针拖动(不是 HTML5 拖放),输入框收不到 +// drop 事件,所以由侧栏在松手时判断落点、派发 SESSION_REFERENCE_DROP_EVENT, +// 落点所在的输入框认领并插入。 +import { extractSessionReferences } from './sessionReference.js'; +import { sessionDisplayTitle } from './sessionTitle.js'; + +export const SESSION_REFERENCE_DROP_EVENT = 'acecode:session-reference-drop'; +// 可接收会话引用的输入框根元素带这个属性(值为 "true");禁用时不带。 +export const SESSION_REFERENCE_DROP_ATTR = 'data-session-reference-drop'; +// 拖动经过时加在输入框根元素上的高亮 class。 +export const SESSION_REFERENCE_DROP_ACTIVE_CLASS = 'is-session-reference-drop'; + +function stringField(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function referenceKey(reference = {}) { + const noWorkspace = !!(reference.no_workspace || reference.noWorkspace); + const id = stringField(reference.session_id || reference.id); + return `${noWorkspace ? 'task' : stringField(reference.workspace_hash || reference.workspaceHash)}::${id}`; +} + +// entries:[{ session }](侧栏拖动 / 多选的条目)。会话组(定时任务分组行)、已归档会话、 +// 子会话不能被引用,与 @ 菜单的候选规则一致。 +export function sessionReferencesFromEntries(entries = [], { + workspaceNameFor = () => '', + noWorkspaceLabel = '任务', +} = {}) { + const seen = new Set(); + const references = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const session = entry?.session || entry; + if (!session || session.sidebar_group) continue; + if (session.archived || session.is_archived) continue; + if (stringField(session.parent_session_id || session.parentSessionId)) continue; + const id = stringField(session.id || session.session_id || session.sessionId); + if (!id) continue; + const noWorkspace = !!(session.no_workspace || session.noWorkspace); + const workspaceHash = noWorkspace ? '' : stringField(session.workspace_hash || session.workspaceHash); + const reference = { + id, + title: sessionDisplayTitle(session, ''), + workspace_hash: workspaceHash, + no_workspace: noWorkspace, + workspace_name: noWorkspace + ? noWorkspaceLabel + : stringField(session.workspaceName || session.workspace_name) || stringField(workspaceNameFor(workspaceHash)), + }; + const key = referenceKey(reference); + if (seen.has(key)) continue; + seen.add(key); + references.push(reference); + } + return references; +} + +// 去掉当前会话自身(@ 菜单同样不列出它)和输入框里已经引用过的会话。 +export function droppableSessionReferences(references = [], { currentSessionId = '', text = '' } = {}) { + const current = stringField(currentSessionId); + const existing = new Set(extractSessionReferences(String(text || '')).references.map(referenceKey)); + return references.filter(reference => reference.id !== current && !existing.has(referenceKey(reference))); +} + +export function sessionReferenceDropTargetFrom(element) { + return element?.closest?.(`[${SESSION_REFERENCE_DROP_ATTR}="true"]`) || null; +} + +export function sessionReferenceDropTargetAt(doc, clientX, clientY) { + if (!doc?.elementFromPoint || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; + return sessionReferenceDropTargetFrom(doc.elementFromPoint(clientX, clientY)); +} + +// 返回 true 表示有输入框认领了这次放下。 +export function dispatchSessionReferenceDrop(target, references, { clientX, clientY } = {}, win = globalThis.window) { + if (!target || !references?.length || !win?.dispatchEvent) return false; + const detail = { target, references, clientX, clientY, handled: false }; + win.dispatchEvent(new win.CustomEvent(SESSION_REFERENCE_DROP_EVENT, { detail })); + return detail.handled; +} diff --git a/web/src/lib/sessionReferenceDrop.test.js b/web/src/lib/sessionReferenceDrop.test.js new file mode 100644 index 000000000..4d8874552 --- /dev/null +++ b/web/src/lib/sessionReferenceDrop.test.js @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + SESSION_REFERENCE_DROP_EVENT, + dispatchSessionReferenceDrop, + droppableSessionReferences, + sessionReferenceDropTargetFrom, + sessionReferencesFromEntries, +} from './sessionReferenceDrop.js'; +import { formatSessionReferenceToken, parseSessionReferenceToken } from './sessionReference.js'; +import { composerSessionTagForReference, composerTextFromDocument } from './richComposerModel.js'; + +function test(name, fn) { + try { fn(); console.log('ok -', name); } + catch (error) { console.error('not ok -', name); throw error; } +} + +function source(path) { + return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); +} + +const workspaceSession = { id: 's1', title: '修复侧栏', workspace_hash: 'ws1' }; +const taskSession = { id: 't1', summary: '整理周报', no_workspace: true }; + +// 场景:从侧栏多选拖动 —— 里面有普通会话、无工作区任务、定时任务分组行、重复项和归档会话。 +// 期望:只产出可被 @ 引用的会话,保持拖动顺序;标题取侧栏显示标题;工作区名优先用会话自带, +// 没有时按 hash 查;分组行(sidebar_group)、归档会话和子会话被跳过,重复项只留一个。 +test('拖动条目转换成会话引用', () => { + const references = sessionReferencesFromEntries([ + { session: workspaceSession }, + { session: { id: 'group-1', sidebar_group: true, title: '每分钟问候' } }, + { session: taskSession }, + { session: workspaceSession }, + { session: { id: 'old', title: '旧会话', workspace_hash: 'ws1', archived: true } }, + { session: { id: 'child', title: '子任务', workspace_hash: 'ws1', parent_session_id: 's1' } }, + ], { workspaceNameFor: hash => (hash === 'ws1' ? 'acecode' : '') }); + assert.deepEqual(references, [ + { id: 's1', title: '修复侧栏', workspace_hash: 'ws1', no_workspace: false, workspace_name: 'acecode' }, + { id: 't1', title: '整理周报', workspace_hash: '', no_workspace: true, workspace_name: '任务' }, + ]); +}); + +// 场景:把当前会话自身、以及输入框里已经 @ 过的会话再拖进来。 +// 期望:这两类被过滤掉(@ 菜单同样不列当前会话),其余照常插入。 +test('过滤当前会话和已引用的会话', () => { + const references = sessionReferencesFromEntries([{ session: workspaceSession }, { session: taskSession }, { session: { id: 'cur', workspace_hash: 'ws1' } }]); + const text = `看看 ${formatSessionReferenceToken({ id: 't1', no_workspace: true, title: '整理周报' })}`; + assert.deepEqual(droppableSessionReferences(references, { currentSessionId: 'cur', text }).map(item => item.id), ['s1']); +}); + +// 场景:拖进输入框后生成的会话标签要与 @ 菜单插入的完全等价,提交时才能被 extractSessionReferences 识别。 +// 期望:标签序列化出的文本就是 @ 菜单使用的 @session: 令牌,解析回来是同一个会话。 +test('拖入生成的标签与 @ 菜单令牌一致', () => { + const [reference] = sessionReferencesFromEntries([{ session: workspaceSession }], { workspaceNameFor: () => 'acecode' }); + const tag = composerSessionTagForReference(reference); + const text = composerTextFromDocument([{ type: 'paragraph', children: [{ text: '' }, tag, { text: '' }] }]); + assert.equal(text, formatSessionReferenceToken(reference, { trailingSpace: false })); + assert.equal(parseSessionReferenceToken(text).session_id, 's1'); + assert.equal(composerSessionTagForReference({}), null); +}); + +// 场景:侧栏松手时由落点元素找到所在的输入框并派发事件。 +// 期望:只有带 data-session-reference-drop="true" 的祖先才算落点;事件被输入框认领后返回 true。 +test('落点查找与事件派发', () => { + const composer = { id: 'composer' }; + const inner = { closest: selector => (selector === '[data-session-reference-drop="true"]' ? composer : null) }; + assert.equal(sessionReferenceDropTargetFrom(inner), composer); + assert.equal(sessionReferenceDropTargetFrom(null), null); + + const listeners = []; + const win = { + CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } }, + dispatchEvent(event) { listeners.forEach(fn => fn(event)); return true; }, + }; + listeners.push(event => { + assert.equal(event.type, SESSION_REFERENCE_DROP_EVENT); + if (event.detail.target === composer) event.detail.handled = true; + }); + assert.equal(dispatchSessionReferenceDrop(composer, [{ id: 's1' }], { clientX: 1, clientY: 2 }, win), true); + assert.equal(dispatchSessionReferenceDrop({ id: 'other' }, [{ id: 's1' }], {}, win), false); + assert.equal(dispatchSessionReferenceDrop(composer, [], {}, win), false); +}); + +// 接线守卫:侧栏拖动在输入框上方切成「引用」并在松手时派发;输入框声明落点、认领事件、 +// 经 RichComposer 在落点插入标签。任何一环断开,拖进去就只会变成一次无效的排序拖动。 +test('侧栏、输入框与编辑器三处接线', () => { + const sidebar = source('../components/Sidebar.jsx'); + assert.match(sidebar, /sessionReferenceDropTargetAt\(document, moveEvent\.clientX, moveEvent\.clientY\)/); + assert.match(sidebar, /dispatchSessionReferenceDrop\(composer, references,/); + assert.match(sidebar, /sessionReferences: sessionReferencesFromEntries\(entries,/); + const inputBar = source('../components/InputBar.jsx'); + assert.match(inputBar, /data-session-reference-drop=\{sessionReferenceDropEnabled \? 'true' : undefined\}/); + assert.match(inputBar, /window\.addEventListener\(SESSION_REFERENCE_DROP_EVENT, onDrop\)/); + assert.match(inputBar, /insertSessionReferences\?\.\(references,/); + const composer = source('../components/RichComposer.jsx'); + assert.match(composer, /insertSessionReferences\(references = \[\], \{ clientX, clientY \} = \{\}\)/); + assert.match(composer, /composerSessionTagForReference/); +}); diff --git a/web/src/lib/sessionTranscript.test.js b/web/src/lib/sessionTranscript.test.js index 45e3d9b42..90decff17 100644 --- a/web/src/lib/sessionTranscript.test.js +++ b/web/src/lib/sessionTranscript.test.js @@ -1672,7 +1672,8 @@ run('history load 后 Created tool result 从匹配调用恢复工具名并保 const tool = activity?.collapsedItems?.find((item) => item.kind === 'tool')?.tool; assert.ok(tool); assert.equal(tool.tool, 'file_write'); - assert.equal(tool.args, null); + assert.deepEqual(tool.args, { file_path: 'src/index.html', content: '

Hello

' }); + assert.equal(loaded.items.find(item => item.kind === 'tool').tool.args, null); assert.deepEqual(createdFileSource(tool), { path: 'src/index.html', content: '

Hello

', diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index 2b074077e..6916e12e1 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -46,6 +46,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { ['channels', 'LINE', 'line messaging api channel secret webhook cloudflared 机器人 im channel 消息通道 连接 取消连接'], ['tools', '内置工具', 'builtin tools'], ['tools', 'Agent 浏览器', 'agent browser'], + ['tools', '保留 Cookie', 'agent browser cookie cookies keep sign in login session 登录 登录状态 记住登录 浏览器 网站数据 清除浏览数据 共享 按会话 退出时清除 until quit shared per session'], ['tools', 'iOS 模拟器', 'ios simulator iphone ipad xcode 模拟器 simctl device stream screenshot 设备 画面 帧率 画质'], ['tools', '电脑操控(实验性)', 'computer use experimental windows macos accessibility screen recording desktop mouse keyboard screenshot pointer cursor style theme color ace plain 指针 样式 主题色'], ['tools', '图像生成', 'image generation drawing'], diff --git a/web/src/lib/settingsSearch.test.js b/web/src/lib/settingsSearch.test.js index cbdfcdab3..3692eb538 100644 --- a/web/src/lib/settingsSearch.test.js +++ b/web/src/lib/settingsSearch.test.js @@ -16,6 +16,16 @@ assert.equal(searchSettings(entries, '侧边聊天存续时间')[0].section, 'ge assert.equal(searchSettings(entries, 'side chat retention')[0].label, '侧边聊天存续时间'); console.log('[pass] bilingual settings search, ranking, cross-section and empty queries'); +// Agent 浏览器「保留 Cookie」(add-agent-browser-cookie-modes)。 +// 触发场景:用户想找「怎么让浏览器记住登录」,搜 cookie / 登录 / 清除浏览数据。 +// 期望:都能命中「工具」分区里的「保留 Cookie」,且排在第一位。 +for (const query of ['cookie', '登录', '清除浏览数据', '保留 Cookie']) { + const hit = searchSettings(entries, query)[0]; + assert.equal(hit?.section, 'tools', query); + assert.equal(hit?.label, '保留 Cookie', query); +} +console.log('[pass] agent browser cookie setting is searchable by cookie and sign-in words'); + // 全局搜索面板 → 设置窗口的选中项对齐(settingsSearchResultIndex)。 // 触发场景:用户在 Ctrl+K 面板里搜 'path',从 8 条设置命中里选了不是第一条的「工作空间路径」。 // 期望:设置窗口用同一个查询重跑后,选中下标指向同一条(按 id 对齐); diff --git a/web/src/lib/shortcutBindings.js b/web/src/lib/shortcutBindings.js index c60602801..cd322e16b 100644 --- a/web/src/lib/shortcutBindings.js +++ b/web/src/lib/shortcutBindings.js @@ -8,6 +8,7 @@ export const APP_SHORTCUTS = Object.freeze({ toggleSidebar: binding('KeyB', 'B', { alt: true }), toggleRightPanel: binding('KeyE', 'E', { alt: true }), previewPresentation: binding('F11', 'F11', { control: true }), + revealPreviewFile: binding('BracketLeft', '[', { control: true }), settings: binding('Comma', ','), shortcuts: binding('Slash', '/'), stop: binding('Period', '.', { shift: true }), forward: binding('Minus', '-'), back: binding('Minus', '-', { shift: true }), diff --git a/web/src/lib/sidebarSessionBatch.js b/web/src/lib/sidebarSessionBatch.js new file mode 100644 index 000000000..3ad00a438 --- /dev/null +++ b/web/src/lib/sidebarSessionBatch.js @@ -0,0 +1,82 @@ +// 侧栏多选批量操作(POST /api/session-batch)的请求组装与结果解析。纯函数, +// Sidebar 只负责把结果写回各自的状态。曾经批量操作是对每个会话逐个调用单项接口, +// 选 20 个会话置顶就是 40 个请求;现在一次请求,服务端合并落盘。 +import { NO_WORKSPACE_PIN_SCOPE, normalizePinnedIds, normalizePinnedOrderItems, sessionPinScope } from './pinnedSessions.js'; + +export const SESSION_BATCH_ACTIONS = Object.freeze(['pin', 'unpin', 'archive', 'unarchive', 'read', 'unread']); + +function sessionIdOf(session = {}) { + return String(session?.id || session?.session_id || session?.sessionId || ''); +} + +function isNoWorkspace(session = {}) { + return !!(session?.no_workspace || session?.noWorkspace); +} + +// entries 来自 useSidebarSessionSelection().entries():[{ key, pinned, session }], +// 顺序即侧栏可见顺序。置顶时服务端按这个顺序插到最前,第一个排在最上面。 +export function sessionBatchItems(action, entries = [], { cursorFor } = {}) { + return entries.map(({ session }) => { + const noWorkspace = isNoWorkspace(session); + const item = { + session_id: sessionIdOf(session), + workspace_hash: noWorkspace ? '' : String(session?.workspace_hash || session?.workspaceHash || ''), + no_workspace: noWorkspace, + }; + if (action === 'read' && typeof cursorFor === 'function') { + const cursor = Number(cursorFor(session)); + if (Number.isFinite(cursor) && cursor > 0) item.cursor = Math.floor(cursor); + } + return item; + }).filter(item => item.session_id); +} + +function resultKey(result = {}) { + const scope = result.no_workspace ? NO_WORKSPACE_PIN_SCOPE : String(result.workspace_hash || ''); + return `${scope}\u0000${String(result.session_id || '')}`; +} + +function entryResultKey(entry = {}) { + const session = entry.session || {}; + const scope = isNoWorkspace(session) + ? NO_WORKSPACE_PIN_SCOPE + : String(session.workspace_hash || session.workspaceHash || ''); + return `${scope}\u0000${sessionIdOf(session)}`; +} + +// 把响应按 entry 对齐。没有出现在 results 里的 entry 一律算失败, +// 这样服务端漏回某项时界面不会误以为成功。 +export function sessionBatchOutcome(response = {}, entries = []) { + const byKey = new Map(); + for (const result of Array.isArray(response?.results) ? response.results : []) { + byKey.set(resultKey(result), result); + } + const succeeded = []; + const failed = []; + for (const entry of entries) { + const result = byKey.get(entryResultKey(entry)); + if (result?.ok) succeeded.push({ entry, result }); + else failed.push({ entry, error: String(result?.error || '') }); + } + const pinnedScopes = new Map(); + for (const scope of Array.isArray(response?.pinned_scopes) ? response.pinned_scopes : []) { + const name = String(scope?.pin_scope || ''); + if (name) pinnedScopes.set(name, normalizePinnedIds(scope?.session_ids || [])); + } + const pinnedOrder = Array.isArray(response?.pinned_order?.items) + ? normalizePinnedOrderItems(response.pinned_order.items) + : null; + return { + succeeded, + failed, + failedKeys: failed.map(({ entry }) => entry.key).filter(Boolean), + pinnedScopes, + pinnedOrder, + statuses: succeeded.map(({ result }) => result.status).filter(status => status && typeof status === 'object'), + }; +} + +// 涉及到的置顶作用域(工作区 hash 或 "__no_workspace__"),用于批量后刷新列表。 +export function sessionBatchScopes(entries = []) { + return Array.from(new Set(entries.map(({ session }) => sessionPinScope(session)).filter(Boolean))); +} diff --git a/web/src/lib/sidebarSessionBatch.test.js b/web/src/lib/sidebarSessionBatch.test.js new file mode 100644 index 000000000..c7eb187f8 --- /dev/null +++ b/web/src/lib/sidebarSessionBatch.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { sessionBatchItems, sessionBatchOutcome, sessionBatchScopes } from './sidebarSessionBatch.js'; + +function test(name, fn) { + try { fn(); console.log('ok -', name); } + catch (error) { console.error('not ok -', name); throw error; } +} + +const workspaceEntry = { key: 'ws1\u0000a', pinned: false, session: { id: 'a', workspace_hash: 'ws1' } }; +const taskEntry = { key: '__no_workspace__\u0000t', pinned: true, session: { id: 't', no_workspace: true, workspace_hash: '' } }; +const otherEntry = { key: 'ws2\u0000b', pinned: false, session: { id: 'b', workspaceHash: 'ws2' } }; + +// 场景:多选三个会话(工作区会话、无工作区任务、另一个工作区)后批量操作。 +// 期望:请求条目保持侧栏可见顺序;无工作区任务的 workspace_hash 置空并带 no_workspace; +// 只有 read 带客户端已看到的游标,游标为 0 / 非法时不发。 +test('批量请求条目保持可见顺序并区分无工作区任务', () => { + assert.deepEqual(sessionBatchItems('pin', [workspaceEntry, taskEntry, otherEntry]), [ + { session_id: 'a', workspace_hash: 'ws1', no_workspace: false }, + { session_id: 't', workspace_hash: '', no_workspace: true }, + { session_id: 'b', workspace_hash: 'ws2', no_workspace: false }, + ]); + const cursors = { a: 12, t: 0 }; + const items = sessionBatchItems('read', [workspaceEntry, taskEntry], { cursorFor: session => cursors[session.id] }); + assert.equal(items[0].cursor, 12); + assert.equal('cursor' in items[1], false); + assert.equal('cursor' in sessionBatchItems('unread', [workspaceEntry], { cursorFor: () => 5 })[0], false); +}); + +// 场景:服务端返回部分成功(b 不存在),并带回置顶作用域与全局顺序。 +// 期望:按「作用域 + id」把结果对齐到 entry,失败项的 key 用来保留选中;置顶数据规范化后返回。 +test('批量结果按作用域与 id 对齐到选中项', () => { + const outcome = sessionBatchOutcome({ + results: [ + { session_id: 'a', workspace_hash: 'ws1', no_workspace: false, ok: true }, + { session_id: 't', workspace_hash: '', no_workspace: true, ok: true, status: { session_id: 't', attention_state: 'read' } }, + { session_id: 'b', workspace_hash: 'ws2', no_workspace: false, ok: false, error: 'session cannot be pinned' }, + ], + pinned_scopes: [{ pin_scope: 'ws1', session_ids: ['a', 'a', 'x'] }], + pinned_order: { items: [{ workspace_hash: 'ws1', session_id: 'a' }] }, + }, [workspaceEntry, taskEntry, otherEntry]); + assert.deepEqual(outcome.succeeded.map(({ entry }) => entry.session.id), ['a', 't']); + assert.deepEqual(outcome.failedKeys, ['ws2\u0000b']); + assert.equal(outcome.failed[0].error, 'session cannot be pinned'); + assert.deepEqual([...outcome.pinnedScopes.entries()], [['ws1', ['a', 'x']]]); + assert.deepEqual(outcome.pinnedOrder, [{ workspace_hash: 'ws1', session_id: 'a' }]); + assert.deepEqual(outcome.statuses, [{ session_id: 't', attention_state: 'read' }]); +}); + +// 场景:服务端漏回了某一项(或旧 daemon 返回了不认识的格式)。 +// 期望:缺失的项按失败处理,界面不会把没处理的会话当成功;没有 pinned_order 时为 null(不改顺序)。 +test('服务端漏回的条目算失败', () => { + const outcome = sessionBatchOutcome({ results: [{ session_id: 'a', workspace_hash: 'ws1', ok: true }] }, [workspaceEntry, otherEntry]); + assert.deepEqual(outcome.failedKeys, ['ws2\u0000b']); + assert.equal(outcome.pinnedOrder, null); + assert.equal(sessionBatchOutcome(null, [workspaceEntry]).failed.length, 1); +}); + +test('批量涉及的作用域去重', () => { + assert.deepEqual(sessionBatchScopes([workspaceEntry, taskEntry, { session: { id: 'c', workspace_hash: 'ws1' } }]), ['ws1', '__no_workspace__']); +}); + +// 回归:批量操作曾逐个调用单项接口(置顶每项 2 个请求,归档每项还要刷新一次列表)。 +// 守住 Sidebar 的批量入口只发一次 batchSessions,不再循环调用单项函数。 +test('Sidebar 批量操作只走一次批量接口', () => { + const sidebar = readFileSync(fileURLToPath(new URL('../components/Sidebar.jsx', import.meta.url)), 'utf8'); + const start = sidebar.indexOf('const runSessionBatch = useStableCallback('); + assert.ok(start > 0); + const body = sidebar.slice(start, sidebar.indexOf('const handleSelectionContextMenu', start)); + assert.match(body, /api\.batchSessions\(action, sessionBatchItems\(action, entries/); + assert.doesNotMatch(body, /togglePinnedSession\(|archiveSession\(|markSessionUnread\(|markSessionRead\(/); + assert.doesNotMatch(body, /for \(const entry of/); +}); diff --git a/web/src/lib/sidebarSessionSelection.js b/web/src/lib/sidebarSessionSelection.js new file mode 100644 index 000000000..904fced11 --- /dev/null +++ b/web/src/lib/sidebarSessionSelection.js @@ -0,0 +1,42 @@ +import { pinnedOrderKey, sessionPinScope } from './pinnedSessions.js'; + +export function sidebarSelectionKey(session) { + return session?.sidebar_group ? '' : pinnedOrderKey(sessionPinScope(session), session?.id); +} + +export function selectSidebarRange(state, visibleKeys, key, { shiftKey, ctrlKey, metaKey } = {}) { + const visible = new Set(visibleKeys); + if (!visible.has(key)) return state; + const additive = ctrlKey || metaKey; + if (shiftKey) { + const anchor = visible.has(state.anchor) ? state.anchor : key; + const from = visibleKeys.indexOf(anchor); + const to = visibleKeys.indexOf(key); + const range = visibleKeys.slice(Math.min(from, to), Math.max(from, to) + 1); + const keys = new Set(additive ? [...state.keys].filter(item => visible.has(item)) : []); + range.forEach(item => keys.add(item)); + return { anchor, keys }; + } + const keys = new Set(additive ? [...state.keys].filter(item => visible.has(item)) : []); + if (additive && keys.has(key)) keys.delete(key); + else keys.add(key); + return { anchor: key, keys }; +} + +export function pruneSidebarSelection(state, visibleKeys) { + const visible = new Set(visibleKeys); + const keys = new Set([...state.keys].filter(key => visible.has(key))); + const anchor = visible.has(state.anchor) ? state.anchor : ''; + return keys.size === state.keys.size && anchor === state.anchor ? state : { keys, anchor }; +} + +export function reorderSidebarSelection(items, selectedKeys, targetKey, placement, keyOf) { + const selected = new Set(selectedKeys); + if (!['before', 'after'].includes(placement) || selected.has(targetKey)) return items; + const moving = items.filter(item => selected.has(keyOf(item))); + const remaining = items.filter(item => !selected.has(keyOf(item))); + const index = remaining.findIndex(item => keyOf(item) === targetKey); + if (!moving.length || moving.length !== selected.size || index < 0) return items; + const at = index + (placement === 'after' ? 1 : 0); + return [...remaining.slice(0, at), ...moving, ...remaining.slice(at)]; +} diff --git a/web/src/lib/sidebarSessionSelection.test.js b/web/src/lib/sidebarSessionSelection.test.js new file mode 100644 index 000000000..c07035bf0 --- /dev/null +++ b/web/src/lib/sidebarSessionSelection.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { pruneSidebarSelection, reorderSidebarSelection, selectSidebarRange, sidebarSelectionKey } from './sidebarSessionSelection.js'; + +const visible = ['w:a', 'w:b', 'w:c', 'x:a']; +let state = selectSidebarRange({ anchor: '', keys: new Set() }, visible, 'w:b'); +state = selectSidebarRange(state, visible, 'x:a', { shiftKey: true }); +assert.deepEqual([...state.keys], ['w:b', 'w:c', 'x:a']); +assert.equal(state.anchor, 'w:b'); +state = selectSidebarRange(state, visible, 'w:a', { shiftKey: true }); +assert.deepEqual([...state.keys], ['w:a', 'w:b']); +state = selectSidebarRange(state, visible, 'w:c', { ctrlKey: true }); +assert.deepEqual([...state.keys], ['w:a', 'w:b', 'w:c']); +state = selectSidebarRange(state, visible, 'w:b', { metaKey: true }); +assert.deepEqual([...state.keys], ['w:a', 'w:c']); +state = pruneSidebarSelection(state, ['w:c', 'x:a']); +assert.equal(state.anchor, ''); +assert.deepEqual([...state.keys], ['w:c']); +assert.equal(pruneSidebarSelection(state, visible), state); +state = selectSidebarRange(state, visible, 'x:a', { shiftKey: true }); +assert.deepEqual([...state.keys], ['x:a']); +assert.notEqual(sidebarSelectionKey({ id: 'same', workspace_hash: 'w' }), sidebarSelectionKey({ id: 'same', no_workspace: true })); +assert.equal(sidebarSelectionKey({ id: 'group', workspace_hash: 'w', sidebar_group: true }), ''); +const keyOf = item => item; +assert.deepEqual(reorderSidebarSelection(visible, ['w:c', 'w:a'], 'x:a', 'after', keyOf), ['w:b', 'x:a', 'w:a', 'w:c']); +assert.deepEqual(reorderSidebarSelection(visible, ['w:b', 'w:c'], 'w:a', 'before', keyOf), ['w:b', 'w:c', 'w:a', 'x:a']); +assert.equal(reorderSidebarSelection(visible, ['w:a', 'missing'], 'x:a', 'after', keyOf), visible); +assert.equal(reorderSidebarSelection(visible, ['w:a'], 'w:a', 'after', keyOf), visible); +console.log('sidebarSessionSelection tests passed'); diff --git a/web/src/lib/sidebarWorkspaceDragArchitecture.test.js b/web/src/lib/sidebarWorkspaceDragArchitecture.test.js index 283909fce..996ec7ea7 100644 --- a/web/src/lib/sidebarWorkspaceDragArchitecture.test.js +++ b/web/src/lib/sidebarWorkspaceDragArchitecture.test.js @@ -36,7 +36,7 @@ test('workspace and no-workspace groups share scope-isolated tree dragging', () assert.match(sidebar, /data-sidebar-workspace-session-key=\{workspaceReorderable/); assert.match(sidebar, /data-sidebar-workspace-session-workspace=\{workspaceReorderable/); - assert.match(sidebar, /workspaceDropTargetForPointer\(clientY, drag\.workspaceHash, drag\.sourceId, drag\.lastX\)/); + assert.match(sidebar, /workspaceDropTargetForPointer\(clientY, drag\.workspaceHash, drag\.sourceId, drag\.lastX, drag\.entries\.map/); assert.match( sidebar, /\.filter\(\(row\) => row\.dataset\.sidebarWorkspaceSessionWorkspace === workspace\)/, diff --git a/web/src/lib/sidebarWorkspaceRefresh.test.js b/web/src/lib/sidebarWorkspaceRefresh.test.js index 2a1da590b..eb578421f 100644 --- a/web/src/lib/sidebarWorkspaceRefresh.test.js +++ b/web/src/lib/sidebarWorkspaceRefresh.test.js @@ -98,7 +98,7 @@ function fixture({ workspaceSessionLoadSeqRef: new Map(), pendingWorkspaceLoadsRef: new Map(), refreshingRef: false, pendingRefreshHashRef: '', - treeSavingRef: false, treeRevisionRef: 0, + treeSavingRef: false, treeRevisionRef: 0, batchOperationRef: false, archiveOperationsRef: new Set(), refreshSelectionRef: { activeWorkspaceHash: 'w', revealTarget: { noWorkspace: false, workspaceHash: 'w' } }, expandedRef: new Set(['w']), diff --git a/web/src/lib/submenuPosition.js b/web/src/lib/submenuPosition.js new file mode 100644 index 000000000..4d5cdb96e --- /dev/null +++ b/web/src/lib/submenuPosition.js @@ -0,0 +1,33 @@ +// 二级菜单(flyout)位置:贴着父菜单的左侧或右侧展开,顶边对齐触发项。 +// Agent Browser 的「⋮」菜单在工具栏最右边,所以默认向左展开(与 Claude 桌面版一致), +// 左侧放不下才放右侧;都放不下时贴视口边缘。纵向不超出视口。 +export const SUBMENU_GAP = 4; +export const SUBMENU_VIEWPORT_MARGIN = 8; + +export function submenuPosition({ + menuRect, + itemRect, + submenuWidth, + submenuHeight, + viewportWidth, + viewportHeight, + preferredSide = 'left', +}) { + const width = Math.max(0, Number(submenuWidth) || 0); + const height = Math.max(0, Number(submenuHeight) || 0); + const leftCandidate = menuRect.left - SUBMENU_GAP - width; + const rightCandidate = menuRect.right + SUBMENU_GAP; + const fitsLeft = leftCandidate >= SUBMENU_VIEWPORT_MARGIN; + const fitsRight = rightCandidate + width <= viewportWidth - SUBMENU_VIEWPORT_MARGIN; + let side = preferredSide === 'right' ? 'right' : 'left'; + if (side === 'left' && !fitsLeft && fitsRight) side = 'right'; + if (side === 'right' && !fitsRight && fitsLeft) side = 'left'; + let left = side === 'left' ? leftCandidate : rightCandidate; + const maxLeft = viewportWidth - SUBMENU_VIEWPORT_MARGIN - width; + left = Math.max(SUBMENU_VIEWPORT_MARGIN, Math.min(left, maxLeft)); + // 子菜单第一项的文字与触发项对齐:减去子菜单自身的内边距(约 4px)。 + let top = itemRect.top - SUBMENU_GAP; + const maxTop = viewportHeight - SUBMENU_VIEWPORT_MARGIN - height; + top = Math.max(SUBMENU_VIEWPORT_MARGIN, Math.min(top, maxTop)); + return { left: Math.round(left), top: Math.round(top), side }; +} diff --git a/web/src/lib/submenuPosition.test.js b/web/src/lib/submenuPosition.test.js new file mode 100644 index 000000000..a9d207385 --- /dev/null +++ b/web/src/lib/submenuPosition.test.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { submenuPosition } from './submenuPosition.js'; + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +const viewport = { viewportWidth: 1200, viewportHeight: 800 }; + +// 场景:Agent Browser 的「⋮」菜单在工具栏右侧打开,「保留 Cookie」子菜单展开。 +// 期望:向左展开(与 Claude 桌面版一致),右边缘离父菜单 4px,顶边与触发项对齐。 +await run('submenu opens to the left of a right-edge menu', () => { + const position = submenuPosition({ + menuRect: { left: 900, right: 1180, top: 60, bottom: 260 }, + itemRect: { top: 120, bottom: 152 }, + submenuWidth: 300, + submenuHeight: 180, + ...viewport, + }); + assert.deepEqual(position, { left: 596, top: 116, side: 'left' }); +}); + +// 场景:窗口很窄,父菜单左侧放不下子菜单。 +// 期望:改到右侧;两侧都放不下时贴视口边缘,不能跑出窗口。 +await run('submenu flips or clamps when the preferred side has no room', () => { + const flipped = submenuPosition({ + menuRect: { left: 40, right: 320, top: 60, bottom: 260 }, + itemRect: { top: 100, bottom: 132 }, + submenuWidth: 300, + submenuHeight: 100, + ...viewport, + }); + assert.equal(flipped.side, 'right'); + assert.equal(flipped.left, 324); + + const clamped = submenuPosition({ + menuRect: { left: 100, right: 380, top: 60, bottom: 260 }, + itemRect: { top: 100, bottom: 132 }, + submenuWidth: 300, + submenuHeight: 100, + viewportWidth: 500, + viewportHeight: 800, + }); + assert.ok(clamped.left >= 8 && clamped.left + 300 <= 492, JSON.stringify(clamped)); +}); + +// 场景:触发项靠近窗口底部。 +// 期望:子菜单整体上移,底边不超出视口;顶边也不低于 8px 边距。 +await run('submenu stays inside the viewport vertically', () => { + const position = submenuPosition({ + menuRect: { left: 900, right: 1180, top: 500, bottom: 790 }, + itemRect: { top: 760, bottom: 790 }, + submenuWidth: 300, + submenuHeight: 180, + ...viewport, + }); + assert.equal(position.top, 800 - 8 - 180); + const tall = submenuPosition({ + menuRect: { left: 900, right: 1180, top: 0, bottom: 300 }, + itemRect: { top: 2, bottom: 30 }, + submenuWidth: 300, + submenuHeight: 900, + ...viewport, + }); + assert.equal(tall.top, 8); +}); diff --git a/web/src/lib/toolPresentation.js b/web/src/lib/toolPresentation.js new file mode 100644 index 000000000..c477e7f27 --- /dev/null +++ b/web/src/lib/toolPresentation.js @@ -0,0 +1,186 @@ +import { tr } from '../i18n/index.js'; +import { builtinTranscriptToolNames, toolTranscriptEnUS } from '../i18n/catalogs/toolTranscript.js'; +import { basenameForPath } from './selectionChatContext.js'; +import { compactOneLinePreview } from './compactMessagePreview.js'; +import { formatBytes, formatElapsed } from './format.js'; +import { shellCommandPresentation } from './shellCommandPresentation.js'; + +const BUILTINS = new Set(builtinTranscriptToolNames); +const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key); +const ALIASES = { + read: 'file_read', read_file: 'file_read', write: 'file_write', write_file: 'file_write', + edit: 'file_edit', edit_file: 'file_edit', multiedit: 'file_edit', + ask_user_question: 'askuserquestion', todo_write: 'todowrite', skill: 'skill_view', + view_image: 'show_image', shell: 'bash', exec_command: 'bash', execute_command: 'bash', +}; +const FILE_TOOLS = new Set(['file_read', 'file_write', 'file_edit', 'show_image', 'vision_analyze', 'lsp']); +const PATH_KEYS = new Set(['file_path', 'path', 'image_path', 'app_path', 'project', 'workspace', 'cwd', 'output_path']); +const ARG_LABELS = { + file_path: 'path', image_path: 'path', app_path: 'path', output_path: 'path', + include_pattern: 'files', head_limit: 'limit', max_results: 'limit', + thread_id: 'target', session_id: 'target', bundle_id: 'target', task_name: 'name', + mouse_button: 'button', model_name: 'model', secondary_action: 'action', + threadId: 'target', timeoutMs: 'timeout_ms', turnLimit: 'limit', + includeArchived: 'includeArchived', includeOutputs: 'includeOutputs', noWorkspace: 'noWorkspace', +}; +const METRIC_KEYS = { time: 'elapsed', bytes: 'size', encoding: 'enc', line_ending: 'eol', '+': '+', '-': '-' }; +const ENUM_FIELDS = new Set(['cache', 'partial', 'truncated', 'mode', 'hint', 'sandbox', 'status', 'quality', 'enabled', 'pinned', 'archived', 'aborted', 'timeout', 'includeArchived', 'includeOutputs', 'noWorkspace']); +const SENSITIVE_KEY = /(?:password|passwd|secret|token|authorization|credential|private.?key|api.?key)/i; + +export function canonicalTranscriptTool(entry = {}) { + if (!entry) return ''; + const raw = String(entry.tool || entry.metadata?.tool_name || '').replace(/^functions\./i, '').toLowerCase(); + if (raw) return ALIASES[raw] || raw; + if (entry.askUserQuestionResult) return 'askuserquestion'; + const verb = String(entry.summary?.verb || '').toLowerCase(); + if (BUILTINS.has(verb) || ALIASES[verb]) return ALIASES[verb] || verb; + if (verb === 'ran') return 'bash'; + if (verb === 'wrote') return 'file_write'; + if (verb === 'edited' && entry.hunks?.length) return 'file_edit'; + if (verb === 'created' && entry.hunks?.length) return 'file_write'; + if (verb === 'complete' || verb === 'done') return 'task_complete'; + return ''; +} + +export function toolArguments(value) { + if (typeof value === 'string') { + try { return toolArguments(JSON.parse(value)); } catch { return {}; } + } + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function textValue(value) { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.filter(v => typeof v === 'string').join(', '); + return ''; +} + +function select(args, keys) { + for (const key of keys) { + const value = textValue(args[key]); + if (value) return { key, value }; + } + return { key: '', value: '' }; +} + +function primaryObject(tool, args, entry, t) { + let keys; + if (FILE_TOOLS.has(tool)) keys = ['file_path', 'path', 'image_path', 'image_paths']; + else if (tool === 'grep' || tool === 'glob') keys = ['pattern']; + else if (tool === 'bash') return { key: 'command', value: shellCommandPresentation({ ...entry, tool })?.command || '' }; + else if (tool === 'web_search') keys = ['query']; + else if (tool === 'image_generate') keys = ['prompt']; + else if (tool === 'apply_patch') { + const paths = [...new Set((Array.isArray(entry.hunks) ? entry.hunks : []).map(h => h?.file).filter(Boolean))]; + if (!paths.length) { + const patch = String(args.patch || args.input || ''); + for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) paths.push(match[1].trim()); + } + return { key: '', value: paths.length === 1 ? paths[0] : paths.length ? t('toolTranscript.fileCount', { count: paths.length }) : '', path: paths.length === 1 }; + } else if (tool.startsWith('browser_')) keys = ['url', 'key', 'name', 'target', 'selector', 'page_id']; + else if (tool.startsWith('computer_')) keys = ['app', 'key', 'window']; + else if (tool === 'ios_simulator') keys = ['device', 'bundle_id', 'app_path', 'url', 'key', 'name']; + else if (tool === 'ios_simulator_build') keys = ['scheme', 'project', 'workspace']; + else if (tool === 'skill_view' || tool.startsWith('memory_')) keys = ['name', 'query']; + else if (tool === 'create_workspace') keys = ['path', 'cwd', 'name']; + else if (tool === 'create_goal') keys = ['objective']; + else if (tool === 'update_goal') keys = ['status']; + else if (tool === 'create_thread' || tool === 'set_thread_title') keys = ['title']; + else if (tool === 'spawn_subagent' || tool === 'agent_spawn') keys = ['task_name', 'description']; + else if (tool === 'theme_create' || tool === 'create_scheduled_task' || tool === 'enterworktree') keys = ['name', 'title']; + else keys = ['title', 'name', 'target', 'threadId', 'thread_id', 'session_id']; + const selected = select(args, keys); + if (selected.value) return selected; + // Native summaries can supply old-history objects. Generic fallbacks are an + // unlabelled concatenation of arguments and must not leak back into the row. + const verb = String(entry.summary?.verb || '').toLowerCase(); + const object = textValue(entry.summary?.object); + if (object && Object.keys(args).length === 0 && (!BUILTINS.has(verb) || FILE_TOOLS.has(tool))) { + return { key: '', value: BUILTINS.has(verb) ? object.split(' · ')[0] : object }; + } + return { key: '', value: '' }; +} + +function metricPairs(metrics) { + return (Array.isArray(metrics) ? metrics : []).map(m => Array.isArray(m) + ? [m[0], m[1]] : [m?.label || m?.key || m?.name, m?.value]); +} + +export function presentTool(entry = {}, t = tr, { elapsed = entry?.elapsed || 0 } = {}) { + entry = entry || {}; + const tool = canonicalTranscriptTool(entry); + const builtin = BUILTINS.has(tool); + const args = toolArguments(entry.args); + let label = builtin ? t(`toolTranscript.names.${tool}`) + : String(entry.tool || entry.summary?.verb || t('toolTranscript.unknown')); + const action = tool === 'lsp' ? args.operation : tool === 'ios_simulator' ? args.action : ''; + if (action && hasOwn(toolTranscriptEnUS.actions, action)) label = t(`toolTranscript.actions.${action}`); + const primary = primaryObject(tool, args, entry, t); + const pathObject = FILE_TOOLS.has(tool) || primary.path || PATH_KEYS.has(primary.key); + const fullObject = primary.value; + let detail = pathObject ? (primary.key === 'image_paths' + ? (Array.isArray(args.image_paths) ? args.image_paths : [args.image_paths]).map(basenameForPath).join(', ') + : basenameForPath(fullObject)) : fullObject; + if (primary.key === 'status') detail = t(`toolTranscript.values.${detail}`, { defaultValue: detail }); + detail = detail ? compactOneLinePreview(detail, tool === 'bash' ? 80 : 120) : ''; + const fields = []; + const seen = new Set(); + const add = (key, value, options = {}) => { + if (value === '' || value == null || seen.has(key)) return; + if (SENSITIVE_KEY.test(key) && key !== 'token_budget') return; + const raw = textValue(value); + if (!raw) return; + seen.add(key); + const localized = ENUM_FIELDS.has(key) && hasOwn(toolTranscriptEnUS.values, raw) + ? t(`toolTranscript.values.${raw}`) : raw; + fields.push({ key, label: key === '+' || key === '-' ? key + : t(`toolTranscript.fields.${key}`, { defaultValue: key.replace(/_/g, ' ') }), + value: localized, ...options }); + }; + if (pathObject && fullObject) add('path', fullObject, { standalone: true }); + // A shortened query/prompt is recoverable in details; short primary objects + // are already readable in the row. Shell handles actual layout truncation. + if (!pathObject && tool !== 'bash' && fullObject && detail !== fullObject) { + add(ARG_LABELS[primary.key] || primary.key || 'target', fullObject); + } + if (builtin) { + for (const [key, value] of Object.entries(args)) { + if (key === primary.key || key === 'command' || key === 'operation' || key === 'action') continue; + if (key === 'start_line' || key === 'end_line') continue; + const field = key === 'path' && (tool === 'grep' || tool === 'glob') ? 'scope' : (ARG_LABELS[key] || key); + if (!hasOwn(toolTranscriptEnUS.fields, field)) continue; + // False opt-in flags are defaults; explicit pin/archive changes still matter. + if (value === false && !['enabled', 'pinned', 'archived'].includes(key)) continue; + if ((key === 'path' || key === 'cwd') && value === '.') continue; + if (field === 'limit' && value > 0) add(field, t('toolTranscript.maxResults', { count: Number(value) }), { standalone: true }); + else add(field, value, { standalone: field === 'path' }); + } + const start = Number(args.start_line); + const end = Number(args.end_line); + if (start > 0 || end > 0) add('range', end > 0 + ? t('toolTranscript.lineRange', { start: start || 1, end }) + : t('toolTranscript.fromLine', { line: start }), { standalone: true }); + } + for (const [rawKey, value] of metricPairs(entry.summary?.metrics)) { + if (!rawKey || ['summary', 'action'].includes(rawKey)) continue; + const key = METRIC_KEYS[rawKey] || rawKey; + if ((key === '+' || key === '-') && Number(value) === 0) continue; + if (value === false || value === 'false') continue; + if (key === 'path' && String(value) === fullObject) continue; + // Prefer the actual result metric over the requested value (e.g. quality). + const existing = fields.findIndex(f => f.key === key); + if (existing >= 0) { fields.splice(existing, 1); seen.delete(key); } + add(key, value); + } + if (!entry.isDone) { + if (entry.totalLines > 0) add('outputLines', entry.totalLines); + if (entry.totalBytes > 0) add('size', formatBytes(entry.totalBytes)); + } + if (elapsed > 0) add('elapsed', formatElapsed(elapsed)); + return { + tool, builtin, label, detail, fullObject, fields, + separator: t('toolTranscript.separator'), + output: String(builtin ? (entry.resultOutput ?? entry.output ?? '') : (entry.output ?? '')), + }; +} diff --git a/web/src/lib/toolPresentation.test.js b/web/src/lib/toolPresentation.test.js new file mode 100644 index 000000000..c4b501dcd --- /dev/null +++ b/web/src/lib/toolPresentation.test.js @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { i18n } from '../i18n/index.js'; +import { builtinTranscriptToolNames } from '../i18n/catalogs/toolTranscript.js'; +import { canonicalTranscriptTool, presentTool } from './toolPresentation.js'; +import { projectCollapsedTranscriptItems } from './transcriptProjection.js'; +import { createTranscriptState, loadTranscriptHistory } from './sessionTranscript.js'; + +const t = lng => (key, options) => i18n.t(key, { ...options, lng }); +const zh = t('zh-CN'); +const en = t('en-US'); +function run(name, fn) { fn(); console.log(`[pass] ${name}`); } +const root = fileURLToPath(new URL('../../../', import.meta.url)); +function source(file) { return fs.readFileSync(path.join(root, file), 'utf8'); } +function cppFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() + ? cppFiles(path.join(dir, e.name)) : e.name.endsWith('.cpp') ? [path.join(dir, e.name)] : []); +} + +run('全部内置定义和动态工具家族都有独立中英文名称', () => { + const names = new Set(); + const roots = ['src/adapters/tool', 'src/host/session_host/tools', 'src/host/loop']; + for (const file of roots.flatMap(dir => cppFiles(path.join(root, dir)))) { + const text = fs.readFileSync(file, 'utf8'); + for (const pattern of [/\b(?:def|definition)\.name\s*=\s*"([\w]+)"/g, /make_tool\(\s*"([\w]+)"/g, /\bkToolName\s*=\s*"([\w]+)"/g]) { + for (const match of text.matchAll(pattern)) if (match[1] !== 'computer_') names.add(match[1].toLowerCase()); + } + } + for (const match of source('src/adapters/tool/computer_use_tool.cpp').matchAll(/\badd\("([\w]+)"/g)) names.add(`computer_${match[1]}`); + for (const match of source('src/adapters/tool/agent_browser/browser_tools.cpp').matchAll(/^\s*"(browser_[\w]+)",?\s*$/gm)) { + if (!match[1].endsWith('_failed')) names.add(match[1]); + } + // Runtime-selected names in create_message_tool are not string arguments. + names.add('agent_send_message'); names.add('agent_followup_task'); + assert.ok(names.size >= 75, `inventory unexpectedly small: ${names.size}`); + for (const name of names) { + assert.ok(builtinTranscriptToolNames.includes(name), `missing native tool: ${name}`); + const a = presentTool({ tool: name }, zh), b = presentTool({ tool: name }, en); + assert.ok(a.builtin && b.builtin, name); + assert.match(a.label, /[\u3400-\u9fff]/, name); + assert.match(b.label, /^[A-Za-z]/, name); + assert.doesNotMatch(b.label, /toolTranscript|_/, name); + } + console.log(`Verified ${names.size} registered native tool names`); +}); + +run('读取文件在各生命周期用同一名称,Windows/POSIX/UNC 路径仅显示文件名', () => { + for (const file of ['N:\\work\\src\\index.vue', '/project/src/index.vue', '\\\\server\\share\\index.vue']) { + for (const isDone of [true, false]) for (const success of [true, false]) { + const entry = { tool: 'file_read', args: { file_path: file, max_bytes: 15000 }, isDone, success, + summary: { verb: success ? 'Read' : 'File_read', object: file, metrics: [{ label: 'lines', value: '200' }] } }; + const a = presentTool(entry, zh), b = presentTool(entry, en); + assert.equal(a.label + a.separator + a.detail, '读取文件:index.vue'); + assert.equal(b.label + b.separator + b.detail, 'Read File: index.vue'); + assert.equal(a.fields.filter(f => f.key === 'path').length, 1); + assert.equal(a.fields.find(f => f.key === 'path').value, file); + assert.equal(a.fields.find(f => f.key === 'max_bytes').value, '15000'); + } + } +}); + +run('原始协议别名与无名称的历史摘要仍可读', () => { + for (const tool of ['Read', 'read_file', 'functions.file_read']) assert.equal(canonicalTranscriptTool({ tool }), 'file_read'); + assert.equal(presentTool({ summary: { verb: 'Read', object: '/tmp/index.vue' } }, zh).detail, 'index.vue'); + assert.equal(presentTool({ tool: 'read', args: { file_path: '/tmp/index.vue' } }, en).label, 'Read File'); + assert.equal(presentTool({ tool: 'mcp__server__read', args: {} }, zh).label, 'mcp__server__read'); +}); + +run('搜索仅显示搜索词,返回上限带标签且不重复搜索词', () => { + const p = presentTool({ tool: 'grep', args: { pattern: '权限下发', path: 'web/src', include_pattern: '*.vue', head_limit: 50 }, + summary: { verb: 'Grep', object: '权限下发 · *.vue · 50' } }, zh); + assert.equal(p.detail, '权限下发'); + assert.deepEqual(p.fields.map(f => f.value), ['web/src', '*.vue', '最多 50 条']); + assert.equal(p.fields.some(f => f.value === '权限下发'), false); +}); + +run('命令字段不重复,耗时去重,原始输出与命令空白不被修改', () => { + const entry = { tool: 'bash', isDone: true, args: { command: 'pnpm test', cwd: '/tmp', timeout_ms: 10000 }, + output: 'raw\n\nresult\n', elapsed: 3, summary: { verb: 'Ran', metrics: [{ label: 'time', value: '2.1s' }, { label: 'bytes', value: '5KB' }, { label: 'exit', value: '0' }] } }; + const original = structuredClone(entry); + const p = presentTool(entry, zh); + assert.equal(p.detail, 'pnpm test'); + assert.equal(p.fields.filter(f => f.key === 'elapsed').length, 1); + assert.equal(p.fields.some(f => f.key === 'command'), false); + assert.equal(p.output, entry.output); + assert.deepEqual(entry, original); +}); + +run('详情保留参数/零值结果,省略空值默认开关并不暴露敏感参数', () => { + const p = presentTool({ tool: 'browser_click', args: { target: '@e3', x: 0, y: 10, password: 'secret', enabled: false, text: '', key: '' } }, en); + assert.equal(p.detail, '@e3'); + assert.equal(p.fields.find(f => f.key === 'x').value, '0'); + assert.equal(JSON.stringify(p).includes('secret'), false); + assert.equal(p.fields.some(f => f.key === 'text'), false); +}); + +run('代码分析和模拟器操作按实际动作翻译,文件参数精简', () => { + const p = presentTool({ tool: 'lsp', args: { operation: 'goToDefinition', file_path: '/src/main.cpp', line: 4, character: 8 } }, zh); + assert.equal(p.label, '跳转定义'); assert.equal(p.detail, 'main.cpp'); + assert.equal(p.fields.some(f => f.key === 'operation'), false); + assert.equal(presentTool({ tool: 'ios_simulator', args: { action: 'screenshot', device: 'iPhone' } }, en).label, 'Capture Simulator'); +}); + +run('历史结构化非 Shell 工具恢复身份参数,投影不修改原始记录', () => { + const args = { pattern: '权限', path: 'web', head_limit: 40 }; + const history = { messages: [ + { id: 'c', role: 'assistant', content: '', tool_calls: [{ id: 'one', type: 'function', function: { name: 'grep', arguments: JSON.stringify(args) } }] }, + { id: 'r', role: 'tool', tool_call_id: 'one', content: 'index.vue:42', metadata: { tool_summary: { verb: 'Grep', object: '权限 · web · 40' }, tool_success: true } }, + ] }; + const raw = loadTranscriptHistory(createTranscriptState(), history).state.items; + const original = structuredClone(raw); + const [item] = projectCollapsedTranscriptItems(raw, { messageAutoCollapse: false }); + assert.equal(item.tool.tool, 'grep'); assert.deepEqual(item.tool.args, args); + assert.equal(presentTool(item.tool, zh).detail, '权限'); + assert.deepEqual(raw, original); +}); + +run('旧参数/结果包装只在结果区展示真实结果,实时参数优先', () => { + const call = { kind: 'msg', id: 1, role: 'tool_call', toolCallId: 'one', content: '[Tool: file_read] {"file_path":"/tmp/index.vue"}' }; + const result = { kind: 'msg', id: 2, role: 'tool_result', toolCallId: 'one', content: '1: