Compare commits

...
21 Commits
Author SHA1 Message Date
mesalogo 47306c5b0e docs: include local Agent inventory fix in 0.16.3
GoodBuddy Agents / Validate Agent source (push) Waiting to run
GoodBuddy Agents / Build darwin arm64 Agent (push) Blocked by required conditions
GoodBuddy Agents / Build linux arm64 Agent (push) Blocked by required conditions
GoodBuddy Agents / Build linux x64 Agent (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / Authorize desktop packaging (push) Blocked by required conditions
Cross-platform packages / linux arm64 (push) Blocked by required conditions
Cross-platform packages / macos arm64 (push) Blocked by required conditions
Cross-platform packages / windows arm64 (push) Blocked by required conditions
Cross-platform packages / linux x64 (push) Blocked by required conditions
Cross-platform packages / macos x64 (push) Blocked by required conditions
Cross-platform packages / windows x64 (push) Blocked by required conditions
Cross-platform packages / Publish GitHub and OSS release (push) Blocked by required conditions
Deploy website to GitHub Pages / Deploy static website (push) Waiting to run
2026-10-14 00:58:03 +08:00
mesalogo a63060e097 fix(agent-packages): show local inventory before refresh 2026-10-14 00:53:23 +08:00
mesalogo 3ba1ef305d chore: prepare Desktop 0.16.3 release 2026-10-14 00:50:13 +08:00
mesalogo af86e2f8a1 fix(release): publish AppImages with SquashFS names
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
2026-10-14 00:22:53 +08:00
mesalogo 5e255747ee docs(hosts): record the Hosts application first phase
Mark the host management entry migration as implemented across the PRD,
logic, UI and technical designs, and add a progress record with the
verification results and remaining gaps. Update the feature lists to point
to the new remote capability switch.
2026-10-13 08:09:20 +08:00
mesalogo 2e204e6a4b fix(workspace): align project menu edge 2026-10-13 01:30:18 +08:00
mesalogo 05838d2c50 feat(hosts): move host management into the Hosts application
Hosts becomes a built-in application opened from the Apps menu, with Hosts
and Agent packages tabs. The remote capability switch keeps using
remoteProjectsEnabled and moves to the application settings, so Main-side
gating and saved values are unchanged. Saved navigation gains the new entry
at the end, unpinned.

Settings drops the SSH hosts category and the Remote projects tab. The host
list collapses to one-line summaries with status badges, versions show as a
table, and application settings switches align to the right edge.
2026-10-13 01:15:53 +08:00
mesalogo 89bf5840aa fix(workspace): resize project menu 2026-10-13 01:08:59 +08:00
mesalogo a70fb2c13b fix(workspace): clarify system app open label
The workspace action was labeled as opening with the default application, which did not clearly describe that the operating system chooses the handler. Update the Chinese and English labels, accessibility text, tests, and feature documentation to refer to the system application.

Release note: 将工作区文件和目录操作中的“使用默认应用打开”改为“使用系统应用打开”,让系统应用选择行为更清晰。
2026-10-13 00:44:56 +08:00
mesalogo b22be5146b feat(settings): add a scrollbar minimap switch under Appearance
chatMinimapEnabled defaults to on; turning it off restores the native message scrollbar while keeping continuous history and DOM windowing.
2026-10-12 21:26:39 +08:00
mesalogo 4edc34c625 fix(chat): allow all files in attachment picker 2026-10-12 14:25:39 +08:00
mesalogo 6694aa141e feat(chat): replace the history scrollbar with a minimap
Loaded history is reachable without 80-message batches; DOM windowing stays at ~60 rows. The minimap draws a stable content-weighted overview, expands to 80 px on hover, and mutes cancelled replies like their status line.
2026-10-12 12:51:59 +08:00
mesalogo bcb1ebbe62 fix(chat): add shimmer to waiting status 2026-10-11 22:26:50 +08:00
mesalogo ae46cccc20 fix(supervisor): anchor 3D graph zoom to the pointer 2026-10-11 20:18:19 +08:00
mesalogo 8b10ed583e chore: prepare Desktop 0.16.2 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
2026-10-11 11:46:35 +08:00
mesalogo f2f4c1273e fix(supervisor): review only finished sources and add an all-time range
Reviews now snapshot only messages in a terminal state (complete or error) and finished tasks, so a reply still streaming when the review starts waits for the next review instead of changing under it. A frozen source that still changes is skipped for that run and left for the next review, replacing the re-review at the latest version and its 200-source cap. The review period picker gains an All time option.
2026-10-10 15:44:14 +08:00
mesalogo ba30852058 fix(magic-notes): label entry actions and show note more button on hover 2026-10-10 13:43:14 +08:00
mesalogo 12a0291279 fix(terminal): contain IME composition layout 2026-10-10 12:55:18 +08:00
mesalogo 3ebb4f0b79 chore: prepare Desktop 0.16.1 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
2026-10-10 01:25:01 +08:00
mesalogo c7f09e1e53 fix(composer): let messages fade right up to the composer edge
Overlap the composer wrap with the message region by its top padding so messages scroll under the fade until the input outline, and paint the solid backdrop only below the composer's top edge. Drop the dark theme rule that still painted the whole wrap, keep the fade at 16px with a stronger curve, and lift the scroll-to-bottom button to preserve its gap.

Release note: 修复深色主题下聊天输入框上方残留的实色横条,消息可一直淡出到输入框边缘。
2026-10-10 01:13:44 +08:00
mesalogo d9dcb65d10 fix(composer): blend the message fade into the composer outline
Extend the composer fade down to the input card's top edge and layer it beneath the card, so scrolling messages fade straight into the composer outline. Remove the full-width composer-wrap background that produced a hard horizontal band, and soften the focus ring. Keep the dark theme fade on the canvas color.

Release note: 优化聊天输入框上方的渐变过渡,消息自然淡出到输入框边缘,不再出现横向色带。
2026-10-10 01:00:31 +08:00
97 changed files with 3368 additions and 1479 deletions
+11 -1
View File
@@ -206,8 +206,9 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
sudo apt-get update sudo apt-get update
sudo apt-get install --no-install-recommends --yes rpm sudo apt-get install --no-install-recommends --yes rpm file
rpmbuild --version rpmbuild --version
file --version
- name: Resolve macOS signing mode - name: Resolve macOS signing mode
id: macos-signing id: macos-signing
@@ -312,6 +313,15 @@ jobs:
ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_CACHE: ${{ runner.temp }}/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
- name: Verify AppImage uses SquashFS
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
appimage="$(find "dist/release/${{ matrix.platform }}-${{ matrix.arch }}" -maxdepth 1 -type f -name '*.AppImage' -print -quit)"
test -n "$appimage"
file "$appimage" | grep -qi squashfs
- name: Upload release packages - name: Upload release packages
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
+17 -5
View File
@@ -291,8 +291,8 @@ records are listed separately and do not introduce another feature status.
Runtime activation reuses the registry and manifest verified during setup Runtime activation reuses the registry and manifest verified during setup
instead of rehashing or rechecking the complete OpenCode binary. instead of rehashing or rechecking the complete OpenCode binary.
- [x] **Managed SSH OpenCode and Continue**: Controlled by the - [x] **Managed SSH OpenCode and Continue**: Controlled by the
separate Remote Projects tab under Settings / Platform Enable remote capability switch in the Hosts application settings
Features and disabled by default. Disabling it does not affect local and disabled by default. Disabling it does not affect local
projects, ordinary desktop capabilities, or desktop releases. When enabled, projects, ordinary desktop capabilities, or desktop releases. When enabled,
users can manage SSH Hosts with pinned Host Keys, browse bounded remote users can manage SSH Hosts with pinned Host Keys, browse bounded remote
directories, and create remote projects. Requests start the signed Runtime directories, and create remote projects. Requests start the signed Runtime
@@ -983,9 +983,19 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases ### Open source, builds, and releases
- The current Desktop candidate is `0.16.0`; Agent `0.15.6` remains paired with Desktop `0.16.0`, with - The current Desktop candidate is `0.16.3`; Agent `0.15.6` remains paired with Desktop `0.16.3`, with
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
Desktop and Agent release channels. Desktop and Agent release channels.
- Desktop `0.16.3` moves remote-host management into the Apps center, adds a chat-history minimap
with an Appearance switch, and improves navigation, waiting-state feedback, graph zoom, and
attachment boundaries. Hosts shows locally available Agent packages before refreshing remote
information, and schema remains unchanged.
- Desktop `0.16.2` adds an all-time Supervisor review range and waits for messages and tasks to
settle before including them in a review. It also improves Magic Notes action labels, contains
terminal IME composition layout, and upgrades the database to schema 63.
- Desktop `0.16.1` smooths the message-to-composer transition: messages extend to the composer
outline before fading, and light/dark theme backgrounds and focus boundaries no longer create
an abrupt solid band above the input. Schema remains unchanged.
- Desktop `0.16.0` moves expert and team-role selection into the composer and persists the - Desktop `0.16.0` moves expert and team-role selection into the composer and persists the
selection per conversation. Historical messages retain the role used for their request, selection per conversation. Historical messages retain the role used for their request,
including after a role is renamed or removed; schema remains unchanged. including after a role is renamed or removed; schema remains unchanged.
@@ -1217,8 +1227,10 @@ records are listed separately and do not introduce another feature status.
offline GoodBuddy transfer release acceptance. Until these gates pass, this offline GoodBuddy transfer release acceptance. Until these gates pass, this
path must not be described as published or as having passed formal release path must not be described as published or as having passed formal release
acceptance. acceptance.
- [ ] **Host and cloud environment management** (planned): Move host management - [ ] **Host and cloud environment management** (partly done): On 2026-10-13 host
into App Center with a list/detail workspace. A sibling cloud application management moved into App Center as the Hosts application, with the host list
and Agent packages; see [progress](./docs/features/remote-host/progress.md).
The list/detail workspace is still planned. A sibling cloud application
reuses host connections and remote execution. The first-version adapter plan reuses host connections and remote execution. The first-version adapter plan
covers AWS, Azure, Alibaba Cloud, Tencent Cloud, VMware vCenter / vSphere, covers AWS, Azure, Alibaba Cloud, Tencent Cloud, VMware vCenter / vSphere,
and ZStack, delivered in batches with real instance discovery, import, and and ZStack, delivered in batches with real instance discovery, import, and
+7 -3
View File
@@ -53,7 +53,7 @@
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数按当前账号权限发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并应用多文件补丁;读取和补丁 API 保留工作区相对路径合同。用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。 - [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数按当前账号权限发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并应用多文件补丁;读取和补丁 API 保留工作区相对路径合同。用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。 - [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。请求使用所选 SSH 账号权限和已接入能力,无产品模式或一般工具审批。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。 - [x] **托管 SSH OpenCode 与 Continue**:该能力由应用中心“主机管理 > 应用设置”中的“启用远程能力”开关控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。请求使用所选 SSH 账号权限和已接入能力,无产品模式或一般工具审批。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。 - [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- **SSH Host 环境准备验证记录**:此前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。 - **SSH Host 环境准备验证记录**:此前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。 - [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
@@ -159,7 +159,11 @@
### 开源、构建与发布 ### 开源、构建与发布
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。 - Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
- 当前 Desktop 候选为 `0.16.0`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.0`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。 - 当前 Desktop 候选为 `0.16.3`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.3`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.16.3` 将远程主机管理迁移到应用中心,增加可在外观设置中关闭的聊天历史缩略图,并改进导航、等待状态、图谱缩放和附件边界;schema 保持不变。
- Hosts 会在刷新远程信息前先显示本地已有的 Agent 包,刷新过程中不会暂时隐藏这些包。
- Desktop `0.16.2` 增加全部时间的监督回顾范围,并等待消息和任务完成后再纳入回顾;同时改进 Magic Notes 操作标签、修复终端输入法布局,数据库升级到 schema 63。
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。 - Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。 - Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
- Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。 - Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。
@@ -196,7 +200,7 @@
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。 - [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
- **SSH 主机与远程执行空间历史验证记录**:当时已接线主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验。Linux x64 当时源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包;现有记录未覆盖当前 Linux x64/arm64 正式签名工件的完整公开核对及 GitHub/北京镜像、双架构和离线 GoodBuddy 传输矩阵,发布结果另行记录。 - **SSH 主机与远程执行空间历史验证记录**:当时已接线主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验。Linux x64 当时源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包;现有记录未覆盖当前 Linux x64/arm64 正式签名工件的完整公开核对及 GitHub/北京镜像、双架构和离线 GoodBuddy 传输矩阵,发布结果另行记录。
- [ ] **主机管理与云环境管理**(规划中):主机管理迁入应用中心,提供列表与详情工作页面;云环境管理作为同级应用,复用主机连接和执行能力。首版适配计划覆盖 AWS、Azure、阿里云、腾讯云、VMware vCenter / vSphere、ZStack,分批完成已有实例发现、接入及真实执行验收;实例创建、启停、销毁和网络编排后续交付。平台 API 尚未实现,现有云主机可按 SSH 条件手工接入。详见[产品范围](./docs/features/remote-host/prd.md)。 - [ ] **主机管理与云环境管理**(部分完成):2026-10-13 主机管理已迁入应用中心,提供主机列表与 Agent 离线包,见[实施进度](./docs/features/remote-host/progress.md);列表与详情两栏仍待实现;云环境管理作为同级应用,复用主机连接和执行能力。首版适配计划覆盖 AWS、Azure、阿里云、腾讯云、VMware vCenter / vSphere、ZStack,分批完成已有实例发现、接入及真实执行验收;实例创建、启停、销毁和网络编排后续交付。平台 API 尚未实现,现有云主机可按 SSH 条件手工接入。详见[产品范围](./docs/features/remote-host/prd.md)。
## 规划原则 ## 规划原则
+1 -1
View File
@@ -163,7 +163,7 @@
- 页面顶部和底部内边距默认为 `32px`。 - 页面顶部和底部内边距默认为 `32px`。
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。 - `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。 - 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
- 输入区顶部向消息区延伸 `--space-4`(16px)的透明到 `--surface-raised` 渐变,柔化消息裁切边缘;渐变不占布局空间、不拦截鼠标事件,并随主题切换。 - 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。 - 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
## 5. 信息层级 ## 5. 信息层级
+27
View File
@@ -1419,6 +1419,31 @@ function verifyArtifacts(directory, options) {
} }
} }
function normalizeAppImageArtifact(directory, options) {
if (!options.formats.includes('AppImage')) {
return
}
const candidates = readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.AppImage'))
.map((entry) => entry.name)
if (candidates.length !== 1) {
throw new Error(
`AppImage 产物数量错误:${candidates.join(', ') || '未生成'}`
)
}
const artifactName =
`${productName}-${packageJson.version}-` +
`${options.arch === 'x64' ? 'x86_64' : 'arm64'}.AppImage`
const source = join(directory, candidates[0])
const destination = join(directory, artifactName)
if (source !== destination) {
if (existsSync(destination)) {
throw new Error(`AppImage 目标文件已存在:${artifactName}`)
}
renameSync(source, destination)
}
}
function verifyArtifactSignature(filePath, format, arch) { function verifyArtifactSignature(filePath, format, arch) {
if (format === 'nsis') { if (format === 'nsis') {
if (readChunk(filePath, 2).toString('ascii') !== 'MZ') { if (readChunk(filePath, 2).toString('ascii') !== 'MZ') {
@@ -1656,6 +1681,7 @@ async function main(argv = process.argv.slice(2)) {
builderArguments, builderArguments,
electronBuilderEnvironment(options) electronBuilderEnvironment(options)
) )
normalizeAppImageArtifact(stagingDirectory, options)
const unpackedDirectory = verifyUnpackedOutput( const unpackedDirectory = verifyUnpackedOutput(
stagingDirectory, stagingDirectory,
options options
@@ -1718,6 +1744,7 @@ module.exports = {
verifyUnpackedOutput, verifyUnpackedOutput,
verifyArtifacts, verifyArtifacts,
verifyArtifactSignature, verifyArtifactSignature,
normalizeAppImageArtifact,
verifyPortableZip, verifyPortableZip,
writeManifest writeManifest
} }
@@ -0,0 +1,18 @@
# Desktop 0.16.1 发布准备
日期:2026-10-10。基线 `v0.16.0`,候选源码包含 `d9dcb65d` 和 `c7f09e1e`
两个 Composer 视觉修复提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
## 范围与兼容性
- 消息内容延伸到 Composer 输入框轮廓前再淡出,不再被输入区上方的实色区域提前截断。
- 浅色和深色主题使用匹配的消息到输入框渐变,输入框焦点边界不再形成粗边。
- 滚动到底部按钮保持与新的输入区过渡相配的可见位置。
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
## 发布门槛
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.1` 标签。
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
@@ -0,0 +1,24 @@
# Desktop 0.16.2 发布准备
日期:2026-10-10。基线 `v0.16.1`,候选源码包含 `12a02912`、`ba308520`
和 `f2f4c127` 三个提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
## 范围与兼容性
- 监督回顾增加从最早记录到当前时间的全部时间范围。
- 监督回顾只读取已完成消息和任务,实时生成中的内容等待完成后再进入回顾。
- Magic Notes 操作入口增加文字标签,次要更多操作按需显示。
- 终端输入法组合区域保持在终端边界内。
- 数据库 schema 从 62 升到 63;Agent、runtime/acp、wire 和运行时锁版本不变。
## 升级须知
首次启动会执行 schema 63 数据库升级。升级前关闭应用并备份完整数据目录、
相邻 `notes/` 和设置文件。旧客户端回退需要恢复升级前备份。
## 发布门槛
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.2` 标签。
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
@@ -0,0 +1,24 @@
# Desktop 0.16.3 发布准备
日期:2026-10-14。基线 `v0.16.2`,候选源码包含 `ae46cccc` 至 `af86e2f8`
之间的已提交变更。本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。
标准六平台 Desktop,不含 LoongArch。
## 范围与兼容性
- 将远程主机管理迁移到应用中心,提供 Hosts 和 Agent 包页签;原有远程项目和连接配置继续保留。
- Hosts 先显示本地已有的 Agent 包,再刷新远程信息,避免刷新期间隐藏本地库存。
- 聊天历史增加可关闭的缩略图导航,保留连续历史加载和窗口化显示。
- 改进应用中心、设置和项目菜单导航,等待状态、监督图谱缩放和附件选择边界。
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
## 工作区边界
开始准备时工作区包含用户未提交的 Hosts 文档修改:
`docs/features/application-tool-navigation/README.md` 和未跟踪的
`docs/features/external-apps/`。这些内容不属于候选,不得覆盖或提交。
## 发布门槛
完成发布说明校验、测试、三套 typecheck 和 lint 后,再确认候选提交并推送到
`origin` 与 `github`。本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
@@ -272,7 +272,7 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
[技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。 [技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。
原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。 原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
### FR-17 主机管理与云环境管理(待实现) ### FR-17 主机管理与云环境管理(主机管理已实现)
2026-09-28 确认新增两个同级应用:“主机管理”和“云环境管理”。主机管理从设置中心迁入, 2026-09-28 确认新增两个同级应用:“主机管理”和“云环境管理”。主机管理从设置中心迁入,
两个应用均从应用菜单、管理应用和常驻入口打开主内容区独立页面,不嵌入管理 Modal。 两个应用均从应用菜单、管理应用和常驻入口打开主内容区独立页面,不嵌入管理 Modal。
@@ -281,4 +281,8 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用 导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
主机能力。两个应用的能力依赖、迁移范围及产品验收由 主机能力。两个应用的能力依赖、迁移范围及产品验收由
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义, [主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。 业务页面布局见[界面设计](../remote-host/ui-design.md)。
2026-10-13 “主机管理”已实现,应用标识 `remote-hosts`,默认排在末尾、不常驻;“可打开”与
“已启用”分开判断,远程能力关闭时仍可打开页面。实现事实见
[主机管理实施进度](../remote-host/progress.md)。“云环境管理”仍待实现。
+1 -1
View File
@@ -444,7 +444,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
- 文件预览保留在右侧工作区内;预览期间隐藏列表但保持目录树挂载,返回后保留已展开 - 文件预览保留在右侧工作区内;预览期间隐藏列表但保持目录树挂载,返回后保留已展开
目录、滚动位置和选中行,并恢复触发行焦点。Diff 返回未提交更改列表时同样恢复 目录、滚动位置和选中行,并恢复触发行焦点。Diff 返回未提交更改列表时同样恢复
滚动位置、选中行和焦点。 滚动位置、选中行和焦点。
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。 - 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用系统应用打开”。
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。 刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。 - Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。 文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
@@ -1,6 +1,6 @@
# 聊天历史导航 # 聊天历史导航
聊天历史导航负责长会话的连续滚动、窗口化渲染,以及后续的对话 minimap。第一阶段先取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制;第二阶段再评估 minimap。 聊天历史导航负责长会话的连续滚动、窗口化渲染和对话 minimap。当前实现已经取消向上滚动时每次增加 80 条消息的产品限制,保留已有的消息窗口化机制,并用轻量 canvas 提供消息分布和快速定位。
## 文档导航 ## 文档导航
@@ -14,4 +14,4 @@
- **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。 - **历史窗口**:允许滚动访问的消息范围。第一阶段扩大为当前已加载会话的全部消息。
- **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。 - **DOM 窗口**:实际挂载消息组件的范围。继续由消息高度估算和最多约 60 条挂载消息控制。
- **minimap**:对话右侧的轻量纵向全局导航概览,不等于完整渲染所有消息。 - **minimap**:替代消息区原生滚动条的 canvas 全局概览,高度与滚动区一致,悬停时展开为较宽的缩略长图;不等于完整渲染所有消息。设置界面中称为“滚动条缩略图”(设置 › 外观),对应应用设置 `chatMinimapEnabled`,默认开启;关闭后恢复原生滚动条,历史窗口不受影响。
@@ -14,4 +14,69 @@
- App 测试通过 341/349;剩余 8 个失败集中在既有图片附件异步场景,未出现聊天历史窗口相关失败。 - App 测试通过 341/349;剩余 8 个失败集中在既有图片附件异步场景,未出现聊天历史窗口相关失败。
- 全局 `npm run typecheck` 仍被既有 `src/main/desktop-storage-electron-fixture.ts:215-217` 类型错误阻断;全局 lint 仍包含生成的 VitePress cache 错误。 - 全局 `npm run typecheck` 仍被既有 `src/main/desktop-storage-electron-fixture.ts:215-217` 类型错误阻断;全局 lint 仍包含生成的 VitePress cache 错误。
- A 组临时恢复后连续两次在 composer 初始化阶段超时,未进入性能场景,因此没有有效 A 组数据,不能据此推导 A/B 数值差异。B 组代码已恢复并重新完成 bundle、定向类型检查、定向 lint 和 58/58 聊天窗口测试。 - A 组临时恢复后连续两次在 composer 初始化阶段超时,未进入性能场景,因此没有有效 A 组数据,不能据此推导 A/B 数值差异。B 组代码已恢复并重新完成 bundle、定向类型检查、定向 lint 和 58/58 聊天窗口测试。
- 下一步实现 minimap 前仍需先修复性能驱动在 A 组接线下的 composer 初始化问题,或建立不依赖 composer 的长会话专用 benchmark,再进行严格 A/B 测量。
## 2026-10-11
- 已确认工作区干净,生产代码保持 `47917094` 的 A 组:向上滚动仍按 80 条揭示,DOM 窗口化仍保留。
- 当前聊天窗口和时间线定向测试:59/59 通过,包含 2,000 条消息连续滚动、实际挂载数量上限、锚点稳定、状态恢复和 80 条历史揭示。
- App 单元测试:342/350 通过;8 个失败均为等待状态圆点的既有异步断言,失败位置不涉及历史窗口逻辑。
- `npm run build:bundle`:通过。
- 当前 A 组首次可重复的 `npm run perf:app`:通过。长会话打开交互延迟 54.4 ms;长会话滚动无长任务,帧间隔 p95 8.5 ms;流式输入延迟 p95 10.0 ms;流式输入场景无长任务。
- 性能基准内存回收:24 次切换后 mounted panes 保持 6,DOM 从 24,746 降至约 2,446,堆从 50.5 MB 回收至约 22 MB,后半段增长约 0.1 MB。
- 之前记录的“A 组 composer 初始化超时”未在当前回退后的基线复现;现在已经取得有效 A 组数据,后续可在同一 bundle 和环境下重新接线 B 组做严格交替 A/B。
- minimap 尚未实现;在 B 组完成交替测量前不进入 minimap 性能对比。
- B 组已重新接线:`ConversationHistorySlot` 对完整会话传入 `conversation.messages.length`,App 层不再维护 80 条可见数量状态;摘要会话仍走原有历史加载流程。
- B 组 App 历史回归:4/4 通过,覆盖无历史闸门、长会话有界提交、暖切换位置和展开状态、超过 500 条历史保留。
- B 组 `npm run build:bundle`:通过;改动文件 web 类型检查和 lint:通过。
- B 组 `npm run perf:app`:通过。长会话打开交互延迟 46.8 ms,长会话滚动无长任务、帧间隔 p95 8.5 ms;流式输入延迟 p95 9.5 ms,无长任务。
- A/B 基准对比:B 组 DOM 在长会话滚动后约 2,644、24 次切换回收后约 2,446;A 组对应约 2,632 和 2,446。B 组 24 次切换后的堆约 22.9 MB,后半段增长约 0.1 MB;A 组约 22.0 MB,后半段增长约 0.1 MB。当前没有观察到明显滚动、流式或内存回收回归。
- 全量 App 测试:B 组 341/350 通过;剩余 9 个中 1 个为已迁移历史测试前的旧断言结果,8 个仍为既有等待状态圆点异步断言。迁移后的历史相关测试已单独通过。
- B 组尚未提交;下一步继续做多轮交替 A/B 和低端机模拟,再决定是否进入 minimap。
## 2026-10-12
- C 组已实现 minimap:使用单个 canvas 绘制用户、助手、工具/待处理和错误消息色带,不为每条消息增加 DOM 节点。
- minimap 显示当前滚动视口,支持点击、指针拖动、Home、End、PageUp 和 PageDown;使用现有滚动容器和消息高度估算定位。
- 新增 `ChatMinimap.test.tsx`,覆盖颜色分类、坐标边界、跳转和键盘操作;minimap、历史窗口和时间线定向测试共 62/62 通过。
- C 组 App 历史相关回归 4/4 通过;改动文件 lint、web 类型检查和 `npm run build:bundle` 通过。
- C 组 `npm run perf:app`:通过。长会话打开交互延迟 46.4 ms;长会话滚动帧间隔 p95 8.5 ms、无长任务;流式输入延迟 p95 9.6 ms、无长任务。
- B/C 单次对比:滚动 p95 均为 8.5 ms 且无长任务;B 组流式输入 p95 9.5 ms,C 组 9.6 ms;B 组 24 次切换后 DOM/堆约 2,446/22.9 MB,C 组约 2,449/23.1 MB,后半段堆增长均约 0.1 MB。
- C 组尚未做多轮交替和低端机模拟;当前结果支持继续保留实现并进入更广泛验证,不能单凭单次数据宣称最终性能结论。
### 2026-10-12 交互修订
评审发现初版 minimap 是悬浮在中部的 220 px 小条,视口指示经 `requestAnimationFrame` + React 状态更新而滞后于滚动,且没有悬停放大。已修订:
- minimap 替代消息滚动区的原生滚动条:`.chat-history-pane > .chat` 隐藏原生滚动条,minimap 高度与滚动区 `clientHeight` 一致并贴合右侧。
- 视口滑块在 `scroll` 事件和 `ResizeObserver` 回调中同步写入 `transform`,不经过 React 提交或下一帧;滚动区尺寸不变时只写 `transform`,不读布局。
- canvas 仅在消息变化、行高测量变化、尺寸变化或展开/收起时重绘;普通滚动不重绘。
- 悬停时展开为 96 px(窄屏 64 px)的缩略长图:用户消息靠右、助手消息靠左并按内容长度给出线宽;拖动中离开不收起。
- 点击轨道将滑块中心移到指针处,拖动滑块保持抓取偏移;滚轮转发给消息区;保留 Home/End/PageUp/PageDown;无障碍角色为 `scrollbar`,文案使用 i18n `chat.minimap`。
- 验证:minimap、历史窗口和时间线定向测试 65/65;web 类型检查和改动文件 lint 通过;`App.test.tsx` 342/350,8 个失败与此前相同(等待状态圆点异步断言);`npm run build:bundle` 通过。
- `npm run perf:app` 通过:长会话打开帧 p95 8.6 ms、无长任务;长会话滚动帧 p95 8.6 ms、无长任务;`stream-seeded`/`stream-and-type` 无长任务、帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23 MB,后半段增长 0.1 MB。
- 未验证:真实鼠标悬停/拖动的视觉效果仍需在桌面端人工确认;多轮交替与低端机模拟仍未做。
### 2026-10-12 第二次交互修订
- minimap 只覆盖阅读区:顶部从 `--chat-context-height`(任务条)开始,毛玻璃模式下再让出 58 px 标题栏;滑块轨道高度取 rail 实际高度,rail 自身尺寸变化由独立 `ResizeObserver` 触发重算。
- 展开宽度调为 120 px(后续按反馈改为 80 px)。展开视图改为逐行描线:`minimapLines` 按约 72 列折行(CJK 按两列计)、保留空行、代码块缩进并降低不透明度,每条消息最多取 600 行;消息块高度装不下时按行均匀抽样。行剖面按消息对象缓存在 `WeakMap`,流式更新产生新对象时才重算。
- 验证:定向测试 67/67(新增折行剖面和逐行绘制用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
- `npm run perf:app` 通过,各场景帧 p95 8.5–8.6 ms;长会话打开、长会话滚动、两个流式场景均无长任务;内存回收与上一轮一致。运行中出现的 renderer error 为网络更新检查 `AbortError`,与本功能无关。
### 2026-10-12 第三次交互修订
- 展开宽度改为 80 px(窄屏仍为 64 px)。
- 已取消的回复在缩略图中改为助手灰色,与正文状态行一致;只有失败的回复显示红色(复用 `isCancelledMessage`)。
- 修正滚动时缩略图密度变化:此前每条消息的色块高度取 `MessageRowHeights.size()`,未测量行使用“已测行平均高度”估算;滚动挂载新行后估算值和实测值变化,每次提交都会按新高度重画整张图。现改为双轴:
- 绘制轴:`minimapMessageWeight`(折行数 + 3 行头部 + 每个工具 2 行),只依赖消息内容;画布只在消息数组变化(新消息、流式输出)或尺寸、展开状态变化时重画,行测量不再触发重画。
- 滚动轴:行高前缀和,仅在 `scrollHeight` 变化时重建。滑块位置和高度按行在两轴之间分段线性映射,覆盖视口内消息在缩略图上的实际区段(最小 16 px);点击和拖动走反向映射。
- 验证:minimap、历史窗口、时间线定向测试 69/69(新增“行测量不��发重画”和“滑块对齐可见行的概览区段”用例);web 类型检查与 lint 通过;`npm run build:bundle` 通过。
- `npm run perf:app` 连续两次通过。第二次结果:长会话打开、长会话滚动、`stream-seeded`、`stream-and-type` 均无长任务,帧 p95 8.5 ms;24 次切换后 DOM 2,449、堆 23.2 MB,后半段增长 0.2 MB。第一次运行中长会话滚动帧 p95 为 16.6 ms,`stream-seeded` 有 1 个 62 ms 长任务,第二次未复现,按单次噪声记录。
- 未验证:真实桌面端滚动时的视觉稳定性需人工确认。
### 2026-10-12 外观设置开关
- 新增应用设置 `chatMinimapEnabled`(`z.boolean().default(true)`),旧设置文件缺省时读为开启,无需升级设置版本;`application.update` 配置工具复用同一更新 schema,自动接受该字段。
- 设置 › 外观新增开关“滚动条缩略图”/“Scrollbar minimap”,位于“透明磨砂特效”之后,保存后立即生效。
- 关闭时 `ChatHistoryPane` 不挂载 `ChatMinimap`,也不再设置 `data-minimap`,原生滚动条随之恢复;历史窗口(完整已加载消息)和约 60 条 DOM 窗口不变。
- 验证:设置存储(缺省默认、两种取值的持久化与重载、非法值拒绝)、外观开关(中英文)、窗格开关切换用例均通过;相关 8 个测试文件 410 项中修正配置快照夹具后全部通过;`App.test.tsx` 342/350,8 个失败与此前相同;web/node 类型检查(除既有 `desktop-storage-electron-fixture.ts`)与改动文件 lint 通过。
@@ -2,7 +2,9 @@
## 当前边界 ## 当前边界
`ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。第一阶段只移除前者在生产接线中的作用,保留后者以及消息高度占位和锚点恢复。 `ChatHistoryPane` 当前有两层机制:`messageRenderBatchSize = 80` 控制向上滚动时可访问的历史窗口;`maxRenderedMessageCount = 60` 控制实际挂载的消息组件。当前工作区处于 C 组实验:生产接线直接使用已加载消息总数,并用基于高度估算的 canvas minimap 替代消息区原生滚动条;DOM 窗口化、消息高度占位和锚点恢复保持不变。
minimap(`ChatMinimap.tsx`)的更新路径不经过 React:`scroll` 事件和 `ResizeObserver` 回调同步写入滑块 `transform`,只有滚动区尺寸、消息或行高测量变化时才重绘 canvas;悬停展开改写 `data-expanded` 后重绘一次。React 只在 `ChatHistoryPane` 自身提交时刷新消息数据。
已加载历史的消息数组仍由会话 store 提供。带 `messageSummary` 的会话继续先显示加载状态,由现有 `loadHistory` 路径取得完整历史。这样本实验测量的是滚动历史窗口的变化,不混入存储或 IPC 改造。 已加载历史的消息数组仍由会话 store 提供。带 `messageSummary` 的会话继续先显示加载状态,由现有 `loadHistory` 路径取得完整历史。这样本实验测量的是滚动历史窗口的变化,不混入存储或 IPC 改造。
@@ -14,7 +16,7 @@
| B 本阶段 | 当前已加载消息全部可访问 | 保留 | 无 | | B 本阶段 | 当前已加载消息全部可访问 | 保留 | 无 |
| C 下一阶段 | 当前已加载消息全部可访问 | 保留 | 有 | | C 下一阶段 | 当前已加载消息全部可访问 | 保留 | 有 |
A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B 组稳定后实现。 A 组是已记录的生产基线;B 组是无 minimap 的历史导航实验;C 组是当前工作区实验,待多轮对比和低端机模拟完成后再决定发布范围。
## 功能测试计划 ## 功能测试计划
@@ -38,7 +40,7 @@ A 组保留在组件级回归测试中;B 组是当前生产实验;C 组待 B
- 实际挂载消息数量; - 实际挂载消息数量;
- 流式输出时输入延迟。 - 流式输出时输入延迟。
通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。若 B 组稳定,再以同一批数据实现 C 组并做增量对比。 通过条件:B 组功能测试全部通过,实际挂载消息数量仍受窗口上限控制;相对 A 组没有明显的输入、流式输出或滚动回归。C 组还需在同一批数据上确认 minimap 的定位行为和增量性能成本。当前 A/B/C 数据记录在 `progress.md`。
## 验证命令 ## 验证命令
@@ -14,7 +14,7 @@ Main。IPC 先验证可信 sender,再使用共享 `contextImportFilesSchema`
没有本地路径的截图沿用 `context.addPastedImage`;无路径的非图片文件和混有无路径项目 没有本地路径的截图沿用 `context.addPastedImage`;无路径的非图片文件和混有无路径项目
的本地文件批次显示可处理的错误,不静默丢弃项目。 的本地文件批次显示可处理的错误,不静默丢弃项目。
`ContextManager.selectFiles` 只负责文件对话框,再调用与粘贴共用的 `importFiles`。 `ContextManager.selectFiles` 打开不按文件类型过滤的多选文件对话框,再调用与粘贴共用的 `importFiles`。
这个方法完成路径规范化、格式及大小检查、文本读取、图片转换、文档解析与上下文暂存。 这个方法完成路径规范化、格式及大小检查、文本读取、图片转换、文档解析与上下文暂存。
任一文件失败时删除本批次已经暂存的项目,保留之前的附件。文件系统错误不向 Renderer 任一文件失败时删除本批次已经暂存的项目,保留之前的附件。文件系统错误不向 Renderer
暴露绝对路径。 暴露绝对路径。
+9 -6
View File
@@ -3,8 +3,10 @@
本功能负责 SSH Host 管理、Host Key、远程环境安装、GoodBuddy Agent、签名 Runtime、 本功能负责 SSH Host 管理、Host Key、远程环境安装、GoodBuddy Agent、签名 Runtime、
远程 Workspace 和无模式工具执行生产路径。 远程 Workspace 和无模式工具执行生产路径。
2026-09-28 新增待实现方向:主机管理迁入应用中心,与“云环境管理”成为同级应用; 2026-09-28 确认方向:主机管理迁入应用中心,与“云环境管理”成为同级应用;
云平台发现已有实例,接入执行时复用 SSH 主机。产品范围见 [PRD](./prd.md)。 云平台发现已有实例,接入执行时复用 SSH 主机。产品范围见 [PRD](./prd.md)。
2026-10-13 已完成一期:主机管理成为应用中心中的独立应用,包含主机和 Agent 离线包,
设置中心不再提供主机入口,见[实施进度](./progress.md)。云环境管理仍待实现。
本文中的平台连接指云账号或私有化 API 连接,云实例指平台中的虚拟机,主机指 GoodBuddy 本文中的平台连接指云账号或私有化 API 连接,云实例指平台中的虚拟机,主机指 GoodBuddy
保存的 SSH 连接目标;三者不等同。现有实现和历史验收仍以下列技术文档为准。 保存的 SSH 连接目标;三者不等同。现有实现和历史验收仍以下列技术文档为准。
@@ -12,15 +14,16 @@
| 文档 | 权威职责 | | 文档 | 权威职责 |
| --- | --- | | --- | --- |
| [产品需求](./prd.md) | 应用迁移、六平台适配范围、分期与产品验收;待实现 | | [产品需求](./prd.md) | 应用迁移、六平台适配范围、分期与产品验收;主机管理一期已实现 |
| [功能逻辑](./logic-design.md) | 平台、实例、主机关系,独立状态、依赖和删除规则;待实现 | | [功能逻辑](./logic-design.md) | 平台、实例、主机关系,独立状态、依赖和删除规则;主机管理部分已实现 |
| [界面设计](./ui-design.md) | 独立工作页面、主机列表与详情、云实例接入和响应式交互;待实现 | | [界面设计](./ui-design.md) | 主机管理页面、主机列表与详情、云实例接入和响应式交互;一期已实现 |
| [实施进度](./progress.md) | 主机管理与云环境管理的已验证实现事实、验证结果和剩余工作 |
| [远程主机与 Agent 技术设计](./technical-design.md) | 当前产品语义、Agent、Runtime、Workspace、协议、生命周期和验证事实 | | [远程主机与 Agent 技术设计](./technical-design.md) | 当前产品语义、Agent、Runtime、Workspace、协议、生命周期和验证事实 |
| [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 | | [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 |
| [执行清单验证记录](./runtime-checklist-validation.md) | 远程 OC/CN 原生清单、CN 开发包安装、取消及断线重放的实测结果与剩余验收 | | [执行清单验证记录](./runtime-checklist-validation.md) | 远程 OC/CN 原生清单、CN 开发包安装、取消及断线重放的实测结果与剩余验收 |
既有技术文档保留当前 SSH 行为及历史进度;新增 PRD、逻辑和 UI 文档只定义本次待实现范围, 既有技术文档保留当前 SSH 行为及历史进度;PRD、逻辑和 UI 文档定义主机管理与云环境范围,
不替代已有验证事实。验收场景随 PRD 维护,本次不单独创建 User Stories 或进度占位文档。 实施进度只记录该范围的验证事实。验收场景随 PRD 维护,不单独创建 User Stories。
跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见 跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。 [Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
+5 -1
View File
@@ -1,6 +1,6 @@
# 主机与云环境功能逻辑 # 主机与云环境功能逻辑
状态:2026-09-28 设计,待实现。对应[产品需求 FR-RH-1 至 FR-RH-6](./prd.md),验收场景使用其中的 AC-RH 编号。 状态:2026-09-28 设计;“导航与能力依赖”中主机管理部分 2026-10-13 已实现(见[实施进度](./progress.md)),云环境部分待实现。对应[产品需求 FR-RH-1 至 FR-RH-6](./prd.md),验收场景使用其中的 AC-RH 编号。
## 对象与关联 ## 对象与关联
@@ -29,8 +29,12 @@
常驻只控制导航捷径,取消常驻不停止任务、禁用能力或删除数据。应用中心的主机管理设置承载共享远程能力启用状态,迁移保留原 `remoteProjectsEnabled` 值;旧设置不再维护第二个独立开关。云环境应用的启用单独控制云管理入口和新平台操作。(FR-RH-1、FR-RH-4、FR-RH-6) 常驻只控制导航捷径,取消常驻不停止任务、禁用能力或删除数据。应用中心的主机管理设置承载共享远程能力启用状态,迁移保留原 `remoteProjectsEnabled` 值;旧设置不再维护第二个独立开关。云环境应用的启用单独控制云管理入口和新平台操作。(FR-RH-1、FR-RH-4、FR-RH-6)
主机管理的“已启用”即 `remoteProjectsEnabled`,但“可打开”与“已启用”分开判断:设置读取成功后即可打开页面,以便就地启用和管理 Agent 离线包;主机服务仍由 Main 按 `remoteProjectsEnabled` 校验。常驻入口同样按“可打开”显示。
| 操作 | 目标行为 | | 操作 | 目标行为 |
| --- | --- | | --- | --- |
| 打开主机管理但远程能力未启用 | “主机”页签提供就地启用,不调用主机服务;“Agent 离线包”照常可用 |
| 在任一入口关闭远程能力 | 当前项目为 SSH 项目时切回第一个普通本地项目;若正在主机管理页面则停留在该页面 |
| 打开云环境但远程能力未启用 | 允许配置平台及查询实例;接入主机时提供就地启用,保存成功后继续 | | 打开云环境但远程能力未启用 | 允许配置平台及查询实例;接入主机时提供就地启用,保存成功后继续 |
| 取消主机常驻 | 云环境仍可引用主机,已有关联和运行不变 | | 取消主机常驻 | 云环境仍可引用主机,已有关联和运行不变 |
| 关闭共享远程能力 | 沿用现有远程项目停用语义,保留主机、凭据和项目;云资源浏览仍可用,依赖操作显示原因与启用入口 | | 关闭共享远程能力 | 沿用现有远程项目停用语义,保留主机、凭据和项目;云资源浏览仍可用,依赖操作显示原因与启用入口 |
+4 -2
View File
@@ -1,6 +1,6 @@
# 主机管理与云环境管理产品需求 # 主机管理与云环境管理产品需求
状态:2026-09-28 确认产品方向,待实现。本次仅更新文档;现有 SSH 实现与验证范围见[技术设计](./technical-design.md)。 状态:2026-09-28 确认产品方向。主机管理一期(FR-RH-1 中的主机管理入口,FR-RH-2 中的搜索与精简列表,FR-RH-6 中的配置迁移)2026-10-13 已实现,见[实施进度](./progress.md);列表与详情两栏、云环境管理和平台适配器待实现。现有 SSH 实现与验证范围见[技术设计](./technical-design.md)。
## 目标与范围 ## 目标与范围
@@ -25,7 +25,9 @@
### FR-RH-1 应用入口 ### FR-RH-1 应用入口
应用中心分别提供“主机管理”和“云环境管理”,打开独立主内容区页面,支持排序与常驻。应用中心只负责访问和通用应用设置,主机和云资源操作位于业务页面。迁移后移除设置中心内的重复主机管理表单;旧入口可提供跳转。 应用中心分别提供“主机管理”和“云环境管理”,打开独立主内容区页面,支持排序与常驻。应用中心只负责访问和通用应用设置,主机和云资源操作位于业务页面。迁移后移除设置中心内的重复主机管理表单。
主机管理页面包含主机和 Agent 离线包两部分;离线包只服务于远程主机的环境安装,随主机管理迁出设置中心。
### FR-RH-2 主机工作页面 ### FR-RH-2 主机工作页面
+32
View File
@@ -0,0 +1,32 @@
# 主机管理实施进度
本文只记录主机管理与云环境管理([PRD](./prd.md) FR-RH-1 至 FR-RH-6)的已验证实现事实。现有 SSH、Agent 和 Runtime 的历史验证见[技术设计](./technical-design.md)和[执行清单验证记录](./runtime-checklist-validation.md)。
## 2026-10-13 一期:主机管理迁入应用中心
提交:`05838d2c feat(hosts): move host management into the Hosts application`。
### 已完成
| 范围 | 对应需求 | 实现事实 |
| --- | --- | --- |
| 应用入口 | FR-RH-1 | 应用中心、应用菜单和常驻侧栏新增“主机管理”,打开独立工作页面;应用标识 `remote-hosts`,默认排在末尾、不常驻 |
| 远程能力开关 | FR-RH-1、FR-RH-6 | 开关移到“主机管理 > 应用设置”,沿用 `remoteProjectsEnabled`,Main 校验不变;远程能力关闭时页面仍可打开并就地启用 |
| Agent 离线包 | FR-RH-1 | 从“平台功能 > 远程项目”迁到主机管理的“Agent 离线包”页签;远程能力关闭时仍可管理 |
| 设置中心 | FR-RH-1 | 删除“主机与远程执行”分类和“平台功能 > 远程项目”页签,无重复入口 |
| 主机列表 | FR-RH-2(部分) | 名称或地址搜索;多台主机默认收起为一行,显示验证、执行环境和关联项目徽标;编辑、删除在卡片头部;版本信息改为表格 |
| 配置迁移 | FR-RH-6 | 已保存的导航顺序和常驻偏好保留,新应用追加到末尾;远程能力开关值、主机、凭据、Host Key 和项目引用不变 |
| 应用设置 | — | 各应用设置中的开关统一靠右对齐,包括带说明图标的开关 |
### 验证
- `npm run typecheck` 通过;改动文件的 `eslint` 无问题。
- 下列测试 9 个文件、265 项全部通过:`SshHostsSettingsSection`、`RemoteHostsPage`、`AgentPackageInventoryPanel`、`ApplicationCenter`、`ApplicationMenu`、`SettingsPanel`、`settings-categories`、`application-settings-store`、`goodbuddy-config-contracts`。
- 存储测试覆盖:已发布的五应用完整顺序在远程能力开启和关闭两种情况下升级,新应用追加到末尾且常驻为否,重新读取后不变。
- `App.test.tsx` 350 项中有 8 项失败,均为流式消息与持久化终态收敛用例。在改动前的提交 `b22be514` 上单独运行这 8 项同样失败,与本次改动无关。
### 未验证与剩余工作
- 尚未在真实运行的应用中检查主机列表、离线包页签和应用设置开关对齐,也未覆盖深色主题和窄窗口;以上界面行为目前只有组件测试。
- 尚未连接真实主机执行添加、验证、安装和创建远程项目的完整流程。
- 未实现:列表与详情两栏、来源与连接状态筛选、关联项目页签(FR-RH-2 其余部分),云环境管理和平台适配器(FR-RH-3 至 FR-RH-5)。
+32 -16
View File
@@ -64,11 +64,12 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
## 产品语义 ## 产品语义
本节记录现有实现。2026-09-28 确认的应用中心迁移与云环境管理目标见 本节记录现有实现。2026-09-28 确认的应用中心迁移与云环境管理目标见
[PRD](./prd.md)、[功能逻辑](./logic-design.md)及[UI 设计](./ui-design.md),尚未接入源码。 [PRD](./prd.md)、[功能逻辑](./logic-design.md)及[UI 设计](./ui-design.md);其中主机管理
入口迁移已于 2026-10-13 接入源码,云环境管理尚未实现。
远程 Host 与项目能力默认关闭。用户在“设置 > 平台功能”的独立 远程 Host 与项目能力默认关闭。用户在应用中心“主机管理 > 应用设置”的“启用远程能力”开关,
“远程项目”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存 或主机管理页面“主机”页签的就地启用按钮开启后,Renderer 才显示 SSH Host 管理、托管 SSH
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、 项目创建和已保存远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定 项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。 选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
@@ -84,21 +85,35 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
## 组件 ## 组件
### 应用迁移与云平台接入计划(待实现) ### 主机管理应用(2026-10-13 已实现)
实现顺序对应 FR-RH-1 至 FR-RH-6: - 应用标识 `remote-hosts` 加入 `builtInApplicationIds` 和 `editableApplicationIds`,
常驻键 `pinned['remote-hosts']` 默认 `false`。公开导航 schema 中该键在输入侧默认
`false`,旧调用方不传也能通过;顺序数组仍须包含全部六个应用。
- 不新增设置字段,也不提升设置文件版本:启用状态直接使用 `remoteProjectsEnabled`。
已保存的导航由现有 `storedApplicationSettingsSchema` 归一化,把缺失的 `remote-hosts`
追加到末尾并补齐常驻默认值,原有相对顺序不变。Main 的 `requireRemoteProjectsEnabled`、
IPC 通道名(`settings:ssh-hosts:*`)和 `ssh-hosts.json` 均未改动;配置工具 schema
由 zod 派生,自动包含新应用。
- Renderer 新增 `RemoteHostsPage`(懒加载,由 `KeepAliveRoute` 缓存),包含复用的
`SshHostsSettingsSection` 和从平台功能抽出的 `AgentPackageInventoryPanel`。两个面板
切换时保持挂载;主机草稿、环境安装或离线包传输进行中时,通过
`useWorkspaceUnsavedChanges` 固定该路由,避免页面缓存淘汰。
- `isApplicationOpenable` 与 `isApplicationEnabled` 分开:主机管理在设置读取成功后即可
打开,应用菜单、应用中心和常驻入口都按此判断。
- 远程开关的副作用(当前 SSH 项目切回本地项目)由 `applyApplicationSettings` 在开关值
变化时统一触发,覆盖应用中心、主机页面和外部设置变更三种来源。
- 设置中心删除 `ssh-hosts` 分类、平台功能的“远程项目”页签及相关 props;平台功能只剩
通用设置,不再显示页签条。
1. 从 `SshHostsSettingsSection` 提取独立主机工作页面,接入 `ApplicationCenter` 和 `App` 列表与详情两栏、来源和连接状态筛选、关联项目页签尚未实现。
页面导航;复用现有 SSH 服务、验证弹窗、安装操作及通知,完成列表与详情改版。
2. 扩展应用标识、排序、常驻和配置读取。读取已发布四应用配置时保留原相对顺序及偏好, ### 云平台接入计划(待实现)
新入口追加且默认不常驻;云管理初始关闭,共享远程能力继承 `remoteProjectsEnabled`。
配置工具公开契约、默认值、持久化、Preload 和 Main 校验同步修改,不能仅扩展 Renderer。 1. 云平台适配器在 Main 中查询真实 API,凭据复用加密存储;Renderer 仅接收脱敏配置、
3. 按功能逻辑整理能力依赖,将共享远程开关的设置入口迁入应用中心。保留旧值、Host ID、
加密凭据、Host Key 与项目引用;不为页面迁移重建主机或修改 Agent 协议。
4. 云平台适配器在 Main 中查询真实 API,凭据复用加密存储;Renderer 仅接收脱敏配置、
实例快照与操作结果。采用满足首批平台发现和导入的最小接口,不预建资源编排框架。 实例快照与操作结果。采用满足首批平台发现和导入的最小接口,不预建资源编排框架。
平台连接和实例关联采用独立记录,SSH 主机仍为执行目标,项目继续使用 `local / ssh`。 平台连接和实例关联采用独立记录,SSH 主机仍为执行目标,项目继续使用 `local / ssh`。
5. 确认首批平台版本及测试环境,补齐需要的私钥和私网连接路径,逐个验收发现、导入、 2. 确认首批平台版本及测试环境,补齐需要的私钥和私网连接路径,逐个验收发现、导入、
SSH 验证、环境安装和真实任务执行。具体存储字段、迁移及适配 API 在实施时细化; SSH 验证、环境安装和真实任务执行。具体存储字段、迁移及适配 API 在实施时细化;
变更已发布存储前遵守[数据库迁移规范](../../development/database-migrations.md)。 变更已发布存储前遵守[数据库迁移规范](../../development/database-migrations.md)。
@@ -187,7 +202,8 @@ Detached GoodBuddy Agent
GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host; GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host;
用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮 用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮
解耦且不建立连接;实际远程操作才建立或复用 SSH/Agent 连接。 解耦且不建立连接;实际远程操作才建立或复用 SSH/Agent 连接。
- “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中 - “主机管理 > Agent 离线包”中的本地 Agent 包清单与 Host 状态分离(2026-10-13 前位于
“平台功能 > 远程项目”)。包清单 IPC 不受远程能力开关限制。Main 对用户数据目录中
Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime
签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的 签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、 小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
+28 -8
View File
@@ -1,18 +1,34 @@
# 主机管理与云环境界面设计 # 主机管理与云环境界面设计
状态:2026-09-28 设计,待实现。范围见[PRD](./prd.md),状态及依赖遵守[功能逻辑](./logic-design.md)。视觉、页面壳层和组件遵守[统一界面设计系统](../../../UI-DESIGN.md)。 状态:主机管理一期(入口迁移与列表精简)2026-10-13 已实现,见[实施进度](./progress.md);列表与详情两栏和云环境页面仍待实现。范围见[PRD](./prd.md),状态及依赖遵守[功能逻辑](./logic-design.md)。视觉、页面壳层和组件遵守[统一界面设计系统](../../../UI-DESIGN.md)。
## 应用入口 ## 应用入口
应用中心新增“主机管理”和“云环境管理”两个同级应用。应用菜单、管理应用中的“打开”和常驻侧栏入口进入同一主内容区页面,关闭来源菜单或管理 Modal,不在应用中心 Modal 内管理主机。业务页面使用全局范围标识;打开页面不切换当前项目、不连接主机或执行安装。 应用中心新增“主机管理”和“云环境管理”两个同级应用,其中“主机管理”已实现。应用菜单、管理应用中的“打开”和常驻侧栏入口进入同一主内容区页面,关闭来源菜单或管理 Modal,不在应用中心 Modal 内管理主机。业务页面使用全局范围标识;打开页面不切换当前项目、不连接主机或执行安装。
通用启用、常驻和排序遵守[应用导航设计](../application-tool-navigation/ui-design.md)。设置中心移除主机操作表单,保留必要的迁移跳转;共享远程能力开关归入主机管理的应用设置。Agent 包下载、导入等现有全局包管理暂留原位置,主机执行环境页提供相关入口,不复制包状态存储。 通用启用、常驻和排序遵守[应用导航设计](../application-tool-navigation/ui-design.md)。设置中心不再包含“主机与远程执行”分类和“平台功能 > 远程项目”页签。共享远程能力开关只在应用中心的“主机管理 > 应用设置”中,开关文字为“启用远程能力”;业务页面不重复提供设置页签。
## 主机页面 远程能力关闭时,主机管理仍可从应用菜单和应用中心打开:“主机”页签显示“远程能力未启用”及“启用远程能力”按钮,不调用主机服务;“Agent 离线包”页签照常可用,便于提前下载或导入。
页面使用共享 `dashboard` 壳层。标题区显示“主机管理”、全局范围和主操作“添加主机”。工具栏提供名称或地址搜索、来源筛选、连接状态筛选。列表显示名称、地址、来源、SSH 状态和执行环境摘要,避免每行展开安装表单。 ## 主机页面(一期,已实现)
宽窗口使用列表与详情两栏,点击主机只更新详情,不重排列或滚动列表。详情使用共享 `PageTabs`: 页面使用共享 `standard` 壳层,标题区显示“主机管理”和全局范围。页签为“主机”和“Agent 离线包”;两个面板切换时保持挂载,安装、下载和导入的进度不中断。离线包页签首次显示时才读取包清单。
“主机”页签顶部是名称或地址搜索和“添加主机”。主机多于一台时,每台主机默认收起为一行,包含:
| 区域 | 内容 |
| --- | --- |
| 左侧 | 展开按钮、名称、`用户@地址:端口` |
| 状态徽标 | 验证状态;已验证时显示执行环境摘要(环境就绪、环境待更新、环境未安装、环境未检查、安装中);有关联项目时显示项目数 |
| 右侧 | “编辑”或“验证并保存”、“删除”,均为纯文字按钮 |
只有一台主机时默认展开。安装进行中、安装失败、版本读取失败或正在确认删除时,该主机强制展开且不能收起。保存或重新验证成功后自动展开该主机。展开后显示认证方式、凭据、验证状态、Host Key 指纹、连接结果和执行环境;执行环境的版本以表格呈现,列为“组件 / Host 已安装 / GoodBuddy 所需 / 状态”,悬停版本号显示架构。
删除确认在主机头部下方占满整行,列出会一并删除的关联项目记录,并说明不会连接主机或删除远端内容。展开状态只保存在当前页面,不持久化。
## 主机页面(后续,待实现)
后续改为列表与详情两栏,点击主机只更新详情,不重排列或滚动列表;工具栏补充来源筛选和连接状态筛选。详情使用共享 `PageTabs`:
| 页签 | 内容与操作 | | 页签 | 内容与操作 |
| --- | --- | | --- | --- |
@@ -35,10 +51,14 @@
首版不展示尚未实现的创建、销毁或网络编排按钮。各适配器随真实能力交付,不能用不可操作的平台卡片代替接入功能。 首版不展示尚未实现的创建、销毁或网络编排按钮。各适配器随真实能力交付,不能用不可操作的平台卡片代替接入功能。
## 应用设置
各应用的“应用设置”中,开关统一靠右对齐;带说明图标的开关,说明图标紧跟文字,开关仍在右侧对齐。
## 响应式与交互 ## 响应式与交互
两栏无法满足共享最小宽度时改为单面板,选择主机进入详情并提供“返回主机列表”,返回恢复筛选、滚动和列表项焦点。页签可横向滚动,搜索与筛选换行,长地址和错误信息折行,页面不整体横向溢出。 一期在窄容器下,主机行的状态徽标换到第二行,地址与名称上下排列,事实列改为单列。后续两栏无法满足共享最小宽度时改为单面板,选择主机进入详情并提供“返回主机列表”,返回恢复筛选、滚动和列表项焦点。页签可横向滚动,搜索与筛选换行,长地址和错误信息折行,页面不整体横向溢出。
列表项及操作使用语义按钮,提供可访问名称和可见焦点;页签沿用 `tablist`、`tab`、`tabpanel`、方向键及 Home/End。弹窗遵守共享焦点恢复及未保存编辑规则。通知使用全局应用通知,进行中的安装进度保留在对应主机,页面切换不重启操作。 列表项及操作使用语义按钮,提供可访问名称和可见焦点;一期展开按钮使用 `aria-expanded` 和 `aria-controls`,名称为“展开 / 收起 {主机名} 的详情”,编辑、删除按钮名称带主机名;页签沿用 `tablist`、`tab`、`tabpanel`、方向键及 Home/End。弹窗遵守共享焦点恢复及未保存编辑规则。通知使用全局应用通知,进行中的安装进度保留在对应主机,页面切换不重启操作。
验收对应 AC-RH-1 至 AC-RH-7,覆盖浅深主题、窄窗口、键盘操作、空列表、长名称、认证失败及安装取消;具体实施记录需附真实 UI 验证结果。 验收对应 AC-RH-1 至 AC-RH-7,覆盖浅深主题、窄窗口、键盘操作、空列表、长名称、认证失败及安装取消;具体实施记录需附真实 UI 验证结果。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.16.0", "version": "0.16.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.16.0", "version": "0.16.3",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1", "@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.16.0", "version": "0.16.3",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
+96
View File
@@ -1,6 +1,102 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"releases": [ "releases": [
{
"version": "0.16.3",
"releasedAt": "2026-10-14",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**将远程主机管理迁移到应用中心**:现在可以从“应用”菜单进入 Hosts,集中查看和管理远程主机、Agent 版本与可用安装包。主机管理和 Agent 包管理分成独立页签,原有远程项目和连接配置继续保留。",
"**更快查看本地 Agent 包**:打开 Hosts 时会先展示已经存在的本地 Agent 包库存,刷新远程信息不会让已有包暂时消失。",
"**为聊天历史增加缩略图导航**:长对话可以通过右侧缩略图快速定位消息,仍保留连续历史加载和窗口化显示;也可以在外观设置中关闭缩略图,恢复原生滚动条。",
"**优化应用中心和设置入口**:远程主机不再作为设置中的独立主机管理入口,应用中心、项目菜单和设置入口的布局与文字提示更加清晰。",
"**改善聊天和监督体验**:等待状态增加更明确的视觉反馈,监督图谱缩放会以指针位置为中心,文件附件选择范围和界面边界处理更加一致。"
],
"fixes": [],
"notices": [
"本版本不新增数据库 schema。首次使用 Hosts 应用前,请确认远程主机连接信息和 Agent 包来源;Agent 0.15.6 不需要更新。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Move remote-host management into the Apps center**: Open Hosts from the Apps menu to view and manage remote hosts, Agent versions, and available packages in one place. Host management and Agent package management use separate tabs, while existing remote projects and connection settings remain available.",
"**Show local Agent packages sooner**: Hosts displays the Agent packages already available locally before refreshing remote information, so existing packages do not temporarily disappear during refresh.",
"**Add a chat-history minimap**: Use the minimap beside long conversations to jump quickly between messages while continuous history loading and windowed rendering remain available. You can disable it in Appearance to restore the native scrollbar.",
"**Improve the Apps center and settings entry points**: Remote hosts are no longer managed through a separate settings entry, and the Apps center, project menu, and settings labels now provide clearer navigation.",
"**Improve chat and Supervisor interaction**: Waiting states provide clearer visual feedback, graph zoom centers on the pointer, and attachment selection plus related interface boundaries behave more consistently."
],
"fixes": [],
"notices": [
"This release adds no database schema version. Before using Hosts, confirm your remote-host connection details and Agent package source; Agent 0.15.6 does not need to be updated."
]
}
}
},
{
"version": "0.16.2",
"releasedAt": "2026-10-10",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**支持全部时间的监督回顾**:监督者现在可以选择从最早保存的工作记录到当前时间的全部时间范围,适合查看长期积累的工作进展。",
"**只在稳定后纳入监督回顾**:正在生成的消息和运行中的任务会等到完成后再进入回顾,避免回顾内容随着实时输出变化。"
],
"fixes": [
"**让 Magic Notes 操作更容易理解**:笔记入口和操作按钮增加清晰的文字标签,更多操作在需要时显示,减少误解和界面干扰。",
"**修复终端输入法布局**:在终端中输入中文或其他组合文字时,输入候选和组合区域会保持在终端边界内,不再撑开或遮挡周围界面。"
],
"notices": [
"本版本首次启动会将数据库升级到 schema 63。升级前请关闭应用并备份完整数据目录、相邻 `notes/` 和设置文件;Agent 0.15.6 无需更新。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Review all time in Supervisor**: Supervisor now offers an all-time range from the earliest saved work records through the current time, making it easier to review long-running progress.",
"**Review sources only after they settle**: Messages that are still streaming and tasks that are still running wait until completion before entering a review, so review content does not change underneath an active run."
],
"fixes": [
"**Make Magic Notes actions easier to understand**: Note entry points and actions now use clearer text labels, while secondary actions appear when needed to reduce confusion and visual noise.",
"**Contain terminal IME layout**: Chinese and other composition input in the terminal now stays inside the terminal bounds instead of expanding into or obscuring surrounding UI."
],
"notices": [
"First launch upgrades the database to schema 63. Close the app and back up the complete data directory, adjacent `notes/`, and settings files before upgrading; Agent 0.15.6 does not need to be updated."
]
}
}
},
{
"version": "0.16.1",
"releasedAt": "2026-10-10",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**让消息自然延伸到输入框边缘**:修复聊天消息在输入框上方提前被实色区域截断的问题,现在消息会沿着输入框轮廓自然淡出,输入区和消息区的过渡更平滑。",
"**改善输入框与消息区的主题适配**:调整浅色和深色主题下的渐变、背景和焦点边界,减少输入框上方出现粗边或明显横线的问题;滚动到底部按钮也会保持合适的可见位置。"
],
"notices": [
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Let messages fade naturally to the composer edge**: Chat messages no longer stop at a solid band above the composer. They now fade along the composer outline for a smoother transition between the message area and input area.",
"**Improve composer and message-area theme behavior**: Light and dark theme gradients, backgrounds, and focus boundaries are refined to reduce thick edges and visible horizontal bands above the composer; the scroll-to-bottom control also remains positioned for clear access."
],
"notices": [
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
]
}
}
},
{ {
"version": "0.16.0", "version": "0.16.0",
"releasedAt": "2026-10-09", "releasedAt": "2026-10-09",
+3
View File
@@ -87,6 +87,9 @@
: format === "rpm" : format === "rpm"
? "aarch64" ? "aarch64"
: "arm64"; : "arm64";
if (format === "AppImage") {
return `GoodBuddy-${version}-${artifactArch}.AppImage`;
}
return `GoodBuddy-${version}-linux-${artifactArch}.${format}`; return `GoodBuddy-${version}-linux-${artifactArch}.${format}`;
}; };
+2 -2
View File
@@ -42,12 +42,12 @@ const canonicalFileName = (version, platform, arch, format) => {
zip: `GoodBuddy-${version}-mac-arm64.zip`, zip: `GoodBuddy-${version}-mac-arm64.zip`,
}, },
"linux-x64": { "linux-x64": {
AppImage: `GoodBuddy-${version}-linux-x86_64.AppImage`, AppImage: `GoodBuddy-${version}-x86_64.AppImage`,
deb: `GoodBuddy-${version}-linux-amd64.deb`, deb: `GoodBuddy-${version}-linux-amd64.deb`,
rpm: `GoodBuddy-${version}-linux-x86_64.rpm`, rpm: `GoodBuddy-${version}-linux-x86_64.rpm`,
}, },
"linux-arm64": { "linux-arm64": {
AppImage: `GoodBuddy-${version}-linux-arm64.AppImage`, AppImage: `GoodBuddy-${version}-arm64.AppImage`,
deb: `GoodBuddy-${version}-linux-arm64.deb`, deb: `GoodBuddy-${version}-linux-arm64.deb`,
rpm: `GoodBuddy-${version}-linux-aarch64.rpm`, rpm: `GoodBuddy-${version}-linux-aarch64.rpm`,
}, },
+34 -4
View File
@@ -84,6 +84,20 @@ describe('ApplicationSettingsStore', () => {
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true) expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true)
}) })
it('defaults a missing scrollbar minimap setting to on and persists both choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null }
delete legacy.chatMinimapEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).chatMinimapEnabled).toBe(true)
for (const chatMinimapEnabled of [false, true]) {
expect((await store.update({ chatMinimapEnabled })).chatMinimapEnabled).toBe(chatMinimapEnabled)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).chatMinimapEnabled).toBe(chatMinimapEnabled)
}
await expect(store.update({ chatMinimapEnabled: 'false' })).rejects.toThrow()
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => { it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(true) expect((await store.get()).transparentFrostedEffectEnabled).toBe(true)
@@ -268,7 +282,7 @@ describe('ApplicationSettingsStore', () => {
const settings = await store.get() const settings = await store.get()
expect(settings.warnings).toBeUndefined() expect(settings.warnings).toBeUndefined()
expect(settings.magicNotesEnabled).toBe(false) expect(settings.magicNotesEnabled).toBe(false)
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } }) expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing', 'remote-hosts'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } })
expect(applicationSettingsSchema.parse(settings)).toEqual(settings) expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
await store.update({ checkUpdatesOnStartup: false }) await store.update({ checkUpdatesOnStartup: false })
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation) expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
@@ -278,8 +292,8 @@ describe('ApplicationSettingsStore', () => {
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => { it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
const { store, filePath } = await createStore() const { store, filePath } = await createStore()
const applicationNavigation = { const applicationNavigation = {
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'], order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false }, pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true },
} }
const changed = vi.fn() const changed = vi.fn()
store.onChanged(changed) store.onChanged(changed)
@@ -323,11 +337,27 @@ describe('ApplicationSettingsStore', () => {
pinned: { 'magic-notes': false, 'local-inference': pinned } pinned: { 'magic-notes': false, 'local-inference': pinned }
} }
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8') await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] } const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true, 'remote-hosts': false }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] }
expect((await store.get()).applicationNavigation).toEqual(normalized) expect((await store.get()).applicationNavigation).toEqual(normalized)
await store.update({ checkUpdatesOnStartup: false }) await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized) expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
}) })
it.each([true, false])('adds Hosts after a saved full order and keeps remote projects %s', async (remoteProjectsEnabled) => {
const { filePath, store } = await createStore()
const applicationNavigation = {
order: ['device-sharing', 'local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
pinned: { 'magic-notes': false, heartbeat: false, 'local-inference': true }
}
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, remoteProjectsEnabled, applicationNavigation, version: 12, lastSeenReleaseNotesVersion: null }), 'utf8')
const expected = { order: [...applicationNavigation.order, 'remote-hosts'], pinned: { ...applicationNavigation.pinned, 'remote-hosts': false } }
const migrated = await store.get()
expect(migrated.warnings).toBeUndefined()
expect(migrated.remoteProjectsEnabled).toBe(remoteProjectsEnabled)
expect(migrated.applicationNavigation).toEqual(expected)
expect(applicationSettingsSchema.parse(migrated)).toEqual(migrated)
await store.update({ checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ remoteProjectsEnabled, applicationNavigation: expected })
})
it('migrates released settings without replacing explicit false values or comment choices', async () => { it('migrates released settings without replacing explicit false values or comment choices', async () => {
const { filePath, store } = await createStore() const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' } const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
+4
View File
@@ -252,6 +252,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: true, transparentFrostedEffectEnabled: true,
chatMinimapEnabled: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -295,6 +296,7 @@ export class ApplicationSettingsStore {
| 'conversationHtmlRenderingEnabled' | 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled' | 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled' | 'transparentFrostedEffectEnabled'
| 'chatMinimapEnabled'
| 'localToolEnvironment' | 'localToolEnvironment'
| 'version' | 'version'
| 'applicationNavigation' | 'applicationNavigation'
@@ -532,6 +534,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup, checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled, desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled, transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
chatMinimapEnabled: stored.chatMinimapEnabled,
updateSource: stored.updateSource, updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource, modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment, localToolEnvironment: stored.localToolEnvironment,
@@ -581,6 +584,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: next.checkUpdatesOnStartup, checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled, desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled, transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
chatMinimapEnabled: next.chatMinimapEnabled,
updateSource: next.updateSource, updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource, modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment, localToolEnvironment: next.localToolEnvironment,
+12 -2
View File
@@ -134,7 +134,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage' } from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62 export const ASSISTANT_DATABASE_SCHEMA_VERSION = 63
function withoutHistoricalWorkMode(value: unknown): unknown { function withoutHistoricalWorkMode(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value if (!value || typeof value !== 'object' || Array.isArray(value)) return value
@@ -11919,6 +11919,7 @@ export class AssistantDatabase {
database.exec('BEGIN IMMEDIATE') database.exec('BEGIN IMMEDIATE')
try { try {
database.exec(supervisionReviewMigration) database.exec(supervisionReviewMigration)
database.exec(supervisionReviewCurrentView)
database.exec('PRAGMA user_version = 47; COMMIT;') database.exec('PRAGMA user_version = 47; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error } } catch (error) { database.exec('ROLLBACK'); throw error }
} }
@@ -12250,6 +12251,15 @@ export class AssistantDatabase {
`) `)
} catch (error) { database.exec('ROLLBACK'); throw error } } catch (error) { database.exec('ROLLBACK'); throw error }
} }
if (version.user_version < 63) {
// Reviews read only finished messages and tasks, so a frozen manifest never changes.
database.exec('BEGIN IMMEDIATE')
try {
database.exec('DROP VIEW IF EXISTS supervision_review_current')
database.exec(supervisionReviewCurrentView)
database.exec('PRAGMA user_version = 63; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
} }
supervisionExperiences(): SupervisionExperienceStore { supervisionExperiences(): SupervisionExperienceStore {
@@ -12314,7 +12324,7 @@ export class AssistantDatabase {
} }
} }
import type { ImageOperation } from '../../shared/image-generation-contracts' import type { ImageOperation } from '../../shared/image-generation-contracts'
import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store' import { SupervisionReviewStore, supervisionReviewCurrentView, supervisionReviewMigration } from './supervision-review-store'
import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories' import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories'
import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences' import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences'
import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline' import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline'
@@ -70,9 +70,9 @@ it('scans read-only, transfers at most 200 rows, yields without a transaction, a
expect(wrote).toBe(true) expect(wrote).toBe(true)
expect(f.store.progress(f.runId).sources).toBe(450) expect(f.store.progress(f.runId).sources).toBe(450)
expect(f.store.load(f.runId).initializing).toBeUndefined() expect(f.store.load(f.runId).initializing).toBeUndefined()
// A concurrent write is detected on resume and that one source is re-frozen, not the whole run failed. // A concurrent write is detected on resume and that one source is skipped, not the whole run failed.
await f.store.resumeWithReader(f.runId, f.reader) await f.store.resumeWithReader(f.runId, f.reader)
expect(f.store.progress(f.runId)).toMatchObject({ sources: 450, revisedSources: 1 }) expect(f.store.progress(f.runId)).toMatchObject({ sources: 449, skippedSources: 1 })
expect(f.store.load(f.runId).restartRequired).toBeUndefined() expect(f.store.load(f.runId).restartRequired).toBeUndefined()
await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot') await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot')
}) })
+35 -37
View File
@@ -10,14 +10,7 @@ import { setImmediate as yieldTurn } from 'node:timers/promises'
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline' export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 } export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; revisedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] } export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; skippedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
/**
* A source that keeps changing while it is reviewed (for example an assistant reply
* that is still being rewritten) is re-frozen at most this many times per run in total;
* after that further changed sources are omitted so the run always terminates.
*/
export const MAX_REVISED_REVIEW_SOURCES = 200
export class ReviewSourcesOmitted extends Error {} export class ReviewSourcesOmitted extends Error {}
@@ -59,6 +52,14 @@ export const supervisionReviewMigration = `
id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL, id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL,
PRIMARY KEY(run_id, id) PRIMARY KEY(run_id, id)
); );
`
/**
* Reviewable sources. Only finished messages (complete or error) and finished tasks are
* visible: their content no longer changes, so the manifest frozen when a review starts
* is a fixed snapshot. Replies still streaming and running tasks wait for the next review.
*/
export const supervisionReviewCurrentView = `
CREATE VIEW supervision_review_current AS CREATE VIEW supervision_review_current AS
SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner, SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner,
c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence, c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence,
@@ -67,14 +68,15 @@ export const supervisionReviewMigration = `
json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context, json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context,
json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error')
UNION ALL UNION ALL
SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title, SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title,
t.created_at, COALESCE(t.completed_at, t.created_at), t.created_at, COALESCE(t.completed_at, t.created_at),
json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at), json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at),
json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id) json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id)
FROM tasks t LEFT JOIN projects p ON p.id = t.project_id FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active') WHERE t.visible = 1 AND t.status IN ('completed', 'failed', 'cancelled', 'interrupted')
AND (t.project_id IS NULL OR p.status = 'active')
UNION ALL UNION ALL
SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge', SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge',
COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'), COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'),
@@ -86,7 +88,7 @@ export const supervisionReviewMigration = `
json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key)) json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key))
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
JOIN json_each(m.metadata_json, '$.sourceReferences') ref JOIN json_each(m.metadata_json, '$.sourceReferences') ref
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant'); WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error');
` `
export class SupervisionReviewStore { export class SupervisionReviewStore {
@@ -248,13 +250,13 @@ export class SupervisionReviewStore {
resume(runId: string, signal?: AbortSignal): void { resume(runId: string, signal?: AbortSignal): void {
const state = this.prepareResume(runId) const state = this.prepareResume(runId)
if (state.initializing) { this.initializeSources(runId, state, signal); return } if (state.initializing) { this.initializeSources(runId, state, signal); return }
// Sources changed since they were frozen are re-frozen and reprocessed; removed ones are omitted. // A source that no longer matches its frozen version is skipped and left for the next review.
let after = '' let after = ''
for (;;) { for (;;) {
const rows = this.resumePage(runId, after) const rows = this.resumePage(runId, after)
if (!rows.length) break if (!rows.length) break
const changed = this.changedSource(rows, signal) const changed = this.changedSource(rows, signal)
if (changed) { this.reconcileSource(runId, changed); after = changed; continue } if (changed) { this.skipSource(runId, changed); after = changed; continue }
after = rows.at(-1)!.source after = rows.at(-1)!.source
} }
} }
@@ -287,8 +289,8 @@ export class SupervisionReviewStore {
if (!rows.length) break if (!rows.length) break
const changed = await reader.changedSource(rows, signal) const changed = await reader.changedSource(rows, signal)
this.checkRunning(runId, signal) this.checkRunning(runId, signal)
// The read snapshot may lag the writer; reconciliation re-reads the source here. // The read snapshot may lag the writer; skipSource re-reads the source here.
if (changed) { this.reconcileSource(runId, changed); after = changed; await yieldTurn(); continue } if (changed) { this.skipSource(runId, changed); after = changed; await yieldTurn(); continue }
after = rows.at(-1)!.source after = rows.at(-1)!.source
await yieldTurn() await yieldTurn()
} }
@@ -348,8 +350,8 @@ export class SupervisionReviewStore {
for (const metadata of rows) { for (const metadata of rows) {
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000)) const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
if (!current || current.current_revision !== metadata.revision) { if (!current || current.current_revision !== metadata.revision) {
// Re-freeze (or omit) the changed source, then rebuild the batch from current offsets. // Skip the changed source, then rebuild the batch from current offsets.
this.reconcileSource(runId, metadata.source) this.skipSource(runId, metadata.source)
return this.chunk(runId, projectId, conversationId, config) return this.chunk(runId, projectId, conversationId, config)
} }
const row = { ...metadata, ...current } const row = { ...metadata, ...current }
@@ -396,33 +398,29 @@ export class SupervisionReviewStore {
} }
/** /**
* Brings one frozen source in line with the business database without failing the run. * The manifest is a snapshot of finished sources, so a frozen source should never change.
* A changed source is re-frozen at its current revision and reviewed again from the start * If one still does (or disappears), it is dropped from this run without failing it: leaves
* (leaves that used it are discarded with their navigation); a removed or no longer visible * that used it are discarded with their navigation, and because no checkpoint is written for
* source is omitted. Returns false when the source already matches. * it, the next review picks it up at its current version. Returns false when it still matches.
*/ */
reconcileSource(runId: string, source: string): boolean { skipSource(runId: string, source: string): boolean {
const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined
if (!frozen) return false if (!frozen) return false
const live = this.currentSource(source, 0, 0) const live = this.currentSource(source, 0, 0)
if (live?.current_revision === frozen.revision) return false if (live?.current_revision === frozen.revision) return false
if (this.omitDeletedConversations(runId)) return true if (this.omitDeletedConversations(runId)) return true
const current = live && { revision: live.current_revision, length: Number(live.body_length) } // No longer visible (archived, emptied): omitted. Still visible but changed: left for the next review.
const state = this.load(runId) const counter = live && Number(live.body_length) > 0 ? '$.skippedSources' : '$.omittedSources'
const revise = current !== undefined && current.length > 0 && (state.revisedSources ?? 0) < MAX_REVISED_REVIEW_SOURCES this.db.exec('SAVEPOINT skip_review_source')
this.db.exec('SAVEPOINT reconcile_review_source')
try { try {
this.discardLeaves(runId, new Set([source])) this.discardLeaves(runId, new Set([source]))
if (revise && current) { this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
this.db.prepare(`UPDATE supervision_review_sources SET revision = ?, length = ?, initial_offset = 0, processed_offset = 0
WHERE run_id = ? AND source = ?`).run(current.revision, current.length, runId, source)
} else this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'), this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'),
'${revise ? '$.revisedSources' : '$.omittedSources'}', COALESCE(json_extract(state_json, '${revise ? '$.revisedSources' : '$.omittedSources'}'), 0) + 1, '${counter}', COALESCE(json_extract(state_json, '${counter}'), 0) + 1,
'$.phase', 'extracting') WHERE run_id = ?`).run(runId) '$.phase', 'extracting') WHERE run_id = ?`).run(runId)
this.db.exec('RELEASE reconcile_review_source') this.db.exec('RELEASE skip_review_source')
return true return true
} catch (error) { this.db.exec('ROLLBACK TO reconcile_review_source; RELEASE reconcile_review_source'); throw error } } catch (error) { this.db.exec('ROLLBACK TO skip_review_source; RELEASE skip_review_source'); throw error }
} }
/** /**
@@ -464,9 +462,9 @@ export class SupervisionReviewStore {
try { try {
this.omitDeletedConversations(runId) this.omitDeletedConversations(runId)
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return } if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
// A source changed while the model was working: drop this answer, keep the run going. // A source changed while the model was working: drop this answer and that source, keep the run going.
const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision) const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision)
if (stale) { this.reconcileSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return } if (stale) { this.skipSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ? const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`) WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
for (const item of evidence) { for (const item of evidence) {
@@ -500,8 +498,8 @@ export class SupervisionReviewStore {
FROM supervision_review_batches WHERE run_id = ?`).get(runId)! FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
const state = this.load(runId) const state = this.load(runId)
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)! const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
// Changed sources are reconciled on resume, so no saved run requires a restart. // Changed sources are skipped on resume, so no saved run requires a restart.
return { runId, omittedSources: state.omittedSources, revisedSources: state.revisedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources), return { runId, omittedSources: state.omittedSources, skippedSources: state.skippedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined } remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
} }
+32 -15
View File
@@ -313,21 +313,35 @@ it('still pauses on user request and resumes saved batches without changing batc
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500]) expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
}) })
it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => { it('skips changed source versions and rolls back offsets when batch persistence fails', async () => {
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 }) const f = await fixture([['x'.repeat(2500)], ['Kept']], { concurrency: 1 })
f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END") f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
await expect(f.service().run(request)).rejects.toThrow('disk failure') await expect(f.service().run(request)).rejects.toThrow('disk failure')
const runId = f.db.listSupervisionActivity()[0]!.id const runId = f.db.listSupervisionActivity()[0]!.id
expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0) expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0)
expect(f.sql.prepare('SELECT processed_offset FROM supervision_review_sources').get()!.processed_offset).toBe(0) expect(f.sql.prepare('SELECT MAX(processed_offset) AS offset FROM supervision_review_sources').get()!.offset).toBe(0)
f.sql.exec('DROP TRIGGER fail_batch') f.sql.exec('DROP TRIGGER fail_batch')
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id) f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id)
const resumed = await f.service().resume(runId) const resumed = await f.service().resume(runId)
expect(resumed).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'Changed'.length, complete: true } }) expect(resumed).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, characters: 'Kept'.length, complete: true } })
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Changed']) expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Kept'])
}) })
it('re-reviews a source rewritten while its batch is with the model and discards the stale answer', async () => { it('reviews only finished messages: a streaming reply waits for the next review', async () => {
const f = await fixture([['Finished question', 'Partial reply']], { concurrency: 1 })
const reply = f.conversations[0]!.messages[1]!.id
f.sql.prepare("UPDATE messages SET state = 'streaming' WHERE id = ?").run(reply)
const manual = { ...request, reanalyze: undefined }
const first = await f.service().run(manual)
expect(first.coverage).toMatchObject({ sources: 1, complete: true })
expect(f.db.supervisionReviewStore().batches(first.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Finished question'])
f.sql.prepare("UPDATE messages SET content = ?, state = 'complete' WHERE id = ?").run('Partial reply, finished', reply)
const next = await f.service().run(manual)
expect(next.coverage).toMatchObject({ sources: 1, complete: true })
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Partial reply, finished'])
})
it('skips a source rewritten while its batch is with the model and discards the stale answer', async () => {
const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 }) const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 })
const target = f.conversations[0]!.messages[0]!.id const target = f.conversations[0]!.messages[0]!.id
const base = f.summarize.getMockImplementation()! const base = f.summarize.getMockImplementation()!
@@ -340,13 +354,12 @@ it('re-reviews a source rewritten while its batch is with the model and discards
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') } return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
}) })
const result = await f.service().run(request) const result = await f.service().run(request)
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, sources: 2, remainingSources: 0, complete: true } }) expect(result).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, remainingSources: 0, complete: true } })
const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content) const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content)
expect(contents).toContain('Original reply, continued after reconnect') expect(contents).toEqual(['Other conversation'])
expect(contents).not.toContain('Original reply')
}) })
it('discards a mixed leaf when one of its sources changes and reviews only current versions', async () => { it('discards a mixed leaf when one of its sources changes and skips only that source', async () => {
const f = await fixture([['A', 'B']], { concurrency: 1 }) const f = await fixture([['A', 'B']], { concurrency: 1 })
const store = f.db.supervisionReviewStore() const store = f.db.supervisionReviewStore()
const runId = f.db.startSupervisionRun(request) const runId = f.db.startSupervisionRun(request)
@@ -360,8 +373,8 @@ it('discards a mixed leaf when one of its sources changes and reviews only curre
f.db.failSupervisionRun(runId, 'Provider failed') f.db.failSupervisionRun(runId, 'Provider failed')
await f.service().resume(runId) await f.service().resume(runId)
expect(store.navigation(runId, 'dependent')).toBeUndefined() expect(store.navigation(runId, 'dependent')).toBeUndefined()
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A', 'B2']) expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A'])
expect(store.progress(runId)).toMatchObject({ revisedSources: 1, complete: true }) expect(store.progress(runId)).toMatchObject({ skippedSources: 1, sources: 1, complete: true })
}) })
it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => { it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => {
@@ -443,9 +456,13 @@ it('lets the next automatic review continue a failed run whose source changed in
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id) f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id)
const result = await f.service().run(automatic) const result = await f.service().run(automatic)
expect(result.runId).toBe(runId) expect(result.runId).toBe(runId)
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'New version'.length } }) expect(result).toMatchObject({ status: 'no_change', coverage: { skippedSources: 1, sources: 0 } })
// The leaf of the old version is discarded; only the current version is published. // The leaf of the old version is discarded and no checkpoint is written for the source,
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version']) // so the next automatic review reads its current version.
expect(f.db.supervisionReviewStore().batches(runId)).toEqual([])
const next = await f.service().run(automatic)
expect(next.runId).not.toBe(runId)
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
}) })
it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => { it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => {
@@ -45,7 +45,7 @@ async function fixture() {
{ documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' } { documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' }
] }), conversations[0]!.messages[0]!.id) ] }), conversations[0]!.messages[0]!.id)
db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id, db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id,
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true }) title: 'Visible task', instructions: 'Test', origin: 'user', visible: true, status: 'completed' })
const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' }, const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' },
timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } }, timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } },
config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' } config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' }
@@ -96,15 +96,15 @@ it('rebuilds a partially written manifest after restart and validates completed
await f.storage.resumeSupervisionReview(id, new AbortController().signal) await f.storage.resumeSupervisionReview(id, new AbortController().signal)
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422) expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422)
expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined() expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined()
// A source changed after freezing is re-frozen at its new version instead of failing the run. // A source changed after freezing is skipped for this run instead of failing it.
const changedId = f.conversations[0]!.messages[0]!.id const changedId = f.conversations[0]!.messages[0]!.id
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId) f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId)
await f.storage.resumeSupervisionReview(id, new AbortController().signal) await f.storage.resumeSupervisionReview(id, new AbortController().signal)
const store = f.db.supervisionReviewStore() const store = f.db.supervisionReviewStore()
expect(store.progress(id)).toMatchObject({ sources: 422, revisedSources: 1 }) expect(store.progress(id)).toMatchObject({ sources: 421, skippedSources: 1 })
expect(store.load(id).restartRequired).toBeUndefined() expect(store.load(id).restartRequired).toBeUndefined()
expect(f.sql.prepare('SELECT length, processed_offset FROM supervision_review_sources WHERE run_id = ? AND source = ?') expect(f.sql.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
.get(id, `message:${changedId}`)).toMatchObject({ length: 'Changed'.length, processed_offset: 0 }) .get(id, `message:${changedId}`)).toBeUndefined()
}) })
it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => { it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => {
+2 -18
View File
@@ -514,11 +514,7 @@ describe('ContextManager', () => {
}) })
expect(showOpenDialog).toHaveBeenCalledWith( expect(showOpenDialog).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.objectContaining({ { properties: ['openFile', 'multiSelections'] }
filters: expect.arrayContaining([
expect.objectContaining({ name: '图片' })
])
})
) )
}) })
@@ -555,19 +551,7 @@ describe('ContextManager', () => {
}) })
expect(showOpenDialog).toHaveBeenCalledWith( expect(showOpenDialog).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.objectContaining({ { properties: ['openFile', 'multiSelections'] }
filters: expect.arrayContaining([
expect.objectContaining({
name: 'PDF 和 Office 文档',
extensions: expect.arrayContaining([
'docx',
'pdf',
'pptx',
'xlsx'
])
})
])
})
) )
expect(onProgress.mock.calls.map(([progress]) => { const { operationId, ...rest } = progress; expect(operationId).toEqual(expect.any(String)); return rest })).toEqual([ expect(onProgress.mock.calls.map(([progress]) => { const { operationId, ...rest } = progress; expect(operationId).toEqual(expect.any(String)); return rest })).toEqual([
{ {
+1 -21
View File
@@ -329,27 +329,7 @@ export class ContextManager {
conversationId?: string conversationId?: string
): Promise<ContextAttachment[]> { ): Promise<ContextAttachment[]> {
const result = await dialog.showOpenDialog(window, { const result = await dialog.showOpenDialog(window, {
properties: ['openFile', 'multiSelections'], properties: ['openFile', 'multiSelections']
filters: [
{
name: '文本、代码和配置文件',
extensions: [...supportedExtensions].map((extension) =>
extension.slice(1)
)
},
{
name: '图片',
extensions: [...supportedImageExtensions].map((extension) =>
extension.slice(1)
)
},
{
name: 'PDF 和 Office 文档',
extensions: [...supportedDocumentExtensions].map((extension) =>
extension.slice(1)
)
}
]
}) })
if (result.canceled) { if (result.canceled) {
return [] return []
+11 -1
View File
@@ -1805,6 +1805,7 @@ describe('registerIpcHandlers SSH hosts', () => {
} as const } as const
const agentPackageManager = { const agentPackageManager = {
getAllSnapshot: vi.fn(async () => agentPackageInventory), getAllSnapshot: vi.fn(async () => agentPackageInventory),
getAllInventory: vi.fn(async () => agentPackageInventory),
forPlatform: vi.fn(() => agentPackageManager), forPlatform: vi.fn(() => agentPackageManager),
download: vi.fn( download: vi.fn(
async ( async (
@@ -1944,7 +1945,16 @@ describe('registerIpcHandlers SSH hosts', () => {
).resolves.toEqual(agentPackageInventory) ).resolves.toEqual(agentPackageInventory)
expect( expect(
agentPackageManager.getAllSnapshot agentPackageManager.getAllSnapshot
).toHaveBeenCalledWith({ refresh: true }) ).toHaveBeenCalledWith({ refresh: true, localOnly: false })
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageInventory
)?.(event, { localOnly: true })
).resolves.toEqual(agentPackageInventory)
expect(agentPackageManager.getAllInventory).toHaveBeenCalledWith({
refresh: false,
localOnly: true
})
await expect( await expect(
electronMocks.handlers.get( electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageDownload ipcChannels.sshHostsAgentPackageDownload
+3 -1
View File
@@ -4855,7 +4855,9 @@ export function registerIpcHandlers(
const request = const request =
agentPackageInventoryRequestSchema.parse(input ?? {}) agentPackageInventoryRequestSchema.parse(input ?? {})
return agentPackageInventorySchema.parse( return agentPackageInventorySchema.parse(
await agentPackageManager.getAllSnapshot(request) request.localOnly
? await agentPackageManager.getAllInventory(request)
: await agentPackageManager.getAllSnapshot(request)
) )
} }
) )
@@ -300,6 +300,17 @@ export class AgentPackageManager {
}) })
} }
async getAllInventory(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
const linux = await this.forPlatform('linux').getInventory(options)
const darwin = this.forPlatform('darwin')
darwin.#catalogState = this.forPlatform('linux').#catalogState
const mac = await darwin.getInventory(options)
return agentPackageInventorySchema.parse({
...linux,
entries: [...linux.entries, ...mac.entries]
})
}
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) { #select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform) return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
} }
+2 -2
View File
@@ -528,10 +528,10 @@ const desktopApi: DesktopApi = {
handler handler
) )
}, },
getAgentPackageInventory: (refresh = false) => getAgentPackageInventory: (refresh = false, localOnly = false) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.sshHostsAgentPackageInventory, ipcChannels.sshHostsAgentPackageInventory,
{ refresh } { refresh, localOnly }
) as Promise<AgentPackageInventory>, ) as Promise<AgentPackageInventory>,
downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') => downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') =>
ipcRenderer.invoke( ipcRenderer.invoke(
+1 -1
View File
@@ -292,7 +292,7 @@ describe('sandboxed preload', () => {
'ipcChannels.sshHostsAgentConnectionStatus' 'ipcChannels.sshHostsAgentConnectionStatus'
) )
expect(source).toContain( expect(source).toContain(
'getAgentPackageInventory: (refresh = false)' 'getAgentPackageInventory: (refresh = false, localOnly = false)'
) )
expect(source).toContain('downloadAgentPackage:') expect(source).toContain('downloadAgentPackage:')
expect(source).toContain('importAgentPackage:') expect(source).toContain('importAgentPackage:')
@@ -0,0 +1,186 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentPackageInventory } from '../../shared/agent-package-contracts'
import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel'
import { changeUiLocale } from './i18n'
const agentPackageInventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: {
state: 'available',
checkedAt: '2026-08-24T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.29'
},
{
platform: 'linux',
architecture: 'arm64',
state: 'not-downloaded',
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
agentProtocol: null,
remoteRuntimeVersion: null
}
]
}
const getAgentPackageInventory = vi.fn<(refresh?: boolean, localOnly?: boolean) => Promise<AgentPackageInventory>>()
const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<AgentPackageInventory>>()
const importAgentPackage = vi.fn<() => Promise<AgentPackageInventory | undefined>>()
const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<void>>()
const onAgentPackageProgress = vi.fn(() => () => {})
describe('AgentPackageInventoryPanel', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
getAgentPackageInventory.mockResolvedValue(agentPackageInventory)
downloadAgentPackage.mockResolvedValue(agentPackageInventory)
importAgentPackage.mockResolvedValue(agentPackageInventory)
exportAgentPackage.mockResolvedValue(undefined)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
getAgentPackageInventory,
downloadAgentPackage,
importAgentPackage,
exportAgentPackage,
onAgentPackageProgress
}
}
})
})
afterEach(() => cleanup())
it('waits until shown, then lists, refreshes, downloads, exports, and imports packages', async () => {
const onNotify = vi.fn()
const onBusyChange = vi.fn()
const { rerender } = render(
<AgentPackageInventoryPanel active={false} onBusyChange={onBusyChange} onNotify={onNotify} />
)
expect(getAgentPackageInventory).not.toHaveBeenCalled()
rerender(<AgentPackageInventoryPanel active onBusyChange={onBusyChange} onNotify={onNotify} />)
expect(await screen.findByText('GoodBuddy Agent 包')).toBeInTheDocument()
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(3))
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(3, true)
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement
downloadAgentPackage.mockResolvedValueOnce({
...agentPackageInventory,
entries: agentPackageInventory.entries.map((entry) =>
entry.architecture === 'x64' ? { ...entry, version: '0.11.4', updateAvailable: false } : entry
)
})
fireEvent.click(within(x64Card).getByRole('button', { name: '更新到 0.11.4' }))
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
expect(await within(x64Card).findByText('已是最新')).toBeInTheDocument()
expect(onBusyChange).toHaveBeenCalledWith(true)
await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(false))
fireEvent.click(within(x64Card).getByRole('button', { name: '导出离线包' }))
await waitFor(() => expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
await waitFor(() => expect(within(arm64Card).getByRole('button', { name: '下载 0.11.4' })).toBeEnabled())
fireEvent.click(within(arm64Card).getByRole('button', { name: '下载 0.11.4' }))
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux'))
await waitFor(() => expect(screen.getByRole('button', { name: '导入离线包' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '导入离线包' }))
await waitFor(() => expect(importAgentPackage).toHaveBeenCalledOnce())
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' })))
})
it('shows the local inventory before the online catalog returns', async () => {
const localInventory: AgentPackageInventory = {
...agentPackageInventory,
catalog: { state: 'not-checked', checkedAt: null, error: null },
entries: agentPackageInventory.entries.map((entry) => ({
...entry,
latestVersion: null,
updateAvailable: false
}))
}
let resolveOnline: ((inventory: AgentPackageInventory) => void) | undefined
getAgentPackageInventory
.mockResolvedValueOnce(localInventory)
.mockReturnValueOnce(
new Promise<AgentPackageInventory>((resolve) => {
resolveOnline = resolve
})
)
render(<AgentPackageInventoryPanel />)
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
await act(async () => {
resolveOnline?.(agentPackageInventory)
await Promise.resolve()
})
expect(await screen.findByText('有更新')).toBeInTheDocument()
})
it('shows loading and recovers from a refresh error', async () => {
let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined
getAgentPackageInventory.mockReturnValueOnce(
new Promise<AgentPackageInventory>((resolve) => { resolveInventory = resolve })
)
render(<AgentPackageInventoryPanel />)
expect(await screen.findByText('正在校验本地 Agent 包并检查在线版本…')).toBeInTheDocument()
await act(async () => {
resolveInventory?.(agentPackageInventory)
await Promise.resolve()
})
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
getAgentPackageInventory.mockRejectedValueOnce(new Error('inventory unavailable'))
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
await screen.findByText('inventory unavailable')
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument())
expect(getAgentPackageInventory).toHaveBeenCalledTimes(4)
})
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
const unavailableInventory: AgentPackageInventory = {
...agentPackageInventory,
catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' },
entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false }))
}
getAgentPackageInventory
.mockResolvedValueOnce(unavailableInventory)
.mockResolvedValueOnce(unavailableInventory)
render(<AgentPackageInventoryPanel />)
expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument()
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
expect(within(x64Card).getByRole('button', { name: '检查并更新' })).toBeInTheDocument()
expect(within(x64Card).getByRole('button', { name: '导出离线包' })).toBeInTheDocument()
})
})
@@ -0,0 +1,447 @@
import { Cpu, Download, RefreshCw, Upload } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
import type { AppNotificationInput } from './notifications'
import { displayErrorMessage } from './error-message'
// Local Agent package inventory used to install and update remote hosts.
// Package state lives in Main and is independent of host records.
export function AgentPackageInventoryPanel({
active = true,
onBusyChange,
onNotify
}: {
active?: boolean
onBusyChange?: (busy: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
}): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
useState(false)
const [agentInventoryError, setAgentInventoryError] =
useState<string>()
const [agentPackageBusy, setAgentPackageBusy] =
useState<string>()
const [agentPackageProgress, setAgentPackageProgress] =
useState<AgentPackageDownloadProgress>()
const agentInventoryRequested = useRef(false)
useEffect(() => {
onBusyChange?.(agentPackageBusy !== undefined)
}, [agentPackageBusy, onBusyChange])
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
const loadAgentInventory = useCallback(async (
refresh = false
): Promise<void> => {
const getInventory =
window.goodbuddy.sshHosts?.getAgentPackageInventory
if (!getInventory) {
setAgentInventoryError(
t(
'platformFeatures.remoteProjects.agentInventory.errors.unavailable'
)
)
return
}
setAgentInventoryLoading(true)
setAgentInventoryError(undefined)
try {
if (!refresh) {
setAgentInventory(await getInventory(false, true))
}
setAgentInventory(await getInventory(refresh))
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.readFailed'
)
)
)
} finally {
setAgentInventoryLoading(false)
}
}, [t])
// Reading the inventory checks the online catalog, so it waits until shown.
useEffect(() => {
if (!active || agentInventoryRequested.current) {
return
}
agentInventoryRequested.current = true
void loadAgentInventory()
}, [active, loadAgentInventory])
useEffect(() => {
const subscribe =
window.goodbuddy.sshHosts?.onAgentPackageProgress
return subscribe?.((progress) =>
setAgentPackageProgress(progress)
)
}, [])
const downloadAgentPackage = async (
architecture: AgentArchitecture,
platform: 'linux' | 'darwin'
): Promise<void> => {
const download =
window.goodbuddy.sshHosts?.downloadAgentPackage
if (!download) {
return
}
setAgentPackageBusy(`${platform}-${architecture}`)
setAgentPackageProgress(undefined)
setAgentInventoryError(undefined)
try {
setAgentInventory(await download(architecture, platform))
onNotify?.({
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
),
tone: 'success'
})
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
setAgentPackageProgress(undefined)
}
}
const importAgentPackage = async (): Promise<void> => {
const importPackage =
window.goodbuddy.sshHosts?.importAgentPackage
if (!importPackage) {
return
}
setAgentPackageBusy('import')
setAgentInventoryError(undefined)
try {
const inventory = await importPackage()
if (inventory) {
setAgentInventory(inventory)
onNotify?.({
dedupeKey: 'agent-package-imported',
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.imported'
),
tone: 'success'
})
}
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.importFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
}
}
const exportAgentPackage = async (
architecture: AgentArchitecture,
platform: 'linux' | 'darwin'
): Promise<void> => {
const exportPackage =
window.goodbuddy.sshHosts?.exportAgentPackage
if (!exportPackage) {
return
}
setAgentPackageBusy(`${platform}-${architecture}`)
setAgentInventoryError(undefined)
try {
await exportPackage(architecture, platform)
onNotify?.({
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
),
tone: 'success'
})
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.exportFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
}
}
return (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t(
'platformFeatures.remoteProjects.agentInventory.title'
)}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.agentInventory.description'
)}
</small>
</div>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() => void importAgentPackage()}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.import'
)}
</button>
<button
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
className="secondary-button"
disabled={
agentInventoryLoading ||
agentPackageBusy !== undefined
}
onClick={() => void loadAgentInventory(true)}
type="button"
>
<RefreshCw
aria-hidden="true"
className={
agentInventoryLoading
? 'bundled-agent-inventory__spinner'
: undefined
}
size={13}
/>
{t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
</button>
</div>
</div>
{agentInventory && (
<p className="settings-notice">
{t(
'platformFeatures.remoteProjects.agentInventory.summary',
{
available: agentInventory.entries.filter(
(entry) => entry.state === 'verified'
).length,
total: agentInventory.entries.length
}
)}
</p>
)}
{agentInventoryError && (
<p className="settings-warning" role="alert">
{agentInventoryError}
</p>
)}
{agentInventoryLoading && !agentInventory ? (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.loading'
)}
</p>
) : agentInventory ? (
<div
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.listLabel'
)}
className="bundled-agent-inventory"
role="list"
>
{agentInventory.entries.map((entry) => (
<article
className="bundled-agent-inventory__item"
key={`${entry.platform}-${entry.architecture}`}
role="listitem"
>
<div className="bundled-agent-inventory__item-header">
<Cpu aria-hidden="true" size={16} />
<strong>
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
entry.updateAvailable
? 'update-available'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'up-to-date'
: entry.state
}`}
>
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
)}
</span>
</div>
<dl>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
)}
</dt>
<dd>{entry.version ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
)}
</dt>
<dd>{entry.latestVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
)}
</dt>
<dd>{entry.architecture}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
)}
</dt>
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
)}
</dt>
<dd>
{entry.agentProtocol
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
: '—'}
</dd>
</div>
</dl>
{agentPackageProgress?.architecture ===
entry.architecture &&
agentPackageProgress.platform === entry.platform &&
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
<p className="settings-notice" role="status">
{t(
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
)}
</p>
)}
<div className="capability-card__actions">
{(
entry.updateAvailable ||
(
entry.state !== 'verified' &&
entry.latestVersion !== null
) ||
agentInventory.catalog.state !== 'available'
) && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
: entry.state !== 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
: 'platformFeatures.remoteProjects.agentInventory.update',
{ version: entry.latestVersion }
)}
</button>
)}
{entry.state === 'verified' && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void exportAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.export'
)}
</button>
)}
</div>
</article>
))}
</div>
) : null}
{agentInventory?.catalog.state === 'available' && (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.available'
)}
</p>
)}
{agentInventory?.catalog.state === 'unavailable' && (
<p className="settings-warning" role="alert">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
{ error: agentInventory.catalog.error }
)}
</p>
)}
</article>
)
}
+36 -76
View File
@@ -1256,6 +1256,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github", updateSource: "github",
modelDownloadSource: "modelscope", modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3486,6 +3487,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3591,6 +3593,7 @@ describe("App", () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3608,6 +3611,7 @@ describe("App", () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3711,6 +3715,7 @@ describe("App", () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3728,6 +3733,7 @@ describe("App", () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -4608,7 +4614,7 @@ describe("App", () => {
expect(scrollTo).not.toHaveBeenCalled(); expect(scrollTo).not.toHaveBeenCalled();
}); });
it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => { it("exposes the full loaded history while keeping the DOM windowed", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: "00000000-0000-4000-8000-000000000421", id: "00000000-0000-4000-8000-000000000421",
@@ -4627,62 +4633,17 @@ describe("App", () => {
const { container } = render(<App />); const { container } = render(<App />);
expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
// Only the rows near the bottom are mounted (PERF-14); a spacer stands in // Only the rows near the bottom are mounted (PERF-14); spacers stand in
// for the rest of the 80-message batch. // for the rest of the loaded history.
const mounted = container.querySelectorAll(".message").length; const mounted = container.querySelectorAll(".message").length;
expect(mounted).toBeGreaterThan(0); expect(mounted).toBeGreaterThan(0);
expect(mounted).toBeLessThan(80); expect(mounted).toBeLessThan(80);
expect( expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument(); expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
const chat = container.querySelector<HTMLElement>(".chat"); expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
if (!chat) {
throw new Error("Missing chat scroll container");
}
const topSpacerHeight = (): number =>
Number.parseFloat(
container.querySelector<HTMLElement>('[data-window-spacer="top"]')?.style.height ?? "0",
) || 0;
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: {
configurable: true,
get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(),
},
scrollTop: {
configurable: true,
writable: true,
value: 125,
},
});
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 81 条)",
}),
);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
expect(container.querySelectorAll(".message")).toHaveLength(mounted);
// The 80 earlier messages land above the reader, who keeps their place.
expect(chat.scrollTop).toBe(125 + 80 * 160);
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 1 条)",
}),
);
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /加载更早的消息/u }),
).not.toBeInTheDocument();
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1);
}); });
it("opens a long conversation in one bounded commit without catch-up steps", async () => { it("opens a long conversation in one bounded commit without a history gate", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([ vi.mocked(api.conversations.list).mockResolvedValueOnce([
{ {
id: "00000000-0000-4000-8000-000000000422", id: "00000000-0000-4000-8000-000000000422",
@@ -4720,9 +4681,8 @@ describe("App", () => {
expect(counts).toHaveLength(1); expect(counts).toHaveLength(1);
expect(counts[0]).toBeLessThan(80); expect(counts[0]).toBeLessThan(80);
expect( expect(screen.queryByRole("button", { name: /加载更早的消息/u })).not.toBeInTheDocument();
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), expect(container.querySelector('[data-window-spacer="top"]')).toBeInTheDocument();
).toBeInTheDocument();
}); });
it("keeps the reader position while a response continues below", async () => { it("keeps the reader position while a response continues below", async () => {
@@ -5201,17 +5161,9 @@ describe("App", () => {
const { container } = render(<App />); const { container } = render(<App />);
expect(await screen.findByText("第一段历史 160")).toBeInTheDocument(); expect(await screen.findByText("第一段历史 160")).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 81 条)",
}),
);
const firstPane = container.querySelector<HTMLElement>( const firstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`, `[data-conversation-id="${firstConversationId}"]`,
); );
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
// Windowed (PERF-14): the mounted rows stay bounded and keep their nodes. // Windowed (PERF-14): the mounted rows stay bounded and keep their nodes.
const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0; const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0;
expect(mountedRows).toBeGreaterThan(0); expect(mountedRows).toBeGreaterThan(0);
@@ -6484,6 +6436,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github", updateSource: "github",
modelDownloadSource: "modelscope", modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -8103,9 +8056,9 @@ describe("App", () => {
fireEvent.click(screen.getByLabelText("切换助手工作栏")); fireEvent.click(screen.getByLabelText("切换助手工作栏"));
fireEvent.click(await screen.findByRole("tab", { name: "工作区" })); fireEvent.click(await screen.findByRole("tab", { name: "工作区" }));
fireEvent.click(await screen.findByLabelText("docs 的更多操作")); fireEvent.click(await screen.findByLabelText("docs 的更多操作"));
fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用默认应用打开" })); fireEvent.click(within(screen.getByRole("menu", { name: "docs" })).getByRole("menuitem", { name: "使用系统应用打开" }));
fireEvent.click(screen.getByLabelText("README.md 的更多操作")); fireEvent.click(screen.getByLabelText("README.md 的更多操作"));
fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用默认应用打开" })); fireEvent.click(within(screen.getByRole("menu", { name: "README.md" })).getByRole("menuitem", { name: "使用系统应用打开" }));
await waitFor(() => await waitFor(() =>
expect(api.workspace.openPath).toHaveBeenCalledWith( expect(api.workspace.openPath).toHaveBeenCalledWith(
projectId, projectId,
@@ -9728,15 +9681,14 @@ describe("App", () => {
).toHaveTextContent(remoteProject.name), ).toHaveTextContent(remoteProject.name),
); );
fireEvent.click(screen.getByRole('button', { name: '设置' })); // The switch now lives in the Hosts application settings.
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" })); fireEvent.click(screen.getByRole("button", { name: "应用" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "管理应用" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("tab", { await screen.findByRole("button", { name: "主机管理 应用设置" }),
name: "远程项目",
}),
); );
const remoteProjectsSwitch = await screen.findByRole("switch", { const remoteProjectsSwitch = await screen.findByRole("switch", {
name: "远程项目", name: "启用远程能力",
}); });
expect(remoteProjectsSwitch).toBeChecked(); expect(remoteProjectsSwitch).toBeChecked();
fireEvent.click(remoteProjectsSwitch); fireEvent.click(remoteProjectsSwitch);
@@ -14717,6 +14669,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14734,6 +14687,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14788,6 +14742,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14849,6 +14804,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14887,6 +14843,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14904,6 +14861,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github" as const, updateSource: "github" as const,
modelDownloadSource: "modelscope" as const, modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14967,6 +14925,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github", updateSource: "github",
modelDownloadSource: "modelscope", modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -15027,6 +14986,7 @@ describe("App", () => {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: "github", updateSource: "github",
modelDownloadSource: "modelscope", modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -15063,7 +15023,7 @@ describe("App", () => {
); );
await screen.findByRole("heading", { name: "设置中心" }); await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
await screen.findByRole("tab", { name: "通用设置" }); await screen.findByLabelText("默认工作区目录");
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
expect(api.updates.updateSettings).not.toHaveBeenCalled(); expect(api.updates.updateSettings).not.toHaveBeenCalled();
@@ -15112,24 +15072,24 @@ describe("App", () => {
await waitFor(() => expect(move).toBeEnabled()) await waitFor(() => expect(move).toBeEnabled())
fireEvent.click(move) fireEvent.click(move)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理',
])) ]))
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled()) await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article') const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
fireEvent.dragStart(cards[3]!) fireEvent.dragStart(cards[3]!)
fireEvent.drop(cards[1]!) fireEvent.drop(cards[1]!)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理',
])) ]))
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing']) expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing', 'remote-hosts'])
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '监督者', '知识库', '魔法笔记', '运行记录', '对话', '监督者', '知识库', '魔法笔记', '运行记录',
]) ])
fireEvent.click(screen.getByRole('button', { name: '应用' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用']) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '主机管理', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)']) expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理'])
}) })
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => { it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
+66 -37
View File
@@ -14,6 +14,7 @@ import {
ApplicationCenter, ApplicationCenter,
applicationDefinitions, applicationDefinitions,
isApplicationEnabled, isApplicationEnabled,
isApplicationOpenable,
} from "./ApplicationCenter"; } from "./ApplicationCenter";
import { import {
defaultApplicationNavigation, defaultApplicationNavigation,
@@ -131,10 +132,7 @@ import type {
ImageViewerItem, ImageViewerItem,
Message, Message,
} from "./ChatTimeline"; } from "./ChatTimeline";
import { import type { ChatScrollSnapshot } from "./ChatHistoryPane";
messageRenderBatchSize,
type ChatScrollSnapshot,
} from "./ChatHistoryPane";
import { usePaneOrder } from "./pane-order"; import { usePaneOrder } from "./pane-order";
import { getConversationDisplayTitle, isUnusedConversation, type Conversation } from "./chat-conversation"; import { getConversationDisplayTitle, isUnusedConversation, type Conversation } from "./chat-conversation";
import { import {
@@ -290,6 +288,10 @@ const activityPanelRoute = createPreloadableComponent(
() => import("./ActivityPanel"), () => import("./ActivityPanel"),
(module) => module.ActivityPanel, (module) => module.ActivityPanel,
); );
const remoteHostsPageRoute = createPreloadableComponent(
() => import("./RemoteHostsPage"),
(module) => module.RemoteHostsPage,
);
const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const; const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const;
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component; const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component;
@@ -297,6 +299,7 @@ const HeartbeatCenter = heartbeatCenterRoute.Component;
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component; const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
const SettingsPanel = settingsPanelRoute.Component; const SettingsPanel = settingsPanelRoute.Component;
const ActivityPanel = activityPanelRoute.Component; const ActivityPanel = activityPanelRoute.Component;
const RemoteHostsPage = remoteHostsPageRoute.Component;
const conversationPersistenceIntervalMs = 500; const conversationPersistenceIntervalMs = 500;
// How often streaming deltas are absorbed into the conversation store; rows render them live. // How often streaming deltas are absorbed into the conversation store; rows render them live.
@@ -510,14 +513,14 @@ export function AppNotificationViewport({
} }
type WorkspaceView = type WorkspaceView =
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings"; "chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "remote-hosts" | "activity" | "settings";
// Tracks which kept-alive routes currently report unsaved edits. Reads happen // Tracks which kept-alive routes currently report unsaved edits. Reads happen
// only in event handlers and the sweep timer, so this is not React state. // only in event handlers and the sweep timer, so this is not React state.
function createUnsavedWorkspaceTracker(): { function createUnsavedWorkspaceTracker(): {
pinnedViews: () => Set<WorkspaceView>; pinnedViews: () => Set<WorkspaceView>;
reporters: Record< reporters: Record<
"knowledge" | "heartbeat" | "magic-notes" | "activity", "knowledge" | "heartbeat" | "magic-notes" | "remote-hosts" | "activity",
ReportWorkspaceUnsavedChanges ReportWorkspaceUnsavedChanges
>; >;
} { } {
@@ -538,6 +541,7 @@ function createUnsavedWorkspaceTracker(): {
knowledge: reporterFor("knowledge"), knowledge: reporterFor("knowledge"),
heartbeat: reporterFor("heartbeat"), heartbeat: reporterFor("heartbeat"),
"magic-notes": reporterFor("magic-notes"), "magic-notes": reporterFor("magic-notes"),
"remote-hosts": reporterFor("remote-hosts"),
activity: reporterFor("activity"), activity: reporterFor("activity"),
}, },
}; };
@@ -550,6 +554,7 @@ const intentRoutePreloaders: Partial<
knowledge: knowledgeWorkspaceRoute.preload, knowledge: knowledgeWorkspaceRoute.preload,
activity: activityPanelRoute.preload, activity: activityPanelRoute.preload,
settings: settingsPanelRoute.preload, settings: settingsPanelRoute.preload,
"remote-hosts": remoteHostsPageRoute.preload,
}; };
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void { function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
@@ -1518,6 +1523,9 @@ function App(): React.JSX.Element {
setActiveIdState(next); setActiveIdState(next);
}, [activeConversationViewIds, conversationStore]); }, [activeConversationViewIds, conversationStore]);
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false); const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
const remoteProjectsEnabledRef = useRef(false);
// Assigned after the handler is defined below; reads latest projects at call time.
const remoteProjectsEnabledChangeRef = useRef<(enabled: boolean) => void>(setRemoteProjectsEnabled);
const [ const [
conversationHtmlRenderingEnabled, conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled, setConversationHtmlRenderingEnabled,
@@ -1530,7 +1538,7 @@ function App(): React.JSX.Element {
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0); const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation; const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id => const visibleApplications = useMemo(() => applicationNavigation.order.filter(id =>
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id))) id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationOpenable(applicationSettings, id)))
), [applicationNavigation, applicationSettings, magicNotesEnabled]); ), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => { const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings); setApplicationSettings(settings);
@@ -1552,7 +1560,11 @@ function App(): React.JSX.Element {
} }
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount); setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false); setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled); // Whichever surface changed the switch, leave an active SSH project when it turns off.
if (remoteProjectsEnabledRef.current !== settings.remoteProjectsEnabled) {
remoteProjectsEnabledRef.current = settings.remoteProjectsEnabled;
remoteProjectsEnabledChangeRef.current(settings.remoteProjectsEnabled);
}
}, [guardNoteDraft, setNoteDraft, setView]); }, [guardNoteDraft, setNoteDraft, setView]);
const reloadApplicationSettings = useCallback(async (): Promise<void> => { const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current; const revision = ++applicationSettingsRevisionRef.current;
@@ -1806,9 +1818,6 @@ function App(): React.JSX.Element {
), ),
[], [],
); );
const [visibleMessageCounts, setVisibleMessageCounts] = useState<
Record<string, number>
>({});
const sidebarRef = useRef<HTMLElement>(null); const sidebarRef = useRef<HTMLElement>(null);
const sidebarToggleRef = useRef<HTMLButtonElement>(null); const sidebarToggleRef = useRef<HTMLButtonElement>(null);
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth); const livePrimarySidebarWidthRef = useRef(primarySidebarWidth);
@@ -1863,15 +1872,6 @@ function App(): React.JSX.Element {
}, },
[], [],
); );
const handleVisibleMessageCountChange = useCallback(
(conversationId: string, count: number): void => {
setVisibleMessageCounts((current) => ({
...current,
[conversationId]: count,
}));
},
[],
);
const closeNarrowSidebar = useCallback((): void => { const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false); setSidebarOpen(false);
requestAnimationFrame(() => sidebarToggleRef.current?.focus()); requestAnimationFrame(() => sidebarToggleRef.current?.focus());
@@ -4325,9 +4325,15 @@ function App(): React.JSX.Element {
} }
const localProject = projects.find(isOrdinaryLocalProject); const localProject = projects.find(isOrdinaryLocalProject);
if (localProject) { if (localProject) {
// Keep the Hosts page open when the switch is turned off there.
const currentView = viewRef.current;
commitProjectSelection(localProject); commitProjectSelection(localProject);
if (currentView === "remote-hosts") commitView("remote-hosts");
} }
}; };
useLayoutEffect(() => {
remoteProjectsEnabledChangeRef.current = handleRemoteProjectsEnabledChange;
});
const archiveProject = async (projectId: string): Promise<void> => { const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true); await window.goodbuddy.projects.setArchived(projectId, true);
@@ -4608,11 +4614,6 @@ function App(): React.JSX.Element {
delete next[conversationId]; delete next[conversationId];
return next; return next;
}); });
setVisibleMessageCounts((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
setConversationActivity(conversationId, false); setConversationActivity(conversationId, false);
const remaining = conversationStore.getState().filter( const remaining = conversationStore.getState().filter(
(conversation) => conversation.id !== conversationId, (conversation) => conversation.id !== conversationId,
@@ -5978,7 +5979,6 @@ function App(): React.JSX.Element {
const index = conversation.messages.findIndex(item => item.id === targetId); const index = conversation.messages.findIndex(item => item.id === targetId);
if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') }); if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') });
else { else {
setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) }));
setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() }); setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() });
} }
} }
@@ -6532,6 +6532,8 @@ function App(): React.JSX.Element {
const settingsPanelActions = useStableHandlers({ const settingsPanelActions = useStableHandlers({
onTransparentFrostedEffectEnabledChange: (enabled: boolean) => onTransparentFrostedEffectEnabledChange: (enabled: boolean) =>
updateApplicationSettings({ transparentFrostedEffectEnabled: enabled }), updateApplicationSettings({ transparentFrostedEffectEnabled: enabled }),
onChatMinimapEnabledChange: (enabled: boolean) =>
updateApplicationSettings({ chatMinimapEnabled: enabled }),
onRetryApplicationSettings: () => void reloadApplicationSettings(), onRetryApplicationSettings: () => void reloadApplicationSettings(),
onBrandingPreferencesChange: (preferences: Parameters<typeof saveBrandingPreferences>[0]) => { onBrandingPreferencesChange: (preferences: Parameters<typeof saveBrandingPreferences>[0]) => {
if (!saveBrandingPreferences(preferences)) { if (!saveBrandingPreferences(preferences)) {
@@ -6554,13 +6556,14 @@ function App(): React.JSX.Element {
onExpertsChanged: (experts: AssistantExpert[]) => { onExpertsChanged: (experts: AssistantExpert[]) => {
setAssistantExperts(experts); setAssistantExperts(experts);
}, },
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
onProjectsDeleted: removeProjectsFromUi,
onSaved: (settings: RuntimeSettings) => { onSaved: (settings: RuntimeSettings) => {
setRuntimeSettings(settings); setRuntimeSettings(settings);
}, },
onUpdateProject: updateProject, onUpdateProject: updateProject,
}); });
const remoteHostsActions = useStableHandlers({
onProjectsDeleted: removeProjectsFromUi,
});
const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({ const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({
onBeforeCloseNotes: async () => { onBeforeCloseNotes: async () => {
if (!await guardNoteDraft()) return false; if (!await guardNoteDraft()) return false;
@@ -7090,6 +7093,7 @@ function App(): React.JSX.Element {
conversationHtmlRenderingEnabled={ conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled conversationHtmlRenderingEnabled
} }
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
conversationId={conversationId} conversationId={conversationId}
key={conversationId} key={conversationId}
loadHistory={ensureConversationHistory} loadHistory={ensureConversationHistory}
@@ -7112,19 +7116,12 @@ function App(): React.JSX.Element {
onSelectTask={setSelectedAssistantTaskId} onSelectTask={setSelectedAssistantTaskId}
onSetInput={setQuickActionInput} onSetInput={setQuickActionInput}
onSetScheduleEnabled={setAssistantScheduleEnabled} onSetScheduleEnabled={setAssistantScheduleEnabled}
onVisibleMessageCountChange={
handleVisibleMessageCountChange
}
quickActions={quickActions} quickActions={quickActions}
schedules={assistantSchedules} schedules={assistantSchedules}
scrollSnapshot={chatScrollSnapshots[conversationId]} scrollSnapshot={chatScrollSnapshots[conversationId]}
selectedAssistantTaskId={selectedAssistantTaskId} selectedAssistantTaskId={selectedAssistantTaskId}
store={conversationStore} store={conversationStore}
tasks={tasksByConversation.get(conversationId) ?? emptyConversationTasks} tasks={tasksByConversation.get(conversationId) ?? emptyConversationTasks}
visibleMessageCount={
visibleMessageCounts[conversationId] ??
messageRenderBatchSize
}
/> />
))} ))}
{activeProject?.kind === "channel" && {activeProject?.kind === "channel" &&
@@ -7364,6 +7361,38 @@ function App(): React.JSX.Element {
</KeepAliveRoute> </KeepAliveRoute>
)} )}
{view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />} {view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />}
{(view === "remote-hosts" ||
cachedWorkspaceViewKeys.has("remote-hosts")) && (
<KeepAliveRoute
active={view === "remote-hosts"}
onUnsavedChanges={workspaceUnsavedChangesReporters["remote-hosts"]}
route="remote-hosts"
>
<RouteErrorBoundary
key="remote-hosts"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={<RouteLoadingStatus label={t("route.loading")} />}
>
<RemoteHostsPage
active={view === "remote-hosts"}
settings={applicationSettings}
settingsPending={applicationSettingsPending}
settingsLocked={applicationSettingsUnconfirmed}
onUpdateSettings={updateApplicationSettings}
onNotify={notify}
onProjectsDeleted={remoteHostsActions.onProjectsDeleted}
/>
</Suspense>
</RouteErrorBoundary>
</KeepAliveRoute>
)}
{localInferenceOpen && ( {localInferenceOpen && (
<LocalInferencePage <LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")} enabled={isApplicationEnabled(applicationSettings, "local-inference")}
@@ -7381,7 +7410,7 @@ function App(): React.JSX.Element {
error={applicationSettingsError} error={applicationSettingsError}
onClose={() => setApplicationCenterOpen(false)} onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => { onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return; if (!isApplicationOpenable(applicationSettings, id)) return;
setApplicationCenterOpen(false); setApplicationCenterOpen(false);
setView(id); setView(id);
if (window.innerWidth < 900) setSidebarOpen(false); if (window.innerWidth < 900) setSidebarOpen(false);
@@ -7422,6 +7451,7 @@ function App(): React.JSX.Element {
{...settingsPanelActions} {...settingsPanelActions}
appearanceTheme={appearanceTheme} appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true} transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
chatMinimapEnabled={applicationSettings?.chatMinimapEnabled ?? true}
applicationSettingsPending={applicationSettingsPending} applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings} applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError} applicationSettingsError={applicationSettingsError}
@@ -7434,7 +7464,6 @@ function App(): React.JSX.Element {
initialCategory={settingsInitialCategory} initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel} initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled} magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={ onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled setConversationHtmlRenderingEnabled
} }
+44 -14
View File
@@ -17,6 +17,7 @@ import {
ApplicationCenter, ApplicationCenter,
ApplicationSettingsView, ApplicationSettingsView,
isApplicationEnabled, isApplicationEnabled,
isApplicationOpenable,
} from './ApplicationCenter' } from './ApplicationCenter'
const settings = applicationSettingsSchema.parse({ const settings = applicationSettingsSchema.parse({
@@ -39,7 +40,7 @@ const pinnedSettings = {
...settings, ...settings,
applicationNavigation: { applicationNavigation: {
...defaultApplicationNavigation, ...defaultApplicationNavigation,
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true }, pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true, 'remote-hosts': false },
}, },
} }
afterEach(async () => { afterEach(async () => {
@@ -73,6 +74,35 @@ describe('Application Center', () => {
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing') expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
}) })
it('opens Hosts while remote capability is off and switches it through the shared remote projects setting', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} />)
expect(isApplicationEnabled(settings, 'remote-hosts')).toBe(false)
expect(isApplicationOpenable(settings, 'remote-hosts')).toBe(true)
expect(isApplicationOpenable(undefined, 'remote-hosts')).toBe(false)
const card = screen.getByText('主机管理', { exact: true }).closest('article')!
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith('remote-hosts')
fireEvent.click(within(card).getByRole('button', { name: '主机管理 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用远程能力' })
expect(enable).not.toBeChecked()
expect(screen.getByText(/主机、凭据、远程项目和 Agent 离线包都会保留/)).toBeVisible()
fireEvent.click(enable)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ remoteProjectsEnabled: true })
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, remoteProjectsEnabled: true }} />)
expect(enable).toBeChecked()
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
expect(pin).not.toBeChecked()
fireEvent.click(pin)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...defaultApplicationNavigation,
pinned: { ...defaultApplicationNavigation.pinned, 'remote-hosts': true },
},
})
})
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => { it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
await changeUiLocale(locale) await changeUiLocale(locale)
const onUpdate = vi.fn(async () => true) const onUpdate = vi.fn(async () => true)
@@ -127,7 +157,7 @@ describe('Application Center', () => {
fireEvent.drop(card) fireEvent.drop(card)
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
...defaultApplicationNavigation, ...defaultApplicationNavigation,
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'], order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'],
} }) } })
}) })
@@ -239,7 +269,7 @@ describe('Application Center', () => {
fireEvent.dragStart(notes) fireEvent.dragStart(notes)
fireEvent.drop(inference) fireEvent.drop(inference)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
} }) } })
fireEvent.click(within(inference).getByRole('button', { name: '打开' })) fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).not.toHaveBeenCalled() expect(handlers.onOpen).not.toHaveBeenCalled()
@@ -263,9 +293,9 @@ describe('Application Center', () => {
const handlers = props() const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />) const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent) const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)']) expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled() expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' }) const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
expect(moveUp.closest('article')).not.toBeNull() expect(moveUp.closest('article')).not.toBeNull()
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控') expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
@@ -273,18 +303,18 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.applicationNavigation, ...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
}, },
}) })
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement() expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)']) expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
rerender(<ApplicationCenter {...handlers} settings={{ rerender(<ApplicationCenter {...handlers} settings={{
...pinnedSettings, ...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] }, applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] },
}} />) }} />)
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)']) expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)', '主机管理'])
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' })) fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation }) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
@@ -306,7 +336,7 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenCalledWith({ expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.applicationNavigation, ...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
}, },
}) })
}) })
@@ -352,7 +382,7 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenCalledWith({ expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.applicationNavigation, ...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
}, },
}) })
}) })
@@ -367,7 +397,7 @@ describe('Application Center', () => {
}) })
expect(screen.getByText('未找到相关应用')).toBeInTheDocument() expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getAllByRole('article')).toHaveLength(5) expect(screen.getAllByRole('article')).toHaveLength(6)
}) })
it('edits all notes preferences through the shared settings form', () => { it('edits all notes preferences through the shared settings form', () => {
+22 -3
View File
@@ -14,6 +14,7 @@ import {
Network, Network,
HeartPulse, HeartPulse,
Library, Library,
Server,
Settings, Settings,
Sparkles, Sparkles,
X, X,
@@ -52,6 +53,12 @@ export const applicationDefinitions = {
title: 'applications.localInference', title: 'applications.localInference',
enabled: 'localInferenceEnabled', enabled: 'localInferenceEnabled',
}, },
// Shares the remote projects switch: the backend gates hosts on it.
'remote-hosts': {
icon: Server,
title: 'applications.remoteHosts',
enabled: 'remoteProjectsEnabled',
},
} as const } as const
export function isApplicationEnabled( export function isApplicationEnabled(
@@ -60,6 +67,7 @@ export function isApplicationEnabled(
): boolean { ): boolean {
if (id === 'knowledge') return true if (id === 'knowledge') return true
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
if (id === 'remote-hosts') return settings?.remoteProjectsEnabled === true
if (id === 'heartbeat') return settings?.heartbeatEnabled === true if (id === 'heartbeat') return settings?.heartbeatEnabled === true
return ( return (
settings !== undefined && settings !== undefined &&
@@ -67,6 +75,17 @@ export function isApplicationEnabled(
) )
} }
// Hosts stays openable while remote capability is off: Agent packages can be
// prepared ahead of time, and the page offers the switch itself.
export function isApplicationOpenable(
settings: ApplicationSettings | undefined,
id: BuiltInApplicationId,
): boolean {
return id === 'remote-hosts'
? settings !== undefined
: isApplicationEnabled(settings, id)
}
export function ApplicationAvailability({ export function ApplicationAvailability({
enabled, enabled,
children, children,
@@ -120,7 +139,7 @@ export function ApplicationSettingsView({
}) })
} }
/> />
<span>{t('applications.enable')}</span> <span>{t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')}</span>
</label> </label>
{id !== 'device-sharing' && <div className="inline-help-label"> {id !== 'device-sharing' && <div className="inline-help-label">
<label className="toggle-row"> <label className="toggle-row">
@@ -143,7 +162,7 @@ export function ApplicationSettingsView({
</label> </label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp> <InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>} </div>}
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p> <p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : id === 'remote-hosts' ? 'applications.remoteHostsHelp' : 'applications.disableHelp')}</p>
{id === 'magic-notes' && ( {id === 'magic-notes' && (
<label className="toggle-row"> <label className="toggle-row">
<input <input
@@ -464,7 +483,7 @@ export function ApplicationCenter({
<button <button
className="secondary-button" className="secondary-button"
type="button" type="button"
disabled={!enabled} disabled={!isApplicationOpenable(settings, id)}
onClick={() => onOpen(id)} onClick={() => onOpen(id)}
> >
{t('applications.open')} {t('applications.open')}
+11 -1
View File
@@ -35,10 +35,20 @@ it('only shows device sharing when explicitly enabled and removes it on disable'
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
}) })
it('lists Hosts once settings load, even while remote capability is off', () => {
const onOpen = vi.fn()
const { rerender } = render(<Harness value={null} pending onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.queryByRole('menuitem', { name: '主机管理' })).not.toBeInTheDocument()
rerender(<Harness onOpen={onOpen} />)
fireEvent.click(screen.getByRole('menuitem', { name: '主机管理' }))
expect(onOpen).toHaveBeenCalledWith('remote-hosts')
})
it('filters the shared order by enablement only, including reordered always-shown apps', () => { it('filters the shared order by enablement only, including reordered always-shown apps', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true, const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'], applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } } } pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } } }
const onOpen = vi.fn() const onOpen = vi.fn()
const { rerender } = render(<Harness value={value} onOpen={onOpen} />) const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher')) fireEvent.click(screen.getByText('Launcher'))
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMemo, type RefObject } from 'react'
import { LayoutGrid } from 'lucide-react' import { LayoutGrid } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts' import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter' import { applicationDefinitions, isApplicationOpenable } from './ApplicationCenter'
import { AnchoredMenu } from './AnchoredMenu' import { AnchoredMenu } from './AnchoredMenu'
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: { export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
@@ -19,7 +19,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
const applications = useMemo(() => { const applications = useMemo(() => {
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
return order return order
.filter(id => isApplicationEnabled(settings, id)) .filter(id => isApplicationOpenable(settings, id))
}, [settings]) }, [settings])
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}> return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
{pending && <p role="status">{t('route.loading')}</p>} {pending && <p role="status">{t('route.loading')}</p>}
+13
View File
@@ -181,6 +181,19 @@ afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
describe('ChatHistoryPane scrollbar minimap setting', () => {
it('replaces the native scrollbar only while the minimap is enabled', () => {
const messages = makeMessages(5)
const { container, rerender, props } = setup(messages)
const pane = container.querySelector<HTMLElement>('.chat-history-pane')!
expect(pane).toHaveAttribute('data-minimap', 'true')
expect(container.querySelector('.chat-minimap')).not.toBeNull()
rerender(<ChatHistoryPane {...props} chatMinimapEnabled={false} />)
expect(pane).not.toHaveAttribute('data-minimap')
expect(container.querySelector('.chat-minimap')).toBeNull()
})
})
describe('ChatHistoryPane windowing', () => { describe('ChatHistoryPane windowing', () => {
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => { it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' })) const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
+12
View File
@@ -16,6 +16,7 @@ import type {
KnowledgeSearchReference, KnowledgeSearchReference,
} from "../../shared/contracts"; } from "../../shared/contracts";
import type { ImageOperation } from "../../shared/image-generation-contracts"; import type { ImageOperation } from "../../shared/image-generation-contracts";
import { ChatMinimap } from "./ChatMinimap";
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline"; import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
import { isUnusedConversation, type Conversation } from "./chat-conversation"; import { isUnusedConversation, type Conversation } from "./chat-conversation";
import { MessageExpansionProvider } from "./message-expansion"; import { MessageExpansionProvider } from "./message-expansion";
@@ -151,6 +152,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
active, active,
artifactById, artifactById,
conversationHtmlRenderingEnabled, conversationHtmlRenderingEnabled,
chatMinimapEnabled = true,
conversation, conversation,
locale, locale,
onCopyMessage, onCopyMessage,
@@ -176,6 +178,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
active: boolean; active: boolean;
artifactById: ReadonlyMap<string, AssistantArtifact>; artifactById: ReadonlyMap<string, AssistantArtifact>;
conversationHtmlRenderingEnabled: boolean; conversationHtmlRenderingEnabled: boolean;
/** Replaces the native scrollbar with the minimap; off restores the scrollbar. */
chatMinimapEnabled?: boolean;
conversation: Conversation; conversation: Conversation;
locale: TimeFormatLocale; locale: TimeFormatLocale;
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>; onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
@@ -1070,6 +1074,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
className="chat-history-pane" className="chat-history-pane"
data-active={active ? "true" : "false"} data-active={active ? "true" : "false"}
data-conversation-id={conversation.id} data-conversation-id={conversation.id}
data-minimap={chatMinimapEnabled ? "true" : undefined}
hidden={!active} hidden={!active}
inert={!active} inert={!active}
> >
@@ -1141,6 +1146,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
/> />
</div> </div>
</section> </section>
{active && chatMinimapEnabled && (
<ChatMinimap
messages={messages}
rowHeights={heights}
scrollContainerRef={scrollRef}
/>
)}
{active && showScrollToBottom && ( {active && showScrollToBottom && (
<button <button
aria-controls="chat-message-list" aria-controls="chat-message-list"
+172
View File
@@ -0,0 +1,172 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatMinimap, minimapLines, minimapMessageColor, minimapScrollTop } from "./ChatMinimap";
import { MessageRowHeights } from "./chat-message-window";
import type { Message } from "./ChatTimeline";
function message(role: Message["role"], id: string, extra: Partial<Message> = {}): Message {
return { id, role, content: id, createdAt: 1, state: "complete", ...extra } as Message;
}
function scroller(scrollHeight = 1_000, clientHeight = 200, scrollTop = 0): HTMLElement {
const element = document.createElement("section");
let top = scrollTop;
Object.defineProperties(element, {
clientHeight: { configurable: true, value: clientHeight },
scrollHeight: { configurable: true, value: scrollHeight },
offsetTop: { configurable: true, value: 0 },
scrollTop: {
configurable: true,
get: () => top,
set: (value: number) => { top = Math.max(0, Math.min(scrollHeight - clientHeight, value)); },
},
});
element.scrollTo = vi.fn(({ top: next }: ScrollToOptions) => {
element.scrollTop = next ?? 0;
element.dispatchEvent(new Event("scroll"));
}) as typeof element.scrollTo;
return element;
}
const messages = Array.from({ length: 20 }, (_, index) => message(index % 2 ? "assistant" : "user", `m${index}`));
describe("ChatMinimap", () => {
let fillRect: ReturnType<typeof vi.fn>;
beforeEach(() => {
fillRect = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
clearRect: vi.fn(),
fillRect,
setTransform: vi.fn(),
set fillStyle(_value: string) {},
} as unknown as CanvasRenderingContext2D);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("maps message roles and states to stable overview colors", () => {
expect(minimapMessageColor(message("user", "user"))).toBe("#6d9ee8");
expect(minimapMessageColor(message("assistant", "assistant"))).toBe("#8f98a8");
expect(minimapMessageColor(message("assistant", "tool", { tools: [{ callId: "1" }] as never }))).toBe("#d9a441");
expect(minimapMessageColor(message("assistant", "error", { state: "error" }))).toBe("#e06b75");
expect(minimapMessageColor(message("assistant", "failed", { state: "error", terminalStatus: "failed" }))).toBe("#e06b75");
expect(minimapMessageColor(message("assistant", "cancelled", { state: "error", terminalStatus: "cancelled" }))).toBe("#8f98a8");
});
it("clamps minimap positions to the scrollable range", () => {
expect(minimapScrollTop(-1, 1_000, 200)).toBe(0);
expect(minimapScrollTop(0.5, 1_000, 200)).toBe(400);
expect(minimapScrollTop(2, 1_000, 200)).toBe(800);
});
it("profiles messages as wrapped text lines with blank lines and code", () => {
const lines = minimapLines(message("assistant", "a", {
content: `${"x".repeat(100)}\n\n\`\`\`ts\n code\n\`\`\`\n中文`,
}));
expect(lines).toEqual([
{ indent: 0, width: 72 / 72, code: false },
{ indent: 0, width: 28 / 72, code: false },
{ indent: 0, width: 0, code: false },
{ indent: 4 / 72, width: 4 / 72, code: true },
{ indent: 0, width: 4 / 72, code: false },
]);
expect(minimapLines(message("assistant", "empty", { content: "" }))).toHaveLength(1);
});
it("draws one stroke per text line in the expanded overview", () => {
const long = [message("assistant", "long", { content: Array.from({ length: 30 }, () => "line").join("\n") }), message("user", "u")];
const heights = new MessageRowHeights();
heights.set("long", 900);
heights.set("u", 100);
const view = render(<ChatMinimap messages={long} rowHeights={heights} scrollContainerRef={{ current: scroller() }} />);
fillRect.mockClear();
fireEvent.pointerEnter(view.getByRole("scrollbar"));
// 180 px block at a 3 px pitch fits all 30 lines; the user turn draws one.
expect(fillRect).toHaveBeenCalledTimes(31);
});
it("spans the scroller height and moves the slider synchronously on scroll", () => {
const scroll = scroller();
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
const rail = view.getByRole("scrollbar");
expect(rail.style.display).toBe("");
expect(fillRect).toHaveBeenCalledTimes(messages.length);
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
expect(slider.style.height).toBe("40px");
scroll.scrollTop = 800;
scroll.dispatchEvent(new Event("scroll"));
// No frame or React commit in between: the slider is already at the end.
expect(slider.style.transform).toBe("translateY(160px)");
expect(rail).toHaveAttribute("aria-valuenow", "100");
});
it("keeps the overview drawing stable while rows are measured during scrolling", () => {
const scroll = scroller();
const heights = new MessageRowHeights();
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
fillRect.mockClear();
// Scrolling mounts and measures rows: heights change, messages do not.
heights.set("m0", 900);
heights.set("m1", 30);
view.rerender(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
scroll.dispatchEvent(new Event("scroll"));
expect(fillRect).not.toHaveBeenCalled();
});
it("places the slider on the overview span of the rows in view", () => {
// Row 0 is tall in pixels but short in text: the overview gives it 1/20.
const scroll = scroller(1_000, 200);
const heights = new MessageRowHeights();
heights.set("m0", 810);
for (let index = 1; index < messages.length; index += 1) heights.set(`m${index}`, 10);
const view = render(<ChatMinimap messages={messages} rowHeights={heights} scrollContainerRef={{ current: scroll }} />);
const slider = view.getByRole("scrollbar").querySelector<HTMLElement>(".chat-minimap__viewport")!;
// Viewport [0, 200] lies inside row 0, i.e. inside its 10 px overview block.
expect(slider.style.transform).toBe("translateY(0px)");
expect(slider.style.height).toBe("16px");
});
it("hides itself when the conversation does not scroll", () => {
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller(200, 200) }} />);
expect(view.getByRole("scrollbar", { hidden: true }).style.display).toBe("none");
});
it("expands on hover and redraws the wider overview", () => {
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroller() }} />);
const rail = view.getByRole("scrollbar");
fillRect.mockClear();
fireEvent.pointerEnter(rail);
expect(rail).toHaveAttribute("data-expanded", "true");
expect(fillRect).toHaveBeenCalledTimes(messages.length);
fireEvent.pointerLeave(rail);
expect(rail).toHaveAttribute("data-expanded", "false");
});
it("jumps on track clicks, drags the slider, and pages by keyboard", () => {
const scroll = scroller();
const view = render(<ChatMinimap messages={messages} rowHeights={new MessageRowHeights()} scrollContainerRef={{ current: scroll }} />);
const rail = view.getByRole("scrollbar");
const slider = rail.querySelector<HTMLElement>(".chat-minimap__viewport")!;
vi.spyOn(rail, "getBoundingClientRect").mockReturnValue({ top: 0, height: 200, left: 0, right: 14 } as DOMRect);
vi.spyOn(slider, "getBoundingClientRect").mockImplementation(() => {
const top = Number(/translateY\((.+)px\)/.exec(slider.style.transform)?.[1] ?? 0);
return { top, bottom: top + 40, height: 40 } as DOMRect;
});
// Track click centers the slider under the pointer: (100 - 20) / 160.
fireEvent.pointerDown(rail, { button: 0, clientY: 100, pointerId: 1 });
expect(scroll.scrollTop).toBe(400);
fireEvent.pointerMove(rail, { clientY: 180, pointerId: 1 });
expect(scroll.scrollTop).toBe(800);
fireEvent.pointerUp(rail, { clientY: 180, pointerId: 1 });
fireEvent.pointerMove(rail, { clientY: 0, pointerId: 1 });
expect(scroll.scrollTop).toBe(800);
act(() => fireEvent.keyDown(rail, { key: "Home" }));
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: "auto" });
act(() => fireEvent.keyDown(rail, { key: "End" }));
expect(scroll.scrollTo).toHaveBeenLastCalledWith({ top: 1_000, behavior: "auto" });
});
});
+402
View File
@@ -0,0 +1,402 @@
import { useCallback, useLayoutEffect, useRef, type KeyboardEvent, type PointerEvent, type RefObject, type WheelEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Message } from "./ChatTimeline";
import type { MessageRowHeights } from "./chat-message-window";
import { isCancelledMessage } from "./message-terminal-status";
/** Rail width when idle and when hovered (expanded overview), in CSS pixels. */
export const minimapCollapsedWidth = 14;
export const minimapExpandedWidth = 80;
export function minimapMessageColor(message: Message): string {
// Cancelled replies are muted like their status line; only failures are red.
if (message.state === "error") return isCancelledMessage(message) ? "#8f98a8" : "#e06b75";
if (message.tools?.length || message.pendingQuestions?.length) return "#d9a441";
return message.role === "user" ? "#6d9ee8" : "#8f98a8";
}
export function minimapScrollTop(
pointerRatio: number,
scrollHeight: number,
viewportHeight: number,
): number {
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight);
return Math.min(1, Math.max(0, pointerRatio)) * maxScrollTop;
}
/**
* Stable overview weight of a message, in text-line units: its wrapped lines
* plus fixed room for the header/padding and each tool row. It depends only on
* the message content, never on measured DOM heights, so the overview keeps
* its density while the timeline measures rows during scrolling.
*/
export function minimapMessageWeight(message: Message): number {
return minimapLines(message).length + 3 + 2 * (message.tools?.length ?? 0);
}
/** Largest index i in [0, prefix.length - 2] with prefix[i] <= value. */
function locate(prefix: Float64Array, value: number): number {
let low = 0;
let high = prefix.length - 2;
while (low < high) {
const middle = (low + high + 1) >> 1;
if (prefix[middle]! <= value) low = middle;
else high = middle - 1;
}
return Math.max(0, low);
}
/** Maps a position on one prefix-sum axis to the other, row by row. */
function mapAxis(from: Float64Array, to: Float64Array, value: number): number {
const count = from.length - 1;
if (count <= 0) return 0;
const clamped = Math.min(from[count]!, Math.max(0, value));
const index = locate(from, clamped);
const span = from[index + 1]! - from[index]!;
const fraction = span > 0 ? (clamped - from[index]!) / span : 0;
return to[index]! + fraction * (to[index + 1]! - to[index]!);
}
function prefixSums(messages: readonly Message[], size: (message: Message) => number): Float64Array {
const prefix = new Float64Array(messages.length + 1);
for (let index = 0; index < messages.length; index += 1) {
prefix[index + 1] = prefix[index]! + Math.max(0, size(messages[index]!));
}
return prefix;
}
/** Characters per minimap text line, roughly one reading-column line. */
const minimapCharsPerLine = 72;
/** Upper bound of text lines kept per message; very long replies are sampled. */
const minimapMaxLines = 600;
/**
* Coarse text-line profile of a message: one entry per wrapped visual line,
* holding its indent and length as fractions of a full line. Empty entries
* (`width === 0`) are blank lines. Code fences become indented strokes.
*/
export type MinimapLine = { indent: number; width: number; code: boolean };
const lineCache = new WeakMap<Message, MinimapLine[]>();
export function minimapLines(message: Message): MinimapLine[] {
const cached = lineCache.get(message);
if (cached) return cached;
const lines: MinimapLine[] = [];
let code = false;
const source = message.content ?? "";
for (const raw of source.split("\n")) {
if (lines.length >= minimapMaxLines) break;
if (/^\s*(```|~~~)/.test(raw)) {
code = !code;
continue;
}
const text = raw.replace(/\t/g, " ");
const trimmed = text.trimStart();
if (!trimmed) {
lines.push({ indent: 0, width: 0, code });
continue;
}
const indent = Math.min(0.5, (text.length - trimmed.length) / minimapCharsPerLine);
// Wide (CJK) characters take about two columns.
let columns = 0;
for (const char of trimmed) columns += char.charCodeAt(0) > 0x2e7f ? 2 : 1;
const span = Math.max(1, Math.round(minimapCharsPerLine * (1 - indent)));
while (columns > 0 && lines.length < minimapMaxLines) {
const take = Math.min(span, columns);
lines.push({ indent, width: take / minimapCharsPerLine, code });
columns -= take;
}
}
// Tool calls or attachments without text still show as one stroke.
if (lines.length === 0) lines.push({ indent: 0, width: 0.35, code: false });
lineCache.set(message, lines);
return lines;
}
type ChatMinimapProps = {
messages: readonly Message[];
rowHeights: MessageRowHeights;
scrollContainerRef: RefObject<HTMLElement | null>;
};
type Drag = { pointerId: number; grabOffset: number };
/**
* Replaces the native scrollbar of the chat scroller with a canvas overview.
* Geometry follows the scroller on every scroll/resize event by writing styles
* directly, so the viewport indicator never waits for a React commit.
*/
export function ChatMinimap({ messages, rowHeights, scrollContainerRef }: ChatMinimapProps): React.JSX.Element {
const { t } = useTranslation("app");
const railRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sliderRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<Drag | undefined>(undefined);
const expandedRef = useRef(false);
const drawnRef = useRef<{ width: number; height: number; messages: readonly Message[] | undefined }>({ width: 0, height: 0, messages: undefined });
const layoutRef = useRef({ scrollable: false, railHeight: -1, percent: -1, scrollHeight: -1 });
const dataRef = useRef({ messages, rowHeights });
// Overview axis: stable content weights. Scroll axis: current row heights
// (measured or estimated). The slider maps between them row by row, so the
// drawing never changes when rows are measured during scrolling.
const axesRef = useRef<{ messages: readonly Message[] | undefined; weights: Float64Array; pixels: Float64Array }>({
messages: undefined, weights: new Float64Array(1), pixels: new Float64Array(1),
});
const refreshAxes = useCallback((): void => {
const { messages: current, rowHeights: heights } = dataRef.current;
const axes = axesRef.current;
const weights = axes.messages === current ? axes.weights : prefixSums(current, minimapMessageWeight);
axesRef.current = { messages: current, weights, pixels: prefixSums(current, (message) => heights.size(message.id)) };
}, []);
const draw = useCallback((force: boolean): void => {
const canvas = canvasRef.current;
const rail = railRef.current;
const container = scrollContainerRef.current;
if (!canvas || !rail || !container) return;
const width = rail.clientWidth ||
(expandedRef.current ? minimapExpandedWidth : minimapCollapsedWidth);
const height = Math.max(1, rail.clientHeight || container.clientHeight);
const { messages: current } = dataRef.current;
const drawn = drawnRef.current;
if (!force && drawn.width === width && drawn.height === height && drawn.messages === current) return;
drawnRef.current = { width, height, messages: current };
if (axesRef.current.messages !== current) refreshAxes();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
const { weights } = axesRef.current;
const totalWeight = weights[current.length] ?? 0;
if (totalWeight <= 0) return;
const expanded = expandedRef.current;
const inset = expanded ? 6 : 2;
const lineSpan = width - inset * 2;
// Expanded rows: thin strokes on a device-pixel-friendly pitch.
const pitch = ratio >= 2 ? 2 : 3;
const stroke = ratio >= 2 ? 1.5 : 2;
for (let index = 0; index < current.length; index += 1) {
const message = current[index]!;
const top = (weights[index]! / totalWeight) * height;
const blockHeight = Math.max(1, ((weights[index + 1]! - weights[index]!) / totalWeight) * height);
context.fillStyle = minimapMessageColor(message);
if (!expanded) {
context.fillRect(inset, top, lineSpan, blockHeight);
continue;
}
// Expanded: one stroke per wrapped text line, like an editor minimap.
// User turns are right-aligned and narrower, echoing the chat layout.
const user = message.role === "user";
const span = user ? lineSpan * 0.7 : lineSpan;
const base = user ? width - inset - span : inset;
const lines = minimapLines(message);
const rows = Math.max(1, Math.min(lines.length, Math.floor(blockHeight / pitch)));
const rowStep = rows > 1 ? Math.min(pitch * 1.6, blockHeight / rows) : 0;
for (let row = 0; row < rows; row += 1) {
const line = lines[Math.floor((row * lines.length) / rows)];
if (!line || line.width <= 0) continue;
const lineWidth = Math.max(2, Math.min(1, line.width) * span);
const left = user ? base + span - lineWidth : base + line.indent * span;
context.globalAlpha = line.code ? 0.6 : 1;
context.fillRect(left, top + row * rowStep, Math.min(lineWidth, base + span - left), Math.min(stroke, blockHeight));
}
context.globalAlpha = 1;
}
}, [refreshAxes, scrollContainerRef]);
/** Overview position (0..railHeight) of a scroll-content offset. */
const toRail = (offset: number, scrollHeight: number, railHeight: number): number => {
const { weights, pixels } = axesRef.current;
const count = pixels.length - 1;
const totalPixels = pixels[count] ?? 0;
const totalWeight = weights[count] ?? 0;
if (totalPixels <= 0 || totalWeight <= 0 || scrollHeight <= 0) return (offset / Math.max(1, scrollHeight)) * railHeight;
// Scroll content and the row model differ only by padding; scale linearly.
const pixel = (offset / scrollHeight) * totalPixels;
return (mapAxis(pixels, weights, pixel) / totalWeight) * railHeight;
};
/** Scroll-content offset of an overview position (inverse of toRail). */
const fromRail = (y: number, scrollHeight: number, railHeight: number): number => {
const { weights, pixels } = axesRef.current;
const count = pixels.length - 1;
const totalPixels = pixels[count] ?? 0;
const totalWeight = weights[count] ?? 0;
if (totalPixels <= 0 || totalWeight <= 0 || railHeight <= 0) return (y / Math.max(1, railHeight)) * scrollHeight;
const pixel = mapAxis(weights, pixels, (y / railHeight) * totalWeight);
return (pixel / totalPixels) * scrollHeight;
};
/** Syncs the slider with the scroller; runs synchronously in the event. */
const sync = useCallback((): void => {
const rail = railRef.current;
const slider = sliderRef.current;
const container = scrollContainerRef.current;
if (!rail || !slider || !container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollable = scrollHeight > clientHeight + 1 && dataRef.current.messages.length > 0;
const last = layoutRef.current;
if (last.scrollable !== scrollable) rail.style.display = scrollable ? "" : "none";
if (!scrollable) {
layoutRef.current = { ...last, scrollable };
return;
}
// The rail spans the reading area below the title bar and context strip
// (CSS). Its height is read only when it may have changed, so a plain
// scroll event does no layout reads.
let { railHeight } = last;
const shown = !last.scrollable;
if (shown || railHeight < 0) railHeight = rail.clientHeight || clientHeight;
// Row measurements change scrollHeight; only then rebuild the pixel axis.
if (shown || last.scrollHeight !== scrollHeight || axesRef.current.messages !== dataRef.current.messages) refreshAxes();
// The slider covers the overview span of the rows in view, so it sits
// exactly on the strokes of the visible messages.
let top = toRail(scrollTop, scrollHeight, railHeight);
let bottom = toRail(scrollTop + clientHeight, scrollHeight, railHeight);
if (bottom - top < 16) {
const center = (top + bottom) / 2;
top = Math.max(0, Math.min(railHeight - 16, center - 8));
bottom = top + 16;
}
slider.style.height = `${bottom - top}px`;
slider.style.transform = `translateY(${top}px)`;
const maxScrollTop = scrollHeight - clientHeight;
const percent = Math.round(maxScrollTop > 0 ? (scrollTop / maxScrollTop) * 100 : 0);
if (percent !== last.percent) rail.setAttribute("aria-valuenow", String(percent));
layoutRef.current = { scrollable, railHeight, percent, scrollHeight };
if (shown) draw(false);
}, [draw, refreshAxes, scrollContainerRef]);
useLayoutEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener("scroll", sync, { passive: true });
// Content growth (streaming, measurement, resize) changes scrollHeight
// without a scroll event.
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(sync);
observer?.observe(container);
if (container.firstElementChild) observer?.observe(container.firstElementChild);
// The rail itself shrinks when the context strip above the reading area
// grows, independently of the scroller size.
const rail = railRef.current;
const railObserver = typeof ResizeObserver === "undefined" || !rail
? undefined
: new ResizeObserver(() => {
if (rail.clientHeight === layoutRef.current.railHeight) return;
layoutRef.current = { ...layoutRef.current, railHeight: -1 };
sync();
draw(false);
});
if (rail) railObserver?.observe(rail);
// The per-commit effect below performs the initial sync.
return () => {
container.removeEventListener("scroll", sync);
observer?.disconnect();
railObserver?.disconnect();
};
}, [draw, scrollContainerRef, sync]);
// Pane commits keep the slider in sync (row measurements move scrollHeight).
// The canvas redraws only when the message list itself changes; draw()
// compares identities, so measurement-only commits never repaint it.
useLayoutEffect(() => {
dataRef.current = { messages, rowHeights };
sync();
if (layoutRef.current.scrollable) draw(false);
});
const scrollToSliderTop = (clientY: number, grabOffset: number): void => {
const rail = railRef.current;
const container = scrollContainerRef.current;
if (!rail || !container) return;
const bounds = rail.getBoundingClientRect();
const target = fromRail(clientY - bounds.top - grabOffset, container.scrollHeight, bounds.height);
const maxScrollTop = container.scrollHeight - container.clientHeight;
container.scrollTop = minimapScrollTop(maxScrollTop > 0 ? target / maxScrollTop : 0, container.scrollHeight, container.clientHeight);
sync();
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return;
const slider = sliderRef.current;
if (!slider) return;
const sliderBounds = slider.getBoundingClientRect();
const onSlider = event.clientY >= sliderBounds.top && event.clientY <= sliderBounds.bottom;
// Grabbing the slider keeps its offset; clicking the track centers it.
const grabOffset = onSlider ? event.clientY - sliderBounds.top : sliderBounds.height / 2;
dragRef.current = { pointerId: event.pointerId, grabOffset };
event.currentTarget.setPointerCapture?.(event.pointerId);
event.preventDefault();
if (!onSlider) scrollToSliderTop(event.clientY, grabOffset);
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>): void => {
const drag = dragRef.current;
if (drag && drag.pointerId === event.pointerId) scrollToSliderTop(event.clientY, drag.grabOffset);
};
const endDrag = (event: PointerEvent<HTMLDivElement>): void => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = undefined;
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const setExpanded = (expanded: boolean): void => {
if (expandedRef.current === expanded) return;
expandedRef.current = expanded;
const rail = railRef.current;
if (rail) rail.dataset.expanded = expanded ? "true" : "false";
draw(true);
};
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const container = scrollContainerRef.current;
if (!container) return;
const step = Math.max(1, container.clientHeight * 0.8);
if (event.key === "Home") container.scrollTo({ top: 0, behavior: "auto" });
else if (event.key === "End") container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
else if (event.key === "PageUp") container.scrollTo({ top: container.scrollTop - step, behavior: "auto" });
else if (event.key === "PageDown") container.scrollTo({ top: container.scrollTop + step, behavior: "auto" });
else return;
event.preventDefault();
};
return (
<div
aria-controls="chat-message-list"
aria-label={t("chat.minimap")}
aria-orientation="vertical"
aria-valuemax={100}
aria-valuemin={0}
className="chat-minimap"
data-expanded="false"
onKeyDown={onKeyDown}
onLostPointerCapture={() => { dragRef.current = undefined; }}
onPointerCancel={endDrag}
onPointerDown={onPointerDown}
onPointerEnter={() => setExpanded(true)}
onPointerLeave={() => { if (!dragRef.current) setExpanded(false); }}
onPointerMove={onPointerMove}
onPointerUp={(event) => {
endDrag(event);
const bounds = railRef.current?.getBoundingClientRect();
if (bounds && (event.clientX < bounds.left || event.clientX > bounds.right)) setExpanded(false);
}}
onWheel={onWheel}
ref={railRef}
// Shown by sync() once the scroller overflows; React never rewrites it.
style={{ display: "none" }}
role="scrollbar"
tabIndex={0}
>
<canvas aria-hidden="true" className="chat-minimap__canvas" ref={canvasRef} />
<span aria-hidden="true" className="chat-minimap__viewport" ref={sliderRef} />
</div>
);
}
+5 -1
View File
@@ -560,17 +560,21 @@ describe('ChatTimeline', () => {
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1 messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
} }
const message: Message = { const message: Message = {
id: 'status-message', role: 'assistant', content: '已有内容', id: 'status-message', role: 'assistant', content: '',
createdAt: Date.now(), state: 'streaming' createdAt: Date.now(), state: 'streaming'
} }
const { rerender } = render(<ChatTimeline {...props} messages={[message]} />) const { rerender } = render(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible() expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
expect(screen.getByRole('status', { name: '等待后续进展' })).toHaveClass('message__status--active')
expect(document.querySelector('.message__status-dot--active')).toBeNull() expect(document.querySelector('.message__status-dot--active')).toBeNull()
rerender(<ChatTimeline {...props} messages={[{ ...message, content: '已有内容' }]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).not.toHaveClass('message__status--active')
const tool = { callId: 'read-1', name: 'read', summary: 'Read file' } const tool = { callId: 'read-1', name: 'read', summary: 'Read file' }
rerender(<ChatTimeline {...props} messages={[{ rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'pending' }] ...message, tools: [{ ...tool, state: 'pending' }]
}]} />) }]} />)
expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible() expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible()
expect(screen.getByRole('status', { name: '工具待执行:read' })).toHaveClass('message__status--active')
rerender(<ChatTimeline {...props} messages={[{ rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'running' }] ...message, tools: [{ ...tool, state: 'running' }]
}]} />) }]} />)
+10 -4
View File
@@ -754,6 +754,10 @@ function ChatMessageRowView({
? t('chat.status.waitingForSubagent') ? t('chat.status.waitingForSubagent')
: t('chat.status.waitingForProgress')) : t('chat.status.waitingForProgress'))
: message.status : message.status
const isWaitingForResponse = message.role === 'assistant' &&
message.state === 'streaming' &&
!message.content.trim() &&
!message.blocks?.some(block => block.type === 'text' && block.content.trim())
const roleCaption = message.role !== 'assistant' const roleCaption = message.role !== 'assistant'
? undefined ? undefined
@@ -1232,9 +1236,11 @@ function ChatMessageRowView({
: 'polite' : 'polite'
} }
className={ className={
failed [
? 'message__status message__status--error' 'message__status',
: 'message__status' failed && 'message__status--error',
isWaitingForResponse && 'message__status--active'
].filter(Boolean).join(' ')
} }
role={ role={
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0) message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
@@ -1248,7 +1254,7 @@ function ChatMessageRowView({
aria-hidden="true" aria-hidden="true"
className="message__status-dot" className="message__status-dot"
/> />
{statusText} <span className="message__status-label">{statusText}</span>
</div> </div>
)} )}
{canRetry && Boolean(retryContent?.trim()) && ( {canRetry && Boolean(retryContent?.trim()) && (
+11 -2
View File
@@ -37,7 +37,9 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
</section>; </section>;
} }
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById">; type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById" | "onVisibleMessageCountChange" | "visibleMessageCount">;
const ignoreVisibleMessageCountChange = (): void => undefined;
export type ConversationHistorySlotProps = PaneProps & { export type ConversationHistorySlotProps = PaneProps & {
conversationId: string; conversationId: string;
@@ -110,5 +112,12 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
/> />
); );
} }
return <ChatHistoryPane {...paneProps} artifactById={artifactById} conversation={conversation} taskStrip={taskStrip} />; return <ChatHistoryPane
{...paneProps}
artifactById={artifactById}
conversation={conversation}
onVisibleMessageCountChange={ignoreVisibleMessageCountChange}
taskStrip={taskStrip}
visibleMessageCount={conversation.messages.length}
/>;
}); });
@@ -79,6 +79,7 @@ const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-0000000
const settings: ApplicationSettings = { const settings: ApplicationSettings = {
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation, localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
@@ -1460,7 +1461,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />) const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote() await openNote()
expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题')) expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题'))
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('icon-button', 'magic-note-entry__delete-button') expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('magic-note-entry__action-button', 'magic-note-entry__delete-button')
fireEvent.click(screen.getByRole('button', { name: '编辑' })) fireEvent.click(screen.getByRole('button', { name: '编辑' }))
expect(container.querySelector('.magic-note-entry__editor')).toBeVisible() expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled() expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled()
+6 -4
View File
@@ -3091,7 +3091,7 @@ function MagicNotesWorkspaceView({
</button> </button>
)} )}
<button <button
className="icon-button" className="magic-note-entry__action-button"
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')} aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')} title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
disabled={Boolean(busy) || editingEntry?.id === entry.id} disabled={Boolean(busy) || editingEntry?.id === entry.id}
@@ -3104,12 +3104,13 @@ function MagicNotesWorkspaceView({
}) })
} }
> >
<Pencil aria-hidden="true" size={15} /> <Pencil aria-hidden="true" size={14} />
<span>{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}</span>
</button> </button>
<button <button
aria-label={t('actions.deleteEntry')} aria-label={t('actions.deleteEntry')}
title={t('actions.deleteEntry')} title={t('actions.deleteEntry')}
className="icon-button magic-note-entry__delete-button" className="magic-note-entry__action-button magic-note-entry__delete-button"
disabled={Boolean(busy) || Boolean(editingEntry)} disabled={Boolean(busy) || Boolean(editingEntry)}
type="button" type="button"
onClick={() => { onClick={() => {
@@ -3119,7 +3120,8 @@ function MagicNotesWorkspaceView({
setDeletingEntryId(entry.id) setDeletingEntryId(entry.id)
}} }}
> >
<Trash2 aria-hidden="true" size={15} /> <Trash2 aria-hidden="true" size={14} />
<span>{t('actions.deleteEntry')}</span>
</button> </button>
</div>} </div>}
</header> </header>
@@ -1,16 +1,10 @@
import { import {
Cpu,
Download,
FolderOpen, FolderOpen,
RefreshCw,
RotateCcw, RotateCcw,
Save, Save
Upload
} from 'lucide-react' } from 'lucide-react'
import { import {
useCallback,
useEffect, useEffect,
useRef,
useState useState
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -18,13 +12,6 @@ import { InlineHelp } from './InlineHelp'
import type { import type {
ApplicationSettings ApplicationSettings
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
AgentArchitecture
} from '../../shared/agent-installation-contracts'
import { import {
canonicalizeShortcutAccelerator, canonicalizeShortcutAccelerator,
type GlobalShortcutSettings, type GlobalShortcutSettings,
@@ -32,7 +19,6 @@ import {
type GlobalShortcutUpdateErrorCode type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut' } from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { PageTabs } from './WorkspacePrimitives'
import { import {
SettingsCategoryHeader, SettingsCategoryHeader,
SettingsWarningList SettingsWarningList
@@ -43,7 +29,6 @@ type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: ( onConversationHtmlRenderingEnabledChange: (
enabled: boolean enabled: boolean
) => void ) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onWorkspacePathChange: (path: string) => void onWorkspacePathChange: (path: string) => void
workspacePath: string workspacePath: string
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -53,8 +38,6 @@ type PlatformFeaturesSettingsSectionProps = {
) => void ) => void
} }
type PlatformFeaturesTab = 'general' | 'remote-projects'
const shortcutErrorTranslationKeys: Record< const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode, GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict' | 'platformFeatures.shortcut.errors.conflict'
@@ -69,7 +52,6 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({ export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange, onConversationHtmlRenderingEnabledChange,
onRemoteProjectsEnabledChange,
onWorkspacePathChange, onWorkspacePathChange,
workspacePath, workspacePath,
onNotify, onNotify,
@@ -78,8 +60,6 @@ export function PlatformFeaturesSettingsSection({
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element { }: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections') const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings') const { t: settingsT } = useTranslation('settings')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>() const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] = const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>() useState<GlobalShortcutSettingsSnapshot>()
@@ -93,17 +73,6 @@ export function PlatformFeaturesSettingsSection({
: t('platformFeatures.shortcut.errors.serviceUnavailable') : t('platformFeatures.shortcut.errors.serviceUnavailable')
) )
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
useState(false)
const [agentInventoryError, setAgentInventoryError] =
useState<string>()
const [agentPackageBusy, setAgentPackageBusy] =
useState<string>()
const [agentPackageProgress, setAgentPackageProgress] =
useState<AgentPackageDownloadProgress>()
const agentInventoryRequested = useRef(false)
const [error, setError] = useState<string | undefined>(() => const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates window.goodbuddy.updates
? undefined ? undefined
@@ -135,162 +104,6 @@ export function PlatformFeaturesSettingsSection({
} }
}, [t]) }, [t])
const loadAgentInventory = useCallback(async (
refresh = false
): Promise<void> => {
const getInventory =
window.goodbuddy.sshHosts?.getAgentPackageInventory
if (!getInventory) {
setAgentInventoryError(
t(
'platformFeatures.remoteProjects.agentInventory.errors.unavailable'
)
)
return
}
setAgentInventoryLoading(true)
setAgentInventoryError(undefined)
try {
setAgentInventory(await getInventory(refresh))
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.readFailed'
)
)
)
} finally {
setAgentInventoryLoading(false)
}
}, [t])
useEffect(() => {
if (
activeSection !== 'remote-projects' ||
agentInventoryRequested.current
) {
return
}
agentInventoryRequested.current = true
void loadAgentInventory()
}, [activeSection, loadAgentInventory])
useEffect(() => {
const subscribe =
window.goodbuddy.sshHosts?.onAgentPackageProgress
return subscribe?.((progress) =>
setAgentPackageProgress(progress)
)
}, [])
const downloadAgentPackage = async (
architecture: AgentArchitecture,
platform: 'linux' | 'darwin'
): Promise<void> => {
const download =
window.goodbuddy.sshHosts?.downloadAgentPackage
if (!download) {
return
}
setAgentPackageBusy(`${platform}-${architecture}`)
setAgentPackageProgress(undefined)
setAgentInventoryError(undefined)
try {
setAgentInventory(await download(architecture, platform))
onNotify?.({
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
),
tone: 'success'
})
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
setAgentPackageProgress(undefined)
}
}
const importAgentPackage = async (): Promise<void> => {
const importPackage =
window.goodbuddy.sshHosts?.importAgentPackage
if (!importPackage) {
return
}
setAgentPackageBusy('import')
setAgentInventoryError(undefined)
try {
const inventory = await importPackage()
if (inventory) {
setAgentInventory(inventory)
onNotify?.({
dedupeKey: 'agent-package-imported',
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.imported'
),
tone: 'success'
})
}
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.importFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
}
}
const exportAgentPackage = async (
architecture: AgentArchitecture,
platform: 'linux' | 'darwin'
): Promise<void> => {
const exportPackage =
window.goodbuddy.sshHosts?.exportAgentPackage
if (!exportPackage) {
return
}
setAgentPackageBusy(`${platform}-${architecture}`)
setAgentInventoryError(undefined)
try {
await exportPackage(architecture, platform)
onNotify?.({
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
),
tone: 'success'
})
} catch (reason) {
setAgentInventoryError(
displayErrorMessage(
reason,
t(
'platformFeatures.remoteProjects.agentInventory.errors.exportFailed'
)
)
)
} finally {
setAgentPackageBusy(undefined)
}
}
useEffect(() => { useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts const shortcuts = window.goodbuddy.shortcuts
let active = true let active = true
@@ -475,69 +288,20 @@ export function PlatformFeaturesSettingsSection({
} }
} }
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
remoteProjectsEnabled: enabled
})
setSettings(nextSettings)
onRemoteProjectsEnabledChange(
nextSettings.remoteProjectsEnabled
)
} catch {
setError(
t('platformFeatures.errors.saveRemoteProjectsFailed')
)
} finally {
setSaving(false)
}
}
return ( return (
<> <>
<SettingsCategoryHeader <SettingsCategoryHeader
category="platform-features" category="platform-features"
error={error} error={error}
headingId="platform-features-heading" headingId="platform-features-heading"
navigation={
<div className="platform-features-tabs">
<PageTabs
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
idPrefix="platform-features"
onChange={setActiveSection}
tabs={[
{
id: 'general',
label: t('platformFeatures.tabs.general')
},
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
}
]}
value={activeSection}
variant="segmented"
/>
</div>
}
/> />
<SettingsWarningList warnings={settings?.warnings} /> <SettingsWarningList warnings={settings?.warnings} />
{activeSection === 'general' && ( <section
<section aria-labelledby="platform-features-heading"
aria-labelledby="platform-features-tab-general" className="settings-section"
className="settings-section" id="platform-features-panel-general"
id="platform-features-panel-general" >
role="tabpanel"
>
<article className="capability-card"> <article className="capability-card">
<div className="capability-card__header"> <div className="capability-card__header">
<span className="inline-help-label"> <span className="inline-help-label">
@@ -740,304 +504,7 @@ export function PlatformFeaturesSettingsSection({
</div> </div>
</label> </label>
</article> </article>
</section> </section>
)}
{activeSection === 'remote-projects' && (
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<span className="inline-help-label">
<strong>
{t('platformFeatures.remoteProjects.title')}
</strong>
<InlineHelp label={t('platformFeatures.remoteProjects.title')}>
{t(
'platformFeatures.remoteProjects.description'
)}
</InlineHelp>
</span>
</div>
<label className="toggle-row">
<input
aria-label={t(
'platformFeatures.remoteProjects.title'
)}
checked={settings.remoteProjectsEnabled}
disabled={saving}
onChange={(event) =>
void changeRemoteProjects(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.remoteProjects.enabled')}
</span>
</label>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t(
'platformFeatures.remoteProjects.agentInventory.title'
)}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.agentInventory.description'
)}
</small>
</div>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() => void importAgentPackage()}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.import'
)}
</button>
<button
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
className="secondary-button"
disabled={
agentInventoryLoading ||
agentPackageBusy !== undefined
}
onClick={() => void loadAgentInventory(true)}
type="button"
>
<RefreshCw
aria-hidden="true"
className={
agentInventoryLoading
? 'bundled-agent-inventory__spinner'
: undefined
}
size={13}
/>
{t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
</button>
</div>
</div>
{agentInventory && (
<p className="settings-notice">
{t(
'platformFeatures.remoteProjects.agentInventory.summary',
{
available: agentInventory.entries.filter(
(entry) => entry.state === 'verified'
).length,
total: agentInventory.entries.length
}
)}
</p>
)}
{agentInventoryLoading && !agentInventory ? (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.loading'
)}
</p>
) : agentInventoryError ? (
<p className="settings-warning" role="alert">
{agentInventoryError}
</p>
) : agentInventory ? (
<div
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.listLabel'
)}
className="bundled-agent-inventory"
role="list"
>
{agentInventory.entries.map((entry) => (
<article
className="bundled-agent-inventory__item"
key={`${entry.platform}-${entry.architecture}`}
role="listitem"
>
<div className="bundled-agent-inventory__item-header">
<Cpu aria-hidden="true" size={16} />
<strong>
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
entry.updateAvailable
? 'update-available'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'up-to-date'
: entry.state
}`}
>
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
)}
</span>
</div>
<dl>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
)}
</dt>
<dd>{entry.version ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
)}
</dt>
<dd>{entry.latestVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
)}
</dt>
<dd>{entry.architecture}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
)}
</dt>
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
)}
</dt>
<dd>
{entry.agentProtocol
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
: '—'}
</dd>
</div>
</dl>
{agentPackageProgress?.architecture ===
entry.architecture &&
agentPackageProgress.platform === entry.platform &&
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
<p className="settings-notice" role="status">
{t(
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
)}
</p>
)}
<div className="capability-card__actions">
{(
entry.updateAvailable ||
(
entry.state !== 'verified' &&
entry.latestVersion !== null
) ||
agentInventory.catalog.state !== 'available'
) && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
: entry.state !== 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
: 'platformFeatures.remoteProjects.agentInventory.update',
{ version: entry.latestVersion }
)}
</button>
)}
{entry.state === 'verified' && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void exportAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.export'
)}
</button>
)}
</div>
</article>
))}
</div>
) : null}
{agentInventory?.catalog.state === 'available' && (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.available'
)}
</p>
)}
{agentInventory?.catalog.state === 'unavailable' && (
<p className="settings-warning" role="alert">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
{ error: agentInventory.catalog.error }
)}
</p>
)}
</article>
</section>
)}
</> </>
) )
} }
+4 -2
View File
@@ -395,12 +395,14 @@ describe('ProjectSwitcher runtime fields', () => {
it('floats the project menu outside the sidebar and keeps menu interactions open', async () => { it('floats the project menu outside the sidebar and keeps menu interactions open', async () => {
renderSwitcher() renderSwitcher()
const trigger = screen.getByRole('button', { name: '当前项目' }) const trigger = screen.getByRole('button', { name: '当前项目' })
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(16, 16, 240, 60))
fireEvent.click(trigger) fireEvent.click(trigger)
const menu = screen.getByRole('menu') const menu = screen.getByRole('menu')
expect(trigger.closest('.project-switcher')).not.toContainElement(menu) expect(trigger.closest('.project-switcher')).not.toContainElement(menu)
expect(menu.closest('.floating-portal')?.parentElement).toBe(document.body) expect(menu.closest('.floating-portal')?.parentElement).toBe(document.body)
const surface = screen.getByRole('dialog', { name: '项目与活动' }) const surface = screen.getByRole('dialog', { name: '项目与活动' })
expect(surface.style.width).toBe('920px') expect(surface.style.width).toBe('680px')
expect(surface.style.left).toBe('14px')
const selected = within(menu).getByRole('menuitemradio', { checked: true }) const selected = within(menu).getByRole('menuitemradio', { checked: true })
await waitFor(() => expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus()) await waitFor(() => expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus())
fireEvent.pointerDown(selected) fireEvent.pointerDown(selected)
@@ -409,7 +411,7 @@ describe('ProjectSwitcher runtime fields', () => {
const width = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360) const width = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
fireEvent(window, new Event('resize')) fireEvent(window, new Event('resize'))
expect(surface.style.width).toBe('328px') expect(surface.style.width).toBe('328px')
expect(surface.style.left).toBe('16px') expect(surface.style.left).toBe('14px')
width.mockRestore() width.mockRestore()
fireEvent.keyDown(selected, { key: 'Escape' }) fireEvent.keyDown(selected, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument() expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+118
View File
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentPackageInventory } from '../../shared/agent-package-contracts'
import {
applicationSettingsSchema,
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import { changeUiLocale } from './i18n'
import { RemoteHostsPage } from './RemoteHostsPage'
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
const inventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: { state: 'available', checkedAt: '2026-08-24T00:00:00.000Z', error: null },
entries: []
}
const getSnapshot = vi.fn(async () => ({ hosts: [], secureStorageAvailable: true }))
const getAgentPackageInventory = vi.fn(async () => inventory)
function renderPage(
overrides: Partial<Parameters<typeof RemoteHostsPage>[0]> & { value?: ApplicationSettings } = {},
report = vi.fn()
) {
const onUpdateSettings = vi.fn(async () => true)
const { value = settings, ...rest } = overrides
const view = render(
<WorkspaceUnsavedChangesContext.Provider value={report}>
<RemoteHostsPage
active
settings={value}
settingsPending={false}
settingsLocked={false}
onUpdateSettings={onUpdateSettings}
onNotify={vi.fn()}
{...rest}
/>
</WorkspaceUnsavedChangesContext.Provider>
)
return { ...view, onUpdateSettings, report }
}
describe('RemoteHostsPage', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
getSnapshot,
getAgentPackageInventory,
onAgentPackageProgress: vi.fn(() => () => {}),
onRemoteEnvironmentUpdateProgress: vi.fn(() => () => {})
}
}
})
})
afterEach(() => cleanup())
it('offers enablement on the Hosts tab without calling host services while remote capability is off', async () => {
const { onUpdateSettings } = renderPage()
expect(screen.getByRole('heading', { level: 1, name: '主机管理' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '主机' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('远程能力未启用')).toBeVisible()
expect(getSnapshot).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '启用远程能力' }))
expect(onUpdateSettings).toHaveBeenCalledWith({ remoteProjectsEnabled: true })
})
it('shows hosts when enabled and keeps every tab panel mounted while switching', async () => {
renderPage({ value: { ...settings, remoteProjectsEnabled: true } })
expect(await screen.findByText('尚未配置 SSH 主机')).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('tab', { name: 'Agent 离线包' }))
expect(await screen.findByText('GoodBuddy Agent 包')).toBeVisible()
await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledOnce())
expect(screen.getByText('尚未配置 SSH 主机')).not.toBeVisible()
fireEvent.click(screen.getByRole('tab', { name: '主机' }))
expect(screen.getByText('尚未配置 SSH 主机')).toBeVisible()
// Panels stayed mounted: nothing was reloaded.
expect(getSnapshot).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
})
it('lets Agent packages be managed while remote capability is off', async () => {
renderPage()
fireEvent.click(screen.getByRole('tab', { name: 'Agent 离线包' }))
expect(await screen.findByText('GoodBuddy Agent 包')).toBeVisible()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
})
it('has only Hosts and Agent packages tabs; the switch stays in application settings', () => {
renderPage()
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['主机', 'Agent 离线包'])
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('pins the route while a host draft is open so the page cache keeps it', async () => {
const report = vi.fn()
renderPage({ value: { ...settings, remoteProjectsEnabled: true } }, report)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(screen.getAllByRole('button', { name: '添加主机' })[0]!)
fireEvent.change(screen.getByLabelText('主机名称'), { target: { value: '未保存的主机' } })
await waitFor(() => expect(report).toHaveBeenLastCalledWith(expect.any(String), true))
})
})
+131
View File
@@ -0,0 +1,131 @@
import { Server } from 'lucide-react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel'
import type {
ApplicationSettings,
ApplicationSettingsUpdate
} from '../../shared/application-settings-contracts'
import type { AppNotificationInput } from './notifications'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
import { EmptyState, PageHeader, PageShell, PageTabs } from './WorkspacePrimitives'
import './remote-hosts.css'
export type RemoteHostsTab = 'hosts' | 'packages'
/**
* Hosts application: SSH hosts and local Agent packages. The shared remote
* capability switch lives in the application settings (Application Center).
* Host operations stay gated by remoteProjectsEnabled in Main, so the Hosts
* tab offers enabling it instead of failing.
*/
export function RemoteHostsPage({
active,
settings,
settingsPending,
settingsLocked,
onUpdateSettings,
onNotify,
onHostUpdated,
onProjectsDeleted
}: {
active: boolean
settings?: ApplicationSettings
settingsPending: boolean
settingsLocked: boolean
onUpdateSettings: (patch: ApplicationSettingsUpdate) => Promise<boolean>
onNotify: (notification: AppNotificationInput) => void
onHostUpdated?: (hostId: string) => void
onProjectsDeleted?: (projectIds: string[]) => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const [tab, setTab] = useState<RemoteHostsTab>('hosts')
const [hostDraftDirty, setHostDraftDirty] = useState(false)
const [hostsBusy, setHostsBusy] = useState(false)
const [packagesBusy, setPackagesBusy] = useState(false)
const remoteEnabled = settings?.remoteProjectsEnabled === true
const [packagesVisited, setPackagesVisited] = useState(false)
// Pins the route while a host draft, installation, or package transfer is
// in progress so the cache never discards it.
useWorkspaceUnsavedChanges(hostDraftDirty || hostsBusy || packagesBusy)
// Derived during render: the package inventory loads once its tab is first shown.
if (active && tab === 'packages' && !packagesVisited) setPackagesVisited(true)
const handleHostsDirty = useCallback((dirty: boolean) => setHostDraftDirty(dirty), [])
const handleHostsBusy = useCallback((busy: boolean) => setHostsBusy(busy), [])
const handlePackagesBusy = useCallback((busy: boolean) => setPackagesBusy(busy), [])
return (
<PageShell variant="standard">
<PageHeader
headingId="remote-hosts-title"
icon={<Server size={18} />}
title={t('remoteHosts.title')}
description={t('remoteHosts.description')}
scope={{ kind: 'global' }}
/>
<div className="remote-hosts-tabs">
<PageTabs
ariaLabel={t('remoteHosts.tabsLabel')}
idPrefix="remote-hosts"
onChange={setTab}
tabs={[
{ id: 'hosts', label: t('remoteHosts.tabs.hosts') },
{ id: 'packages', label: t('remoteHosts.tabs.packages') }
]}
value={tab}
/>
</div>
{/* Panels stay mounted so in-flight installs and transfers keep reporting progress. */}
<section
aria-labelledby="remote-hosts-tab-hosts"
className="remote-hosts-panel"
hidden={tab !== 'hosts'}
id="remote-hosts-panel-hosts"
role="tabpanel"
>
{remoteEnabled ? (
<SshHostsSettingsSection
onBusyChange={handleHostsBusy}
onDirtyChange={handleHostsDirty}
onHostUpdated={onHostUpdated}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
) : (
<EmptyState
action={
<button
className="primary-button"
disabled={!settings || settingsPending || settingsLocked}
onClick={() => void onUpdateSettings({ remoteProjectsEnabled: true })}
type="button"
>
{t('remoteHosts.disabled.enable')}
</button>
}
description={t('remoteHosts.disabled.description')}
icon={<Server size={22} />}
title={t('remoteHosts.disabled.title')}
/>
)}
</section>
<section
aria-labelledby="remote-hosts-tab-packages"
className="remote-hosts-panel"
hidden={tab !== 'packages'}
id="remote-hosts-panel-packages"
role="tabpanel"
>
<AgentPackageInventoryPanel
active={packagesVisited}
onBusyChange={handlePackagesBusy}
onNotify={onNotify}
/>
</section>
</PageShell>
)
}
+61 -363
View File
@@ -382,8 +382,7 @@ const heartbeatSettingsProps = {
onUpdateProject: vi.fn(async () => { onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test') throw new Error('Project update is not used in this test')
}), }),
projects: [], projects: []
remoteProjectsEnabled: true
} }
const assistantExpert: AssistantExpert = { const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101', id: '00000000-0000-4000-8000-000000000101',
@@ -520,6 +519,7 @@ let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -815,6 +815,7 @@ describe('SettingsPanel runtime files', () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -1594,6 +1595,32 @@ describe('SettingsPanel runtime files', () => {
expect(onChange).toHaveBeenLastCalledWith(true) expect(onChange).toHaveBeenLastCalledWith(true)
}) })
it.each([
['zh-CN', '外观', '滚动条缩略图'],
['en-US', 'Appearance', 'Scrollbar minimap']
] as const)('exposes the scrollbar minimap Switch under Appearance in %s', async (locale, category, label) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onChatMinimapEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).toBeChecked()
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(false)
rerender(<SettingsPanel {...props} chatMinimapEnabled={false} />)
expect(toggle).not.toBeChecked()
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(true)
})
it.each([ it.each([
['zh-CN', '平台功能', '桌面通知'], ['zh-CN', '平台功能', '桌面通知'],
['en-US', 'Platform features', 'Desktop notifications'] ['en-US', 'Platform features', 'Desktop notifications']
@@ -2007,12 +2034,37 @@ describe('SettingsPanel runtime files', () => {
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([ expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features', 'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime', 'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
'settings-tab-ssh-hosts',
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles', 'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-about' 'settings-tab-capabilities', 'settings-tab-about'
]) ])
}) })
it('keeps hosts, Agent packages, and the remote switch out of settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await screen.findByLabelText('默认工作区目录')
expect(
screen.queryByRole('tab', { name: '远程项目' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: '远程项目' })
).not.toBeInTheDocument()
expect(screen.queryByText('GoodBuddy Agent 包')).not.toBeInTheDocument()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
})
it('omits the redundant close-only footer on passive settings pages', () => { it('omits the redundant close-only footer on passive settings pages', () => {
render( render(
<SettingsPanel <SettingsPanel
@@ -2328,304 +2380,6 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('persists Remote Projects from its own tab and notifies its parent', async () => {
const onRemoteProjectsEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目'
})
expect(remoteProjectsSwitch).not.toBeChecked()
expect(
await screen.findByText('GoodBuddy Agent 包')
).toBeInTheDocument()
expect(
screen.getByText('当前 1 / 2 个平台与架构目标可用。')
).toBeInTheDocument()
expect(screen.getByText('Linux x64')).toBeInTheDocument()
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false)
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)
)
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true)
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
const arm64Card = screen
.getByText('Linux arm64')
.closest('[role="listitem"]')
expect(x64Card).not.toBeNull()
expect(arm64Card).not.toBeNull()
downloadAgentPackage.mockResolvedValueOnce({
...agentPackageInventory,
entries: agentPackageInventory.entries.map((entry) =>
entry.architecture === 'x64'
? {
...entry,
version: '0.11.4',
updateAvailable: false
}
: entry
)
})
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '更新到 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
expect(
await within(x64Card as HTMLElement).findByText('已是最新')
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).queryByRole('button', {
name: /更新到/u
})
).not.toBeInTheDocument()
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
)
await waitFor(() =>
expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
fireEvent.click(
within(arm64Card as HTMLElement).getByRole('button', {
name: '下载 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')
)
fireEvent.click(
screen.getByRole('button', { name: '导入离线包' })
)
await waitFor(() =>
expect(importAgentPackage).toHaveBeenCalledOnce()
)
fireEvent.click(remoteProjectsSwitch)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: true
})
)
expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true)
expect(remoteProjectsSwitch).toBeChecked()
})
it('shows Agent package loading and recovers from a refresh error', async () => {
let resolveInventory:
| ((inventory: AgentPackageInventory) => void)
| undefined
getAgentPackageInventory.mockReturnValueOnce(
new Promise<AgentPackageInventory>((resolve) => {
resolveInventory = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByText(
'正在校验本地 Agent 包并检查在线版本…'
)
).toBeInTheDocument()
await act(async () => {
resolveInventory?.(agentPackageInventory)
await Promise.resolve()
})
expect(
await screen.findByText('当前 1 / 2 个平台与架构目标可用。')
).toBeInTheDocument()
getAgentPackageInventory.mockRejectedValueOnce(
new Error('inventory unavailable')
)
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await screen.findByText('inventory unavailable')
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(
screen.queryByText('inventory unavailable')
).not.toBeInTheDocument()
)
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
})
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
getAgentPackageInventory.mockResolvedValueOnce({
...agentPackageInventory,
catalog: {
state: 'unavailable',
checkedAt: '2026-08-24T00:00:00.000Z',
error: 'network unavailable'
},
entries: agentPackageInventory.entries.map((entry) => ({
...entry,
latestVersion: null,
updateAvailable: false
}))
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByText(/network unavailable/u)
).toBeInTheDocument()
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
})
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
).toBeInTheDocument()
})
it('shows the SSH Hosts category and its local capability boundary only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
expect(
screen.getByText(
'管理 SSH 主机、固定主机密钥并验证远端执行环境;本机配置的 Skills 和 MCP 不会应用于远程主机'
)
).toBeInTheDocument()
})
it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => {
const commonProps = {
...heartbeatSettingsProps,
initialCategory: 'ssh-hosts' as const,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
await screen.findByText('尚未配置 SSH 主机')
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByRole('switch', {
name: '远程项目'
})
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => { it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = { shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot, ...shortcutSettingsSnapshot,
@@ -2754,59 +2508,6 @@ describe('SettingsPanel runtime files', () => {
expect(onClose).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled()
}) })
it('protects unsaved SSH host drafts across settings navigation', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="ssh-hosts"
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
)
fireEvent.change(screen.getByLabelText('主机名称'), {
target: { value: '尚未保存的主机' }
})
const appearanceTab = screen.getByRole('tab', { name: '外观' })
fireEvent.click(appearanceTab)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
expect(screen.getByLabelText('主机名称')).toHaveValue(
'尚未保存的主机'
)
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(appearanceTab)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并切换' })
)
expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
await waitFor(() => expect(appearanceTab).toHaveFocus())
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
await screen.findByText('尚未配置 SSH 主机')
expect(screen.queryByLabelText('主机名称')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('keeps shortcut input after a registration conflict', async () => { it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({ updateShortcutSettings.mockResolvedValueOnce({
ok: false, ok: false,
@@ -2955,15 +2656,15 @@ describe('SettingsPanel runtime files', () => {
runtimeTab.focus() runtimeTab.focus()
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' }) fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
const sshHostsTab = screen.getByRole('tab', { const documentParsingTab = screen.getByRole('tab', {
name: '主机与远程执行' name: '文档解析'
}) })
expect(sshHostsTab).toHaveFocus() expect(documentParsingTab).toHaveFocus()
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true') expect(documentParsingTab).toHaveAttribute('aria-selected', 'true')
expect(sshHostsTab).toHaveAttribute('tabindex', '0') expect(documentParsingTab).toHaveAttribute('tabindex', '0')
expect(runtimeTab).toHaveAttribute('tabindex', '-1') expect(runtimeTab).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(sshHostsTab, { key: 'End' }) fireEvent.keyDown(documentParsingTab, { key: 'End' })
const aboutTab = screen.getByRole('tab', { name: '关于与更新' }) const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
expect(aboutTab).toHaveFocus() expect(aboutTab).toHaveFocus()
expect(aboutTab).toHaveAttribute('aria-selected', 'true') expect(aboutTab).toHaveAttribute('aria-selected', 'true')
@@ -4393,9 +4094,6 @@ describe('SettingsPanel runtime files', () => {
.getByRole('heading', { level: 2, name: '平台功能' }) .getByRole('heading', { level: 2, name: '平台功能' })
.closest('.settings-category-header') .closest('.settings-category-header')
expect(platformHeader).toHaveClass('settings-category-header--sticky') expect(platformHeader).toHaveClass('settings-category-header--sticky')
expect(
platformHeader?.querySelector('.platform-features-tabs')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' })) fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
const capabilitiesHeader = screen const capabilitiesHeader = screen
+26 -41
View File
@@ -61,7 +61,6 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection' import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection' import { DshMarketplaceSection } from './DshMarketplaceSection'
import { displayErrorMessage } from './error-message' import { displayErrorMessage } from './error-message'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import { import {
RuntimeCustomizationSection, RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle type RuntimeCustomizationSectionHandle
@@ -227,7 +226,6 @@ type SettingsPanelProps = {
initialCategory?: SettingsCategoryId initialCategory?: SettingsCategoryId
initialChannel?: ProjectChannel initialChannel?: ProjectChannel
onClose: () => void onClose: () => void
onProjectsDeleted?: (projectIds: string[]) => void
onSaved: (settings: RuntimeSettings) => void onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: ( onUpdateProject: (
@@ -243,6 +241,8 @@ type SettingsPanelProps = {
applicationSettingsLocked?: boolean applicationSettingsLocked?: boolean
applicationSettingsError?: string applicationSettingsError?: string
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean> onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
chatMinimapEnabled?: boolean
onChatMinimapEnabledChange?: (enabled: boolean) => Promise<boolean>
onRetryApplicationSettings?: () => void onRetryApplicationSettings?: () => void
onAppearanceThemeChange?: (theme: AppearanceTheme) => void onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences brandingPreferences?: BrandingPreferences
@@ -251,11 +251,9 @@ type SettingsPanelProps = {
preferences: BrandingPreferences preferences: BrandingPreferences
) => boolean ) => boolean
magicNotesEnabled?: boolean magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: ( onConversationHtmlRenderingEnabledChange?: (
enabled: boolean enabled: boolean
) => void ) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: ( onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot snapshot: GlobalShortcutSettingsSnapshot
) => void ) => void
@@ -645,7 +643,6 @@ function SettingsPanelView({
initialCategory, initialCategory,
initialChannel, initialChannel,
onClose, onClose,
onProjectsDeleted = () => {},
onSaved, onSaved,
onNotify = () => {}, onNotify = () => {},
onUpdateProject, onUpdateProject,
@@ -658,15 +655,15 @@ function SettingsPanelView({
applicationSettingsLocked = false, applicationSettingsLocked = false,
applicationSettingsError, applicationSettingsError,
onTransparentFrostedEffectEnabledChange, onTransparentFrostedEffectEnabledChange,
chatMinimapEnabled = true,
onChatMinimapEnabledChange,
onRetryApplicationSettings, onRetryApplicationSettings,
onAppearanceThemeChange = () => {}, onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences, brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo, brandingFallbackLogo,
onBrandingPreferencesChange = () => false, onBrandingPreferencesChange = () => false,
magicNotesEnabled = false, magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {}, onConversationHtmlRenderingEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onShortcutSettingsChanged = () => {}, onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {} onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null { }: SettingsPanelProps): React.JSX.Element | null {
@@ -793,15 +790,8 @@ function SettingsPanelView({
const [detection, setDetection] = useState<AgentRuntimeDetection>() const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false) const [detecting, setDetecting] = useState(false)
const [selectedTab, setActiveTab] = const [selectedTab, setActiveTab] =
useState<SettingsCategoryId>( useState<SettingsCategoryId>(initialCategory ?? 'runtime')
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled const activeTab = selectedTab
? 'platform-features'
: initialCategory ?? 'runtime'
)
const activeTab =
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('llm') const [modelType, setModelType] = useState<ModelType>('llm')
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false) const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>() const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
@@ -825,7 +815,6 @@ function SettingsPanelView({
useState(false) useState(false)
const [documentParsingDirty, setDocumentParsingDirty] = const [documentParsingDirty, setDocumentParsingDirty] =
useState(false) useState(false)
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState< const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' } | { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean } | { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
@@ -911,7 +900,6 @@ function SettingsPanelView({
const categoryRendersOwnHeader = const categoryRendersOwnHeader =
activeTab === 'platform-features' || activeTab === 'platform-features' ||
activeTab === 'document-parsing' || activeTab === 'document-parsing' ||
activeTab === 'ssh-hosts' ||
activeTab === 'channels' || activeTab === 'channels' ||
activeTab === 'capabilities' || activeTab === 'capabilities' ||
activeTab === 'about' activeTab === 'about'
@@ -1014,22 +1002,19 @@ function SettingsPanelView({
runtimeCustomizationDirty || runtimeCustomizationDirty ||
brandingDirty || brandingDirty ||
platformFeaturesDirty || platformFeaturesDirty ||
documentParsingDirty || documentParsingDirty
sshHostsDirty
const navigationWouldLoseDraft = const navigationWouldLoseDraft =
runtimeCustomizationDirty || runtimeCustomizationDirty ||
brandingDirty || brandingDirty ||
platformFeaturesDirty || platformFeaturesDirty ||
documentParsingDirty || documentParsingDirty
sshHostsDirty
const unsavedCloseMessage = const unsavedCloseMessage =
runtimeCustomizationDirty && runtimeCustomizationDirty &&
!runtimeDraftDirty && !runtimeDraftDirty &&
!speechModelSelectionDirty && !speechModelSelectionDirty &&
!brandingDirty && !brandingDirty &&
!platformFeaturesDirty && !platformFeaturesDirty &&
!documentParsingDirty && !documentParsingDirty
!sshHostsDirty
? t('runtime.customization.unsavedClose') ? t('runtime.customization.unsavedClose')
: t('unsaved.close') : t('unsaved.close')
@@ -1073,9 +1058,7 @@ function SettingsPanelView({
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true) if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
} }
const visibleSettingsCategories = getSettingsCategoryList( const visibleSettingsCategories = getSettingsCategoryList()
remoteProjectsEnabled
)
const settingsTabs = visibleSettingsCategories.map(({ id }) => id) const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
const handleTabKeyDown = ( const handleTabKeyDown = (
@@ -1127,7 +1110,6 @@ function SettingsPanelView({
setSpeechModelSelectionDirty(false) setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined) setPendingLeave(undefined)
setAgentRuntimeType('opencode') setAgentRuntimeType('opencode')
hydrateSettings(value) hydrateSettings(value)
@@ -1296,7 +1278,6 @@ function SettingsPanelView({
setBrandingDirty(false) setBrandingDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined) setPendingLeave(undefined)
setError(undefined) setError(undefined)
} }
@@ -1322,7 +1303,6 @@ function SettingsPanelView({
setBrandingDirty(false) setBrandingDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
const leave = pendingLeave const leave = pendingLeave
setPendingLeave(undefined) setPendingLeave(undefined)
setError(undefined) setError(undefined)
@@ -2380,6 +2360,22 @@ function SettingsPanelView({
<p id="frosted-glass-description" className="settings-panel__description"> <p id="frosted-glass-description" className="settings-panel__description">
{t('appearance.frostedGlass.description')} {t('appearance.frostedGlass.description')}
</p> </p>
<label className="toggle-row">
<span>{t('appearance.chatMinimap.label')}</span>
<input
type="checkbox"
role="switch"
checked={chatMinimapEnabled}
disabled={applicationSettingsPending || applicationSettingsLocked || !onChatMinimapEnabledChange}
aria-describedby="chat-minimap-description"
onChange={(event) => {
void onChatMinimapEnabledChange?.(event.target.checked)
}}
/>
</label>
<p id="chat-minimap-description" className="settings-panel__description">
{t('appearance.chatMinimap.description')}
</p>
{applicationSettingsError && ( {applicationSettingsError && (
<div role="alert"> <div role="alert">
<p>{applicationSettingsError}</p> <p>{applicationSettingsError}</p>
@@ -2444,9 +2440,6 @@ function SettingsPanelView({
onConversationHtmlRenderingEnabledChange={ onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange onConversationHtmlRenderingEnabledChange
} }
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onWorkspacePathChange={setWorkspacePath} onWorkspacePathChange={setWorkspacePath}
workspacePath={workspacePath} workspacePath={workspacePath}
onNotify={onNotify} onNotify={onNotify}
@@ -4139,14 +4132,6 @@ function SettingsPanelView({
/> />
)} )}
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
)}
{activeTab === 'platform-features' && ( {activeTab === 'platform-features' && (
<> <>
<div className="settings-section settings-section--danger"> <div className="settings-section settings-section--danger">
@@ -204,10 +204,18 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
fireEvent.click(continueButton) fireEvent.click(continueButton)
} }
function expandHost(host: HTMLElement, name: string): void {
const toggle = within(host).queryByRole('button', {
name: `展开 ${name} 的详情`
})
if (toggle) fireEvent.click(toggle)
}
async function refreshHostEnvironment( async function refreshHostEnvironment(
name = 'Build host' name = 'Build host'
): Promise<HTMLElement> { ): Promise<HTMLElement> {
const host = await screen.findByRole('region', { name }) const host = await screen.findByRole('region', { name })
expandHost(host, name)
fireEvent.click( fireEvent.click(
within(host).getByRole('button', { within(host).getByRole('button', {
name: `刷新 ${name} 的远程运行环境版本` name: `刷新 ${name} 的远程运行环境版本`
@@ -695,7 +703,7 @@ describe('SshHostsSettingsSection', () => {
}) })
fireEvent.click( fireEvent.click(
within(host).getByRole('button', { name: '删除' }) within(host).getByRole('button', { name: '删除 Build host' })
) )
const confirmation = screen.getByRole('alertdialog') const confirmation = screen.getByRole('alertdialog')
expect( expect(
@@ -737,7 +745,8 @@ describe('SshHostsSettingsSection', () => {
name: '验证 Build host' name: '验证 Build host'
}) })
).toHaveTextContent('验证并保存') ).toHaveTextContent('验证并保存')
expect(screen.getByText('需要重新验证')).toBeInTheDocument() // Shown as a summary badge and in the expanded details.
expect(screen.getAllByText('需要重新验证')).toHaveLength(2)
expect(getRemoteEnvironment).not.toHaveBeenCalled() expect(getRemoteEnvironment).not.toHaveBeenCalled()
}) })
@@ -773,6 +782,10 @@ describe('SshHostsSettingsSection', () => {
expect(within(host).getByText('0.10.4')).toBeInTheDocument() expect(within(host).getByText('0.10.4')).toBeInTheDocument()
expect(within(host).getByText('0.11.1')).toBeInTheDocument() expect(within(host).getByText('0.11.1')).toBeInTheDocument()
expect(within(host).getAllByText('1.18.29')).toHaveLength(2) expect(within(host).getAllByText('1.18.29')).toHaveLength(2)
const versions = within(host).getByRole('table')
expect(within(versions).getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['组件', 'Host 已安装', 'GoodBuddy 所需', '状态'])
expect(within(versions).getByRole('rowheader', { name: 'GoodBuddy Agent' })).toBeInTheDocument()
expect(within(versions).getByRole('rowheader', { name: 'OpenCode Runtime' })).toBeInTheDocument()
expect(within(host).getByText('安装方式')).toBeInTheDocument() expect(within(host).getByText('安装方式')).toBeInTheDocument()
expect( expect(
within(host) within(host)
@@ -822,13 +835,28 @@ describe('SshHostsSettingsSection', () => {
}) })
render(<SshHostsSettingsSection />) render(<SshHostsSettingsSection />)
await screen.findByRole('region', { name: 'Deploy host' }) const deployHost = await screen.findByRole('region', { name: 'Deploy host' })
expect(getRemoteEnvironment).not.toHaveBeenCalled() expect(getRemoteEnvironment).not.toHaveBeenCalled()
// Several hosts start as one-line summaries.
expect(within(deployHost).getByText('deploy.example.com', { exact: false })).toBeInTheDocument()
expect(within(deployHost).getByText('环境未检查')).toBeInTheDocument()
expect( expect(
screen.getAllByText( screen.queryByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。' '尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
) )
).toHaveLength(2) ).not.toBeInTheDocument()
const toggle = within(deployHost).getByRole('button', { name: '展开 Deploy host 的详情' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(
within(deployHost).getByRole('button', { name: '收起 Deploy host 的详情' })
).toHaveAttribute('aria-expanded', 'true')
expect(
within(deployHost).getByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).toBeInTheDocument()
expect(getRemoteEnvironment).not.toHaveBeenCalled()
await refreshHostEnvironment() await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1) expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
@@ -1281,7 +1309,7 @@ describe('SshHostsSettingsSection', () => {
}) })
).toBeDisabled() ).toBeDisabled()
expect( expect(
within(buildHost).getByRole('button', { name: '删除' }) within(buildHost).getByRole('button', { name: '删除 Build host' })
).toBeDisabled() ).toBeDisabled()
expect( expect(
within(deployHost).getByRole('button', { within(deployHost).getByRole('button', {
+284 -145
View File
@@ -2,11 +2,12 @@ import {
Bot, Bot,
Boxes, Boxes,
CheckCircle2, CheckCircle2,
ChevronRight,
Download, Download,
LoaderCircle, LoaderCircle,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
Server, Server,
ShieldCheck, ShieldCheck,
X X
@@ -28,9 +29,9 @@ import type {
SshHostValidationResult SshHostValidationResult
} from '../../shared/ssh-host-contracts' } from '../../shared/ssh-host-contracts'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { displayErrorMessage } from './error-message' import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog' import { SshHostDialog } from './SshHostDialog'
import './remote-hosts.css'
import { import {
getCachedSshHostRemoteEnvironments, getCachedSshHostRemoteEnvironments,
removeCachedSshHostRemoteEnvironment, removeCachedSshHostRemoteEnvironment,
@@ -43,6 +44,7 @@ import {
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
type SshHostsSettingsSectionProps = { type SshHostsSettingsSectionProps = {
onBusyChange?: (busy: boolean) => void
onDirtyChange?: (dirty: boolean) => void onDirtyChange?: (dirty: boolean) => void
onHostUpdated?: (hostId: string) => void onHostUpdated?: (hostId: string) => void
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -95,6 +97,7 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
} }
export function SshHostsSettingsSection({ export function SshHostsSettingsSection({
onBusyChange,
onDirtyChange, onDirtyChange,
onHostUpdated, onHostUpdated,
onNotify, onNotify,
@@ -110,6 +113,14 @@ export function SshHostsSettingsSection({
const [loadError, setLoadError] = useState<string>() const [loadError, setLoadError] = useState<string>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [editingId, setEditingId] = useState<string | 'new'>() const [editingId, setEditingId] = useState<string | 'new'>()
const [query, setQuery] = useState('')
// Hosts start collapsed so long lists stay scannable; a single host starts open.
const [expandedOverrides, setExpandedOverrides] = useState<
ReadonlyMap<string, boolean>
>(() => new Map())
const setHostExpanded = (hostId: string, expanded: boolean): void => {
setExpandedOverrides((current) => new Map(current).set(hostId, expanded))
}
const [busyHostId, setBusyHostId] = useState<string>() const [busyHostId, setBusyHostId] = useState<string>()
const [confirmingDeleteId, setConfirmingDeleteId] = const [confirmingDeleteId, setConfirmingDeleteId] =
useState<string>() useState<string>()
@@ -510,6 +521,8 @@ export function SshHostsSettingsSection({
const edited = hosts.some((host) => host.id === result.host.id) const edited = hosts.some((host) => host.id === result.host.id)
removeCachedSshHostRemoteEnvironment(result.host.id) removeCachedSshHostRemoteEnvironment(result.host.id)
setHosts((current) => upsertHost(current, result.host)) setHosts((current) => upsertHost(current, result.host))
// Show the validation result and next step for the host just saved.
setHostExpanded(result.host.id, true)
setTestResults((current) => ({ setTestResults((current) => ({
...current, ...current,
[result.host.id]: result.connection [result.host.id]: result.connection
@@ -604,27 +617,53 @@ export function SshHostsSettingsSection({
const hasActiveRemoteEnvironmentUpdate = const hasActiveRemoteEnvironmentUpdate =
remoteEnvironmentUpdate !== undefined remoteEnvironmentUpdate !== undefined
useEffect(() => {
onBusyChange?.(hasActiveRemoteEnvironmentUpdate)
}, [hasActiveRemoteEnvironmentUpdate, onBusyChange])
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
const normalizedQuery = query.trim().toLocaleLowerCase()
const visibleHosts = normalizedQuery
? hosts.filter((host) =>
`${host.name} ${host.username}@${host.hostname}:${host.port}`
.toLocaleLowerCase()
.includes(normalizedQuery)
)
: hosts
return ( return (
<div className="ssh-hosts-settings"> <div className="ssh-hosts-settings">
<SettingsCategoryHeader <div className="ssh-hosts-toolbar">
actions={ {hosts.length > 0 && (
<button <label className="ssh-hosts-toolbar__search">
className="primary-button" <Search aria-hidden="true" size={14} />
disabled={loading || Boolean(editingId) || !api} <input
onClick={() => { aria-label={t('sshHosts.search.label')}
setEditingId('new') onChange={(event) => setQuery(event.target.value)}
setError(undefined) placeholder={t('sshHosts.search.placeholder')}
}} type="search"
type="button" value={query}
> />
<Plus aria-hidden="true" size={14} /> </label>
{t('sshHosts.actions.add')} )}
</button> <button
} className="primary-button"
category="ssh-hosts" disabled={loading || Boolean(editingId) || !api}
error={error} onClick={() => {
headingId="ssh-hosts-heading" setEditingId('new')
/> setError(undefined)
}}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('sshHosts.actions.add')}
</button>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<div className="settings-notice"> <div className="settings-notice">
<ShieldCheck aria-hidden="true" size={16} /> <ShieldCheck aria-hidden="true" size={16} />
@@ -676,12 +715,26 @@ export function SshHostsSettingsSection({
icon={<Server size={22} />} icon={<Server size={22} />}
title={t('sshHosts.empty.title')} title={t('sshHosts.empty.title')}
/> />
) : visibleHosts.length === 0 ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => setQuery('')}
type="button"
>
{t('sshHosts.search.clear')}
</button>
}
description={t('sshHosts.search.noResults')}
icon={<Search size={22} />}
/>
) : ( ) : (
<div <div
aria-label={t('sshHosts.listLabel')} aria-label={t('sshHosts.listLabel')}
className="ssh-hosts-list" className="ssh-hosts-list"
> >
{hosts.map((host) => { {visibleHosts.map((host) => {
const relatedProjects = const relatedProjects =
projectReferences?.[host.id] ?? [] projectReferences?.[host.id] ?? []
const result = testResults[host.id] const result = testResults[host.id]
@@ -722,20 +775,98 @@ export function SshHostsSettingsSection({
'function' && 'function' &&
typeof api.onRemoteEnvironmentUpdateProgress === typeof api.onRemoteEnvironmentUpdateProgress ===
'function' 'function'
const environmentSummary = remoteEnvironment?.value
? environmentComponents.some(
({ state }) => state === 'not-installed'
)
? 'not-installed'
: environmentComponents.some(
({ state }) => state === 'update-available'
)
? 'update-available'
: 'current'
: 'unknown'
// Progress, failures, and removal confirmation must stay visible.
const forcedOpen =
Boolean(hostEnvironmentUpdate) ||
Boolean(remoteEnvironmentUpdateError) ||
Boolean(remoteEnvironment?.error) ||
confirmingDeleteId === host.id
const expanded =
forcedOpen ||
(expandedOverrides.get(host.id) ?? hosts.length === 1)
const detailsId = `ssh-host-details-${host.id}`
return ( return (
<section <section
aria-label={host.name} aria-label={host.name}
className="settings-section ssh-host-card" className={`settings-section ssh-host-card${expanded ? ' ssh-host-card--expanded' : ''}`}
key={host.id} key={host.id}
> >
<div className="settings-section__title settings-section__title--actions"> <div className="ssh-host-card__summary">
<Server aria-hidden="true" size={17} /> <button
<div> aria-controls={detailsId}
<strong>{host.name}</strong> aria-expanded={expanded}
<small> aria-label={t(
{host.username}@{host.hostname}:{host.port} expanded
</small> ? 'sshHosts.list.collapseNamed'
</div> : 'sshHosts.list.expandNamed',
{ name: host.name }
)}
className="ssh-host-card__toggle"
disabled={forcedOpen}
onClick={() => setHostExpanded(host.id, !expanded)}
type="button"
>
<ChevronRight
aria-hidden="true"
className="ssh-host-card__chevron"
size={15}
/>
<Server aria-hidden="true" size={16} />
<span className="ssh-host-card__identity">
<strong>{host.name}</strong>
<small>
{host.username}@{host.hostname}:{host.port}
</small>
</span>
</button>
<span className="ssh-host-card__badges">
<span
className={`ssh-host-badge ssh-host-badge--${host.lastValidatedAt ? 'ok' : 'warn'}`}
>
{t(
host.lastValidatedAt
? 'sshHosts.list.validated'
: 'sshHosts.status.needsValidation'
)}
</span>
{host.lastValidatedAt && (
<span
className={`ssh-host-badge ssh-host-badge--${
hostEnvironmentUpdate
? 'busy'
: environmentSummary === 'current'
? 'ok'
: environmentSummary === 'unknown'
? 'muted'
: 'warn'
}`}
>
{t(
hostEnvironmentUpdate
? 'sshHosts.list.environmentUpdating'
: `sshHosts.list.environment.${environmentSummary}`
)}
</span>
)}
{relatedProjects.length > 0 && (
<span className="ssh-host-badge ssh-host-badge--muted">
{t('sshHosts.list.projects', {
count: relatedProjects.length
})}
</span>
)}
</span>
<button <button
aria-label={t( aria-label={t(
host.lastValidatedAt host.lastValidatedAt
@@ -755,13 +886,57 @@ export function SshHostsSettingsSection({
}} }}
type="button" type="button"
> >
<Pencil aria-hidden="true" size={13} />
{host.lastValidatedAt {host.lastValidatedAt
? t('sshHosts.actions.edit') ? t('sshHosts.actions.edit')
: t('sshHosts.actions.validate')} : t('sshHosts.actions.validate')}
</button> </button>
<DestructiveConfirmActions
confirmLabel={t(
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={
confirmingDeleteId === host.id ? (
<div className="ssh-host-remove-confirmation">
<p>
{t('sshHosts.removeMessage', {
name: host.name
})}
</p>
{relatedProjects.length > 0 && (
<>
<strong>
{t('sshHosts.removeProjectsHeading')}
</strong>
<ul>
{relatedProjects.map((project) => (
<li key={project.id}>
{project.name}
</li>
))}
</ul>
</>
)}
</div>
) : null
}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
onConfirm={() => void remove(host)}
onRequestConfirm={() =>
setConfirmingDeleteId(host.id)
}
triggerAriaLabel={t('sshHosts.actions.removeNamed', { name: host.name })}
triggerLabel={t('sshHosts.actions.remove')}
/>
</div> </div>
{expanded && (
<div className="ssh-host-card__details" id={detailsId}>
<dl className="ssh-host-card__facts"> <dl className="ssh-host-card__facts">
<div> <div>
<dt>{t('sshHosts.fields.authentication')}</dt> <dt>{t('sshHosts.fields.authentication')}</dt>
@@ -1053,39 +1228,49 @@ export function SshHostsSettingsSection({
)} )}
{remoteEnvironment?.value ? ( {remoteEnvironment?.value ? (
<div className="ssh-host-environment__grid"> <table className="ssh-host-environment__table">
<EnvironmentVersionCard <thead>
icon={<Bot aria-hidden="true" size={17} />} <tr>
installed={ <th scope="col">{t('sshHosts.environment.component')}</th>
remoteEnvironment.value.agent.installed <th scope="col">{t('sshHosts.environment.installed')}</th>
} <th scope="col">{t('sshHosts.environment.expected')}</th>
label="GoodBuddy Agent" <th scope="col">{t('sshHosts.environment.status')}</th>
state={remoteEnvironment.value.agent.state} </tr>
t={t} </thead>
expected={ <tbody>
remoteEnvironment.value.agent.expected <EnvironmentVersionRow
} icon={<Bot aria-hidden="true" size={15} />}
/> installed={
{remoteEnvironment.value.runtimes.map( remoteEnvironment.value.agent.installed
(runtime) => ( }
<EnvironmentVersionCard label="GoodBuddy Agent"
icon={ state={remoteEnvironment.value.agent.state}
<Server aria-hidden="true" size={17} /> t={t}
} expected={
installed={runtime.installed} remoteEnvironment.value.agent.expected
key={runtime.runtimeId} }
label={ />
runtime.runtimeId === 'opencode' {remoteEnvironment.value.runtimes.map(
? 'OpenCode Runtime' (runtime) => (
: runtime.runtimeId <EnvironmentVersionRow
} icon={
state={runtime.state} <Server aria-hidden="true" size={15} />
t={t} }
expected={runtime.expected} installed={runtime.installed}
/> key={runtime.runtimeId}
) label={
)} runtime.runtimeId === 'opencode'
</div> ? 'OpenCode Runtime'
: runtime.runtimeId
}
state={runtime.state}
t={t}
expected={runtime.expected}
/>
)
)}
</tbody>
</table>
) : remoteEnvironment?.loading ? ( ) : remoteEnvironment?.loading ? (
<div <div
className="ssh-host-environment__loading" className="ssh-host-environment__loading"
@@ -1106,50 +1291,8 @@ export function SshHostsSettingsSection({
</div> </div>
)} )}
<div className="ssh-host-card__actions">
<DestructiveConfirmActions
confirmLabel={t(
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={
confirmingDeleteId === host.id ? (
<div className="ssh-host-remove-confirmation">
<p>
{t('sshHosts.removeMessage', {
name: host.name
})}
</p>
{relatedProjects.length > 0 && (
<>
<strong>
{t('sshHosts.removeProjectsHeading')}
</strong>
<ul>
{relatedProjects.map((project) => (
<li key={project.id}>
{project.name}
</li>
))}
</ul>
</>
)}
</div>
) : null
}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
onConfirm={() => void remove(host)}
onRequestConfirm={() =>
setConfirmingDeleteId(host.id)
}
triggerLabel={t('sshHosts.actions.remove')}
/>
</div> </div>
)}
</section> </section>
) )
})} })}
@@ -1198,7 +1341,7 @@ function formatPackageSize(bytes: number): string {
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB` return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
} }
function EnvironmentVersionCard({ function EnvironmentVersionRow({
expected, expected,
icon, icon,
installed, installed,
@@ -1216,48 +1359,44 @@ function EnvironmentVersionCard({
t: ReturnType<typeof useTranslation<'settingsSections'>>['t'] t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<article className="ssh-host-environment__runtime"> <tr>
<div className="ssh-host-environment__runtime-header"> <th scope="row">
<span className="ssh-host-environment__runtime-icon"> <span className="ssh-host-environment__component">
{icon} {icon}
{label}
</span> </span>
<strong>{label}</strong> </th>
<td>
{installed ? (
<span
title={t('sshHosts.environment.versionDetail', {
architecture: installed.architecture
})}
>
{installed.version}
</span>
) : (
<span className="ssh-host-environment__missing">
{t('sshHosts.environment.notInstalled')}
</span>
)}
</td>
<td>
<span
title={t('sshHosts.environment.versionDetail', {
architecture: expected.architecture
})}
>
{expected.version}
</span>
</td>
<td>
<span <span
className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`} className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`}
> >
{t(`sshHosts.environment.states.${state}`)} {t(`sshHosts.environment.states.${state}`)}
</span> </span>
</div> </td>
<dl> </tr>
<div>
<dt>{t('sshHosts.environment.installed')}</dt>
<dd>
{installed ? (
<>
<strong>{installed.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: installed.architecture
})}
</small>
</>
) : (
<span>{t('sshHosts.environment.notInstalled')}</span>
)}
</dd>
</div>
<div>
<dt>{t('sshHosts.environment.expected')}</dt>
<dd>
<strong>{expected.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: expected.architecture
})}
</small>
</dd>
</div>
</dl>
</article>
) )
} }
+18 -4
View File
@@ -104,7 +104,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
if (target.node.id !== focusId) setFocusId(target.node.id) if (target.node.id !== focusId) setFocusId(target.node.id)
} }
const [hovered, setHovered] = useState<string>() const [hovered, setHovered] = useState<string>()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 }) const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number; targetX: number; targetZ: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0, targetX: 0, targetZ: 0 })
const animation = useRef<number | undefined>(undefined) const animation = useRef<number | undefined>(undefined)
const cancelAnimation = () => { if (animation.current !== undefined) cancelAnimationFrame(animation.current); animation.current = undefined } const cancelAnimation = () => { if (animation.current !== undefined) cancelAnimationFrame(animation.current); animation.current = undefined }
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined) const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
@@ -265,7 +265,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
} }
const project = (point: THREE.Vector3) => { const v = point.clone().project(view3d); return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, behind: v.z > 1 } } const project = (point: THREE.Vector3) => { const v = point.clone().project(view3d); return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, behind: v.z > 1 } }
const place = () => { const place = () => {
const { yaw, tilt, zoom, pan } = camera.current, ct = Math.cos(tilt), st = Math.sin(tilt), target = new THREE.Vector3(0, pan, 0) const { yaw, tilt, zoom, pan, targetX, targetZ } = camera.current, ct = Math.cos(tilt), st = Math.sin(tilt), target = new THREE.Vector3(targetX, pan, targetZ)
view3d.position.copy(target).add(new THREE.Vector3(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST)) view3d.position.copy(target).add(new THREE.Vector3(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST))
view3d.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st) view3d.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st)
view3d.lookAt(target) view3d.lookAt(target)
@@ -325,7 +325,21 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
const zoom = (event: WheelEvent) => { const zoom = (event: WheelEvent) => {
event.preventDefault() event.preventDefault()
cancelAnimation() cancelAnimation()
// Move the camera target so the point below the pointer stays fixed while zooming.
const target = new THREE.Vector3(camera.current.targetX, camera.current.pan, camera.current.targetZ)
const direction = new THREE.Vector3().subVectors(target, view3d.position).normalize()
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(direction, target)
const pointer = new THREE.Vector2(event.offsetX / width * 2 - 1, 1 - event.offsetY / height * 2)
raycaster.setFromCamera(pointer, view3d)
const point = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) ?? target.clone()
camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012))) camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012)))
place()
raycaster.setFromCamera(pointer, view3d)
const nextPoint = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) ?? target.clone()
const shift = point.sub(nextPoint)
camera.current.targetX += shift.x
camera.current.pan += shift.y
camera.current.targetZ += shift.z
draw() draw()
} }
canvas.addEventListener('wheel', zoom, { passive: false }) canvas.addEventListener('wheel', zoom, { passive: false })
@@ -359,7 +373,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
const step = (now: number) => { const step = (now: number) => {
started ??= now started ??= now
const k = reduce ? 1 : Math.min(1, (now - started) / 480), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2 const k = reduce ? 1 : Math.min(1, (now - started) / 480), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
for (const key of ['yaw', 'tilt', 'zoom', 'pan'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e for (const key of ['yaw', 'tilt', 'zoom', 'pan', 'targetX', 'targetZ'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e
engine.current?.draw() engine.current?.draw()
animation.current = k < 1 ? requestAnimationFrame(step) : undefined animation.current = k < 1 ? requestAnimationFrame(step) : undefined
} }
@@ -379,7 +393,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
animate({ yaw: angle - Math.PI / 2, tilt, pan: (u - 0.5) * HEIGHT - radius * Math.tan(tilt), zoom: Math.max(0.8, Math.min(1.4, camera.current.zoom)) }) animate({ yaw: angle - Math.PI / 2, tilt, pan: (u - 0.5) * HEIGHT - radius * Math.tan(tilt), zoom: Math.max(0.8, Math.min(1.4, camera.current.zoom)) })
}) })
useEffect(() => { focusCamera() }, [request, target, range, attempt, theme]) useEffect(() => { focusCamera() }, [request, target, range, attempt, theme])
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0 }) const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0, targetX: 0, targetZ: 0 })
const open = (node: StoryNode) => { const open = (node: StoryNode) => {
setRequest({ selection: { kind: 'story', id: node.id } }) setRequest({ selection: { kind: 'story', id: node.id } })
if (node.level !== 'root' && node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id) if (node.level !== 'root' && node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
+1 -1
View File
@@ -24,7 +24,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct
</ol> </ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p> <p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>} {!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>}
{!!progress.revisedSources && <p className="supervisor-activity__note">{t('activity.revisedSources', { count: progress.revisedSources })}</p>} {!!progress.skippedSources && <p className="supervisor-activity__note">{t('activity.skippedSources', { count: progress.skippedSources })}</p>}
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>} {!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>} {row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div> </div>
+1 -1
View File
@@ -229,7 +229,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</dl> </dl>
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>} {row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
{!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>} {!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>}
{!!row.reviewProgress?.revisedSources && <p>{t('activity.revisedSources', { count: row.reviewProgress.revisedSources })}</p>} {!!row.reviewProgress?.skippedSources && <p>{t('activity.skippedSources', { count: row.reviewProgress.skippedSources })}</p>}
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>} {row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>} {row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary> {row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
+24 -1
View File
@@ -225,7 +225,7 @@ describe('SupervisorWorkspace', () => {
vi.setSystemTime(now) vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview })) fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
const picker = screen.getByLabelText(copy.period) const picker = screen.getByLabelText(copy.period)
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range']) expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'all', 'custom', 'range'])
expect(picker).toHaveValue('7') expect(picker).toHaveValue('7')
fireEvent.change(picker, { target: { value: 'custom' } }) fireEvent.change(picker, { target: { value: 'custom' } })
fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } }) fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } })
@@ -245,6 +245,29 @@ describe('SupervisorWorkspace', () => {
timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } }) timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } })
}) })
it.each(['zh-CN', 'en-US'] as const)('reviews all time from the epoch through now and names it in the reanalysis dialog in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat.supervisor
const run = vi.fn().mockResolvedValue(undefined)
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace />)
await screen.findByText(copy.empty)
vi.useFakeTimers({ toFake: ['Date'] })
const now = new Date(2026, 9, 5, 15, 30)
vi.setSystemTime(now)
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'all' } })
const timeRange = { from: new Date(0).toISOString(), to: now.toISOString() }
await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange })
fireEvent.click(screen.getByRole('button', { name: copy.more }))
fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze }))
const dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(copy.allTimeLabel)
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm })))
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange })
})
it.each([ it.each([
['2025-11-02', '2025-11-02'], ['2025-11-02', '2025-11-02'],
['2026-03-08', '2026-03-08'], ['2026-03-08', '2026-03-08'],
+3
View File
@@ -240,6 +240,8 @@ export function SupervisorWorkspace({
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError) const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => { const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
// All time: from the epoch, which predates any stored source, through now.
if (period === 'all') return { range: { from: new Date(0).toISOString(), to: now.toISOString() }, label: t('supervisor.allTimeLabel') }
if (period === 'range') { if (period === 'range') {
const from = new Date(`${startDate}T00:00:00`) const from = new Date(`${startDate}T00:00:00`)
const end = new Date(`${endDate}T00:00:00`) const end = new Date(`${endDate}T00:00:00`)
@@ -651,6 +653,7 @@ export function SupervisorWorkspace({
{t('supervisor.days', { count: value })} {t('supervisor.days', { count: value })}
</option> </option>
))} ))}
<option value="all">{t('supervisor.allTime')}</option>
<option value="custom">{t('supervisor.customDays')}</option> <option value="custom">{t('supervisor.customDays')}</option>
<option value="range">{t('supervisor.dateRange')}</option> <option value="range">{t('supervisor.dateRange')}</option>
</select> </select>
+12
View File
@@ -221,6 +221,18 @@ describe('TerminalPanel', () => {
) )
}) })
it('contains IME composition layout inside the terminal', () => {
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator\s*\{[^}]*contain:\s*size layout paint;[^}]*overflow-anchor:\s*none;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__emulator \.xterm-helpers\s*\{[^}]*max-width:\s*0;[^}]*overflow:\s*visible;/u
)
})
it('creates only after the first valid fitted size and forwards input', async () => { it('creates only after the first valid fitted size and forwards input', async () => {
const backend = adapterHarness() const backend = adapterHarness()
const emulator = emulatorHarness() const emulator = emulatorHarness()
@@ -25,6 +25,7 @@ describe('UpdateSettingsSection', () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -501,10 +501,10 @@ describe('WorkspaceFilesPanel', () => {
expect(onListDirectory).toHaveBeenCalledWith('docs') expect(onListDirectory).toHaveBeenCalledWith('docs')
expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md') expect(onOpenFile).toHaveBeenCalledWith('docs/guide.md')
fireEvent.click(screen.getByLabelText('guide.md 的更多操作')) fireEvent.click(screen.getByLabelText('guide.md 的更多操作'))
fireEvent.click(within(screen.getByRole('menu', { name: 'guide.md' })).getByRole('menuitem', { name: '使用默认应用打开' })) fireEvent.click(within(screen.getByRole('menu', { name: 'guide.md' })).getByRole('menuitem', { name: '使用系统应用打开' }))
expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file') expect(onOpenEntry).toHaveBeenCalledWith('docs/guide.md', 'file')
fireEvent.click(screen.getByLabelText('docs 的更多操作')) fireEvent.click(screen.getByLabelText('docs 的更多操作'))
fireEvent.click(within(screen.getByRole('menu', { name: 'docs' })).getByRole('menuitem', { name: '使用默认应用打开' })) fireEvent.click(within(screen.getByRole('menu', { name: 'docs' })).getByRole('menuitem', { name: '使用系统应用打开' }))
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory') expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
expect(screen.getAllByText('M')).not.toHaveLength(0) expect(screen.getAllByText('M')).not.toHaveLength(0)
}) })
+2 -2
View File
@@ -172,9 +172,9 @@ export function WorkspaceMenu({
if (!menu || !anchor) return if (!menu || !anchor) return
const position = (): void => { const position = (): void => {
const rect = anchor.getBoundingClientRect() const rect = anchor.getBoundingClientRect()
const width = Math.min(920, window.innerWidth - 32) const width = Math.min(680, window.innerWidth - 32)
menu.style.width = `${width}px` menu.style.width = `${width}px`
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px` menu.style.left = `${Math.max(14, Math.min(rect.left - 2, window.innerWidth - width - 16))}px`
const top = Math.max(16, rect.bottom + 8) const top = Math.max(16, rect.bottom + 8)
menu.style.top = `${top}px` menu.style.top = `${top}px`
menu.style.maxHeight = `${Math.max(0, window.innerHeight - top - 16)}px` menu.style.maxHeight = `${Math.max(0, window.innerHeight - top - 16)}px`
@@ -364,7 +364,7 @@ describe('WorkspacePrimitives', () => {
it('wraps native client and compaction actions without overlapping composer metadata', () => { it('wraps native client and compaction actions without overlapping composer metadata', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u /\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*\}/u
) )
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u /\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
+10
View File
@@ -132,6 +132,16 @@
.application-settings .toggle-row span { .application-settings .toggle-row span {
font-weight: 500; font-weight: 500;
} }
/* A switch with inline help keeps the help next to its text and the switch
on the right edge, aligned with the other switches. */
.application-settings .inline-help-label {
display: flex;
width: 100%;
min-height: 36px;
}
.application-settings .inline-help-label > .toggle-row {
display: contents;
}
.application-settings .settings-notice { .application-settings .settings-notice {
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
font-size: var(--font-body); font-size: var(--font-body);
@@ -35,12 +35,27 @@ export const app = {
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down', enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings', orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.', confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
remoteHosts: 'Hosts',
remoteHostsEnable: 'Enable remote capability',
remoteHostsHelp: 'Remote capability is off by default. Enable it to add SSH hosts, install execution environments, and create remote projects. Turning it off hides remote projects and stops remote execution; hosts, credentials, remote projects, and Agent packages are kept. Agent packages remain manageable while it is off.',
descriptions: { descriptions: {
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.', 'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.',
'remote-hosts': 'Connect SSH hosts, install remote execution environments, and manage Agent packages.',
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.', 'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.' heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
} }
}, },
remoteHosts: {
title: 'Hosts',
description: 'Connect SSH hosts, install remote execution environments, and manage Agent packages. Local Skills and MCP servers are not applied to remote hosts.',
tabsLabel: 'Hosts',
tabs: { hosts: 'Hosts', packages: 'Agent packages' },
disabled: {
title: 'Remote capability is off',
description: 'Enable it to add SSH hosts, install execution environments, and create remote projects. Agent packages can be downloaded or imported ahead of time while it is off.',
enable: 'Enable remote capability'
}
},
notifications: { notifications: {
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
@@ -439,6 +454,7 @@ export const app = {
copyMessage: 'Copy this response', copyMessage: 'Copy this response',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)', loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom', scrollToBottom: 'Scroll to bottom',
minimap: 'Conversation minimap',
status: { status: {
preparingRequest: 'Preparing request', preparingRequest: 'Preparing request',
waitingForProgress: 'Waiting for further progress', waitingForProgress: 'Waiting for further progress',
@@ -64,7 +64,7 @@ export const heartbeat = {
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.', stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining', coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.', omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.',
revisedSources: '{{count}} sources changed during the review and were reviewed again at their latest version; batches of older versions were discarded.', skippedSources: '{{count}} sources changed during the review. They were skipped this time and will be handled in the next review.',
navigationSaved: '{{count}} navigation merges saved', navigationSaved: '{{count}} navigation merges saved',
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration', resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.', treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
@@ -123,6 +123,7 @@ export const heartbeat = {
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…', empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…',
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze', more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss', reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
allTime: 'All time', allTimeLabel: 'all time',
customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date', customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date',
recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)', recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)',
customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.', customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.',
@@ -876,6 +876,10 @@ export const settings = {
description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.', description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.',
retry: 'Retry' retry: 'Retry'
}, },
chatMinimap: {
label: 'Scrollbar minimap',
description: 'Replaces the message scrollbar with an overview of the conversation that expands on hover; click or drag to jump. Takes effect as soon as it is saved.'
},
theme: { theme: {
title: 'Interface theme', title: 'Interface theme',
description: 'Applies immediately and is saved on this device', description: 'Applies immediately and is saved on this device',
@@ -12,6 +12,25 @@ export const settingsSections = {
loading: 'Loading SSH hosts…', loading: 'Loading SSH hosts…',
loadFailedTitle: 'Could not load SSH hosts', loadFailedTitle: 'Could not load SSH hosts',
listLabel: 'SSH host list', listLabel: 'SSH host list',
list: {
expandNamed: 'Show details for {{name}}',
collapseNamed: 'Hide details for {{name}}',
validated: 'Validated',
environmentUpdating: 'Installing',
environment: {
current: 'Environment ready',
'update-available': 'Update available',
'not-installed': 'Not installed',
unknown: 'Not checked'
},
projects: '{{count}} projects'
},
search: {
label: 'Search hosts',
placeholder: 'Search by name or address',
noResults: 'No matching hosts',
clear: 'Clear search'
},
securityNotice: securityNotice:
'When a host is added or edited, GoodBuddy inspects its host key before sending credentials and running a bounded system probe. The host and encrypted password are saved only after every step succeeds.', 'When a host is added or edited, GoodBuddy inspects its host key before sending credentials and running a bounded system probe. The host and encrypted password are saved only after every step succeeds.',
secureStorageUnavailable: secureStorageUnavailable:
@@ -137,6 +156,8 @@ export const settingsSections = {
reinstallFailedSummary: reinstallFailedSummary:
'This reinstall did not complete. Checking the current versions again.' 'This reinstall did not complete. Checking the current versions again.'
}, },
component: 'Component',
status: 'Status',
installed: 'Installed on Host', installed: 'Installed on Host',
expected: 'Required by GoodBuddy', expected: 'Required by GoodBuddy',
notInstalled: 'Not installed', notInstalled: 'Not installed',
@@ -200,6 +221,7 @@ export const settingsSections = {
validateNamed: 'Validate {{name}}', validateNamed: 'Validate {{name}}',
inspecting: 'Inspecting…', inspecting: 'Inspecting…',
remove: 'Delete', remove: 'Delete',
removeNamed: 'Delete {{name}}',
confirmRemove: 'Confirm delete', confirmRemove: 'Confirm delete',
cancel: 'Cancel', cancel: 'Cancel',
closeDialog: 'Close SSH host validation', closeDialog: 'Close SSH host validation',
@@ -39,7 +39,7 @@ export const workspace = {
importFiles: 'Import files', importing: 'Importing…', importTarget: 'Destination: {{path}}', imported: 'Imported {{count}} files into {{path}}.', created: 'Created', importFiles: 'Import files', importing: 'Importing…', importTarget: 'Destination: {{path}}', imported: 'Imported {{count}} files into {{path}}.', created: 'Created',
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory', gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties', createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
defaultOpen: 'Open with default application', name: 'Name', nameOnly: 'Name cannot contain path separators', destination: 'Destination directory', defaultOpen: 'Open with system application', name: 'Name', nameOnly: 'Name cannot contain path separators', destination: 'Destination directory',
chooseDirectory: 'Choose directory', moveHelp: 'Choose a directory inside the current workspace. The file or folder keeps its name.', chooseDirectory: 'Choose directory', moveHelp: 'Choose a directory inside the current workspace. The file or folder keeps its name.',
destinationOutside: 'Choose a directory inside the current workspace: {{root}}. Moving outside this workspace is not supported.', destinationOutside: 'Choose a directory inside the current workspace: {{root}}. Moving outside this workspace is not supported.',
destinationUnavailable: 'The project directory is unavailable. Reopen the workspace and try again.', destinationUnavailable: 'The project directory is unavailable. Reopen the workspace and try again.',
@@ -147,17 +147,17 @@ export const workspace = {
loadingHosts: 'Loading saved hosts…', loadingHosts: 'Loading saved hosts…',
noHosts: 'No saved SSH hosts', noHosts: 'No saved SSH hosts',
hostHelp: hostHelp:
'Host credentials remain centrally managed in Settings and are not copied into the project.', 'Host credentials remain centrally managed in Apps > Hosts and are not copied into the project.',
readiness: { readiness: {
loading: 'Reading the Host validation record…', loading: 'Reading the Host validation record…',
ready: ready:
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.', 'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
unready: unready:
'This Host has not completed Host Key and connection validation. Validate it under Settings > SSH Hosts first.', 'This Host has not completed Host Key and connection validation. Validate it under Apps > Hosts first.',
error: error:
'Could not read this Host’s local validation record. Check it under Settings > SSH Hosts.', 'Could not read this Host’s local validation record. Check it under Apps > Hosts.',
saveBlocked: saveBlocked:
'The selected Host has not been validated. Validate its connection under Settings > SSH Hosts first.', 'The selected Host has not been validated. Validate its connection under Apps > Hosts first.',
options: { options: {
loading: 'Reading', loading: 'Reading',
ready: 'Validated', ready: 'Validated',
@@ -530,7 +530,7 @@ export const workspace = {
}, },
openFolderAriaLabel: 'Open folder {{name}} in the system file manager', openFolderAriaLabel: 'Open folder {{name}} in the system file manager',
openFolder: 'Open folder', openFolder: 'Open folder',
openFileAriaLabel: 'Open file {{name}} with the default app', openFileAriaLabel: 'Open file {{name}} with the system app',
openFile: 'Open file', openFile: 'Open file',
reading: 'Reading…', reading: 'Reading…',
directoryTruncated: directoryTruncated:
@@ -32,12 +32,27 @@ export const app = {
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}', enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败', orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。', confirmFailed: '无法确认保存结果,请重新读取后再修改。',
remoteHosts: '主机管理',
remoteHostsEnable: '启用远程能力',
remoteHostsHelp: '远程能力默认关闭。启用后可以添加 SSH 主机、安装执行环境并创建远程项目。关闭后远程项目从项目列表中隐藏,也不能再在远端执行;主机、凭据、远程项目和 Agent 离线包都会保留。Agent 离线包在关闭时仍可管理。',
descriptions: { descriptions: {
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。', 'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
'remote-hosts': '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。',
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。', 'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。' heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
} }
}, },
remoteHosts: {
title: '主机管理',
description: '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。本机配置的 Skills 和 MCP 不会应用到远程主机。',
tabsLabel: '主机管理',
tabs: { hosts: '主机', packages: 'Agent 离线包' },
disabled: {
title: '远程能力未启用',
description: '启用后可以添加 SSH 主机、安装执行环境并创建远程项目。Agent 离线包在未启用时也可以提前下载或导入。',
enable: '启用远程能力'
}
},
notifications: { notifications: {
success: '成功', success: '成功',
error: '错误', error: '错误',
@@ -424,6 +439,7 @@ export const app = {
copyMessage: '复制此回复', copyMessage: '复制此回复',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)', loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部', scrollToBottom: '到底部',
minimap: '对话缩略图',
status: { status: {
preparingRequest: '正在准备请求', preparingRequest: '正在准备请求',
waitingForProgress: '等待后续进展', waitingForProgress: '等待后续进展',
@@ -61,7 +61,7 @@ export const heartbeat = {
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。', stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源', coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。', omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。',
revisedSources: '回顾期间有 {{count}} 个来源被修改,已按最新内容重新回顾;旧版本的批次已丢弃。', skippedSources: '回顾期间有 {{count}} 个来源发生变化,本次已跳过,将在下次回顾中处理。',
navigationSaved: '已保存 {{count}} 次导航合并', navigationSaved: '已保存 {{count}} 次导航合并',
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置', resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。', treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
@@ -120,6 +120,7 @@ export const heartbeat = {
empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…', empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…',
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理', more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示', reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
allTime: '全部', allTimeLabel: '全部时间',
customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期', customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期',
recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)', recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)',
customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。', customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。',
@@ -811,6 +811,10 @@ export const settings = {
description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。', description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。',
retry: '重试' retry: '重试'
}, },
chatMinimap: {
label: '滚动条缩略图',
description: '在消息区右侧用缩略图代替滚动条,悬停时展开,可点击或拖动定位。保存后立即生效。'
},
theme: { theme: {
title: '界面主题', title: '界面主题',
description: '选择后立即应用,并保存在此设备', description: '选择后立即应用,并保存在此设备',
@@ -7,6 +7,25 @@ export const settingsSections = {
loading: '正在读取 SSH 主机…', loading: '正在读取 SSH 主机…',
loadFailedTitle: 'SSH 主机加载失败', loadFailedTitle: 'SSH 主机加载失败',
listLabel: 'SSH 主机列表', listLabel: 'SSH 主机列表',
list: {
expandNamed: '展开 {{name}} 的详情',
collapseNamed: '收起 {{name}} 的详情',
validated: '已验证',
environmentUpdating: '安装中',
environment: {
current: '环境就绪',
'update-available': '环境待更新',
'not-installed': '环境未安装',
unknown: '环境未检查'
},
projects: '{{count}} 个项目'
},
search: {
label: '搜索主机',
placeholder: '搜索名称或地址',
noResults: '没有匹配的主机',
clear: '清除搜索'
},
securityNotice: securityNotice:
'添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。', '添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。',
secureStorageUnavailable: secureStorageUnavailable:
@@ -129,6 +148,8 @@ export const settingsSections = {
reinstallFailedSummary: reinstallFailedSummary:
'本次重新安装未完成;正在重新检查当前版本。' '本次重新安装未完成;正在重新检查当前版本。'
}, },
component: '组件',
status: '状态',
installed: 'Host 已安装', installed: 'Host 已安装',
expected: 'GoodBuddy 所需', expected: 'GoodBuddy 所需',
notInstalled: '尚未安装', notInstalled: '尚未安装',
@@ -192,6 +213,7 @@ export const settingsSections = {
validateNamed: '验证 {{name}}', validateNamed: '验证 {{name}}',
inspecting: '正在检查…', inspecting: '正在检查…',
remove: '删除', remove: '删除',
removeNamed: '删除 {{name}}',
confirmRemove: '确认删除', confirmRemove: '确认删除',
cancel: '取消', cancel: '取消',
closeDialog: '关闭 SSH 主机验证', closeDialog: '关闭 SSH 主机验证',
@@ -36,7 +36,7 @@ export const workspace = {
importFiles: '导入文件', importing: '正在导入…', importTarget: '目标目录:{{path}}', imported: '已导入 {{count}} 个文件到 {{path}}。', created: '创建时间', importFiles: '导入文件', importing: '正在导入…', importTarget: '目标目录:{{path}}', imported: '已导入 {{count}} 个文件到 {{path}}。', created: '创建时间',
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录', gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性', createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
defaultOpen: '使用默认应用打开', name: '名称', nameOnly: '名称不能包含路径分隔符', destination: '目标目录', defaultOpen: '使用系统应用打开', name: '名称', nameOnly: '名称不能包含路径分隔符', destination: '目标目录',
chooseDirectory: '选择目录', moveHelp: '请选择当前工作区内的目录,移动后保留文件或目录原名。', chooseDirectory: '选择目录', moveHelp: '请选择当前工作区内的目录,移动后保留文件或目录原名。',
destinationOutside: '请选择当前工作区 {{root}} 内的目录,暂不支持移动到此工作区之外。', destinationOutside: '请选择当前工作区 {{root}} 内的目录,暂不支持移动到此工作区之外。',
destinationUnavailable: '项目目录不可用,请重新打开工作区后重试。', destinationUnavailable: '项目目录不可用,请重新打开工作区后重试。',
@@ -143,17 +143,17 @@ export const workspace = {
}, },
loadingHosts: '正在读取已保存主机…', loadingHosts: '正在读取已保存主机…',
noHosts: '没有已保存的 SSH 主机', noHosts: '没有已保存的 SSH 主机',
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。', hostHelp: '主机凭据在“应用 > 主机管理”中统一管理,不会复制到项目中。',
readiness: { readiness: {
loading: '正在读取主机验证记录…', loading: '正在读取主机验证记录…',
ready: ready:
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。', '此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
unready: unready:
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。', '此主机尚未完成 Host Key 和连接验证。请先前往“应用 > 主机管理”完成验证。',
error: error:
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。', '无法读取此主机的本地验证记录。请前往“应用 > 主机管理”检查配置。',
saveBlocked: saveBlocked:
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。', '所选主机尚未完成验证。请先在“应用 > 主机管理”验证连接。',
options: { options: {
loading: '读取中', loading: '读取中',
ready: '已验证', ready: '已验证',
@@ -519,7 +519,7 @@ export const workspace = {
}, },
openFolderAriaLabel: '在系统资源管理器中打开文件夹 {{name}}', openFolderAriaLabel: '在系统资源管理器中打开文件夹 {{name}}',
openFolder: '打开文件夹', openFolder: '打开文件夹',
openFileAriaLabel: '使用默认应用打开文件 {{name}}', openFileAriaLabel: '使用系统应用打开文件 {{name}}',
openFile: '打开文件', openFile: '打开文件',
reading: '正在读取…', reading: '正在读取…',
directoryTruncated: '目录项目超过 500 项,仅显示前 500 项。', directoryTruncated: '目录项目超过 500 项,仅显示前 500 项。',
+260
View File
@@ -0,0 +1,260 @@
.remote-hosts-tabs {
margin-block: var(--space-4) var(--space-5);
}
.remote-hosts-panel {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.remote-hosts-panel[hidden] {
display: none;
}
.ssh-hosts-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
.ssh-hosts-toolbar__search {
display: flex;
flex: 1 1 240px;
max-width: 360px;
align-items: center;
gap: var(--space-2);
margin-right: auto;
padding: 0 var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control, 6px);
background: var(--surface-raised);
color: var(--text-muted);
}
.ssh-hosts-toolbar__search:focus-within {
border-color: var(--accent);
outline: 2px solid var(--accent-selected);
outline-offset: 0;
}
.ssh-hosts-toolbar__search input {
flex: 1;
min-width: 0;
min-height: 32px;
border: 0;
background: transparent;
color: var(--text-primary);
font: inherit;
outline: none;
}
.ssh-hosts-list {
gap: var(--space-2);
}
.ssh-host-card {
gap: 0;
padding: var(--space-2) var(--space-3);
transition: border-color 120ms ease;
}
.ssh-host-card:hover,
.ssh-host-card--expanded {
border-color: var(--border-default);
}
.ssh-host-card__summary {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.ssh-host-card__toggle {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
text-align: left;
cursor: pointer;
}
.ssh-host-card__toggle:disabled {
cursor: default;
}
.ssh-host-card__toggle:focus-visible {
border-radius: var(--radius-control);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ssh-host-card__chevron {
flex: none;
transition: transform 120ms ease;
}
.ssh-host-card--expanded .ssh-host-card__chevron {
transform: rotate(90deg);
}
.ssh-host-card__identity {
display: flex;
min-width: 0;
align-items: baseline;
gap: var(--space-3);
}
.ssh-host-card__identity strong {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.ssh-host-card__identity small {
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono, ui-monospace, Consolas, monospace);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.ssh-host-card__badges {
display: flex;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-1);
}
.ssh-host-badge {
padding: 1px 8px;
border-radius: 999px;
font-size: var(--font-caption);
line-height: 1.6;
white-space: nowrap;
}
.ssh-host-badge--ok {
background: var(--success-subtle);
color: var(--success);
}
.ssh-host-badge--warn {
background: var(--warning-subtle);
color: var(--warning);
}
.ssh-host-badge--busy {
background: var(--accent-subtle);
color: var(--accent);
}
.ssh-host-badge--muted {
background: var(--surface-muted);
color: var(--text-secondary);
}
.ssh-host-card__summary {
flex-wrap: wrap;
}
.ssh-host-card__summary > .secondary-button,
.ssh-host-card__summary > .danger-button {
flex: none;
}
/* The removal confirmation takes the full width under the summary. */
.ssh-host-card__summary > .danger-confirm {
flex: 1 0 100%;
margin-top: var(--space-2);
}
.ssh-host-environment__table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-body);
}
.ssh-host-environment__table th,
.ssh-host-environment__table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
text-align: left;
vertical-align: middle;
}
.ssh-host-environment__table thead th {
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 500;
}
.ssh-host-environment__table tbody tr:last-child > * {
border-bottom: 0;
}
.ssh-host-environment__table tbody th {
color: var(--text-primary);
font-weight: 500;
}
.ssh-host-environment__table td {
font-variant-numeric: tabular-nums;
}
.ssh-host-environment__component {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ssh-host-environment__component svg {
color: var(--accent);
}
.ssh-host-environment__missing {
color: var(--text-muted);
}
.ssh-host-card__details {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
@container (max-width: 760px) {
.ssh-host-card__summary {
flex-wrap: wrap;
}
.ssh-host-card__identity {
flex-direction: column;
gap: 0;
}
.ssh-host-card__badges {
justify-content: flex-start;
padding-left: calc(15px + 16px + var(--space-2) * 2);
}
}
@container (max-width: 640px) {
.remote-hosts-panel .ssh-host-card__facts {
grid-template-columns: minmax(0, 1fr);
}
}
+3 -9
View File
@@ -4,19 +4,13 @@ import { getSettingsCategoryList } from './settings-categories'
describe('getSettingsCategoryList', () => { describe('getSettingsCategoryList', () => {
it('defines an icon for every settings category', () => { it('defines an icon for every settings category', () => {
expect( expect(
getSettingsCategoryList(true).every(({ icon }) => icon != null) getSettingsCategoryList().every(({ icon }) => icon != null)
).toBe(true) ).toBe(true)
}) })
it('hides SSH hosts while Remote Projects is disabled', () => { it('no longer lists SSH hosts, which moved to the Hosts application', () => {
expect( expect(
getSettingsCategoryList(false).map(({ id }) => id) getSettingsCategoryList().map(({ id }) => id as string)
).not.toContain('ssh-hosts') ).not.toContain('ssh-hosts')
}) })
it('includes SSH hosts while Remote Projects is enabled', () => {
expect(
getSettingsCategoryList(true).map(({ id }) => id)
).toContain('ssh-hosts')
})
}) })
+5 -13
View File
@@ -6,7 +6,7 @@ import {
Info, Info,
Palette, Palette,
Radio, Radio,
Server,
SlidersHorizontal, SlidersHorizontal,
Terminal, Terminal,
Users, Users,
@@ -44,12 +44,7 @@ export const settingsCategoryList = [
translationKey: 'runtime', translationKey: 'runtime',
icon: Terminal icon: Terminal
}, },
{
id: 'ssh-hosts',
group: 'execution',
translationKey: 'sshHosts',
icon: Server
},
{ {
id: 'document-parsing', id: 'document-parsing',
group: 'extensions', group: 'extensions',
@@ -86,12 +81,9 @@ export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number] (typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id'] export type SettingsCategoryId = SettingsCategoryDefinition['id']
export function getSettingsCategoryList( // SSH hosts moved to the Hosts application and are no longer a settings category.
remoteProjectsEnabled: boolean export function getSettingsCategoryList(): readonly SettingsCategoryDefinition[] {
): readonly SettingsCategoryDefinition[] { return settingsCategoryList
return remoteProjectsEnabled
? settingsCategoryList
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
} }
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & { type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & {
+196 -9
View File
@@ -2433,7 +2433,8 @@
position: absolute; position: absolute;
top: var(--space-2); top: var(--space-2);
right: var(--space-2); right: var(--space-2);
opacity: 1; opacity: 0;
transition: opacity var(--motion-fast) ease-out;
} }
.magic-note-row:hover .magic-note-more, .magic-note-row:hover .magic-note-more,
@@ -2721,9 +2722,18 @@
line-height: 1; line-height: 1;
} }
.magic-note-entry {
position: relative;
}
/* Floats over the top-right corner so the actions never take their own row. */
.magic-note-entry__actions { .magic-note-entry__actions {
display: flex; display: flex;
gap: 2px; position: absolute;
z-index: 1;
top: var(--space-1);
right: var(--space-2);
gap: var(--space-1);
} }
.magic-note-entry__actions .icon-button { .magic-note-entry__actions .icon-button {
@@ -2731,7 +2741,36 @@
height: 28px; height: 28px;
} }
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled) { .magic-note-entry__action-button {
height: 28px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--font-caption);
white-space: nowrap;
cursor: pointer;
}
.magic-note-entry__action-button:hover:not(:disabled),
.magic-note-entry__action-button:focus-visible {
background: var(--surface-subtle);
color: var(--text-primary);
}
.magic-note-entry__action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.magic-note-entry__actions .magic-note-entry__delete-button {
color: var(--danger-strong);
}
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled),
.magic-note-entry__actions .magic-note-entry__delete-button:focus-visible {
background: var(--danger-subtle); background: var(--danger-subtle);
color: var(--danger-strong); color: var(--danger-strong);
} }
@@ -6483,7 +6522,7 @@ button > svg {
.chat-scroll-to-bottom { .chat-scroll-to-bottom {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
bottom: var(--space-3); bottom: var(--space-6);
left: 50%; left: 50%;
display: grid; display: grid;
width: 40px; width: 40px;
@@ -7304,6 +7343,109 @@ button > svg {
color: var(--danger); color: var(--danger);
} }
/* When enabled, the minimap replaces the native scrollbar of the message scroller. */
.chat-history-pane[data-minimap='true'] > .chat {
scrollbar-width: none;
}
.chat-history-pane[data-minimap='true'] > .chat::-webkit-scrollbar {
display: none;
}
/* The rail covers only the reading area: below the context strip and, with
frosted glass, below the title bar the pane extends under. */
.chat-minimap {
position: absolute;
z-index: 3;
top: var(--chat-context-height, 0px);
right: 0;
bottom: 0;
width: 14px;
overflow: hidden;
background: transparent;
cursor: default;
touch-action: none;
user-select: none;
}
.app-shell[data-frosted-glass='true'] .chat-minimap {
top: calc(58px + var(--chat-context-height, 0px));
}
.chat-minimap[data-expanded='true'] {
width: 80px;
border-left: 1px solid var(--border-default);
background: var(--surface-glass-panel);
box-shadow: var(--shadow-card);
backdrop-filter: blur(8px);
}
.chat-minimap:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.chat-minimap__canvas {
display: block;
width: 100%;
height: 100%;
opacity: 0.55;
}
.chat-minimap[data-expanded='true'] .chat-minimap__canvas {
opacity: 0.85;
}
.chat-minimap__viewport {
position: absolute;
top: 0;
right: 0;
left: 0;
background: color-mix(in srgb, var(--text-muted, #8f98a8) 22%, transparent);
pointer-events: none;
will-change: transform;
}
.chat-minimap:hover .chat-minimap__viewport,
.chat-minimap:active .chat-minimap__viewport {
background: color-mix(in srgb, var(--accent) 24%, transparent);
}
@media (max-width: 700px) {
.chat-minimap {
width: 10px;
}
.chat-minimap[data-expanded='true'] {
width: 64px;
}
}
.message__status--active {
color: var(--text-muted);
}
.message__status-label {
min-width: 0;
}
.message__status--active .message__status-label {
background: linear-gradient(
105deg,
var(--text-muted) 0%,
var(--text-muted) 38%,
var(--text-primary) 50%,
var(--text-muted) 62%,
var(--text-muted) 100%
);
background-size: 320% 100%;
background-position: 140% 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: message-status-shimmer 2.2s linear infinite;
}
.context-compression-event { .context-compression-event {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -7907,24 +8049,54 @@ button > svg {
} }
.composer-wrap { .composer-wrap {
--composer-fade-color: var(--surface-raised);
position: relative; position: relative;
z-index: 1;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
/* Overlap the message region by the top padding so messages run under the
fade right up to the composer outline instead of stopping at a solid band. */
margin-top: calc(-1 * var(--space-3));
padding: padding:
var(--space-3) var(--space-3)
var(--chat-inline-padding) var(--chat-inline-padding)
var(--space-2); var(--space-2);
background: var(--surface-raised); pointer-events: none;
}
.composer-wrap > * {
pointer-events: auto;
} }
.composer-wrap::before { .composer-wrap::before {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 100%; bottom: calc(100% - var(--space-3));
left: 0; left: 0;
z-index: -1;
height: var(--space-4); height: var(--space-4);
background: linear-gradient(to bottom, transparent, var(--surface-raised)); background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--composer-fade-color) 55%, transparent) 35%,
color-mix(in srgb, var(--composer-fade-color) 90%, transparent) 70%,
var(--composer-fade-color)
);
content: "";
pointer-events: none;
}
/* Below the composer the wrap stays opaque so nothing shows through its
bottom padding and metadata row. */
.composer-wrap::after {
position: absolute;
top: var(--space-3);
right: 0;
bottom: 0;
left: 0;
z-index: -1;
background: var(--composer-fade-color);
content: ""; content: "";
pointer-events: none; pointer-events: none;
} }
@@ -8400,7 +8572,7 @@ button > svg {
.composer:focus-within { .composer:focus-within {
border-color: var(--accent); border-color: var(--accent);
box-shadow: box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent),
var(--shadow-card); var(--shadow-card);
} }
@@ -17766,12 +17938,15 @@ textarea.field-control {
.settings-page, .settings-page,
.workspace, .workspace,
.chat, .chat,
.composer-wrap,
.workspace-panel-scroll .workspace-panel-scroll
) { ) {
background: var(--surface-canvas); background: var(--surface-canvas);
} }
:root[data-theme='dark'] .composer-wrap {
--composer-fade-color: var(--surface-canvas);
}
:root[data-theme='dark'] :where( :root[data-theme='dark'] :where(
.sidebar, .sidebar,
.topbar, .topbar,
@@ -18137,6 +18312,18 @@ textarea.field-control {
} }
} }
@keyframes message-status-shimmer {
0%,
12% {
background-position: 140% 0;
}
88%,
100% {
background-position: -40% 0;
}
}
@keyframes composer-recording-pulse { @keyframes composer-recording-pulse {
0%, 0%,
100% { 100% {
+13
View File
@@ -116,11 +116,24 @@
flex: 1; flex: 1;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
contain: size layout paint;
overflow-anchor: none;
background: var(--surface-canvas); background: var(--surface-canvas);
} }
.terminal-panel__emulator .xterm { .terminal-panel__emulator .xterm {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100%; height: 100%;
overflow: hidden;
}
/* IME composition must stay inside the terminal's own scroll boundary. */
.terminal-panel__emulator .xterm-helpers {
max-width: 0;
overflow: visible;
} }
.terminal-panel__status { .terminal-panel__status {
+1 -1
View File
@@ -19,7 +19,7 @@
.workspace-menu__layout { .workspace-menu__layout {
display: grid; display: grid;
grid-template-columns: minmax(240px, 360px) minmax(0, 1fr); grid-template-columns: minmax(260px, 260px) minmax(0, 1fr);
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
+3 -2
View File
@@ -135,9 +135,10 @@ describe('Agent package contracts', () => {
).toThrow() ).toThrow()
}) })
it('accepts only a bounded refresh flag for inventory reads', () => { it('accepts only bounded inventory read flags', () => {
expect(agentPackageInventoryRequestSchema.parse({})).toEqual({ expect(agentPackageInventoryRequestSchema.parse({})).toEqual({
refresh: false refresh: false,
localOnly: false
}) })
expect(() => expect(() =>
agentPackageInventoryRequestSchema.parse({ agentPackageInventoryRequestSchema.parse({
+2 -1
View File
@@ -281,7 +281,8 @@ export type AgentPackageInventory = z.infer<
export const agentPackageInventoryRequestSchema = z export const agentPackageInventoryRequestSchema = z
.object({ .object({
refresh: z.boolean().default(false) refresh: z.boolean().default(false),
localOnly: z.boolean().default(false)
}) })
.strict() .strict()
+9 -5
View File
@@ -37,13 +37,13 @@ export const defaultSupervisorModelConcurrency = 1
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4) export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600) export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
export type BuiltInApplicationId = typeof builtInApplicationIds[number] export type BuiltInApplicationId = typeof builtInApplicationIds[number]
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing'] as const export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
export type EditableApplicationId = typeof editableApplicationIds[number] export type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = { export const defaultApplicationNavigation = {
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[], order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing', 'remote-hosts'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false } pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false, 'remote-hosts': false }
} }
export const applicationNavigationSchema = z.object({ export const applicationNavigationSchema = z.object({
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length) order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
@@ -51,7 +51,9 @@ export const applicationNavigationSchema = z.object({
pinned: z.object({ pinned: z.object({
'magic-notes': z.boolean(), 'magic-notes': z.boolean(),
heartbeat: z.boolean().default(true), heartbeat: z.boolean().default(true),
'local-inference': z.boolean() 'local-inference': z.boolean(),
// Optional on input so callers written before the application existed keep working.
'remote-hosts': z.boolean().default(false)
}).strict() }).strict()
}).strict() }).strict()
@@ -60,6 +62,7 @@ const applicationPreferencesSchema = z
checkUpdatesOnStartup: z.boolean(), checkUpdatesOnStartup: z.boolean(),
desktopNotificationsEnabled: z.boolean().default(true), desktopNotificationsEnabled: z.boolean().default(true),
transparentFrostedEffectEnabled: z.boolean().default(true), transparentFrostedEffectEnabled: z.boolean().default(true),
chatMinimapEnabled: z.boolean().default(true),
updateSource: updateSourceSchema, updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema, modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema, localToolEnvironment: localToolEnvironmentSettingsSchema,
@@ -94,6 +97,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
applicationNavigation: applicationNavigationSchema.optional(), applicationNavigation: applicationNavigationSchema.optional(),
desktopNotificationsEnabled: z.boolean().optional(), desktopNotificationsEnabled: z.boolean().optional(),
transparentFrostedEffectEnabled: z.boolean().optional(), transparentFrostedEffectEnabled: z.boolean().optional(),
chatMinimapEnabled: z.boolean().optional(),
localInferenceEnabled: z.boolean().optional(), localInferenceEnabled: z.boolean().optional(),
deviceSharingEnabled: z.boolean().optional(), deviceSharingEnabled: z.boolean().optional(),
magicNotesEnabled: z.boolean().optional(), magicNotesEnabled: z.boolean().optional(),
+2 -1
View File
@@ -1869,7 +1869,8 @@ export type DesktopApi = {
listener: (status: SshHostAgentConnectionStatus) => void listener: (status: SshHostAgentConnectionStatus) => void
) => () => void ) => () => void
getAgentPackageInventory: ( getAgentPackageInventory: (
refresh?: boolean refresh?: boolean,
localOnly?: boolean
) => Promise<AgentPackageInventory> ) => Promise<AgentPackageInventory>
downloadAgentPackage: ( downloadAgentPackage: (
architecture: AgentArchitecture, architecture: AgentArchitecture,
@@ -23,10 +23,14 @@ describe('GoodBuddy configuration contracts', () => {
operation: 'application.update', operation: 'application.update',
updates: { applicationNavigation: { updates: { applicationNavigation: {
...defaultApplicationNavigation, ...defaultApplicationNavigation,
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'], order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing', 'remote-hosts'],
} }, } },
} }
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation) expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
// The order written before Hosts existed is no longer complete.
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'] },
} }).success).toBe(false)
for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) { for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: { expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order }, applicationNavigation: { ...defaultApplicationNavigation, order },
@@ -154,6 +158,7 @@ describe('GoodBuddy configuration contracts', () => {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false, transparentFrostedEffectEnabled: false,
chatMinimapEnabled: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, localToolEnvironment: defaultLocalToolEnvironmentSettings,
+2 -2
View File
@@ -39,8 +39,8 @@ export type SupervisionReviewProgress = {
remainingSources: number remainingSources: number
/** Sources excluded because their owning conversation was deleted; not reviewed coverage. */ /** Sources excluded because their owning conversation was deleted; not reviewed coverage. */
omittedSources?: number omittedSources?: number
/** Sources that changed during the run and were reviewed again at their newer version. */ /** Frozen sources that changed or disappeared during the run; skipped and left for the next review. */
revisedSources?: number skippedSources?: number
complete: boolean complete: boolean
restartRequired?: boolean restartRequired?: boolean
inFlight?: number inFlight?: number