Compare commits
16
Commits
agent-v0.15.0
...
v0.15.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c9229ac7a | ||
|
|
86347af0b1 | ||
|
|
4baf4ec142 | ||
|
|
1603871f7c | ||
|
|
393a07ccdf | ||
|
|
50e8fde64c | ||
|
|
13ff182858 | ||
|
|
594222a2b5 | ||
|
|
1c6264dd87 | ||
|
|
120076a13d | ||
|
|
d8f0ef1301 | ||
|
|
10f3b4f2c1 | ||
|
|
58e6808f1f | ||
|
|
8650907b75 | ||
|
|
c11f204b17 | ||
|
|
be23c1346b |
@@ -182,6 +182,30 @@ npm run dist:linux:arm64
|
||||
已由 Vite 打包的 `@antv/g6` 归类为构建期依赖,不会再作为独立 Node 模块复制进桌面
|
||||
安装包。
|
||||
|
||||
Desktop packaging includes only `out/main`, `out/preload`, and `out/renderer`;
|
||||
validation builds elsewhere under `out` are not shipped. Renderer-only React,
|
||||
Xterm, Mermaid, Fabric, Quill, i18next, and Markdown/math dependencies are build-time
|
||||
dependencies: Vite ships their browser bundles, while the existing license
|
||||
resources remain included. Install development dependencies before building;
|
||||
do not prune them before bundling and copying license resources.
|
||||
|
||||
The desktop DS Web resource filter excludes JavaScript/TypeScript source maps,
|
||||
`.d.ts` / `.d.mts` / `.d.cts` declarations, and `.pdb` debug symbols. It preserves
|
||||
the installed dependency layout, plugin configuration, browser assets, licenses,
|
||||
native binaries, and non-code `.map` assets. This filter does not change the
|
||||
separately built remote Agent or Runtime payloads.
|
||||
|
||||
Windows x64 development validation on 2026-09-28 compared the existing 0.15.2
|
||||
portable directory (excluding `data`) with a fresh unpacked test build:
|
||||
`app.asar` decreased from 609.4 to 350.5 MiB, and DS Web decreased from 28,701
|
||||
files / 463.4 MiB to 14,984 files / 357.0 MiB. Total measured size decreased from
|
||||
1,758.3 to 1,438.4 MiB despite the test build retaining 45.6 MiB more Electron
|
||||
locales than the portable build. These are unpacked sizes, not installer download
|
||||
sizes. Builder-copy regression tests, production build/type checks, packaged
|
||||
Harness/OpenCode/npm smoke checks, DS Web authentication/page scripts/workspace
|
||||
creation, and packaged App/settings/notes-page loading passed. Full editor/canvas
|
||||
interaction and other platform packages were not revalidated in this check.
|
||||
|
||||
以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带
|
||||
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
|
||||
`agent-release-keys.json`,但所有桌面构建路径(包括本地 `portable`)都不会嵌入
|
||||
|
||||
+23
-7
@@ -64,7 +64,9 @@ records are listed separately and do not introduce another feature status.
|
||||
- [x] **Compact conversation controls**: Composer options share a compact
|
||||
settings panel while Runtime, mode, sending, and queue controls remain
|
||||
available. Conversation search has an inline clear action that restores the
|
||||
scoped list and returns focus to the input.
|
||||
scoped list and returns focus to the input. New conversation and expandable
|
||||
search share one row. Composer shortcut hints reflect successful registration
|
||||
and saved settings, without being overwritten by late startup responses.
|
||||
- [x] **On-demand conversation history**: Lists load lightweight summaries
|
||||
rather than every conversation's process metadata. Opened and active
|
||||
conversations retain full details; search, copy, export, continued requests,
|
||||
@@ -141,10 +143,11 @@ records are listed separately and do not introduce another feature status.
|
||||
models, approval policy, budgets, and timeouts in a Project, with reusable
|
||||
templates.
|
||||
- [x] **Application center and navigation**:
|
||||
Clicking the bottom App Center opens a lightweight upward anchored popup with enabled apps,
|
||||
The footer shows equal-height Apps and Settings buttons with visible labels and a subtle vertical divider.
|
||||
Clicking Apps, marked with an upward chevron, opens a lightweight upward anchored popup with enabled apps,
|
||||
regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes
|
||||
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
|
||||
open their main content pages. Manage Apps opens searchable cards with settings details;
|
||||
open their main content pages. Manage Apps uses a LayoutGrid icon and opens Application Center with searchable cards and settings details;
|
||||
layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md).
|
||||
All four application cards support arrow and drag reordering regardless of enablement or pinning.
|
||||
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
|
||||
@@ -330,7 +333,7 @@ records are listed separately and do not introduce another feature status.
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.15.0`, paired with the current Desktop release candidate `0.15.2`; formal
|
||||
`0.15.0`, paired with the current Desktop release candidate `0.15.3`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Previous macOS validation covered native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -467,6 +470,8 @@ records are listed separately and do not introduce another feature status.
|
||||
direct models report their own backoff phase. Footer dots are static, and
|
||||
unsupported Runtime retry details are not invented. Conversation-list
|
||||
indicators and send/stop behavior remain unchanged.
|
||||
Cancellation and failure retain existing replies and use a single status;
|
||||
cancellation is neutral, and Edit again appears only when input can be restored.
|
||||
- [x] **Simplified Runtime selection**: Conversation and project menus no
|
||||
longer enumerate every Runtime/model combination. Configure Runtime models
|
||||
in system settings; saved fixed project selections remain usable and
|
||||
@@ -495,7 +500,12 @@ records are listed separately and do not introduce another feature status.
|
||||
fields take precedence, and these ordinary connection settings must not be
|
||||
used to store API keys or other secrets. See
|
||||
[model request customization](./docs/features/model-connections/README.md).
|
||||
- [x] **Context usage and automatic compaction**: Direct models update usage
|
||||
- [x] **Context usage and compaction**: Local direct text-model conversations
|
||||
also support manual compaction below the automatic threshold or with automatic
|
||||
compaction disabled. The selected summary model may incur charges; recent turns
|
||||
and full stored history remain available, and saved summaries are reused.
|
||||
Image connections and remote direct-model entry points are excluded.
|
||||
Direct models update usage
|
||||
from each successful provider call. Images and tool rounds use the same
|
||||
accounting, with estimation only when the provider omits usage. The UI
|
||||
distinguishes This Model Call from Post-Compaction Conversation Estimate,
|
||||
@@ -833,7 +843,7 @@ records are listed separately and do not introduce another feature status.
|
||||
- [ ] **Batch runs and comparison lab** (planned): Compares model, Prompt,
|
||||
role, and workflow configurations in batches and summarizes quality,
|
||||
duration, tokens, cost, failure rate, and artifact differences.
|
||||
- **Story Graph read tools (unreleased source)**: Agents can search saved work,
|
||||
- **Story Graph read tools (Desktop 0.15.2 / Agent 0.15.0)**: Agents can search saved work,
|
||||
read current claims or timelines, and page through source evidence. Supervisor
|
||||
enablement and Runtime assignment control discovery and calls, including old
|
||||
sessions. Model, local Harness, and local/remote OpenCode and Continue share
|
||||
@@ -920,9 +930,15 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.2` and the Agent candidate is `0.15.0`, with
|
||||
- The current Desktop candidate is `0.15.3`; published Agent `0.15.0` is unchanged, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.15.3` restores local direct-model manual compaction, retains tool
|
||||
authorization for the request lifetime, clarifies cancellation and sidebar/note
|
||||
controls, avoids redundant startup/storage work, and trims duplicated package
|
||||
dependencies and development files. It does not claim a proven overall rendering
|
||||
speedup. Office live editing remains a design, not a delivered capability.
|
||||
See the [0.15.3 preparation record](./docs/development/release-preparation-0.15.3.md).
|
||||
- Desktop `0.15.1` carries forward the unpublished `0.15.0` Obsidian integration, conversation capture into Magic
|
||||
Notes, and preview local native clients, plus knowledge, Supervisor, and UI
|
||||
fixes. Schema 48 adds conversation-source metadata; older clients require a
|
||||
|
||||
+6
-4
@@ -36,7 +36,7 @@
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [x] **应用中心与导航**:底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
|
||||
@@ -52,7 +52,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [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] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 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.0`,配套 Desktop 候选为 `0.15.2`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 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.0`,配套 Desktop 候选为 `0.15.3`;详细运行边界和 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)。
|
||||
- **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。
|
||||
@@ -73,6 +73,7 @@
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
|
||||
- [x] **本地直连手动压缩**:文本模型会话可在自动压缩关闭或未到阈值时手动生成摘要,保留最近轮次和完整历史,保存的摘要用于后续对话;图像连接和远程直连入口不适用。摘要模型调用可能产生费用。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
|
||||
@@ -129,7 +130,7 @@
|
||||
- [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
- **Story Graph 只读工具(未发布源码)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
|
||||
- **Story Graph 只读工具(Desktop 0.15.2 / Agent 0.15.0)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
|
||||
@@ -153,7 +154,8 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前 Desktop 候选为 `0.15.2`,Agent 候选为 `0.15.0`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.3`,已发布 Agent 保持 `0.15.0`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.1` 已完成六平台原生打包及 GitHub/OSS 发布。Desktop `0.15.2` 新增 Story Graph 只读工具和完整可编辑的监督讨论消息,简化笔记采集并补充退出诊断;远程图谱读取需配套 Agent `0.15.0`。主机与云环境管理应用仍为待实现规划,当前候选证据见[0.15.2 发布准备](./docs/development/release-preparation-0.15.2.md)。
|
||||
- Desktop `0.15.1` 承接未发布的 `0.15.0` Obsidian 接入、会话采集到魔法笔记和预览中的本地原生客户端,并修复知识库、监督者与界面操作。schema 48 新增会话来源元数据,回退旧客户端需使用升级前完整备份。Agent `0.15.0` 要求 Desktop `0.15.2`,保留 Node `24.19.0`,补齐远程遥测关闭配置及 Continue 跳过已关闭的更新检查,不新增远程客户端快捷入口。
|
||||
- Desktop `0.13.15` 承接未发布的 `0.13.14`,改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
|
||||
|
||||
+8
-4
@@ -293,11 +293,15 @@
|
||||
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
|
||||
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
|
||||
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
|
||||
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。
|
||||
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
|
||||
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
|
||||
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
|
||||
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
|
||||
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
@@ -328,7 +332,7 @@
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。
|
||||
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
|
||||
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
@@ -375,7 +379,7 @@
|
||||
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
|
||||
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
|
||||
- 浏览器 Tab 的内容顶部固定使用一条紧凑工具栏,按“返回、刷新/停止加载、地址输入、
|
||||
前往、交互、关闭浏览器”排列。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
前往、交互、关闭浏览器”排列。“前往”使用纯右箭头图标按钮,保留悬停提示和可访问名称。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
“关闭浏览器”必须使用明确文字和与页面导航控件清楚区分的危险操作视觉,不能继续标为
|
||||
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
||||
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
||||
@@ -695,7 +699,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
||||
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 空输入框使用三行原生占位文案:第一行为消息提示(生图模式保留图片描述提示),第二行为“Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴”,第三行为“Ctrl+N 新建对话 · 实际已注册快捷键 快捷唤起”。新建与粘贴快捷键按平台显示,macOS 使用 Command;全局快捷键不可用时省略该项。输入文字后占位提示自然隐藏,输入框下方不再重复全局快捷键,保留模式、安全边界和其他元信息。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Desktop 0.15.3 发布准备
|
||||
|
||||
日期:2026-09-28。用户确认沿用双远端推送、精确提交 CI 和生产构建通过后自动打标签的流程;
|
||||
本次仅发布 Desktop 0.15.3,已发布 Agent 0.15.0 保持不变,不构建 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- 已发布基线:`v0.15.2`,提交 `47ba54f47db4f22f69375310925eedab020b3cf0`。
|
||||
- 本轮已核对 15 个提交,截止 `86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`。
|
||||
包含手动压缩、请求级工具授权、取消/失败状态、聊天/笔记重复工作、
|
||||
侧栏与工作栏布局、打包依赖精简。起始工作区干净。
|
||||
- Office 同会话实时编辑仅更新需求与验收设计,不计入交付能力。
|
||||
- `package.json`、lockfile 两处 Desktop 版本统一为 0.15.3。
|
||||
已发布 0.15.2 与 Agent 0.15.0 的正文保持不变;新双语说明单独追加。
|
||||
- 两份 FEATURES 对照实现更新手动压缩、状态反馈、搜索、箭头方向及发布状态。
|
||||
不用局部 Markdown 基准宣称整体渲染性能提升,不提前承诺跨平台安装包缩小比例。
|
||||
|
||||
## Agent 与验证边界
|
||||
|
||||
`src/agent-daemon`、`src/main/remote-agent`、Agent 和 Runtime 锁在本轮未变。
|
||||
授权修复属于 Desktop 的 `KnowledgeMcpGateway`:请求有效期间不再按固定十分钟撤销,
|
||||
取消和清理继续撤销;不修改远程工具通道或部署包,不需要重发 Agent 0.15.0。
|
||||
手动直连压缩仅用于本地文本会话,UI 与 IPC 均排除 SSH/远程直连入口。
|
||||
|
||||
开发期证据分别见[直连模型进度](../features/direct-model-agent/progress.md)、
|
||||
[导航进度](../features/application-tool-navigation/progress.md)和
|
||||
[性能基准](../roadmap/product-performance-experience-improvement-plan.md)。
|
||||
开发期全量曾报告侧栏和笔记 Electron 用例失败,不把这些历史结果计为本候选通过。
|
||||
本轮 `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
|
||||
网关、Obsidian 授权、桌面资源与 DS 打包、手动压缩真实 Electron 生产链路共 6 文件:
|
||||
**85 通过、1 跳过**。跳过的是 Windows 不适用的 POSIX 符号链接用例。
|
||||
压缩测试使用本机 HTTP fixture,不冒充外部模型调用;本轮没有新增付费模型请求。
|
||||
沿用用户确认的直接推送等待 CI 流程,完整测试及生产构建以精确候选 CI 为发布门槛。
|
||||
不进行本地生产构建、打包或安装包启动。
|
||||
|
||||
schema 保持 48;从更早版本升级前备份完整数据库及相邻 `notes/` 目录。
|
||||
@@ -1,7 +1,7 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
### FR-5 应用中心入口
|
||||
|
||||
侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
|
||||
侧栏底部提供“应用”(英文 `Apps`)和独立“设置”入口,两个按钮均显示文字。点击“应用”向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
|
||||
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
|
||||
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
|
||||
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-09-28: Composer shortcut initialization follow-up
|
||||
|
||||
The composer now retains successful shortcut-settings snapshots independently of
|
||||
the startup app-info request. A failed or pending app-info read no longer drops a
|
||||
successful shortcut save, and a late startup response cannot restore a disabled
|
||||
hint. Registration remains authoritative; no default shortcut is advertised when
|
||||
registration is unavailable. This preserves the existing [composer rule](../../../UI-DESIGN.md#131-聊天).
|
||||
|
||||
The failed/pending read regressions reproduced the missing hint before the fix.
|
||||
The reported screenshot's actual registration status and IPC result were not
|
||||
captured, so its specific runtime cause remains unconfirmed.
|
||||
|
||||
Validation: App, sidebar layout, main IPC, and shortcut-service suites passed
|
||||
(500 tests, one skipped); `npm run typecheck`, ESLint on the four changed source
|
||||
and test files, and `git diff --check` passed. The first broader run hit the
|
||||
60-second command limit; the rerun completed in 95 seconds. No full-repository
|
||||
suite, native Electron screenshot reproduction, packaging, or OS registration
|
||||
probe was run for this follow-up.
|
||||
|
||||
## 2026-09-28:新建对话与搜索共用一行
|
||||
|
||||
已按[会话搜索](./ui-design.md#会话搜索)接入收起与展开状态,移除列表上方的常驻搜索头部。
|
||||
沿用会话创建、延迟搜索和项目过滤接口。后续按用户反馈恢复默认按钮的聊天加号图标、原有
|
||||
字号字重和整体左对齐;搜索展开后保留居中的同一图标。快捷键说明移至聊天输入框原生占位文案,
|
||||
具体规则见[聊天 UI 规范](../../../UI-DESIGN.md#131-聊天)。
|
||||
|
||||
本次调整验证:`App.test.tsx` 与 `AppSidebarLayout.test.ts` 合计 316 项通过;最终长标签换行
|
||||
调整后,布局 5 项与 `magic-notes-capture.electron.test.ts` 1 项复验通过。Windows Electron
|
||||
在上述 6 种侧栏组合检查原字号字重、左对齐、图标居中、文字无溢出及搜索交互,并验证
|
||||
原生输入后占位提示隐藏、清空后恢复。App 回归覆盖中英文、生图提示、macOS 快捷键格式、
|
||||
全局快捷键缺省以及设置保存后的更新和停用。类型检查、修改文件 ESLint 与差异空白检查通过。
|
||||
未运行全仓测试、打包或 macOS/Linux 原生窗口验证。模型调用 0 次。
|
||||
|
||||
此前搜索展开改动的验证记录:
|
||||
|
||||
```text
|
||||
npm test -- src/renderer/src/App.test.tsx src/renderer/src/AppSidebarLayout.test.ts tests/magic-notes-capture.electron.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
三文件合计 311 项通过。最终动画边界与文字宽度调整后,布局及 Electron 共 5 项、App 搜索
|
||||
交互 1 项复跑通过,typecheck 通过。Electron 复用现有 App、生产 Preload、IPC 和临时 SQLite
|
||||
测试环境,以英文界面检查浅深主题下 220px、420px 侧栏和短窗口抽屉,共 6 种组合;检查
|
||||
展开过程及结束后的右边缘、列表位置、完整按钮文字、原生鼠标与 Escape、自动聚焦、关闭
|
||||
焦点恢复、点击列表保留关键词及减少动态效果。中文 App 回归同时覆盖标题、正文、项目
|
||||
过滤和加号创建。模型调用 0 次。
|
||||
|
||||
修改文件的 ESLint 与 `git diff --check` 通过。全仓 ESLint 有 10 个错误,均来自本任务未修改
|
||||
的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或
|
||||
macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。
|
||||
|
||||
## 2026-09-28:并行会话输出时保持列表顺序
|
||||
|
||||
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
|
||||
|
||||
@@ -175,17 +175,21 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
|
||||
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer">
|
||||
<button onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
{t('navigation.applicationCenter')}
|
||||
<div className="sidebar-footer sidebar-footer--applications">
|
||||
<button className="nav-item" onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
<Grid2X2 aria-hidden="true" />
|
||||
<span>{t('applications.menuLabel')}</span>
|
||||
<ChevronUp aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label={t('navigation.settings')} ...>
|
||||
<div className="sidebar-footer__divider" aria-hidden="true" />
|
||||
<button className="nav-item" aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
<span>{t('navigation.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
应用中心和设置为相邻独立按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
“应用”(`applications.menuLabel`:中文“应用”、英文 `Apps`)和设置为横向等高独立按钮,使用 `nav-item` 样式和弱竖分隔线;“管理应用”菜单项使用 `LayoutGrid` 图标,管理 Modal 标题保持“应用中心” / `Application Center`。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
|
||||
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
|
||||
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(仅有活动时显示)
|
||||
新对话
|
||||
搜索
|
||||
[新对话] [搜索图标]
|
||||
|
||||
当前对话
|
||||
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
|
||||
@@ -32,24 +31,37 @@
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[应用中心] [设置]
|
||||
[应用 v] | [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
|
||||
知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
|
||||
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
|
||||
|
||||
### 会话搜索
|
||||
|
||||
新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标;
|
||||
点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。
|
||||
最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用
|
||||
`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。
|
||||
通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。
|
||||
控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。
|
||||
|
||||
展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框,
|
||||
焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。
|
||||
无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。
|
||||
|
||||
## 2. 底部应用中心入口
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,应用中心在前,固定设置靠右,二者为独立按钮:
|
||||
底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间:
|
||||
|
||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||
| 位置 | 图标 | 可见文字 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `Grid2X2` | 应用中心 | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal |
|
||||
| 1 | `Grid2X2` + `ChevronUp` | 应用 / Apps | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 / Settings | 在当前工作区上打开设置 Modal |
|
||||
|
||||
图标使用项目现有 Lucide 库。应用中心保留文字,设置图标采用统一的 `34px × 34px` 点击区域。
|
||||
底部使用共享间距和焦点样式,设置通过弹性空白靠右;不得把整个容器做成设置按钮。
|
||||
图标使用项目现有 Lucide 库。两个按钮均保留可见文字,沿用主导航的高度、间距和焦点样式;
|
||||
“应用”文字后显示向上箭头,与菜单展开方向一致。竖分隔线宽 1px、高 `--space-6`,使用 `--border-default`,与上方横分隔线颜色一致。不得把整个容器做成设置按钮。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
@@ -84,7 +96,7 @@
|
||||
|
||||
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
|
||||
|
||||
管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
“管理应用”菜单项使用 `LayoutGrid` 图标。管理 Modal 标题仍为“应用中心”(英文 `Application Center`),沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
|
||||
应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。
|
||||
每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分
|
||||
@@ -189,7 +201,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 底部入口 | 应用中心 |
|
||||
| 底部入口 | 应用 / Apps |
|
||||
| 管理 Modal 标题 | 应用中心 / Application Center |
|
||||
| 菜单底部管理入口 | 管理应用 |
|
||||
| 应用设置开关 | 启用应用 |
|
||||
| 文件系统工具 | 文件 |
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
- Given 任意主页面和任意侧栏宽度
|
||||
- When 用户查看侧栏底部
|
||||
- Then 设置与应用中心为始终可见的独立按钮,点击区域不重叠
|
||||
- Then “设置”与“应用”为始终显示文字的独立按钮,点击区域不重叠
|
||||
- And 点击后继续遵守未保存内容的离开确认
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
@@ -447,6 +447,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。
|
||||
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
|
||||
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
|
||||
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录、当前浏览目录和已展开目录并去重,保留目录展开状态,
|
||||
刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示,
|
||||
可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-28:消息取消与失败展示
|
||||
|
||||
消息终态按[Runtime 交互边界](./runtime-interactions.md)保存和展示。取消使用结构化
|
||||
`terminalStatus`,保持已有回答;终止提示不再补入正文。“重新编辑”只回填并聚焦输入框,
|
||||
没有可回填文字时隐藏入口。旧快照只对精确重复的形态隐藏状态副本,保留原正文。
|
||||
深色主题已移除覆盖失败提示颜色的通用规则;检索和压缩记录不再屏蔽终态的无障碍角色。
|
||||
|
||||
Windows 本地验证:
|
||||
|
||||
- 本次修复完成时 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。
|
||||
后续并行的应用入口修改新增 `App.test.tsx:14430` 的 `getByRole(..., { exact: true })`,
|
||||
最后一次类型检查报 TS2769;该选项不属于 `ByRoleOptions`。未修改这处他人新增代码,
|
||||
当前组合工作区不能记为类型检查通过。最后一次 lint 和 diff 检查通过。
|
||||
- `npm test`:5,367 通过、84 跳过、1 失败。失败项为
|
||||
`tests/magic-notes-layout.electron.test.ts` 的原生拖拽测试,90 秒超时;单独复跑仍超时。
|
||||
该文件未修改,本次检查不记为全量通过。
|
||||
- 相关五文件复跑为 447 通过、14 失败;14 项均在并行入口改名期间查找“应用”按钮失败。
|
||||
入口源码更新后,单独复跑 `src/renderer/src/App.test.tsx` 的 304 项全部通过。
|
||||
其余四文件(数据库、时间线、清单和终态 Electron 探针)的 157 项在上述复跑中通过。
|
||||
- `tests/terminal-status-probe.electron.test.ts` 通过真实 App、Preload、生产 IPC、
|
||||
直连 Runtime 和 SQLite 验证空回答取消、部分回答取消、HTTP 400 失败;检查提示唯一性、
|
||||
浅深主题 computed style、重新编辑回填与焦点,以及数据库重开和页面恢复。
|
||||
每轮使用 3 次本地确定性 HTTP 请求,外部模型调用 0 次。探针在结束时关闭 Runtime、
|
||||
HTTP 服务和窗口,并删除本轮独立临时目录。
|
||||
- 数据库回归覆盖恢复事件事务回滚、失败与取消重放、空正文和已有回答保留、重开后元数据。
|
||||
App 和时间线回归覆盖新事件结构化判定、旧消息精确去重、正常正文保留、无效按钮隐藏、
|
||||
活动及工具终态。
|
||||
|
||||
本次未连接远程主机,未创建远程资源。远端事件仍通过既有公共事件进入桌面的
|
||||
`reduceRecoveredAgentEvent`,本次仅修改其消息投影与元数据保存,不改变 Agent、SSH
|
||||
传输、取消执行、ACK 或重连控制;本地 SQLite 恢复测试覆盖该修改。未验证 Linux/macOS
|
||||
原生界面,不把本地探针记为远端取消生命周期验收。
|
||||
|
||||
## 2026-09-28:DS Web 资源符号链接与 macOS 发布恢复
|
||||
|
||||
Desktop `v0.15.0` 原生构建中,两个 macOS 目标均因包外符号链接未通过签名校验。
|
||||
|
||||
@@ -100,6 +100,16 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
|
||||
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
|
||||
持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的
|
||||
会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。
|
||||
- 失败和取消的终止提示只进入消息状态,不补写正文或文本块,已有回答、推理与工具输出
|
||||
保留。取消使用中性色和礼貌级 `status`,真实失败使用危险色和 `alert`;运行记录的
|
||||
取消结果标题为“任务已取消”。工具详情已表达同一失败时,不再复制到消息底部。
|
||||
- 最新失败或取消消息只有在上一条用户消息含可回填文字时显示“重新编辑”。点击只回填
|
||||
输入框并聚焦,由用户修改后自行发送;附件单独发送且无文字时不显示此按钮。
|
||||
- 实时事件和 Main 历史恢复均将事件的 `failed / cancelled` 写入消息元数据
|
||||
`terminalStatus`;既有 `state: error` 继续表示未成功终态,不改 SQLite 枚举。
|
||||
新事件不按提示文案识别取消。旧快照无此字段时,仅兼容已发布的精确取消标签。
|
||||
旧记录若正文与状态完全相同,且无文本块或唯一文本块位于末尾并等于该状态,展示时
|
||||
保留正文、隐藏重复状态;不修改持久数据,不删除正文中的同名句子或部分匹配内容。
|
||||
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
|
||||
的原生重试状态。直连模型的重试等待与请求开始文案见
|
||||
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
|
||||
|
||||
@@ -261,6 +261,29 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win
|
||||
MCP 代理复用 Provider 发现快照规则,托管 SSH Runtime 不经过该本机构造路径。本次未
|
||||
修改远端 Agent、桥接或启动器。
|
||||
|
||||
## 2026-09-28 本地直连手动压缩
|
||||
|
||||
- 本地直连文本会话复用“压缩上下文”按钮、摘要状态和会话保存路径,支持关闭自动压缩时
|
||||
手动执行。当前连接选择、图像排除、强制压缩和最新轮次保留规则见
|
||||
[技术设计](./technical-design.md#101-本地直连手动压缩)。未修改远端 Agent、桥接或启动器;
|
||||
model 入口排除远程会话与 SSH 项目,本地 IPC 仍拒绝 SSH 执行空间。
|
||||
- 三个目标文件 `App.test.tsx`、`ipc.test.ts`、`model-runtime.test.ts`:569 passed、2 skipped。
|
||||
覆盖入口点击、自动关闭和低于阈值、运行中禁用、图像排除、摘要保存与后续请求、当前及指定
|
||||
摘要连接选择。补入远程通道入口排除后,App 手动压缩专项复跑 7 passed;最终类型检查和
|
||||
本次文件 ESLint 再次通过。
|
||||
- `manual-compaction.electron.test.ts`:真实 App、preload、IPC、模型工厂和 SQLite 路径通过。
|
||||
2 次本机 HTTP fixture 请求验证较早历史被摘要替换、最新轮次保留、完整历史仍在数据库,
|
||||
页面重载后摘要可读,新建 Runtime 实际使用该摘要。
|
||||
- `manual-compaction-live.electron.test.ts`:真实配置文本模型最终通过,2 次请求完成摘要及
|
||||
新 Runtime 续问。三轮探针累计 6 次真实文本模型请求;前两轮失败来自探针用未转义摘要
|
||||
匹配 JSON payload,修正断言后通过。原设置文件未改变,未记录凭据。
|
||||
- `npm run typecheck` 和本次涉及文件的 ESLint 通过。全仓 `npm run lint` 有 10 个错误,
|
||||
位于已有侧栏探针 `sidebar-final-probe.ts`、`sidebar-final-run.mjs`,保留未修改。
|
||||
- 全量 `npm test`:5407 passed、85 skipped、2 failed。失败分别为已有侧栏探针的新建按钮
|
||||
溢出断言(`magic-notes-capture.electron.test.ts`)及魔法笔记布局 Electron 超时
|
||||
(`magic-notes-layout.electron.test.ts`);手动压缩 Electron 回归通过。未执行构建或跨平台
|
||||
真机测试,不将全仓验证记为全绿。
|
||||
|
||||
## 进度维护要求
|
||||
|
||||
- 只有真实生产路径完成并验证后才能勾选实施项。
|
||||
|
||||
@@ -451,6 +451,18 @@ type SubagentActor =
|
||||
处理。Continue 接收完整历史,已有有效摘要只替换其覆盖的前缀,剩余消息全部交给原生
|
||||
Runtime;GoodBuddy 不再额外保留最后 20 条或限制为 128,000 字符。
|
||||
|
||||
### 10.1 本地直连手动压缩
|
||||
|
||||
`agentCompactConversation` 接受 `model`,结果保留实际 provider,压缩策略为
|
||||
`goodbuddy-summary`。摘要来源为 `current` 时,IPC 使用 `applyRuntimeSelection`
|
||||
解析后的当前直连连接;来源为 `profile` 时,使用既有指定摘要连接配置。
|
||||
图像连接不进入文本摘要路径,SSH 执行空间仍由本地 IPC 拒绝。
|
||||
|
||||
`ModelAgentRuntime.compactConversation` 复用 `prepareCompressedRequest` 的强制压缩,
|
||||
跳过自动开关和触发阈值,保留最新轮次。返回的 `contextCompressionState` 沿用会话保存,
|
||||
不删除历史正文。后续请求校验摘要覆盖范围后,用摘要替换已覆盖前缀;即使关闭自动压缩,
|
||||
已保存的有效摘要仍会使用。
|
||||
|
||||
## 11. 性能
|
||||
|
||||
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由或操作菜单。
|
||||
|
||||
本地直连文本会话复用输入区已有的“压缩上下文”按钮,不依赖原生 Runtime 快照,
|
||||
也不因自动压缩关闭或上下文低于触发阈值而隐藏。运行中或压缩中按钮禁用;图像连接、
|
||||
远程通道会话和 SSH 项目不显示直连模型压缩入口。完成后沿用应用通知及压缩后上下文估算,
|
||||
摘要随会话保存。压缩行为见[技术设计](./technical-design.md#101-本地直连手动压缩)。
|
||||
|
||||
## 2. 设置中的能力目录
|
||||
|
||||
直连模型工具目录使用两个本机开发分组:
|
||||
|
||||
@@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独
|
||||
会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。
|
||||
清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。
|
||||
|
||||
引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录
|
||||
转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件
|
||||
发生变化时仍按原事务更新,临时资产仍须转存。
|
||||
|
||||
助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。
|
||||
知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。
|
||||
知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。
|
||||
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览来源旁提供保存范围帮助,按需展开。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
|
||||
|
||||
FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。
|
||||
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-09-28: Startup Body Read Reuse
|
||||
|
||||
Startup repair now reuses the asset references collected during revision
|
||||
inspection, as described in [file reconciliation](./technical-design.md#schema-38-migration).
|
||||
The persisted format and revision recovery rules are unchanged.
|
||||
|
||||
- Real temporary SQLite/filesystem fixture: 120 entries across 120 notes,
|
||||
half rich text and half canvas, with 4,039,560 bytes of body JSON.
|
||||
Five initializations each read 240 bodies before and 120 after the change.
|
||||
Logical bytes read fell from 8,079,120 to 4,039,560 per initialization.
|
||||
- Baseline durations were 148.23, 134.42, 131.06, 140.66 and 172.90 ms;
|
||||
after durations were 136.89, 119.11, 116.41, 115.14 and 116.26 ms.
|
||||
Medians were 140.66 and 116.41 ms. These Windows runs used warm filesystem
|
||||
caches on a shared machine; they do not measure cold application startup.
|
||||
- `npx vitest run src/main/magic-notes/magic-note-storage.test.ts src/main/assistant/assistant-database.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant-storage-startup.test.ts tests/assistant-storage-worker.test.ts`
|
||||
passed 154 tests. Coverage includes failed media inspection, missing/invalid
|
||||
bodies, retained shared assets, revision mismatch recovery and migration.
|
||||
- Full `npm test`: 5382 passed, 84 skipped, 3 failed. The failures were the
|
||||
App question-round request wait and Electron note capture focus/layout waits.
|
||||
All three passed in targeted reruns; the full run was not all green.
|
||||
The capture rerun exercised production App/preload/IPC/SQLite and reopening.
|
||||
- `npm run typecheck` passed. Full lint still reports 10 errors in the existing
|
||||
untracked `sidebar-final-probe.ts` and `sidebar-final-run.mjs`; lint excluding
|
||||
those two files passed. No provider calls were made for these storage benchmarks.
|
||||
|
||||
## 2026-09-27: Shared Destructive Button Styles
|
||||
|
||||
The draft discard Portal now receives the global `danger-solid` styles. The
|
||||
|
||||
@@ -173,7 +173,12 @@ empty content. Failed creates leave no accepted database membership; their
|
||||
unaccepted files are removed on the next access or reopen.
|
||||
|
||||
After commit, reconciliation derives `note.json` from database membership and
|
||||
scans all retained entry files before removing unreferenced assets. Manifest or
|
||||
inspects all retained entry files before removing unreferenced assets. Startup
|
||||
repair reads each current body once to inspect its revision and collect asset
|
||||
references, then passes the complete per-note reference set to reconciliation.
|
||||
If media inspection fails, cleanup uses its existing deferred retry path rather
|
||||
than trusting a partial set. Revision mismatch recovery still hydrates the body;
|
||||
legacy conversion still verifies its written content. Manifest or
|
||||
GC failures log `console.warn` with the note ID and error and remain in an
|
||||
in-memory retry set. Later database access retries; reopening derives cleanup
|
||||
again from membership. These cleanup failures do not turn a committed save,
|
||||
@@ -469,6 +474,13 @@ The built-in MCP gateway returns invalid scoped-tool arguments as an
|
||||
the model to correct its arguments without an MCP internal-error response.
|
||||
Validation failures do not execute the tool.
|
||||
|
||||
Note tools can be authorized with the knowledge-base switch off. The shared
|
||||
desktop gateway binds authorization to each request, without a fixed elapsed-time
|
||||
expiry, so long-running requests retain tool access. Request completion,
|
||||
cancellation, failure cleanup, or gateway disposal revokes the token. Invalid or
|
||||
revoked tokens report unavailable tool authorization rather than a knowledge-base
|
||||
expiry error.
|
||||
|
||||
These note tools run in the desktop gateway. Remote gbagent ACP sessions
|
||||
currently inject only the image MCP server, so this change requires no
|
||||
daemon implementation update.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
```text
|
||||
加入笔记
|
||||
来自:项目 / 会话标题 · 单条回答或当前对话
|
||||
保存范围:用户与助手正文,不含推理、工具日志和附件文件
|
||||
来源旁帮助按钮:按需查看保存范围
|
||||
|
||||
[可编辑文字预览]
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
[取消] [加入笔记]
|
||||
```
|
||||
|
||||
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
|
||||
范围说明随单条或整段来源调整,通过来源旁的 `InlineHelp` 按需查看;回答未完成的警告仍直接显示。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时禁用保存;初始不显示通用校验说明,正文和标题超限时在对应字段旁提示。普通新建允许创建空笔记。
|
||||
|
||||
底栏默认仅显示右对齐的操作按钮。保存失败时保留草稿,错误与按钮在宽面板中同行,窄面板中错误换行、按钮仍靠右。完整工作区的文字保存按钮在窄窗口中也保持内容宽度并右对齐。
|
||||
|
||||
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-09 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-28 |
|
||||
| 适用产品 | GoodBuddy Desktop、ShareServer |
|
||||
| 推荐编辑引擎 | ONLYOFFICE Docs Developer Edition,最终采用前完成许可证与分发审核 |
|
||||
| 推荐编辑引擎 | 首选 ONLYOFFICE Docs Developer Edition;正式采用前验证实时编辑接口并确认授权与报价 |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
@@ -21,7 +21,8 @@ XLSX 和 PPTX,也无法把当前选区交给 Agent 后在原位置应用修改
|
||||
- Windows、macOS 和 Linux Desktop 使用同一套浏览器编辑能力。
|
||||
- DOCX、XLSX、PPTX 在助手工作栏中以多个独立文档页签打开。
|
||||
- 用户可以手动编辑、保存、撤销和重做。
|
||||
- Agent 可以读取用户明确选择的内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
|
||||
- 用户在左侧与 Agent 对话,在右侧助手工作栏看到当前文档的修改状态和结果,无需重新打开文件。
|
||||
- Agent 可以读取用户明确选择的最新内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
|
||||
- 本地源文件只在用户明确打开后上传工作副本,保存时通过 Desktop 原子回写。
|
||||
- ShareServer 统一承载编辑器接入、短期会话、工作副本、回调、保留和服务端审计。
|
||||
|
||||
@@ -66,10 +67,18 @@ ONLYOFFICE 保存先形成 ShareServer 保存检查点。Desktop 下载并验证
|
||||
|
||||
### FR-6 AI 选区编辑
|
||||
|
||||
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器只提供
|
||||
本次选区的有界结构和必要上下文。Agent 返回编辑器支持的结构化操作,由编辑器插件应用;一次
|
||||
请求形成一个撤销事务,并在完成后标出修改位置。只有 Execute 模式可以应用修改;Ask 模式
|
||||
可以生成建议,但不得调用编辑器写入。
|
||||
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器提供
|
||||
当前会话内本次选区的最新内容、有界结构和必要上下文,包括尚未保存到源文件的人工修改。
|
||||
Agent 通过当前编辑会话应用受支持的结构化操作;一次请求形成一个撤销事务。只有 Execute
|
||||
模式可以应用修改;Ask 模式可以生成建议,但不得调用编辑器写入。
|
||||
|
||||
首期有界操作至少覆盖选区读取、指定文本替换、单元格区域公式与数字格式设置,以及指定幻灯片
|
||||
文本和对象属性修改。操作前明确目标文件与范围,完成后返回实际修改结果;不支持的操作说明
|
||||
限制,不以模型生成了建议或工具请求已发出作为成功依据。
|
||||
|
||||
Agent 思考期间用户可以继续编辑。应用前检查当前文档版本与目标是否仍符合本次请求;内容变化
|
||||
或目标失效时停止应用旧结果,保留人工修改并提示基于最新内容重试。切换页签或选区不得将已
|
||||
提交请求改发到其他目标。已打开文档的交互修改不得通过直接重写源文件并重载编辑器实现。
|
||||
|
||||
### FR-7 权限与数据去向
|
||||
|
||||
@@ -102,6 +111,34 @@ Desktop 记录本地打开、保存、另存为、冲突、AI 编辑请求和终
|
||||
Desktop 只连接用户已明确添加并认证的 ShareServer。ONLYOFFICE 地址、JWT 密钥、对象存储和
|
||||
回调配置由 ShareServer 管理,不进入 Desktop Renderer 或普通设置导出。
|
||||
|
||||
推荐由部署方在服务器 Docker 中独立运行 ONLYOFFICE Document Server,客户端加载编辑器;
|
||||
普通用户无需安装 Docker。启用前验证文件下载、保存回调和中文字体,并说明工作副本会进入
|
||||
所选服务的处理范围。完全离线编辑不属于此部署方案。
|
||||
|
||||
### FR-13 Agent 修改可见性
|
||||
|
||||
右侧文档页签与 Agent 使用同一个编辑会话。请求处理中显示当前目标与执行状态;编辑器确认
|
||||
应用后,内容在当前页签直接更新,用户可定位并看到修改范围的临时高亮。状态提示和高亮不写入
|
||||
文档正文,执行完成不等于源文件已保存。用户可以继续手动编辑,并对该次 AI 修改撤销或重做。
|
||||
|
||||
实时可见以一次已确认的编辑事务为单位,不要求模拟逐字输入。失败、取消或结果未知应明确
|
||||
显示实际状态;提交前取消不修改文档,已应用的事务通过撤销恢复,结果未知时先核实再重试。
|
||||
|
||||
### FR-14 选型验证与授权
|
||||
|
||||
ONLYOFFICE 是统一承载 Word、Excel、PPT 的首选方案。正式采用前,必须在同一嵌入编辑会话中
|
||||
验证选区读取、人工修改反馈、Agent 写入、修改定位与高亮、撤销重做,以及保存后重新打开。
|
||||
验证目标见第 6 节,不能以编辑器可打开或转换成功代替 Agent 编辑验证。
|
||||
|
||||
优先验证 Automation API 与 GoodBuddy 编辑器插件的配合方式。根据
|
||||
[官方 Automation API 文档](https://api.onlyoffice.com/docs/docs-api/usage-api/automation-api/)
|
||||
(2026-09-28 核对),该接口仅适用于 Docs Developer,且需额外付费。选型需取得覆盖桌面嵌入、
|
||||
私有部署、并发连接、分发和所需接口的授权条件及报价;社区版容器部署成功不代表取得这些权益。
|
||||
插件独立接入可作为备选验证,不预先认定与 Automation API 等价或免除商业授权要求。
|
||||
|
||||
三类文件的实际接口覆盖、单次撤销、高亮方式、中文字体与复杂格式兼容性,以及授权成本均为
|
||||
待验证项。未通过的操作记录具体限制,再决定补充实现或调整范围;本 PRD 不表示已有可用实现。
|
||||
|
||||
## 5. 文件范围
|
||||
|
||||
首期可编辑格式:
|
||||
@@ -121,7 +158,22 @@ PDF 保持预览或文档处理用途,不进入首期 Office 编辑。宏格
|
||||
- 同一工作栏至少可以同时保留两个独立文档页签,切换不会串文件、选区或保存状态。
|
||||
- 同名文件的页签可辨认,页签标题和完整来源一致。
|
||||
- 保存失败、回调失败和源文件冲突均不会破坏原文件。
|
||||
- 每次 AI 修改可以单次撤销,拒绝或取消不会产生部分修改。
|
||||
- 每次已完成 AI 修改可以单次撤销和重做;拒绝或提交前取消不修改文档,应用后取消按 FR-13 处理。
|
||||
- ShareServer 不可用时已有本地文件仍可由默认应用打开,但不能伪装成内置编辑可用。
|
||||
- Renderer、编辑器页面和日志中均不存在本机绝对路径、ShareServer 管理密钥或长期文件令牌。
|
||||
- 文档会话过期后,旧启动 URL、文件 URL 和回调令牌均不可重用。
|
||||
|
||||
以下场景按 [用户故事](./user-stories.md) 的 `US-C1` 至 `US-C7`、`US-F1` 和 `US-F2` 验证;
|
||||
记录编辑器版本、授权版本、样本和实际结果,不把待测能力标记为已通过。
|
||||
|
||||
| 样本/场景 | 操作 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| Word | 改写选中段落 | 右侧原位更新,保留约定段落与文字格式,范围外内容不变 |
|
||||
| Excel | 修改指定区域公式并设为两位小数 | 公式重算与预期一致,显示格式正确,区域外内容不变 |
|
||||
| PPT | 修改指定页文本及一个对象属性 | 对象定位准确,非目标对象与布局不变 |
|
||||
| 人工修改反馈 | 人工修改后不保存,立即发起 AI 请求 | Agent 读取到会话内最新内容 |
|
||||
| 并行编辑 | Agent 思考期间用户修改文档 | 旧结果不覆盖新修改,支持基于最新内容重试 |
|
||||
| 共同编辑流程 | 应用、定位、高亮、撤销、重做、保存并重新打开 | 当前页签无需重载即可显示结果;保存后的文件内容与预期一致 |
|
||||
|
||||
文件兼容性使用包含中文字体、分页、表格、图表和公式的实际样本检查。目标平台的嵌入加载、
|
||||
焦点、快捷键与复制粘贴一并验收;接口验证和授权确认完成后再进入完整工作栏与生产部署实施。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Office 协同编辑用户故事
|
||||
|
||||
日期:2026-09-28。状态:需求设计,尚未完成实现与真实编辑器验证。
|
||||
|
||||
需求范围见 [PRD](./prd.md)。本轮补充 ONLYOFFICE 同会话编辑、右侧修改展示和选型验证用例。
|
||||
|
||||
## US-A1 打开本地文档
|
||||
|
||||
**Given** 用户已连接支持 Office 编辑的 ShareServer,并在工作区选择一个受支持文件
|
||||
@@ -45,11 +49,15 @@
|
||||
|
||||
## US-C1 使用 AI 修改选区
|
||||
|
||||
关联:`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 当前 Conversation 使用 Execute,用户在编辑器中选择受支持内容
|
||||
|
||||
**When** 用户提交 AI 修改要求
|
||||
|
||||
**Then** Agent 只接收有界选区和必要上下文,编辑器将完整结果作为一个事务应用并允许单次撤销。
|
||||
**Then** Agent 只接收当前会话的最新有界选区和必要上下文,右侧显示目标与处理状态;编辑器
|
||||
确认后原位更新、临时高亮并支持定位修改范围,无需重载。完整结果作为一个事务应用,可以
|
||||
单次撤销和重做;高亮不进入正文,完成状态不表示已写回源文件。
|
||||
|
||||
## US-C2 Ask 模式请求修改
|
||||
|
||||
@@ -60,6 +68,65 @@
|
||||
**Then** GoodBuddy 可以生成修改建议,但不会调用编辑器写入;界面提供切换 Execute 后重新应用
|
||||
的明确入口。
|
||||
|
||||
## US-C3 读取尚未保存的人工修改
|
||||
|
||||
关联:`FR-6`。
|
||||
|
||||
**Given** 用户已在右侧修改文档,但尚未保存到源文件
|
||||
|
||||
**When** 用户选择刚修改的内容并要求 Agent 继续编辑
|
||||
|
||||
**Then** Agent 基于当前编辑会话中的最新内容执行;磁盘旧文件和旧检查点不能替代本次上下文。
|
||||
|
||||
## US-C4 Agent 思考期间继续编辑
|
||||
|
||||
关联:`FR-6`、`FR-8`。
|
||||
|
||||
**Given** Agent 已读取选区,正在生成修改
|
||||
|
||||
**When** 用户继续修改文档,或切换到另一个文档页签与选区
|
||||
|
||||
**Then** 切换页签不改变原请求目标;原文档版本变化或目标失效时不应用旧结果,保留人工修改并
|
||||
提示基于最新内容重试。同一文档的其他页签也不能接收本次操作。源文件冲突仍按 `US-B2` 处理。
|
||||
|
||||
## US-C5 编辑 Word、Excel 和 PPT
|
||||
|
||||
关联:`FR-3`、`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 已分别准备 DOCX、XLSX 和 PPTX 样本,当前 Conversation 使用 Execute
|
||||
|
||||
**When** 用户分别请求改写选中段落、修改选区公式及两位小数格式、修改指定幻灯片文本和对象属性
|
||||
|
||||
**Then** 按下表验证实际结果;每个样本均完成定位、高亮、单次撤销、重做、保存及重新打开检查。
|
||||
|
||||
| 样本 | 验收结果 |
|
||||
| --- | --- |
|
||||
| DOCX | 目标段落内容符合要求,约定文字与段落格式保留,范围外内容不变 |
|
||||
| XLSX | 目标区域公式及重算值符合预期,数字显示为两位小数,区域外内容不变 |
|
||||
| PPTX | 指定页面和对象定位正确,文本与属性变更符合要求,非目标对象及布局不变 |
|
||||
|
||||
## US-C6 不支持、取消和执行失败
|
||||
|
||||
关联:`FR-6`、`FR-13`。
|
||||
|
||||
**Given** 用户发起一次 AI 编辑请求
|
||||
|
||||
**When** 操作不受支持、用户取消或编辑器未能确认结果
|
||||
|
||||
**Then** 不支持时返回具体限制;提交前取消不修改文档,已应用事务提供撤销。未知结果明确显示
|
||||
待核实,不自动重复写入,不以建议生成成功代替编辑成功;执行失败保留原因和实际已应用状态。
|
||||
|
||||
## US-C7 编辑状态与源文件保存
|
||||
|
||||
关联:`FR-4`、`FR-13`。
|
||||
|
||||
**Given** 右侧已显示 Agent 修改完成,用户又进行了一次人工编辑
|
||||
|
||||
**When** 用户保存文档并重新打开源文件
|
||||
|
||||
**Then** 保存包含已确认的 AI 修改与后续人工修改;只有源文件回写成功才显示“已写回源文件”。
|
||||
回调或回写失败时保留失败状态及可用检查点,不因右侧已经更新而宣称保存完成。
|
||||
|
||||
## US-D1 网络中断后恢复
|
||||
|
||||
**Given** 编辑会话已经建立
|
||||
@@ -83,3 +150,26 @@
|
||||
**When** 用户继续访问旧页签
|
||||
|
||||
**Then** 新读取、保存和 AI 操作被拒绝,旧令牌不能重用,本地源文件不受服务端清理影响。
|
||||
|
||||
## US-F1 启用集中部署的 Office 服务
|
||||
|
||||
关联:`FR-7`、`FR-12`、`FR-14`。
|
||||
|
||||
**Given** 部署方已准备独立的 ONLYOFFICE 服务及所需授权,用户已连接 ShareServer
|
||||
|
||||
**When** 用户首次在 Desktop 打开 Office 文档
|
||||
|
||||
**Then** 无需在用户电脑安装 Docker;打开前能看到工作副本进入哪个服务及保留期限。实际验证
|
||||
文件下载、保存回调和中文字体;在 Windows、macOS 和 Linux 检查加载、焦点、快捷键与复制粘贴。
|
||||
|
||||
## US-F2 决定正式采用 ONLYOFFICE
|
||||
|
||||
关联:`FR-14`。
|
||||
|
||||
**Given** 产品团队以 ONLYOFFICE Docs Developer 为首选,正在进行接口与授权验证
|
||||
|
||||
**When** 评审是否进入完整功能实施
|
||||
|
||||
**Then** 提供 `US-C1` 至 `US-C7` 的真实会话验证结果、三类文件样本兼容性记录,以及覆盖实际
|
||||
部署与嵌入方式的授权条件和报价。Automation API 单独确认额外费用;插件替代方案单独验证。
|
||||
未通过项明确列出限制与范围决定,仅安装容器、打开文档或完成转换不能视为实时编辑验证通过。
|
||||
|
||||
@@ -280,6 +280,44 @@
|
||||
Renderer 堆内存。
|
||||
- **验收:** 测试数据、运行步骤和原始结果可重复执行。
|
||||
|
||||
#### 2026-09-28 Measurement Evidence (Partial)
|
||||
|
||||
`npx vitest run tests/streaming-markdown.electron.test.ts` runs the real
|
||||
MarkdownRenderer in Electron 43.2.0 / Chromium 150.0.7871.129. It compares the
|
||||
original normalizer with fast paths that skip unchanged text, retaining live
|
||||
Markdown, math, code and HTML behavior. It also measures a 102,400-byte answer
|
||||
at six growing prefixes, alternating original/current execution order in the
|
||||
same page, with one warm-up sweep and three measured sweeps (18 samples each).
|
||||
|
||||
| Measurement | Original | Current |
|
||||
| --- | --- | --- |
|
||||
| 100KiB growing-prefix render median, isolated run | 54.80 ms | 54.65 ms |
|
||||
| Same run p95 | 95.50 ms | 106.10 ms |
|
||||
| Growing-prefix render median, full-suite run | 53.50 ms | 53.10 ms |
|
||||
| Same run p95 | 93.70 ms | 83.40 ms |
|
||||
|
||||
These development-mode measurements include synchronous React rendering, DOM
|
||||
commit and style/layout, but exclude frame waiting and paint. With 18 samples,
|
||||
nearest-rank p95 is the maximum sample. The results do not establish an overall
|
||||
rendering improvement. Smaller 6.4-7.2K-character corpora do show lower normalizer
|
||||
cost: prose median 33-34 microseconds to below 1 microsecond, and mixed math/code
|
||||
median 32-35 to 16-20 microseconds. The production changes remain limited to this
|
||||
preprocessing and memoizing unchanged subagent-derived collections. In the
|
||||
four-subagent regression, initial render plus six updates builds the lookup map
|
||||
once instead of seven times; block-dependent scans still run when blocks change.
|
||||
|
||||
Validation: 550 prefix normalization comparisons, 1,100 short-prefix DOM
|
||||
comparisons and 24 long-prefix DOM comparisons passed. The real Electron
|
||||
terminal-status probe covers cancellation, failure, retry and persistence via
|
||||
three loopback model-stub requests; the Mermaid viewer covers nine scenarios
|
||||
and ten PNG downloads. No external model calls were used for these probes.
|
||||
Full `npm test` passed 459 files / 5,397 tests, with 11 files / 84 tests skipped.
|
||||
Type checking and scoped lint passed. Full lint remains blocked by the existing
|
||||
untracked sidebar-final probe files.
|
||||
|
||||
This is partial evidence for PERF-03, not completion: full-App input latency,
|
||||
frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
|
||||
|
||||
### PERF-04 流式更新合并
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
|
||||
Generated
+329
-36
File diff suppressed because it is too large
Load Diff
+28
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -76,14 +76,22 @@
|
||||
"npmRebuild": false,
|
||||
"compression": "maximum",
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"out/main/**/*",
|
||||
"out/preload/**/*",
|
||||
"out/renderer/**/*",
|
||||
"package.json",
|
||||
"!node_modules/npm{,/**/*}"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": ".runtime-resources/dsh-${arch}/node_modules",
|
||||
"to": "runtimes/dsh/node_modules"
|
||||
"to": "runtimes/dsh/node_modules",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.{js,mjs,cjs,ts,mts,cts}.map",
|
||||
"!**/*.d.{ts,mts,cts}",
|
||||
"!**/*.pdb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "resources/skills",
|
||||
@@ -332,37 +340,20 @@
|
||||
"@opencode-ai/sdk": "1.18.29",
|
||||
"@vscode/ripgrep": "1.18.0",
|
||||
"@wecom/aibot-node-sdk": "^1.0.6",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-webgl": "0.19.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dingtalk-stream": "^2.1.6-beta.1",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.8.3",
|
||||
"fabric": "7.4.0",
|
||||
"html-to-text": "^10.0.0",
|
||||
"i18next": "^25.10.10",
|
||||
"json5": "^2.2.3",
|
||||
"jspdf": "4.2.1",
|
||||
"katex": "^0.16.47",
|
||||
"koffi": "3.1.4",
|
||||
"lucide-react": "^1.27.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.2.0-beta.15",
|
||||
"npm": "11.19.0",
|
||||
"onnxruntime-web": "^1.23.2",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"ppu-paddle-ocr": "^6.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sherpa-onnx": "1.13.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^7.29.0",
|
||||
@@ -387,17 +378,34 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-webgl": "0.19.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"electron": "^43.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"fabric": "7.4.0",
|
||||
"globals": "^17.8.0",
|
||||
"i18next": "^25.10.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"katex": "^0.16.47",
|
||||
"lucide-react": "^1.27.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"opencode-ai": "1.18.29",
|
||||
"png-to-ico": "^3.0.2",
|
||||
"pngjs": "^7.0.0",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tar": "7.5.22",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.15.3",
|
||||
"releasedAt": "2026-09-28",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**恢复直连模型手动压缩**:本地直连文本模型现在可使用“压缩上下文”,即使自动压缩已关闭或尚未达到触发阈值。摘要随会话保存并用于后续对话,同时保留最近轮次和完整历史;图像模型及远程直连入口不在本次范围内。",
|
||||
"**长请求不再丢失工具授权**:修复请求仍在执行时,知识、笔记、浏览器、配置及自定义 MCP 等工具在十分钟后失去访问权限的问题。授权随当前请求保持有效,取消或结束后仍会撤销,不改变 Ask 和 Execute 的权限边界。",
|
||||
"**区分取消和失败,避免重复提示**:请求取消或失败时保留已有回复,并只显示一次对应状态;取消采用中性提示,不再显示为错误。历史恢复保留终止状态,只有能恢复输入时才提供“重新编辑”。",
|
||||
"**减少聊天与笔记启动的重复处理**:置顶会话不再重复扫描附件,未变化的消息和附件引用不再重复写入,笔记启动复用正文检查结果。流式回复保留实时 Markdown、公式与代码显示,不以延迟富文本换取速度,也不宣称整体渲染耗时已显著下降。",
|
||||
"**侧栏搜索与快捷键提示更清楚**:“新建对话”和可展开搜索共用一行,应用与设置入口、分隔线和滚动条保持一致布局。输入框提示只显示已成功注册的快捷键,设置保存后及时更新,较晚返回的启动信息不会恢复已关闭的提示。",
|
||||
"**笔记和工作栏操作更紧凑**:笔记采集的按钮靠右排列,范围说明按需查看,输入校验贴近对应字段;窄窗口中的保存按钮不再拉伸。文件差异操作与路径分组,浏览器前往按钮使用图标并保留提示和可访问名称。",
|
||||
"**精简桌面安装包内容**:已打入界面产物的依赖不再重复作为运行依赖分发,DS Web 资源排除类型声明、调试映射和调试符号。保留运行资源与许可证,减少重复内容和散落文件,具体安装包大小以各平台发布资产为准。"
|
||||
],
|
||||
"notices": [
|
||||
"本次仅更新 Desktop,继续使用已发布的 Agent **0.15.0**,无需为本版重新安装 Agent。",
|
||||
"手动压缩会调用所选摘要模型,可能产生费用。数据库仍为 schema 48;从旧版升级前请备份完整数据库和相邻 `notes/` 目录。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Restore manual compaction for direct models**: Local direct text-model conversations can now use Compact context even when automatic compaction is disabled or its threshold has not been reached. Summaries are saved with the conversation and reused in subsequent requests while recent turns and full history remain available; image models and remote direct-model entry points are outside this change.",
|
||||
"**Keep tool access during long requests**: Fixes active requests losing access to knowledge, notes, browser, configuration, and custom MCP tools after ten minutes. Authorization now lasts for the current request and is still revoked on cancellation or completion, without changing Ask or Execute permission boundaries.",
|
||||
"**Distinguish cancellation from failure without duplicate notices**: Cancelled and failed requests retain existing replies and show a single terminal status; cancellation uses neutral feedback rather than an error. Restored history preserves that status, and Edit again is offered only when the input can be recovered.",
|
||||
"**Avoid redundant chat and note startup work**: Pinning conversations no longer rescans attachments, unchanged messages and attachment references avoid repeat writes, and note startup reuses body inspection results. Streaming replies retain live Markdown, math, and code rendering without delaying rich text or claiming a proven overall rendering speedup.",
|
||||
"**Clearer sidebar search and shortcut hints**: New conversation and expandable search share one row, with consistent Apps and Settings entries, dividers, and scrollbars. Composer hints show only successfully registered shortcuts, reflect saved settings, and cannot be restored by a late startup response after being disabled.",
|
||||
"**More compact note and workbar actions**: Note capture uses right-aligned actions, on-demand scope help, and field-specific validation; Save buttons no longer stretch in narrow windows. File-diff actions are grouped with their path, and the browser Go button uses an icon while retaining its tooltip and accessible name.",
|
||||
"**Trim desktop package contents**: Dependencies already bundled into the interface are no longer duplicated as runtime dependencies, and DS Web resources exclude type declarations, source maps, and debug symbols. Runtime assets and licenses remain included, reducing duplicate content and loose files; final sizes are listed with each platform's release assets."
|
||||
],
|
||||
"notices": [
|
||||
"This is a Desktop-only update and continues to use published Agent **0.15.0**. Reinstalling the Agent is not required for this release.",
|
||||
"Manual compaction calls the selected summary model and may incur charges. The database remains on schema 48; back up the complete database and adjacent `notes/` directory before upgrading from older versions."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.2",
|
||||
"releasedAt": "2026-09-28",
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('Obsidian gateway', () => {
|
||||
gateway.revokeObsidianCapabilities()
|
||||
gateway.revokeObsidianCapabilities()
|
||||
for (const revoked of [token, writeToken, mixedToken]) {
|
||||
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired')
|
||||
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('Tool authorization is unavailable')
|
||||
}
|
||||
expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search'])
|
||||
const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined,
|
||||
|
||||
@@ -199,6 +199,7 @@ const temporaryDirectories: string[] = []
|
||||
const httpServers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
@@ -663,13 +664,10 @@ describe('KnowledgeMcpGateway', () => {
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('creates no capability for empty scope and rejects revoked, aborted, and expired capabilities', async () => {
|
||||
it('keeps long-running request capabilities valid until revoked or aborted', async () => {
|
||||
const { service } = createService()
|
||||
let now = 1_000
|
||||
const gateway = new KnowledgeMcpGateway(service, {
|
||||
capabilityTtlMs: 10,
|
||||
now: () => now
|
||||
})
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
const gateway = new KnowledgeMcpGateway(service)
|
||||
gateways.push(gateway)
|
||||
expect(
|
||||
gateway.grant('empty', [], new AbortController().signal)
|
||||
@@ -680,10 +678,12 @@ describe('KnowledgeMcpGateway', () => {
|
||||
[firstLibraryId],
|
||||
new AbortController().signal
|
||||
)!
|
||||
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
|
||||
await expect(gateway.search(revoked, { query: 'x' })).resolves.toHaveLength(1)
|
||||
gateway.revoke(revoked)
|
||||
await expect(
|
||||
gateway.search(revoked, { query: 'x' })
|
||||
).rejects.toThrow('unavailable or expired')
|
||||
).rejects.toThrow('Tool authorization is unavailable')
|
||||
|
||||
const abortController = new AbortController()
|
||||
const aborted = gateway.grant(
|
||||
@@ -694,21 +694,23 @@ describe('KnowledgeMcpGateway', () => {
|
||||
abortController.abort()
|
||||
await expect(
|
||||
gateway.search(aborted, { query: 'x' })
|
||||
).rejects.toThrow('unavailable or expired')
|
||||
).rejects.toThrow('Tool authorization is unavailable')
|
||||
|
||||
const expired = gateway.grant(
|
||||
'expired',
|
||||
const nextRequest = gateway.grant(
|
||||
'next-request',
|
||||
[firstLibraryId],
|
||||
new AbortController().signal
|
||||
)!
|
||||
now += 11
|
||||
expect(nextRequest).not.toBe(revoked)
|
||||
await expect(
|
||||
gateway.search(expired, { query: 'x' })
|
||||
).rejects.toThrow('unavailable or expired')
|
||||
gateway.search(nextRequest, { query: 'x' })
|
||||
).resolves.toHaveLength(1)
|
||||
clock.mockRestore()
|
||||
})
|
||||
|
||||
it('grants bounded global Magic Notes search without a knowledge scope', () => {
|
||||
it('keeps Magic Notes available during long requests without a knowledge scope', () => {
|
||||
const { service } = createService()
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
const searchMagicNotes = vi.fn(() => [
|
||||
{
|
||||
noteId: '00000000-0000-4000-8000-000000000701',
|
||||
@@ -754,6 +756,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
'read'
|
||||
)!
|
||||
|
||||
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
|
||||
expect(gateway.getAvailableToolNames(token)).toEqual([
|
||||
'note_list',
|
||||
'note_get',
|
||||
@@ -777,6 +780,9 @@ describe('KnowledgeMcpGateway', () => {
|
||||
noteIds: ['not-allowed']
|
||||
})
|
||||
).toThrow()
|
||||
gateway.revoke(token)
|
||||
expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable')
|
||||
clock.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => {
|
||||
@@ -980,6 +986,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
|
||||
it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => {
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
const upstreamAuthorizations: Array<string | undefined> = []
|
||||
const upstream = createServer(async (request, response) => {
|
||||
upstreamAuthorizations.push(request.headers.authorization)
|
||||
@@ -1081,6 +1088,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expect(JSON.stringify(listed)).not.toContain(
|
||||
`127.0.0.1:${address.port}`
|
||||
)
|
||||
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
|
||||
const result = await client.callTool({
|
||||
name: listed.tools[0]!.name,
|
||||
arguments: { value: 'hello' }
|
||||
@@ -1094,6 +1102,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
} finally {
|
||||
gateway.revoke(token)
|
||||
await client.close()
|
||||
clock.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -103,8 +103,6 @@ const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
|
||||
const CUSTOM_MCP_TIMEOUT_MS = 30_000
|
||||
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
||||
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
|
||||
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
|
||||
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
|
||||
|
||||
export {
|
||||
@@ -215,7 +213,6 @@ type Capability = {
|
||||
browserTabId?: BrowserTabId
|
||||
browserUsageLease?: BrowserTabUsageLease
|
||||
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
|
||||
expiresAt: number
|
||||
signal: AbortSignal
|
||||
brokerController: AbortController
|
||||
customMcpServers: readonly ResolvedMcpServer[]
|
||||
@@ -256,9 +253,7 @@ type DownstreamMcpSession = {
|
||||
|
||||
export type KnowledgeMcpGatewayOptions = {
|
||||
storyGraphService?: StoryGraphService
|
||||
capabilityTtlMs?: number
|
||||
maximumBodyBytes?: number
|
||||
now?: () => number
|
||||
magicNotesDatabase?: MagicNotesDatabase
|
||||
configService?: GoodBuddyConfigService
|
||||
obsidianService?: ObsidianService
|
||||
@@ -387,8 +382,6 @@ export class KnowledgeMcpGateway {
|
||||
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
|
||||
private readonly customMcpCleanups = new Set<Promise<void>>()
|
||||
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
|
||||
private readonly now: () => number
|
||||
private readonly capabilityTtlMs: number
|
||||
private readonly maximumBodyBytes: number
|
||||
private readonly magicNotesDatabase?: MagicNotesDatabase
|
||||
private readonly configService?: GoodBuddyConfigService
|
||||
@@ -410,18 +403,8 @@ export class KnowledgeMcpGateway {
|
||||
private readonly knowledgeService: KnowledgeService,
|
||||
options: KnowledgeMcpGatewayOptions = {}
|
||||
) {
|
||||
const ttl = options.capabilityTtlMs ?? DEFAULT_CAPABILITY_TTL_MS
|
||||
if (
|
||||
!Number.isSafeInteger(ttl) ||
|
||||
ttl < 1 ||
|
||||
ttl > MAX_CAPABILITY_TTL_MS
|
||||
) {
|
||||
throw new RangeError('Knowledge capability TTL is invalid')
|
||||
}
|
||||
this.capabilityTtlMs = ttl
|
||||
this.maximumBodyBytes =
|
||||
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
|
||||
this.now = options.now ?? Date.now
|
||||
this.storyGraphService = options.storyGraphService
|
||||
this.magicNotesDatabase = options.magicNotesDatabase
|
||||
this.configService = options.configService
|
||||
@@ -615,7 +598,6 @@ export class KnowledgeMcpGateway {
|
||||
private storeCapability(
|
||||
value: Omit<
|
||||
Capability,
|
||||
| 'expiresAt'
|
||||
| 'references'
|
||||
| 'removeAbortListener'
|
||||
| 'brokerController'
|
||||
@@ -628,10 +610,10 @@ export class KnowledgeMcpGateway {
|
||||
const abort = (): void => {
|
||||
this.revoke(token)
|
||||
}
|
||||
// Capabilities live with the request; elapsed time must not interrupt long runs.
|
||||
value.signal.addEventListener('abort', abort, { once: true })
|
||||
this.capabilities.set(token, {
|
||||
...value,
|
||||
expiresAt: this.now() + this.capabilityTtlMs,
|
||||
brokerController,
|
||||
references: new Map(),
|
||||
removeAbortListener: () => {
|
||||
@@ -713,13 +695,9 @@ export class KnowledgeMcpGateway {
|
||||
|
||||
private getCapability(token: string): Capability {
|
||||
const capability = this.capabilities.get(token)
|
||||
if (
|
||||
!capability ||
|
||||
capability.signal.aborted ||
|
||||
capability.expiresAt <= this.now()
|
||||
) {
|
||||
if (!capability || capability.signal.aborted) {
|
||||
this.revoke(token)
|
||||
throw new Error('Knowledge capability is unavailable or expired')
|
||||
throw new Error('Tool authorization is unavailable: the request has ended or the token is invalid')
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
@@ -796,7 +796,7 @@ describe('ModelAgentRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('manually compacts Continue history even when automatic compression is disabled', async () => {
|
||||
it.each(['continue', 'model'] as const)('manually compacts %s history even when automatic compression is disabled', async (provider) => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
new Response(createEventStream('手动压缩摘要'), {
|
||||
status: 200,
|
||||
@@ -831,7 +831,7 @@ describe('ModelAgentRuntime', () => {
|
||||
{
|
||||
requestId: '00000000-0000-4000-8000-000000000081',
|
||||
conversationId: '00000000-0000-4000-8000-000000000082',
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
runtimeSelection: { provider },
|
||||
history,
|
||||
historyMessageIds: [
|
||||
'00000000-0000-4000-8000-000000000083',
|
||||
@@ -850,7 +850,7 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(JSON.stringify(body.messages)).toContain('earlier question')
|
||||
expect(JSON.stringify(body.messages)).not.toContain('recent question')
|
||||
expect(outcome.result).toMatchObject({
|
||||
provider: 'continue',
|
||||
provider,
|
||||
strategy: 'goodbuddy-summary',
|
||||
compacted: true,
|
||||
contextCompressionState: {
|
||||
@@ -862,6 +862,29 @@ describe('ModelAgentRuntime', () => {
|
||||
summary: '手动压缩摘要'
|
||||
}
|
||||
})
|
||||
fetcher.mockResolvedValueOnce(new Response(createEventStream('next answer'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
}))
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'continue',
|
||||
history,
|
||||
historyMessageIds: [
|
||||
'00000000-0000-4000-8000-000000000083',
|
||||
'00000000-0000-4000-8000-000000000084',
|
||||
'00000000-0000-4000-8000-000000000085',
|
||||
'00000000-0000-4000-8000-000000000086'
|
||||
],
|
||||
contextCompressionState: outcome.result.contextCompressionState
|
||||
}, new AbortController().signal)) {
|
||||
expect(event.type).not.toBe('error')
|
||||
}
|
||||
const nextBody = fetcher.mock.calls[1]![1]!.body as string
|
||||
expect(nextBody).toContain('手动压缩摘要')
|
||||
expect(nextBody).toContain('recent question')
|
||||
expect(nextBody).not.toContain('earlier question')
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -2410,23 +2410,23 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
buildConversationSummaryHistory(state.summary)
|
||||
)
|
||||
: 0
|
||||
if (
|
||||
(!compression.settings.enabled && !options.force) ||
|
||||
history.length === 0
|
||||
) {
|
||||
if (history.length === 0) {
|
||||
return { request, compressed: false }
|
||||
}
|
||||
|
||||
const plan = planContextCompression({
|
||||
history: remainingHistory,
|
||||
prompt: requestPrompt,
|
||||
summaryTokens: currentSummaryTokens,
|
||||
settings: compression.settings,
|
||||
contextWindowTokens: compression.contextWindowTokens,
|
||||
allowCompressLatestTurn: options.allowCompressLatestTurn,
|
||||
effectiveTriggerTokens: options.effectiveTriggerTokens,
|
||||
triggerContextTokens: options.triggerContextTokens
|
||||
})
|
||||
const plan =
|
||||
!compression.settings.enabled && !options.force
|
||||
? undefined
|
||||
: planContextCompression({
|
||||
history: remainingHistory,
|
||||
prompt: requestPrompt,
|
||||
summaryTokens: currentSummaryTokens,
|
||||
settings: compression.settings,
|
||||
contextWindowTokens: compression.contextWindowTokens,
|
||||
allowCompressLatestTurn: options.allowCompressLatestTurn,
|
||||
effectiveTriggerTokens: options.effectiveTriggerTokens,
|
||||
triggerContextTokens: options.triggerContextTokens
|
||||
})
|
||||
if (!plan) {
|
||||
return state
|
||||
? {
|
||||
@@ -4153,8 +4153,11 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
) {
|
||||
throw new Error('当前模型连接不支持上下文摘要')
|
||||
}
|
||||
if (request.runtimeSelection.provider !== 'continue') {
|
||||
throw new Error('GoodBuddy 摘要压缩仅适用于 Continue Runtime')
|
||||
if (
|
||||
request.runtimeSelection.provider !== 'continue' &&
|
||||
request.runtimeSelection.provider !== 'model'
|
||||
) {
|
||||
throw new Error('GoodBuddy 摘要压缩仅适用于直连模型和 Continue Runtime')
|
||||
}
|
||||
|
||||
if (request.contextCompressionState) {
|
||||
@@ -4210,11 +4213,13 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
provider: 'continue',
|
||||
provider: request.runtimeSelection.provider,
|
||||
strategy: 'goodbuddy-summary',
|
||||
compacted,
|
||||
detail: compacted
|
||||
? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史'
|
||||
? request.runtimeSelection.provider === 'continue'
|
||||
? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史'
|
||||
: '已使用 GoodBuddy 摘要压缩较早的对话历史'
|
||||
: '当前对话没有可继续压缩的较早历史',
|
||||
...(conversationState
|
||||
? { contextCompressionState: conversationState }
|
||||
|
||||
@@ -4428,6 +4428,7 @@ describe('AssistantDatabase', () => {
|
||||
id: assistantMessageId,
|
||||
content: recoveredText,
|
||||
state: 'error',
|
||||
terminalStatus,
|
||||
runtimeChecklist: { source: 'opencode', items: [] },
|
||||
status: '远端请求已不存在'
|
||||
})
|
||||
@@ -4553,7 +4554,7 @@ describe('AssistantDatabase', () => {
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('rolls back a recovered event when its message update fails', async () => {
|
||||
it.each(['failed', 'cancelled'] as const)('rolls back and durably recovers a %s event without creating reply text', async (terminalStatus) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-remote-event-atomic-')
|
||||
)
|
||||
@@ -4617,7 +4618,7 @@ describe('AssistantDatabase', () => {
|
||||
event: {
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
message: '远程失败'
|
||||
}
|
||||
})
|
||||
@@ -4664,7 +4665,7 @@ describe('AssistantDatabase', () => {
|
||||
event: {
|
||||
requestId: taskId,
|
||||
type: 'error',
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
message: '远程失败'
|
||||
}
|
||||
})
|
||||
@@ -4687,17 +4688,24 @@ describe('AssistantDatabase', () => {
|
||||
expect(
|
||||
recovered.getConversation(conversationId).messages[1]
|
||||
).toMatchObject({
|
||||
content: '远程失败',
|
||||
content: '',
|
||||
state: 'error',
|
||||
terminalStatus,
|
||||
status: '远程失败'
|
||||
})
|
||||
expect(
|
||||
recovered.listTasks().find((task) => task.id === taskId)
|
||||
).toMatchObject({
|
||||
status: 'failed',
|
||||
status: terminalStatus,
|
||||
error: '远程失败'
|
||||
})
|
||||
recovered.close()
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
const message = reopened.getConversation(conversationId).messages[1]
|
||||
expect(message).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' })
|
||||
expect(message?.blocks?.some(block => block.type === 'text')).toBe(false)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('persists and arbitrates a FIFO conversation input queue', async () => {
|
||||
|
||||
@@ -290,6 +290,7 @@ type MessageMetadata = {
|
||||
createdAt?: number
|
||||
queueItemId?: string
|
||||
status?: string
|
||||
terminalStatus?: ConversationMessage['terminalStatus']
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
runtimeChecklist?: ConversationMessage['runtimeChecklist']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
@@ -1213,6 +1214,7 @@ function toConversationSnapshot(
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(message.created_at),
|
||||
state: message.state,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
status: metadata.status,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
@@ -1240,6 +1242,7 @@ function serializeConversationMessageMetadata(
|
||||
createdAt: message.createdAt,
|
||||
queueItemId: message.queueItemId,
|
||||
status: message.status,
|
||||
terminalStatus: message.terminalStatus,
|
||||
reasoning: message.reasoning,
|
||||
runtimeChecklist: message.runtimeChecklist,
|
||||
blocks: message.blocks,
|
||||
@@ -1568,15 +1571,14 @@ function reduceRecoveredAgentEvent(
|
||||
? ('cancelled' as const)
|
||||
: ('failed' as const)
|
||||
: ('interrupted' as const)
|
||||
const fallback =
|
||||
event.type === 'error' && !represented && !message.content
|
||||
? event.message
|
||||
: ''
|
||||
next = {
|
||||
...message,
|
||||
state: event.type === 'error' ? 'error' : 'complete',
|
||||
terminalStatus: event.type === 'error'
|
||||
? event.status === 'cancelled' ? 'cancelled' : 'failed'
|
||||
: undefined,
|
||||
status:
|
||||
event.type === 'error' && !represented
|
||||
event.type === 'error' && (event.status === 'cancelled' || !represented)
|
||||
? event.message
|
||||
: undefined,
|
||||
contextCompression:
|
||||
@@ -1620,10 +1622,9 @@ function reduceRecoveredAgentEvent(
|
||||
: subagent
|
||||
),
|
||||
blocks: terminalizeRecoveredToolBlocks(
|
||||
appendRecoveredMessageBlock(message.blocks, 'text', fallback),
|
||||
message.blocks,
|
||||
toolState
|
||||
),
|
||||
content: fallback || message.content
|
||||
)
|
||||
}
|
||||
}
|
||||
return conversationMessageSchema.parse(next)
|
||||
@@ -1887,7 +1888,7 @@ export class AssistantDatabase {
|
||||
return JSON.stringify({ storage: 'file', version: content.version, revision })
|
||||
}
|
||||
|
||||
private reconcileMagicNoteFiles(noteId?: string): void {
|
||||
private reconcileMagicNoteFiles(noteId?: string, inspectedAssets?: ReadonlySet<string>): void {
|
||||
const database = this.database!
|
||||
// SQL and authoritative bodies are already committed. Cleanup must never
|
||||
// turn an accepted save/delete/reset into a retryable UI write failure.
|
||||
@@ -1900,7 +1901,7 @@ export class AssistantDatabase {
|
||||
else this.noteStorage.clear()
|
||||
} else if (database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(noteId)) {
|
||||
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(noteId) as Array<{ id: string }>
|
||||
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id))
|
||||
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id), inspectedAssets)
|
||||
} else {
|
||||
this.noteStorage.deleteNote(noteId)
|
||||
}
|
||||
@@ -1928,6 +1929,7 @@ export class AssistantDatabase {
|
||||
let processed = 0
|
||||
let migrated = false
|
||||
for (const note of notes) {
|
||||
let referencedAssets: Set<string> | undefined = new Set<string>()
|
||||
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(note.id) as Array<{ id: string }>
|
||||
for (const { id } of entries) {
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
@@ -1936,6 +1938,7 @@ export class AssistantDatabase {
|
||||
// One payload at a time: a canvas may contain a large PDF.
|
||||
const row = database.prepare('SELECT * FROM magic_note_entries WHERE id = ?').get(id) as MagicNoteEntryRow
|
||||
const pointer = JSON.parse(row.content_json) as { storage?: string; revision?: number }
|
||||
const inspected = pointer.storage === 'file' ? this.noteStorage.inspect(note.id, id) : undefined
|
||||
if (pointer.storage !== 'file') {
|
||||
const content = JSON.parse(row.content_json) as MagicNoteContent
|
||||
this.noteStorage.write({ noteId: note.id, id, content, revision: row.revision, updatedAt: row.updated_at })
|
||||
@@ -1947,7 +1950,7 @@ export class AssistantDatabase {
|
||||
magicNotePlainText(content), magicNoteEmbeddedBytes(content), id
|
||||
)
|
||||
migrated = true
|
||||
} else if (this.noteStorage.revision(note.id, id) !== pointer.revision) {
|
||||
} else if (inspected!.revision !== pointer.revision) {
|
||||
const saved = this.noteStorage.read(note.id, id)
|
||||
database.prepare(`UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ?,
|
||||
revision = MAX(revision, ?), updated_at = ?, comments_json = '[]', analyzed_at = NULL WHERE id = ?`).run(
|
||||
@@ -1957,6 +1960,9 @@ export class AssistantDatabase {
|
||||
database.prepare('UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?').run(saved.updatedAt, note.id)
|
||||
this.syncMagicNoteTodos(database, note.id, id, saved.content, saved.updatedAt)
|
||||
}
|
||||
const assets = (inspected ?? this.noteStorage.inspect(note.id, id)).referencedAssets
|
||||
if (!assets) referencedAssets = undefined
|
||||
else if (referencedAssets) for (const asset of assets) referencedAssets.add(asset)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -1965,7 +1971,7 @@ export class AssistantDatabase {
|
||||
processed++
|
||||
if (migrated) onProgress?.({ stage: 'converting', processed, total, bytesBefore })
|
||||
}
|
||||
this.reconcileMagicNoteFiles(note.id)
|
||||
this.reconcileMagicNoteFiles(note.id, referencedAssets)
|
||||
}
|
||||
if (!noteIds) this.reconcileMagicNoteFiles()
|
||||
this.dirtyMagicNotes.clear()
|
||||
@@ -5443,6 +5449,7 @@ export class AssistantDatabase {
|
||||
state: row.state,
|
||||
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
@@ -5858,6 +5865,7 @@ export class AssistantDatabase {
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(row.created_at),
|
||||
status: metadata.status,
|
||||
terminalStatus: metadata.terminalStatus,
|
||||
reasoning: metadata.reasoning,
|
||||
runtimeChecklist: metadata.runtimeChecklist,
|
||||
blocks: metadata.blocks,
|
||||
|
||||
@@ -2,13 +2,147 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DatabaseSync, StatementSync } from 'node:sqlite'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
|
||||
|
||||
describe('conversation attachment files and references', () => {
|
||||
it.each([false, true])('measures repeated references with an attachment: %s', async (populated) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
try {
|
||||
const conversation = randomUUID(), id = randomUUID()
|
||||
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
|
||||
if (populated) storage.save(context, JSON.stringify(context), Buffer.from('source'))
|
||||
const ids = populated ? [id] : []
|
||||
storage.reference(conversation, 'draft', conversation, ids)
|
||||
const get = vi.spyOn(StatementSync.prototype, 'get')
|
||||
const all = vi.spyOn(StatementSync.prototype, 'all')
|
||||
const run = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
let counts: { selects: number; writes: number; transactions: number }
|
||||
try {
|
||||
for (let index = 0; index < 100; index++) storage.reference(conversation, 'draft', conversation, ids)
|
||||
counts = {
|
||||
selects: get.mock.calls.length + all.mock.calls.length,
|
||||
writes: run.mock.calls.length,
|
||||
transactions: exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE').length
|
||||
}
|
||||
} finally { get.mockRestore(); all.mockRestore(); run.mockRestore(); exec.mockRestore() }
|
||||
const durations: number[] = []
|
||||
for (let sample = 0; sample < 3; sample++) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < 1000; index++) storage.reference(conversation, 'draft', conversation, ids)
|
||||
durations.push(Number((performance.now() - start).toFixed(3)))
|
||||
}
|
||||
console.info(JSON.stringify({ populated, countedCalls: 100, ...counts, callsPerSample: 1000, durationsMs: durations }))
|
||||
expect(counts.writes).toBe(0)
|
||||
expect(counts.transactions).toBe(0)
|
||||
expect(storage.draft(conversation)).toEqual(populated ? [context] : [])
|
||||
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it.each(['draft', 'message', 'queue', 'parsing'] as const)('preserves ordered replacement, ownership and cleanup for %s references', async (kind) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
|
||||
try {
|
||||
const conversation = randomUUID(), otherConversation = randomUUID(), owner = conversation
|
||||
const ids = [randomUUID(), randomUUID(), randomUUID()] as const
|
||||
for (const id of ids) {
|
||||
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
|
||||
storage.save(context, JSON.stringify(context), Buffer.from(id))
|
||||
}
|
||||
const refs = () => database.prepare('SELECT attachment_id, conversation_id FROM attachment_refs WHERE kind = ? AND owner_id = ? ORDER BY rowid').all(kind, owner)
|
||||
const expected = (ordered: string[], conversationId = conversation) => ordered.map((id) => ({ attachment_id: id, conversation_id: conversationId }))
|
||||
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1]])
|
||||
expect(refs()).toEqual(expected(ids.slice(0, 2)))
|
||||
expect(storage.original(ids[0]).path).toContain(join('conversation-assets', conversation, 'documents', ids[0]))
|
||||
await expect(readFile(join(root, 'temp', 'document-parsing', ids[0], 'original.txt'))).rejects.toThrow()
|
||||
const get = vi.spyOn(StatementSync.prototype, 'get')
|
||||
const run = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
try {
|
||||
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1], 'missing'])
|
||||
expect(get.mock.calls.map(([id]) => id)).toEqual([ids[0], 'missing', ids[1]])
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(exec).not.toHaveBeenCalled()
|
||||
} finally { get.mockRestore(); run.mockRestore(); exec.mockRestore() }
|
||||
|
||||
storage.reference(conversation, kind, owner, [ids[1], ids[0]])
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]]))
|
||||
if (kind === 'draft') expect(storage.draft(owner).map(({ id }) => id)).toEqual([ids[1], ids[0]])
|
||||
storage.reference(otherConversation, kind, owner, [ids[1], ids[0]])
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
|
||||
storage.deleteConversation(conversation, ids)
|
||||
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
|
||||
|
||||
storage.reference(otherConversation, 'message', 'shared-owner', [ids[0]])
|
||||
storage.reference(otherConversation, kind, owner, [ids[2]])
|
||||
expect(refs()).toEqual(expected([ids[2]], otherConversation))
|
||||
storage.collect()
|
||||
expect(storage.has(ids[0])).toBe(true)
|
||||
expect(storage.has(ids[1])).toBe(false)
|
||||
expect(storage.original(ids[2]).data).toEqual(Buffer.from(ids[2]))
|
||||
storage.reference(otherConversation, kind, owner, ['missing'])
|
||||
expect(refs()).toEqual([])
|
||||
const emptyRun = vi.spyOn(StatementSync.prototype, 'run')
|
||||
const emptyExec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
try {
|
||||
storage.reference(otherConversation, kind, owner, [])
|
||||
storage.reference(otherConversation, kind, owner, ['missing', 'missing'])
|
||||
expect(emptyRun).not.toHaveBeenCalled()
|
||||
expect(emptyExec).not.toHaveBeenCalled()
|
||||
} finally { emptyRun.mockRestore(); emptyExec.mockRestore() }
|
||||
storage.collect()
|
||||
expect(storage.has(ids[2])).toBe(false)
|
||||
storage.deleteConversation(otherConversation)
|
||||
expect(storage.has(ids[0])).toBe(false)
|
||||
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('promotes temporary files even when references already match and rolls back failed replacements', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const storage = new ConversationAttachmentStorage(root, results)
|
||||
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
|
||||
try {
|
||||
const conversation = randomUUID(), first = randomUUID(), second = randomUUID()
|
||||
for (const id of [first, second]) {
|
||||
const context = { id, name: 'image.png', kind: 'image' as const, size: 6, preview: 'source' }
|
||||
storage.save(context, JSON.stringify(context), Buffer.from(id))
|
||||
}
|
||||
database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)').run(first, conversation, 'draft', conversation)
|
||||
database.exec(`CREATE TRIGGER reject_promotion BEFORE UPDATE OF path ON attachments
|
||||
BEGIN SELECT RAISE(ABORT, 'promotion failed'); END`)
|
||||
expect(() => storage.reference(conversation, 'draft', conversation, [first])).toThrow('promotion failed')
|
||||
expect(storage.original(first).path).toContain(join('temp', 'document-parsing', first))
|
||||
expect(storage.original(first).data).toEqual(Buffer.from(first))
|
||||
database.exec('DROP TRIGGER reject_promotion')
|
||||
storage.reference(conversation, 'draft', conversation, [first, first])
|
||||
expect(storage.original(first).path).toContain(join('conversation-assets', conversation, 'images', first))
|
||||
expect(storage.original(first).data).toEqual(Buffer.from(first))
|
||||
await expect(readFile(join(root, 'temp', 'document-parsing', first, 'original.png'))).rejects.toThrow()
|
||||
|
||||
database.exec(`CREATE TRIGGER reject_reference BEFORE INSERT ON attachment_refs
|
||||
WHEN NEW.attachment_id = '${second}' BEGIN SELECT RAISE(ABORT, 'reference failed'); END`)
|
||||
expect(() => storage.reference(conversation, 'draft', conversation, [first, second])).toThrow('reference failed')
|
||||
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([first])
|
||||
// Promotion precedes the reference transaction and remains durable after its rollback.
|
||||
expect(storage.original(second).path).toContain(join('conversation-assets', conversation, 'images', second))
|
||||
expect(storage.original(second).data).toEqual(Buffer.from(second))
|
||||
database.exec('DROP TRIGGER reject_reference')
|
||||
storage.reference(conversation, 'draft', conversation, [second, first])
|
||||
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([second, first])
|
||||
expect(database.prepare('PRAGMA integrity_check').get()).toMatchObject({ integrity_check: 'ok' })
|
||||
expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('retains interrupted originals without turning them into sendable draft attachments', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
|
||||
let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
|
||||
@@ -117,7 +117,17 @@ export class ConversationAttachmentStorage {
|
||||
}
|
||||
|
||||
reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void {
|
||||
const assets = ids.filter((id) => this.has(id)).map((id) => this.row(id))
|
||||
const assets: AssetRow[] = []
|
||||
for (const id of new Set(ids)) {
|
||||
const asset = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
|
||||
if (asset) assets.push(asset)
|
||||
}
|
||||
const refs = this.database.prepare(`SELECT attachment_id, conversation_id FROM attachment_refs
|
||||
WHERE kind = ? AND owner_id = ? ORDER BY rowid`).all(kind, ownerId) as { attachment_id: string; conversation_id: string }[]
|
||||
// Draft order is visible, and matching references can still point to temporary files.
|
||||
if (refs.length === assets.length && assets.every((asset, index) =>
|
||||
refs[index]?.attachment_id === asset.id && refs[index]?.conversation_id === conversationId && !asset.path.startsWith('temp')
|
||||
)) return
|
||||
for (const asset of assets) {
|
||||
if (!asset.path.startsWith(`temp`)) continue
|
||||
const context = JSON.parse(asset.metadata) as ContextAttachment
|
||||
|
||||
@@ -1446,8 +1446,6 @@ if (hasSingleInstanceLock) {
|
||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal)
|
||||
},
|
||||
// This dedicated gateway lives until native-client disposal, not a chat request timeout.
|
||||
now: () => 0,
|
||||
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
|
||||
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
|
||||
})
|
||||
|
||||
+175
-2
@@ -33,6 +33,8 @@ import type {
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
import { ImageGenerationService } from './agent/image-generation-service'
|
||||
import { ApplicationSettingsStore } from './application-settings-store'
|
||||
import * as desktopNotification from './desktop-notification'
|
||||
@@ -518,7 +520,8 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
let browserStateListener:
|
||||
| ((state: BrowserLiveState) => void)
|
||||
| undefined
|
||||
const shortcutSnapshot = {
|
||||
const shortcutSnapshot: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = {
|
||||
platform: 'win32',
|
||||
settings: {
|
||||
enabled: true,
|
||||
accelerator: 'Control+Alt+K'
|
||||
@@ -607,6 +610,18 @@ describe('registerIpcHandlers computer capabilities', () => {
|
||||
shortcut: 'Ctrl + Alt + K',
|
||||
shortcutStatus: 'registered'
|
||||
})
|
||||
for (const status of ['disabled', 'conflict', 'failed'] as const) {
|
||||
shortcutSettingsService.getSnapshot.mockReturnValueOnce({
|
||||
...shortcutSnapshot,
|
||||
registered: false,
|
||||
registeredAccelerator: undefined,
|
||||
status
|
||||
})
|
||||
expect(electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({
|
||||
shortcut: '',
|
||||
shortcutStatus: status
|
||||
})
|
||||
}
|
||||
expect(
|
||||
electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
|
||||
event
|
||||
@@ -4521,6 +4536,164 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each(['current', 'profile', 'image', 'missing'] as const)(
|
||||
'routes direct model manual compaction using %s selection', async (source) => {
|
||||
const profile = {
|
||||
id: crypto.randomUUID(), name: 'Selected', baseUrl: 'https://models.example',
|
||||
modelName: 'selected-text', protocol: 'anthropic-messages', authentication: 'none'
|
||||
}
|
||||
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'default-text' }
|
||||
const summary = { ...profile, id: crypto.randomUUID(), modelName: 'summary-text' }
|
||||
const selection = { provider: 'model' as const, profileId: source === 'missing' ? crypto.randomUUID() : profile.id }
|
||||
const settings = {
|
||||
provider: 'model', workspacePath: 'C:\\Workspace', defaultModelProfileId: fallback.id,
|
||||
modelProfiles: [fallback, { ...profile, protocol: source === 'image' ? 'openai-images-generations' : profile.protocol }, summary],
|
||||
continueModelProfile: fallback,
|
||||
contextCompression: { enabled: false, modelSource: source === 'profile' ? { kind: 'profile', profileId: summary.id } : { kind: 'current' } }
|
||||
}
|
||||
const messages = [
|
||||
{ id: crypto.randomUUID(), role: 'user', content: 'earlier question', state: 'complete' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: 'earlier answer', state: 'complete' }
|
||||
]
|
||||
const conversationId = crypto.randomUUID()
|
||||
const database = {
|
||||
queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getConversation: vi.fn(() => ({ id: conversationId, runtimeSelection: selection, messages })),
|
||||
createTask: vi.fn(), updateTaskStatus: vi.fn()
|
||||
}
|
||||
const compactor = {
|
||||
compactConversation: vi.fn(async () => ({ result: {
|
||||
provider: 'model', strategy: 'goodbuddy-summary', compacted: true, detail: 'Compacted'
|
||||
} })),
|
||||
dispose: vi.fn(async () => {})
|
||||
}
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(compactor)
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
||||
}
|
||||
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never, {} as never, 'CommandOrControl+Shift+Space',
|
||||
{ getResolvedSettings: vi.fn(async () => settings) } as never,
|
||||
{} as never, { clear: vi.fn() } as never, {} as never, database as never,
|
||||
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
|
||||
)
|
||||
try {
|
||||
const result = electronMocks.handlers.get(ipcChannels.agentCompactConversation)?.(
|
||||
{ sender: webContents, senderFrame: webContents.mainFrame },
|
||||
{ requestId: crypto.randomUUID(), conversationId, runtimeSelection: selection,
|
||||
history: messages.map(({ role, content }) => ({ role, content })),
|
||||
historyMessageIds: messages.map(({ id }) => id) }
|
||||
)
|
||||
if (source === 'image' || source === 'missing') {
|
||||
await expect(result).rejects.toThrow()
|
||||
expect(runtimeFactoryMocks.createModelProfileRuntime).not.toHaveBeenCalled()
|
||||
} else {
|
||||
await expect(result).resolves.toMatchObject({ provider: 'model', compacted: true })
|
||||
expect(runtimeFactoryMocks.createModelProfileRuntime).toHaveBeenCalledWith(
|
||||
'C:\\Workspace', expect.objectContaining({ defaultModelProfileId: profile.id }),
|
||||
source === 'profile' ? summary : profile
|
||||
)
|
||||
expect(compactor.dispose).toHaveBeenCalledOnce()
|
||||
expect(database.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'completed')
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('pins and unpins without attachment reconciliation or import checks', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-'))
|
||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
||||
const assets = new ConversationAttachmentStorage(root, results)
|
||||
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
|
||||
try {
|
||||
database.initialize(root)
|
||||
const conversationId = '00000000-0000-4000-8000-000000000301'
|
||||
const messageId = '00000000-0000-4000-8000-000000000302'
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, title: 'Pin regression', updatedAt: 1 },
|
||||
messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }]
|
||||
}])
|
||||
const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original'))
|
||||
assets.reference(conversationId, 'draft', conversationId, [assetId])
|
||||
assets.reference(conversationId, 'message', messageId, [assetId])
|
||||
const before = database.getConversation(conversationId)
|
||||
const reconcile = vi.spyOn(assets, 'reconcile')
|
||||
const collect = vi.spyOn(assets, 'collect')
|
||||
const hasOwner = vi.spyOn(database, 'hasAttachmentOwner')
|
||||
const contextManager = {
|
||||
assets,
|
||||
activeContextIds: vi.fn(() => []),
|
||||
cancelUnavailableImport: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
const webContents = {
|
||||
on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents, isDestroyed: vi.fn(() => false),
|
||||
on: vi.fn(), removeListener: vi.fn()
|
||||
}
|
||||
dispose = registerIpcHandlers(
|
||||
window as never, { capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
||||
contextManager as never, {} as never, database,
|
||||
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
|
||||
)
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)!
|
||||
expect(pin).toBeTypeOf('function')
|
||||
webContents.send.mockClear()
|
||||
for (const pinned of [true, true, false]) {
|
||||
expect(pin(event, { conversationId, pinned })).toBeUndefined()
|
||||
expect(database.getConversation(conversationId)).toEqual({ ...before, pinned })
|
||||
}
|
||||
expect(webContents.send.mock.calls).toEqual([
|
||||
[ipcChannels.conversationsChanged],
|
||||
[ipcChannels.conversationsChanged],
|
||||
[ipcChannels.conversationsChanged]
|
||||
])
|
||||
expect({
|
||||
reconciliations: reconcile.mock.calls.length,
|
||||
ownerChecks: hasOwner.mock.calls.length,
|
||||
collections: collect.mock.calls.length,
|
||||
activeContextReads: contextManager.activeContextIds.mock.calls.length,
|
||||
importChecks: contextManager.cancelUnavailableImport.mock.calls.length
|
||||
}).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 })
|
||||
expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId])
|
||||
expect(assets.get(assetId).parsingState).toBe('parsing')
|
||||
expect(assets.original(assetId).data).toEqual(Buffer.from('original'))
|
||||
|
||||
webContents.send.mockClear()
|
||||
expect(() => pin(event, { conversationId, pinned: 'yes' })).toThrow()
|
||||
expect(() => pin({ ...event, sender: {} }, { conversationId, pinned: true })).toThrow()
|
||||
expect(() => pin(event, {
|
||||
conversationId: '00000000-0000-4000-8000-000000000399', pinned: true
|
||||
})).toThrow()
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
expect(database.getConversation(conversationId)).toEqual(before)
|
||||
|
||||
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
||||
expect(collect).toHaveBeenCalledOnce()
|
||||
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
||||
expect(assets.has(assetId)).toBe(false)
|
||||
} finally {
|
||||
await dispose?.()
|
||||
assets.close()
|
||||
await results.close()
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000301'
|
||||
@@ -8950,7 +9123,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
||||
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
||||
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
||||
createGateway: () => new KnowledgeMcpGateway({} as never, { now: () => 0 }),
|
||||
createGateway: () => new KnowledgeMcpGateway({} as never),
|
||||
openExternal: async (url) => { opened.push(url) }
|
||||
})
|
||||
harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
|
||||
|
||||
+16
-6
@@ -4739,7 +4739,8 @@ export function registerIpcHandlers(
|
||||
const request = runtimeConversationCompactInputSchema.parse(input)
|
||||
if (
|
||||
request.runtimeSelection.provider !== 'opencode' &&
|
||||
request.runtimeSelection.provider !== 'continue'
|
||||
request.runtimeSelection.provider !== 'continue' &&
|
||||
request.runtimeSelection.provider !== 'model'
|
||||
) {
|
||||
throw new Error('当前 Runtime 不支持手动压缩')
|
||||
}
|
||||
@@ -4791,6 +4792,12 @@ export function registerIpcHandlers(
|
||||
settings,
|
||||
request.runtimeSelection
|
||||
)
|
||||
if (
|
||||
request.runtimeSelection.provider === 'model' &&
|
||||
!isAgentRuntimeModelProtocol(selected.settings.modelProtocol)
|
||||
) {
|
||||
throw new Error('当前模型连接不支持上下文摘要')
|
||||
}
|
||||
if (project?.executionSpace?.kind === 'ssh') {
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
@@ -4844,7 +4851,12 @@ export function registerIpcHandlers(
|
||||
(candidate) =>
|
||||
candidate.id === compressionSource.profileId
|
||||
)
|
||||
: selected.settings.continueModelProfile) ??
|
||||
: request.runtimeSelection.provider === 'model'
|
||||
? selected.settings.modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === selected.settings.defaultModelProfileId
|
||||
)
|
||||
: selected.settings.continueModelProfile) ??
|
||||
selected.settings.modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id ===
|
||||
@@ -4854,8 +4866,8 @@ export function registerIpcHandlers(
|
||||
selected.settings.modelProfiles.find((candidate) =>
|
||||
isAgentRuntimeModelProtocol(candidate.protocol)
|
||||
)
|
||||
if (!profile) {
|
||||
throw new Error('没有可用于 Continue 上下文摘要的文本模型连接')
|
||||
if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||
throw new Error('没有可用于上下文摘要的文本模型连接')
|
||||
}
|
||||
if (
|
||||
profile.authentication === 'api-key' &&
|
||||
@@ -6397,8 +6409,6 @@ export function registerIpcHandlers(
|
||||
assistantDatabase.setConversationPinned(
|
||||
conversationSetPinnedSchema.parse(input)
|
||||
)
|
||||
contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds())
|
||||
if (contextManager.assets) contextManager.cancelUnavailableImport()
|
||||
publishConversationChange()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,12 @@ import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magi
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
const readFileSync = vi.fn(actual.readFileSync)
|
||||
return { ...actual, readFileSync, default: { ...actual, readFileSync } }
|
||||
})
|
||||
|
||||
const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync }
|
||||
|
||||
const directories: string[] = []
|
||||
@@ -16,6 +22,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const connection of connections.splice(0)) connection.close()
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
|
||||
vi.mocked(readFileSync).mockClear()
|
||||
})
|
||||
|
||||
function setup(options: ConstructorParameters<typeof AssistantDatabase>[1] = {}) {
|
||||
@@ -53,6 +60,36 @@ function canvas(size = 40): MagicNoteCanvasContent {
|
||||
}
|
||||
|
||||
describe('Magic note SQLite and filesystem storage', () => {
|
||||
it('measures current entry startup body reads on real SQLite and filesystem fixtures', () => {
|
||||
const { database, directory, sql } = setup()
|
||||
const paths: string[] = []
|
||||
for (let index = 0; index < 120; index++) {
|
||||
const content = index % 2 ? canvas() : structuredClone(rich)
|
||||
if (content.version === 1) content.ops.unshift({ insert: 'x'.repeat(32 * 1024) })
|
||||
else content.pages[0]!.objects.push({ type: 'IText', text: 'x'.repeat(32 * 1024) })
|
||||
const note = database.createMagicNote({ title: `Synthetic ${index}`, content })
|
||||
paths.push(join(directory, 'notes', note.id, 'entries', `${note.entries[0]!.id}.json`))
|
||||
}
|
||||
database.close()
|
||||
const bodyPaths = new Set(paths)
|
||||
const reads = vi.mocked(readFileSync)
|
||||
const durations: number[] = []
|
||||
const counts: number[] = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
reads.mockClear()
|
||||
const start = performance.now()
|
||||
database.initialize(directory)
|
||||
durations.push(Number((performance.now() - start).toFixed(2)))
|
||||
counts.push(reads.mock.calls.filter(([path]) => bodyPaths.has(String(path))).length)
|
||||
for (const path of paths) expect(reads.mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
|
||||
expect(reads.mock.calls.some(([path]) => String(path).includes(join(directory, 'notes')) && String(path).includes('assets'))).toBe(false)
|
||||
database.close()
|
||||
}
|
||||
console.info('Synthetic note startup', { entries: paths.length, bodyBytes: paths.reduce((sum, path) => sum + statSync(path).size, 0), counts, durationsMs: durations })
|
||||
expect(counts).toEqual(Array(5).fill(paths.length))
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
})
|
||||
|
||||
it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => {
|
||||
const { database, sql, path, directory } = setup()
|
||||
const created = database.createMagicNote({ title: 'Existing note', content: rich })
|
||||
@@ -77,6 +114,43 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
expect(database.getMagicNote(created.id)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => {
|
||||
const { database, directory } = setup()
|
||||
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
|
||||
const entries = database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' }).entries
|
||||
const root = join(directory, 'notes', note.id)
|
||||
const bodyPath = join(root, 'entries', `${entries[1]!.id}.json`)
|
||||
const original = readFileSync(bodyPath, 'utf8')
|
||||
const manifest = readFileSync(join(root, 'note.json'), 'utf8')
|
||||
writeFileSync(join(root, 'assets', 'unused.bin'), 'keep until inspection succeeds')
|
||||
const assets = readdirSync(join(root, 'assets')).map((name) => [name, readFileSync(join(root, 'assets', name))] as const)
|
||||
database.close()
|
||||
if (failure === 'missing') rmSync(bodyPath)
|
||||
else if (failure === 'invalid-json') writeFileSync(bodyPath, '{')
|
||||
else {
|
||||
const body = JSON.parse(original)
|
||||
body.content.pages = null
|
||||
writeFileSync(bodyPath, JSON.stringify(body))
|
||||
}
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
if (failure === 'invalid-media') {
|
||||
database.initialize(directory)
|
||||
expect(warning).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id }))
|
||||
} else expect(() => database.initialize(directory)).toThrow()
|
||||
for (const [name, bytes] of assets) expect(readFileSync(join(root, 'assets', name))).toEqual(bytes)
|
||||
expect(readFileSync(join(root, 'note.json'), 'utf8')).toBe(manifest)
|
||||
database.close()
|
||||
writeFileSync(bodyPath, original)
|
||||
vi.mocked(readFileSync).mockClear()
|
||||
database.initialize(directory)
|
||||
for (const entry of entries) {
|
||||
const path = join(root, 'entries', `${entry.id}.json`)
|
||||
expect(vi.mocked(readFileSync).mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
|
||||
}
|
||||
expect(database.getMagicNote(note.id).entries).toEqual(entries)
|
||||
expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => {
|
||||
const { database, sql, path, directory } = setup()
|
||||
const text = 'data:text/plain;base64,aGVsbG8'
|
||||
@@ -339,14 +413,20 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
const { database, sql, directory } = setup()
|
||||
const note = database.createMagicNote({ title: 'Repair', content: rich })
|
||||
const entry = note.entries[0]!
|
||||
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision,
|
||||
comments: [{ id: randomUUID(), kind: 'summary', content: 'Stale analysis' }] })
|
||||
const analyzed = database.getMagicNoteEntry(entry.id)
|
||||
sql.exec(`CREATE TRIGGER fail_note_update BEFORE UPDATE OF content_json ON magic_note_entries
|
||||
BEGIN SELECT RAISE(ABORT, 'index unavailable'); END`)
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
|
||||
expect(() => database.searchMagicNotes('PDF text', 10)).toThrow('index unavailable')
|
||||
sql.exec('DROP TRIGGER fail_note_update')
|
||||
if (reopen) { database.close(); database.initialize(directory) }
|
||||
expect(database.searchMagicNotes('PDF text', 10)).toHaveLength(1)
|
||||
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: 1 })
|
||||
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: analyzed.revision + 1, comments: [], analyzedAt: undefined })
|
||||
const assetNames = readdirSync(join(directory, 'notes', note.id, 'assets'))
|
||||
expect(assetNames).toHaveLength(2)
|
||||
expect(assetNames.some((name) => name.endsWith('.pdf'))).toBe(true)
|
||||
expect(database.listMagicTodos()).toEqual([expect.objectContaining({ title: 'Canvas task' })])
|
||||
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow('已被更新')
|
||||
})
|
||||
|
||||
@@ -75,8 +75,19 @@ export class MagicNoteStorage {
|
||||
return join(this.rootPath, noteId, 'entries', `${entryId}.json`)
|
||||
}
|
||||
|
||||
revision(noteId: string, entryId: string): number {
|
||||
return (JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry).revision
|
||||
inspect(noteId: string, entryId: string): { revision: number; referencedAssets?: Set<string> } {
|
||||
const entry = JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry
|
||||
const referencedAssets = new Set<string>()
|
||||
try {
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referencedAssets.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
} catch {
|
||||
// Let reconciliation retry failed media inspection through its deferred-cleanup path.
|
||||
return { revision: entry.revision }
|
||||
}
|
||||
return { revision: entry.revision, referencedAssets }
|
||||
}
|
||||
|
||||
write(entry: MagicNoteStoredEntry): void {
|
||||
@@ -108,18 +119,21 @@ export class MagicNoteStorage {
|
||||
return entry
|
||||
}
|
||||
|
||||
reconcile(noteId: string, entryIds: string[]): void {
|
||||
reconcile(noteId: string, entryIds: string[], inspectedAssets?: ReadonlySet<string>): void {
|
||||
const notePath = join(this.rootPath, noteId)
|
||||
const entriesPath = join(notePath, 'entries')
|
||||
const retained = new Set(entryIds.map((id) => `${id}.json`))
|
||||
const referenced = new Set<string>()
|
||||
// Read every retained body before deleting anything, including shared assets.
|
||||
for (const name of retained) {
|
||||
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
// Repair can supply references only after inspecting every retained body.
|
||||
// Otherwise read them all before deleting anything, including shared assets.
|
||||
if (!inspectedAssets) {
|
||||
for (const name of retained) {
|
||||
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
|
||||
mapMediaFields(entry.content, (value) => {
|
||||
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
this.atomicWrite(join(notePath, 'note.json'), JSON.stringify({ id: noteId, entries: entryIds }))
|
||||
if (existsSync(entriesPath)) {
|
||||
@@ -133,7 +147,7 @@ export class MagicNoteStorage {
|
||||
const assetsPath = join(notePath, 'assets')
|
||||
if (existsSync(assetsPath)) {
|
||||
for (const name of readdirSync(assetsPath)) {
|
||||
if (!referenced.has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
|
||||
if (!(inspectedAssets ?? referenced).has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,23 @@ describe('ShortcutSettingsService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['disabled', 'conflict', 'failed'] as const)('reports an unavailable shortcut at startup when %s', async (status) => {
|
||||
const fixture = createFixture({
|
||||
enabled: status !== 'disabled',
|
||||
accelerator: 'CommandOrControl+Shift+Space'
|
||||
})
|
||||
if (status === 'conflict') fixture.registry.register.mockReturnValueOnce(false)
|
||||
if (status === 'failed') fixture.registry.register.mockImplementationOnce(() => { throw new Error('Registration failed') })
|
||||
|
||||
await expect(fixture.service.initialize()).resolves.toMatchObject({
|
||||
registered: false,
|
||||
displayAccelerator: 'Ctrl+Shift+Space',
|
||||
status
|
||||
})
|
||||
expect(fixture.service.getSnapshot().registeredAccelerator).toBeUndefined()
|
||||
if (status === 'disabled') expect(fixture.registry.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'win32',
|
||||
|
||||
@@ -15,6 +15,18 @@ import { ActivityPanel } from './ActivityPanel'
|
||||
import { upsertActivityRecord, type ActivityRecord } from './activity-store'
|
||||
import i18n from './i18n'
|
||||
|
||||
const activityRenderProbe = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./WorkspacePrimitives', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./WorkspacePrimitives')>()
|
||||
return {
|
||||
...original,
|
||||
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
|
||||
if (props.headingId === 'activity-panel-title') activityRenderProbe()
|
||||
return <original.PageHeader {...props} />
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeRecord(
|
||||
index: number,
|
||||
status: ActivityRecord['status'] = 'completed'
|
||||
@@ -77,6 +89,37 @@ function makeTokenUsage(): TokenUsageSummary {
|
||||
}
|
||||
|
||||
describe('ActivityPanel', () => {
|
||||
it('skips unchanged hidden renders while retaining selection and accepting real updates', () => {
|
||||
const record = makeRecord(1, 'running')
|
||||
const props = { records: [record], tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() }
|
||||
const { rerender } = render(<div hidden={false}><ActivityPanel {...props} /></div>)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
|
||||
const node = screen.getByRole('button', { name: /活动 1,工具/u })
|
||||
fireEvent.click(node)
|
||||
const detail = screen.getByLabelText('选中的活动节点详情')
|
||||
activityRenderProbe.mockClear()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
rerender(<div hidden data-update={index}><ActivityPanel {...props} /></div>)
|
||||
}
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(0)
|
||||
const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() }
|
||||
rerender(<div hidden><ActivityPanel {...updated} /></div>)
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
|
||||
rerender(<div hidden={false}><ActivityPanel {...updated} /></div>)
|
||||
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail)
|
||||
expect(node).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(node).toHaveClass('activity-track__node--failed')
|
||||
expect(within(detail).getByText('Real update')).toBeInTheDocument()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: '打开所属对话' }))
|
||||
expect(updated.onOpenConversation).toHaveBeenCalledWith(record.conversationId)
|
||||
expect(props.onOpenConversation).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空 1 条活动记录' }))
|
||||
expect(updated.onClear).toHaveBeenCalledOnce()
|
||||
expect(props.onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens timeline guidance separately from tab navigation', () => {
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
@@ -239,7 +239,7 @@ function groupActivityRecordsByProject(
|
||||
})
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
export const ActivityPanel = memo(function ActivityPanel({
|
||||
projects = [],
|
||||
records,
|
||||
tokenUsage,
|
||||
@@ -1165,4 +1165,4 @@ export function ActivityPanel({
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
+292
-72
@@ -49,6 +49,18 @@ const speechRecognitionMocks = vi.hoisted(() => ({
|
||||
|
||||
const messageRenderProbe = vi.hoisted(() => vi.fn());
|
||||
const assistantTasksProbe = vi.hoisted(() => vi.fn());
|
||||
const activityRenderProbe = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
|
||||
return {
|
||||
...original,
|
||||
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
|
||||
if (props.headingId === "activity-panel-title") activityRenderProbe();
|
||||
return <original.PageHeader {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./use-unviewed-completions", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./use-unviewed-completions")>();
|
||||
@@ -1404,6 +1416,7 @@ describe("App", () => {
|
||||
const initialTasks = deferred<AssistantTask[]>();
|
||||
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "搜索对话" }));
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
fireEvent.change(search, { target: { value: "no matching conversation" } });
|
||||
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
|
||||
@@ -1552,6 +1565,7 @@ describe("App", () => {
|
||||
fireEvent.click(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务"));
|
||||
fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!);
|
||||
expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
|
||||
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
@@ -1824,6 +1838,7 @@ describe("App", () => {
|
||||
.mockResolvedValueOnce(second);
|
||||
const { container } = render(<App />);
|
||||
expect(await screen.findByText("Opened body")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
|
||||
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } });
|
||||
const list = within(container.querySelector<HTMLElement>(".conversation-list")!);
|
||||
fireEvent.click(await list.findByText("Unopened"));
|
||||
@@ -2019,11 +2034,11 @@ describe("App", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text",
|
||||
"Message GoodBuddy…\nEnter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access",
|
||||
);
|
||||
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
|
||||
"title",
|
||||
"Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text",
|
||||
"Enter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access",
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -2982,6 +2997,46 @@ describe("App", () => {
|
||||
expect(route).not.toHaveAttribute("hidden");
|
||||
});
|
||||
|
||||
it("isolates hidden Activity renders from composer and streaming updates", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Activity render isolation" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
const heading = await screen.findByRole("heading", { name: "运行记录" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "活动时间线" }));
|
||||
const node = document.querySelector<HTMLButtonElement>(".activity-track__node")!;
|
||||
fireEvent.click(node);
|
||||
const detail = screen.getByLabelText("选中的活动节点详情");
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
const route = heading.closest('[data-route="activity"]')!;
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
activityRenderProbe.mockClear();
|
||||
for (const value of ["a", "ab", "abc"]) {
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value } });
|
||||
}
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
activityRenderProbe.mockClear();
|
||||
for (const delta of ["Stream", " response", " updated"]) {
|
||||
act(() => agentListener?.({ requestId, type: "text", delta }));
|
||||
await screen.findByText(new RegExp(delta.trim(), "u"));
|
||||
}
|
||||
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
|
||||
act(() => agentListener?.({ requestId, type: "done" }));
|
||||
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
|
||||
expect(screen.getByRole("tab", { name: "活动时间线" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("选中的活动节点详情")).toBe(detail);
|
||||
expect(node).toHaveAttribute("aria-pressed", "true");
|
||||
expect(document.querySelectorAll(".activity-track__node")).toHaveLength(2);
|
||||
fireEvent.click(within(detail).getByRole("button", { name: "打开所属对话" }));
|
||||
expect(route).toHaveAttribute("hidden");
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("abc");
|
||||
});
|
||||
|
||||
it("keeps Settings outside the workspace KeepAlive cache", async () => {
|
||||
let now = 2_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => ++now);
|
||||
@@ -3053,11 +3108,23 @@ describe("App", () => {
|
||||
},
|
||||
]);
|
||||
const { container } = render(<App />);
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
const searchControls = within(search.parentElement!);
|
||||
expect(
|
||||
searchControls.queryByRole("button", { name: "清除搜索" }),
|
||||
).not.toBeInTheDocument();
|
||||
const trigger = await screen.findByRole("button", { name: "搜索对话" });
|
||||
expect(screen.queryByRole("textbox", { name: "搜索对话" })).not.toBeInTheDocument();
|
||||
const controls = trigger.closest(".sidebar-conversation-controls")!;
|
||||
const create = within(controls as HTMLElement).getByRole("button", { name: "新建对话" });
|
||||
expect(create).toHaveTextContent("新建对话");
|
||||
expect(create.querySelector(".lucide-message-square-plus")).not.toBeNull();
|
||||
fireEvent.click(trigger);
|
||||
let search = screen.getByRole("textbox", { name: "搜索对话" });
|
||||
expect(search).toHaveFocus();
|
||||
expect(create).toHaveAttribute("title", "新建对话");
|
||||
expect(create).toHaveTextContent(/^$/);
|
||||
expect(create.querySelector(".lucide-message-square-plus")).not.toBeNull();
|
||||
expect(search.closest(".sidebar-conversation-controls")).toBe(controls);
|
||||
expect(container.querySelector(".sidebar-conversations__header")).toBeNull();
|
||||
expect(search.closest(".sidebar-conversations")).toBeNull();
|
||||
expect(search.closest(".conversation-list")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "关闭并清除搜索" })).toBeInTheDocument();
|
||||
const conversationList =
|
||||
container.querySelector<HTMLElement>(".conversation-list");
|
||||
if (!conversationList) {
|
||||
@@ -3094,16 +3161,19 @@ describe("App", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const clearSearch = searchControls.getByRole("button", {
|
||||
name: "清除搜索",
|
||||
fireEvent.blur(search);
|
||||
fireEvent.click(within(conversationList).getByText("标题里的 Alpha"));
|
||||
expect(search).toHaveValue("ALPHA");
|
||||
expect(search).toBeInTheDocument();
|
||||
|
||||
const clearSearch = screen.getByRole("button", {
|
||||
name: "关闭并清除搜索",
|
||||
});
|
||||
clearSearch.focus();
|
||||
fireEvent.click(clearSearch);
|
||||
expect(search).toHaveValue("");
|
||||
expect(search).toHaveFocus();
|
||||
expect(
|
||||
searchControls.queryByRole("button", { name: "清除搜索" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(search).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus());
|
||||
expect(create).toHaveTextContent("新建对话");
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(conversationList).getByText("标题里的 Alpha"),
|
||||
@@ -3115,10 +3185,14 @@ describe("App", () => {
|
||||
within(conversationList).queryByText("其他项目里的 Alpha"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
|
||||
search = screen.getByRole("textbox", { name: "搜索对话" });
|
||||
expect(search).toHaveValue("");
|
||||
fireEvent.change(search, { target: { value: " " } });
|
||||
fireEvent.click(
|
||||
searchControls.getByRole("button", { name: "清除搜索" }),
|
||||
);
|
||||
fireEvent.keyDown(search, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus());
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
|
||||
search = screen.getByRole("textbox", { name: "搜索对话" });
|
||||
expect(search).toHaveValue("");
|
||||
|
||||
fireEvent.change(search, { target: { value: "missing topic" } });
|
||||
@@ -3139,6 +3213,9 @@ describe("App", () => {
|
||||
expect(
|
||||
await within(conversationList).findByText("标题里的 Alpha"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(create);
|
||||
await waitFor(() => expect(container.querySelector(".conversation-item--active")).toHaveTextContent("新对话"));
|
||||
expect(search).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Settings open when the interface language changes", async () => {
|
||||
@@ -3479,8 +3556,8 @@ describe("App", () => {
|
||||
),
|
||||
);
|
||||
expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument();
|
||||
expect(screen.getByText("快捷唤起:", { exact: false })).toHaveTextContent(
|
||||
"快捷唤起:Ctrl+Shift+Space",
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起"),
|
||||
);
|
||||
expect(screen.queryByText(/CommandOrControl/)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -5245,9 +5322,8 @@ describe("App", () => {
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("只读问答,不修改文件")).not.toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("快捷唤起:", { exact: false }),
|
||||
).toHaveTextContent("快捷唤起:Ctrl+Shift+Space");
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
|
||||
.toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起")));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "中".repeat(1_000) },
|
||||
@@ -5656,7 +5732,27 @@ describe("App", () => {
|
||||
await waitFor(() => expect(composer).toHaveFocus());
|
||||
});
|
||||
|
||||
it("updates and removes the composer shortcut hint immediately after saving Settings", async () => {
|
||||
it.each([
|
||||
["darwin", "Command", "Command+Alt+K"],
|
||||
["linux", "Ctrl", ""],
|
||||
])("formats composer shortcuts for %s using the actual available global shortcut", async (platform, modifier, shortcut) => {
|
||||
const info = await api.app.getInfo();
|
||||
vi.mocked(api.app.getInfo).mockResolvedValueOnce({ ...info, platform, shortcut });
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
await waitFor(() => expect(composer).toHaveAttribute("placeholder",
|
||||
`给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · ${modifier}+V 粘贴\n${modifier}+N 新建对话${shortcut ? ` · ${shortcut} 快捷唤起` : ""}`));
|
||||
expect(document.querySelector(".composer-meta__shortcut")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["loaded", "failed", "pending"] as const)("updates and removes the composer shortcut hint after saving Settings with %s app info", async (infoState) => {
|
||||
const initialInfo = await api.app.getInfo();
|
||||
const pendingInfo = deferred<typeof initialInfo>();
|
||||
if (infoState === "failed") {
|
||||
vi.mocked(api.app.getInfo).mockRejectedValueOnce(new Error("App info unavailable"));
|
||||
} else if (infoState === "pending") {
|
||||
vi.mocked(api.app.getInfo).mockReturnValueOnce(pendingInfo.promise);
|
||||
}
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
@@ -5718,7 +5814,12 @@ describe("App", () => {
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument();
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
await waitFor(() => expect(composer).toHaveAttribute("placeholder",
|
||||
`给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话${infoState === "loaded" ? " · Ctrl+Shift+Space 快捷唤起" : ""}`));
|
||||
expect(document.querySelector(".composer-meta__shortcut")).toBeNull();
|
||||
fireEvent.change(composer, { target: { value: "草稿" } });
|
||||
expect(composer).toHaveValue("草稿");
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
@@ -5734,7 +5835,9 @@ describe("App", () => {
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
|
||||
.toHaveAttribute("placeholder", expect.stringContaining("Ctrl+N 新建对话 · Ctrl+Alt+K 快捷唤起")));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("草稿");
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
@@ -5753,6 +5856,13 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
expect(screen.queryByText("Ctrl+Alt+K")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
|
||||
.toHaveAttribute("placeholder", "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话"));
|
||||
if (infoState === "pending") {
|
||||
await act(async () => pendingInfo.resolve(initialInfo));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute("placeholder",
|
||||
"给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话");
|
||||
}
|
||||
} finally {
|
||||
delete api.shortcuts;
|
||||
delete api.updates;
|
||||
@@ -5935,9 +6045,13 @@ describe("App", () => {
|
||||
expect(screen.getByText(toolError, { selector: ".tool-execution__error-preview" })).toBeVisible();
|
||||
expect(screen.queryByText(runtimeError)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.getByRole("button", { name: "重新编辑" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新编辑" }));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("读取演示文稿");
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "继续处理" },
|
||||
});
|
||||
@@ -5945,7 +6059,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.queryByRole("button", { name: "重新编辑" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -6204,7 +6318,7 @@ describe("App", () => {
|
||||
name: "选择知识库,本次已启用 1 个",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }));
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } });
|
||||
expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument();
|
||||
@@ -7732,7 +7846,7 @@ describe("App", () => {
|
||||
).toHaveTextContent(/^Ask$/u);
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本",
|
||||
"给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
|
||||
);
|
||||
expect(
|
||||
within(conversationSettings).getByRole("button", {
|
||||
@@ -8469,7 +8583,7 @@ describe("App", () => {
|
||||
screen.queryByText("上次运行意外中断,可以重新发送问题"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "重新编辑并发送" }),
|
||||
screen.queryByRole("button", { name: "重新编辑" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(api.conversationQueue.ready).not.toHaveBeenCalled();
|
||||
|
||||
@@ -9177,9 +9291,8 @@ describe("App", () => {
|
||||
});
|
||||
expect(mode).toBeEnabled();
|
||||
expect(mode.closest(".composer")).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText("快捷唤起:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
|
||||
.toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起")));
|
||||
selectComposerOption("工作模式", "Execute · 完全权限");
|
||||
expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限");
|
||||
expect(mode).toHaveTextContent(/^Execute$/u);
|
||||
@@ -9823,7 +9936,9 @@ describe("App", () => {
|
||||
expect(await screen.findByRole("tab", { name: /终端 1/ })).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("manually compacts Continue context and persists the summary state", async () => {
|
||||
it.each([
|
||||
["continue", false], ["model", false], ["model", true],
|
||||
] as const)("manually compacts %s context below threshold with auto compression %s and persists the summary state", async (provider, enabled) => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000731";
|
||||
const messages = [
|
||||
{
|
||||
@@ -9852,7 +9967,7 @@ describe("App", () => {
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
contextCompression: {
|
||||
enabled: true,
|
||||
enabled,
|
||||
triggerTokens: 20_000,
|
||||
recentRawTokens: 4_000,
|
||||
modelSource: { kind: "current" },
|
||||
@@ -9863,15 +9978,15 @@ describe("App", () => {
|
||||
{
|
||||
id: conversationId,
|
||||
projectId,
|
||||
runtimeSelection: { provider: "continue" },
|
||||
title: "Continue 长对话",
|
||||
runtimeSelection: { provider },
|
||||
title: "手动压缩对话",
|
||||
updatedAt: 1_775_000_000_001,
|
||||
messages,
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
|
||||
id: "continue",
|
||||
label: "Continue",
|
||||
id: provider,
|
||||
label: provider,
|
||||
available: true,
|
||||
supportsToolExecution: true,
|
||||
detail: "Ready",
|
||||
@@ -9902,10 +10017,10 @@ describe("App", () => {
|
||||
},
|
||||
);
|
||||
vi.mocked(api.agent.compactConversation).mockResolvedValueOnce({
|
||||
provider: "continue",
|
||||
provider,
|
||||
strategy: "goodbuddy-summary",
|
||||
compacted: true,
|
||||
detail: "已压缩 Continue 对话历史",
|
||||
detail: "已压缩对话历史",
|
||||
contextCompressionState: summaryState,
|
||||
});
|
||||
|
||||
@@ -9917,16 +10032,20 @@ describe("App", () => {
|
||||
expect(compactContext.parentElement).toHaveClass(
|
||||
"composer-meta__actions",
|
||||
);
|
||||
expect(compactContext.parentElement?.firstElementChild).toBe(
|
||||
screen.getByRole("button", { name: "在终端中打开" }),
|
||||
);
|
||||
if (provider === "continue") {
|
||||
expect(compactContext.parentElement?.firstElementChild).toBe(
|
||||
screen.getByRole("button", { name: "在终端中打开" }),
|
||||
);
|
||||
} else {
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).not.toHaveBeenCalled();
|
||||
}
|
||||
fireEvent.click(compactContext);
|
||||
await waitFor(() =>
|
||||
expect(api.agent.compactConversation).toHaveBeenCalledWith({
|
||||
requestId: expect.any(String),
|
||||
conversationId,
|
||||
projectId,
|
||||
runtimeSelection: { provider: "continue" },
|
||||
runtimeSelection: { provider },
|
||||
history: messages.map(({ role, content }) => ({
|
||||
role,
|
||||
content,
|
||||
@@ -9936,11 +10055,9 @@ describe("App", () => {
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("已压缩 Continue 对话历史"),
|
||||
await screen.findByText("已压缩对话历史"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/压缩后对话估算/u)).not.toHaveTextContent(
|
||||
"压缩线",
|
||||
);
|
||||
expect(await screen.findByText(/压缩后对话估算/u)).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
@@ -9954,6 +10071,48 @@ describe("App", () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "向 GoodBuddy 提问" }), {
|
||||
target: { value: "继续" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "发送" }));
|
||||
await waitFor(() => expect(api.agent.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ contextCompressionState: summaryState }),
|
||||
));
|
||||
expect(screen.getByRole("button", { name: "压缩上下文" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides manual compaction for the selected image profile even when the default is text", async () => {
|
||||
const settings = await api.settings.getRuntime();
|
||||
const imageProfile = {
|
||||
...settings.modelProfiles[0]!,
|
||||
id: "00000000-0000-4000-8000-000000000739",
|
||||
name: "Image model",
|
||||
protocol: "openai-images-generations" as const,
|
||||
};
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings, modelProfiles: [...settings.modelProfiles, imageProfile],
|
||||
});
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: "00000000-0000-4000-8000-000000000738", projectId,
|
||||
runtimeSelection: { provider: "model", profileId: imageProfile.id },
|
||||
title: "Image conversation", updatedAt: Date.now(), messages: [],
|
||||
}]);
|
||||
render(<App />);
|
||||
await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ provider: "model", profileId: imageProfile.id }));
|
||||
expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides direct model manual compaction for remote channel conversations", async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: "00000000-0000-4000-8000-000000000740", projectId,
|
||||
runtimeSelection: { provider: "model", profileId: modelProfileId },
|
||||
title: "Remote model conversation", updatedAt: Date.now(), messages: [],
|
||||
remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" },
|
||||
}]);
|
||||
render(<App />);
|
||||
await waitFor(() => expect(api.agent.getStatus).toHaveBeenCalledWith({ provider: "model", profileId: modelProfileId }));
|
||||
expect(screen.queryByRole("button", { name: "压缩上下文" })).not.toBeInTheDocument();
|
||||
expect(api.agent.compactConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the direct-model mode after leaving an Agent Runtime", async () => {
|
||||
@@ -10074,6 +10233,40 @@ describe("App", () => {
|
||||
expect(mode).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancelled", ""], ["cancelled", "Partial answer"],
|
||||
["failed", ""], ["failed", "Partial answer"],
|
||||
] as const)("preserves reply content and persists a single %s status (%s)", async (status, content) => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "Test terminal reply" },
|
||||
});
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
// Deliberately unrelated to cancellation copy: classification uses status.
|
||||
const detail = "Terminal event detail";
|
||||
act(() => {
|
||||
if (content) agentListener?.({ requestId, type: "text", delta: content });
|
||||
agentListener?.({ requestId, type: "error", status, message: detail });
|
||||
});
|
||||
const notice = await screen.findByRole(status === "cancelled" ? "status" : "alert", { name: detail });
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1);
|
||||
expect(notice.classList.contains("message__status--error")).toBe(status === "failed");
|
||||
const article = notice.closest("article")!;
|
||||
expect(article.querySelector(".message__content")?.textContent ?? "").toBe(content);
|
||||
await waitFor(() => {
|
||||
const saved = vi.mocked(api.conversations.saveLocal).mock.calls
|
||||
.flatMap(([batch]) => batch).flatMap(item => item.messages ?? [])
|
||||
.findLast(message => message.terminalStatus === status);
|
||||
expect(saved).toMatchObject({ state: "error", terminalStatus: status, content, status: detail });
|
||||
expect(saved?.blocks?.filter(block => block.type === "text").map(block => block.content).join("") ?? "").toBe(content);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新编辑" }));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Test terminal reply");
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("terminalizes tools and activity when a request is cancelled", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValue({
|
||||
id: "opencode",
|
||||
@@ -10111,15 +10304,17 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("已取消")).toBeInTheDocument();
|
||||
const cancelledStatus = screen
|
||||
.getAllByText("请求已取消")
|
||||
.find((element) => element.classList.contains("message__status"));
|
||||
expect(cancelledStatus).toBeDefined();
|
||||
const cancelledStatus = screen.getByRole("status", { name: "请求已取消" });
|
||||
expect(screen.getAllByText("请求已取消")).toHaveLength(1);
|
||||
expect(cancelledStatus).not.toHaveClass("message__status--error");
|
||||
expect(cancelledStatus).toHaveAttribute("aria-live", "polite");
|
||||
const cancelledDot = cancelledStatus?.querySelector(".message__status-dot");
|
||||
expect(cancelledDot).toHaveClass("message__status-dot");
|
||||
expect(cancelledDot).not.toHaveClass("message__status-dot--active");
|
||||
fireEvent.click(screen.getByText("运行记录"));
|
||||
expect((await screen.findAllByText("已取消")).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("任务已取消")).toBeInTheDocument();
|
||||
expect(screen.queryByText("任务执行失败")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /^进行中 \d+$/u }));
|
||||
expect(
|
||||
screen.getByText("当前没有等待中或正在运行的活动。"),
|
||||
@@ -11335,7 +11530,7 @@ describe("App", () => {
|
||||
expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本",
|
||||
"描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
|
||||
);
|
||||
await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled());
|
||||
|
||||
@@ -12194,6 +12389,28 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Persist question resolution" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => agentListener?.({ requestId, type: "question", questionId: "persisted-question",
|
||||
questions: [{ header: "Input", question: "Resolve me?", options: [], multiple: false, custom: true }] }));
|
||||
expect(screen.getByText("Resolve me?")).toBeInTheDocument();
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
|
||||
expect(screen.queryByText("Resolve me?")).not.toBeInTheDocument();
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(api.conversations.saveLocal).mock.calls[0]![0].flatMap(item => item.messages)).toHaveLength(1);
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" }));
|
||||
await act(async () => { await beforeQuitListener!(); });
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
|
||||
const view = render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -12364,7 +12581,7 @@ describe("App", () => {
|
||||
} else {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Already queued?")).not.toBeInTheDocument();
|
||||
if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result");
|
||||
if (outcome !== "done") expect(screen.getByRole(outcome === "cancelled" ? "status" : "alert", { name: "Terminal queue result" })).toHaveTextContent("Terminal queue result");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13904,7 +14121,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
|
||||
expect(api.updates.updateSettings).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭设置" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用中心" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" }));
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
@@ -13942,7 +14159,7 @@ describe("App", () => {
|
||||
await updates.updateSettings({ magicNotesEnabled: true })
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '魔法笔记' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const move = screen.getByRole('button', { name: '下移 知识库' })
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
@@ -13962,7 +14179,7 @@ describe("App", () => {
|
||||
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(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记'])
|
||||
@@ -13982,7 +14199,7 @@ describe("App", () => {
|
||||
},
|
||||
}
|
||||
vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
@@ -14014,7 +14231,7 @@ describe("App", () => {
|
||||
await screen.findByRole('button', { name: '监督者' })
|
||||
const reply = deferred<ApplicationSettings>()
|
||||
if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise)
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
@@ -14181,7 +14398,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建笔记' }))
|
||||
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const center = await screen.findByRole('dialog', { name: '应用中心' })
|
||||
fireEvent.click(within(center).getByRole('button', { name: /魔法笔记.*设置|设置.*魔法笔记/ }))
|
||||
@@ -14235,7 +14452,7 @@ describe("App", () => {
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '关闭设置' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '知识库' }))
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
|
||||
@@ -14263,7 +14480,7 @@ describe("App", () => {
|
||||
if (entry === 'sidebar') await api.updates!.updateSettings({ applicationNavigation: { ...defaultTestApplicationNavigation, pinned: { ...defaultTestApplicationNavigation.pinned, 'local-inference': true } } })
|
||||
render(<App />)
|
||||
await waitFor(() => expect(api.updates!.getSettings).toHaveBeenCalled())
|
||||
const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用中心' })
|
||||
const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用' })
|
||||
if (entry !== 'sidebar') expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual(['对话', '知识库', '监督者', '运行记录'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
const workspace = await screen.findByLabelText('知识工作区')
|
||||
@@ -14271,7 +14488,7 @@ describe("App", () => {
|
||||
nav.focus()
|
||||
fireEvent.click(nav)
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
else {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
@@ -14290,7 +14507,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('知识工作区')).toBe(workspace)
|
||||
expect(workspace.closest('.app-shell')).toHaveProperty('inert', false)
|
||||
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus()
|
||||
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('saves Supervisor timeouts and concurrency through application settings and restores them on reopen', async () => {
|
||||
@@ -14342,7 +14559,7 @@ describe("App", () => {
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: '监督者' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '监督者 应用设置' }))
|
||||
const enable = screen.getByRole('switch', { name: '启用应用' })
|
||||
@@ -14362,7 +14579,7 @@ describe("App", () => {
|
||||
{ name: '监督者' },
|
||||
),
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.queryByRole('menuitem', { name: '监督者' })).not.toBeInTheDocument()
|
||||
expect(api.heartbeats.remove).not.toHaveBeenCalled()
|
||||
expect(await api.heartbeats.list()).toEqual([plan])
|
||||
@@ -14377,7 +14594,7 @@ describe("App", () => {
|
||||
})
|
||||
render(<App />)
|
||||
const inferenceNav = await screen.findByRole('button', { name: '本机推理监控' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
@@ -14385,11 +14602,14 @@ describe("App", () => {
|
||||
fireEvent.click(pin)
|
||||
await waitFor(() => expect(inferenceNav).not.toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
const trigger = screen.getByRole('button', { name: '应用中心' })
|
||||
const trigger = screen.getByRole('button', { name: '应用', expanded: false })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(within(screen.getByRole('button', { name: '设置' })).getByText('设置')).toBeVisible()
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
const workspace = await screen.findByRole('region', { name: '本机推理服务' })
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument()
|
||||
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
|
||||
@@ -14403,14 +14623,14 @@ describe("App", () => {
|
||||
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
|
||||
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭本机推理监控' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked())
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
|
||||
expect(await screen.findByRole('region', { name: '本机推理服务' })).toBeVisible()
|
||||
})
|
||||
@@ -14418,7 +14638,7 @@ describe("App", () => {
|
||||
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '知识库' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const updates = api.updates!
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
|
||||
+133
-96
@@ -31,6 +31,7 @@ import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
CircleHelp,
|
||||
@@ -222,7 +223,7 @@ import { OverflowMarquee } from "./OverflowMarquee";
|
||||
import { findTaskSchedule } from "./TaskScheduleActions";
|
||||
import type { SettingsCategoryId } from "./settings-categories";
|
||||
import type { SettingsLeaveRequester } from "./SettingsPanel";
|
||||
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
|
||||
import { formatShortcutForDisplay, type GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
|
||||
import goodbuddyDarkIcon from "./assets/goodbuddy-dark.png";
|
||||
import goodbuddyLightIcon from "./assets/goodbuddy-light.png";
|
||||
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
|
||||
@@ -1027,6 +1028,7 @@ function toConversationMessage(message: Message): ConversationMessage {
|
||||
displayCaptureTruncated: message.displayCaptureTruncated,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
terminalStatus: message.terminalStatus,
|
||||
status: message.status,
|
||||
runtimeChecklist: message.runtimeChecklist,
|
||||
contextCompression: message.contextCompression,
|
||||
@@ -2034,6 +2036,18 @@ function App(): React.JSX.Element {
|
||||
[t],
|
||||
);
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>();
|
||||
const [savedShortcut, setSavedShortcut] = useState<GlobalShortcutSettingsSnapshot>();
|
||||
const shortcutPlatform = savedShortcut?.platform ?? appInfo?.platform ?? "";
|
||||
// A settings save is newer than the startup app-info request, even if it finishes first.
|
||||
const composerShortcut = savedShortcut
|
||||
? savedShortcut.registered ? savedShortcut.displayAccelerator : ""
|
||||
: appInfo?.shortcut;
|
||||
const composerKeyboardHint = t("composer.keyboardHint", {
|
||||
pasteShortcut: formatShortcutForDisplay("CommandOrControl+V", shortcutPlatform),
|
||||
});
|
||||
const composerConversationHint = t("composer.newConversationHint", {
|
||||
shortcut: formatShortcutForDisplay("CommandOrControl+N", shortcutPlatform),
|
||||
}) + (composerShortcut ? ` · ${t("composer.shortcut", { shortcut: composerShortcut })}` : "");
|
||||
const [narrowWindow, setNarrowWindow] = useState(
|
||||
() => window.innerWidth < 900,
|
||||
);
|
||||
@@ -2213,15 +2227,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const handleShortcutSettingsChanged = useCallback(
|
||||
(snapshot: GlobalShortcutSettingsSnapshot): void => {
|
||||
setAppInfo((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
shortcut: snapshot.registered ? snapshot.displayAccelerator : "",
|
||||
shortcutStatus: snapshot.status,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setSavedShortcut(snapshot);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -2337,7 +2343,13 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeConversationSearch = (): void => {
|
||||
setSearchQuery("");
|
||||
setSearchOpen(false);
|
||||
requestAnimationFrame(() => searchTriggerRef.current?.focus());
|
||||
};
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [localSearchMatches, setLocalSearchMatches] =
|
||||
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
|
||||
@@ -3874,19 +3886,21 @@ function App(): React.JSX.Element {
|
||||
messageId: string,
|
||||
update: (message: Message) => Message,
|
||||
): void => {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === conversationId
|
||||
? {
|
||||
...conversation,
|
||||
updatedAt: Date.now(),
|
||||
messages: conversation.messages.map((message) =>
|
||||
message.id === messageId ? update(message) : message,
|
||||
),
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
setConversations((current) => {
|
||||
const conversationIndex = current.findIndex(item => item.id === conversationId);
|
||||
const conversation = current[conversationIndex];
|
||||
if (!conversation) return current;
|
||||
const messageIndex = conversation.messages.findIndex(message => message.id === messageId);
|
||||
const message = conversation.messages[messageIndex];
|
||||
if (!message) return current;
|
||||
const updated = update(message);
|
||||
if (updated === message) return current;
|
||||
const messages = [...conversation.messages];
|
||||
messages[messageIndex] = updated;
|
||||
const next = [...current];
|
||||
next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -4835,7 +4849,8 @@ function App(): React.JSX.Element {
|
||||
kind: "result",
|
||||
title:
|
||||
event.type === "error"
|
||||
? tRef.current("chat.status.taskFailed")
|
||||
? tRef.current(event.status === "cancelled"
|
||||
? "chat.status.taskCancelled" : "chat.status.taskFailed")
|
||||
: tRef.current("chat.status.taskCompleted"),
|
||||
detail:
|
||||
event.type === "error"
|
||||
@@ -4869,15 +4884,14 @@ function App(): React.JSX.Element {
|
||||
? ("cancelled" as const)
|
||||
: ("failed" as const)
|
||||
: ("interrupted" as const);
|
||||
const fallbackError =
|
||||
event.type === "error" && !representedToolError && !message.content
|
||||
? event.message
|
||||
: "";
|
||||
return {
|
||||
...message,
|
||||
state: event.type === "error" ? "error" : "complete",
|
||||
terminalStatus: event.type === "error"
|
||||
? event.status === "cancelled" ? "cancelled" : "failed"
|
||||
: undefined,
|
||||
status:
|
||||
event.type === "error" && !representedToolError
|
||||
event.type === "error" && (event.status === "cancelled" || !representedToolError)
|
||||
? event.message
|
||||
: event.type === "done"
|
||||
? tRef.current("chat.status.taskCompleted")
|
||||
@@ -4928,21 +4942,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
: subagent,
|
||||
),
|
||||
blocks: toolTerminalState
|
||||
? terminalizeMessageToolBlocks(
|
||||
appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"text",
|
||||
fallbackError,
|
||||
),
|
||||
toolTerminalState,
|
||||
)
|
||||
: appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"text",
|
||||
fallbackError,
|
||||
),
|
||||
content: fallbackError || message.content,
|
||||
blocks: terminalizeMessageToolBlocks(message.blocks, toolTerminalState),
|
||||
};
|
||||
});
|
||||
activeRuns.current.delete(event.requestId);
|
||||
@@ -7531,8 +7531,7 @@ function App(): React.JSX.Element {
|
||||
if (
|
||||
!activeConversation ||
|
||||
!activeRuntimeSelection ||
|
||||
(activeRuntimeSelection.provider !== "opencode" &&
|
||||
activeRuntimeSelection.provider !== "continue") ||
|
||||
!runtimeContextCompactAvailable ||
|
||||
runtimeContextCompacting ||
|
||||
isRunning
|
||||
) {
|
||||
@@ -8171,7 +8170,13 @@ function App(): React.JSX.Element {
|
||||
return 'opened';
|
||||
};
|
||||
|
||||
const openActivityConversation = (conversationId: string): void => {
|
||||
const clearActivity = useCallback((): void => {
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false;
|
||||
setLegacyActivityHistoryMayBeIncomplete(false);
|
||||
setActivityRecords([]);
|
||||
}, []);
|
||||
|
||||
const openActivityConversation = useCallback((conversationId: string): void => {
|
||||
const open = async (): Promise<void> => {
|
||||
let conversation = conversationsRef.current.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
@@ -8207,7 +8212,7 @@ function App(): React.JSX.Element {
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
requestWorkspaceLeave("chat", () => { void open(); });
|
||||
};
|
||||
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]);
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
if (!task.conversationId) {
|
||||
@@ -8504,9 +8509,17 @@ function App(): React.JSX.Element {
|
||||
: undefined,
|
||||
].filter(Boolean).join(" · ");
|
||||
const runtimeContextCompactAvailable =
|
||||
(activeRuntimeSelection?.provider === "opencode" ||
|
||||
activeRuntimeSelection?.provider === "continue") &&
|
||||
runtimeNativeSnapshot?.context.manualCompact === true;
|
||||
activeRuntimeSelection?.provider === "model"
|
||||
? !activeConversation?.remote &&
|
||||
activeProject?.executionSpace.kind !== "ssh" &&
|
||||
runtimeSettings?.modelProfiles.some(
|
||||
(profile) =>
|
||||
profile.id === getRuntimeSelectionProfileId(activeRuntimeSelection, runtimeSettings) &&
|
||||
profile.protocol !== "openai-images-generations",
|
||||
) === true
|
||||
: (activeRuntimeSelection?.provider === "opencode" ||
|
||||
activeRuntimeSelection?.provider === "continue") &&
|
||||
runtimeNativeSnapshot?.context.manualCompact === true;
|
||||
|
||||
const nativeClientAvailable = activeRuntimeSelection?.provider === "continue" ||
|
||||
activeRuntimeSelection?.provider === "opencode" ||
|
||||
@@ -8720,37 +8733,62 @@ function App(): React.JSX.Element {
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
{activeProject?.kind !== "channel" && (
|
||||
<button className="new-chat" onClick={newConversation} type="button">
|
||||
<MessageSquarePlus size={17} />
|
||||
<span>{t("sidebar.newConversation")}</span>
|
||||
<kbd>Ctrl N</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="sidebar-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
aria-label={t("sidebar.searchLabel")}
|
||||
ref={searchInputRef}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t("sidebar.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<div
|
||||
className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
|
||||
onKeyDown={(event) => {
|
||||
if (searchOpen && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeConversationSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeProject?.kind !== "channel" && (
|
||||
<button
|
||||
aria-label={t("conversation.clearSearch")}
|
||||
className="icon-button sidebar-search__clear"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
title={t("conversation.clearSearch")}
|
||||
className="new-chat"
|
||||
onClick={newConversation}
|
||||
type="button"
|
||||
aria-label={t("sidebar.newConversation")}
|
||||
title={t("sidebar.newConversation")}
|
||||
>
|
||||
<X aria-hidden="true" size={14} />
|
||||
<MessageSquarePlus aria-hidden="true" size={17} />
|
||||
{!searchOpen && <span>{t("sidebar.newConversation")}</span>}
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-search-slot">
|
||||
{searchOpen ? (
|
||||
<div className="sidebar-search">
|
||||
<input
|
||||
autoFocus
|
||||
aria-label={t("sidebar.searchLabel")}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t("sidebar.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
/>
|
||||
<button
|
||||
aria-label={t("sidebar.closeSearch")}
|
||||
className="icon-button sidebar-search__clear"
|
||||
onClick={closeConversationSearch}
|
||||
title={t("sidebar.closeSearch")}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={searchTriggerRef}
|
||||
className="icon-button sidebar-search-trigger"
|
||||
aria-label={t("sidebar.searchLabel")}
|
||||
aria-expanded={false}
|
||||
title={t("sidebar.searchLabel")}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Search aria-hidden="true" size={17} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="primary-nav" aria-label={t("navigation.label")}>
|
||||
@@ -8812,8 +8850,8 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
|
||||
<div className="conversation-list">
|
||||
<p className="section-label">{t("sidebar.recent")}</p>
|
||||
{!conversationLoadError &&
|
||||
filteredConversations.map((conversation) => {
|
||||
const conversationTasks =
|
||||
@@ -8901,7 +8939,11 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<span className="conversation-item__primary">
|
||||
{conversation.pinned && <Pin className="conversation-pin" size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>}
|
||||
{conversation.pinned && (
|
||||
<span className="conversation-pin">
|
||||
<Pin size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>
|
||||
</span>
|
||||
)}
|
||||
{branchSourceTitle && (
|
||||
<ConversationBranchBadge
|
||||
sourceTitle={branchSourceTitle}
|
||||
@@ -9340,6 +9382,8 @@ function App(): React.JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="sidebar-footer sidebar-footer--applications">
|
||||
<button
|
||||
className="nav-item"
|
||||
@@ -9362,10 +9406,12 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<Grid2X2 size={17} aria-hidden="true" />
|
||||
<span>{t("applications.title")}</span>
|
||||
<span>{t("applications.menuLabel")}</span>
|
||||
<ChevronUp size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="sidebar-footer__divider" aria-hidden="true" />
|
||||
<button
|
||||
className="icon-button"
|
||||
className="nav-item"
|
||||
aria-label={t('navigation.settings')}
|
||||
title={t('navigation.settings')}
|
||||
type="button"
|
||||
@@ -9375,7 +9421,8 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
|
||||
>
|
||||
<Settings size={16} />
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<span>{t('navigation.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -9852,10 +9899,10 @@ function App(): React.JSX.Element {
|
||||
runtime?.capability === "image-generation"
|
||||
? t("composer.imagePlaceholder")
|
||||
: t("composer.placeholder")
|
||||
}\n${t("composer.keyboardHint")}`}
|
||||
}\n${composerKeyboardHint}\n${composerConversationHint}`}
|
||||
ref={inputRef}
|
||||
rows={3}
|
||||
title={t("composer.keyboardHint")}
|
||||
title={`${composerKeyboardHint}\n${composerConversationHint}`}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
@@ -10788,12 +10835,6 @@ function App(): React.JSX.Element {
|
||||
{contextError}
|
||||
</span>
|
||||
)}
|
||||
{appInfo?.shortcut && (
|
||||
<span className="composer-meta__shortcut">
|
||||
{t("composer.shortcut")}
|
||||
<kbd>{appInfo.shortcut}</kbd>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -11363,11 +11404,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
<ActivityPanel
|
||||
onClear={() => {
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false;
|
||||
setLegacyActivityHistoryMayBeIncomplete(false);
|
||||
setActivityRecords([]);
|
||||
}}
|
||||
onClear={clearActivity}
|
||||
onOpenConversation={openActivityConversation}
|
||||
projects={projects}
|
||||
records={activityRecords}
|
||||
|
||||
@@ -8,6 +8,46 @@ const stylesheet = readFileSync(
|
||||
).replaceAll('\r\n', '\n')
|
||||
|
||||
describe('primary sidebar layout', () => {
|
||||
it('keeps the original new conversation typography and left alignment, centering only its compact icon', () => {
|
||||
const button = stylesheet.match(/\.new-chat\s*\{([^}]*)\}/u)?.[1]
|
||||
const label = stylesheet.match(/\.new-chat span\s*\{([^}]*)\}/u)?.[1]
|
||||
const compact = stylesheet.match(/\.sidebar-conversation-controls--searching \.new-chat\s*\{([^}]*)\}/u)?.[1]
|
||||
expect(button).toContain('justify-content: flex-start;')
|
||||
expect(button).toContain('padding: 0 var(--space-3);')
|
||||
expect(label).toContain('font-size: 13px;')
|
||||
expect(label).toContain('font-weight: 600;')
|
||||
expect(label).not.toContain('white-space: nowrap;')
|
||||
expect(compact).toContain('justify-content: center;')
|
||||
expect(compact).toContain('padding: 0;')
|
||||
})
|
||||
it('keeps conversation controls fixed above an independently scrolling list', () => {
|
||||
const section = stylesheet.match(/\.sidebar-conversations\s*\{([^}]*)\}/u)?.[1]
|
||||
const controls = stylesheet.match(/\.sidebar-conversation-controls\s*\{([^}]*)\}/u)?.[1]
|
||||
const list = stylesheet.match(/\.conversation-list\s*\{([^}]*)\}/u)?.[1]
|
||||
expect(section).toContain('min-height: 0;')
|
||||
expect(section).toContain('flex-direction: column;')
|
||||
expect(section).toContain('background: var(--surface-raised);')
|
||||
expect(controls).toContain('flex: 0 0 44px;')
|
||||
expect(controls).toContain('height: 44px;')
|
||||
expect(stylesheet).not.toContain('.sidebar-conversations__header')
|
||||
expect(list).toContain('min-width: 0;')
|
||||
expect(list).toContain('min-height: 0;')
|
||||
expect(list).toContain('overflow: auto;')
|
||||
const shortWindow = stylesheet.split('@media (max-height: 600px) {')[1]?.split('@media')[0]
|
||||
expect(shortWindow).toContain('overflow-y: auto;')
|
||||
expect(shortWindow).toContain('flex-shrink: 0;')
|
||||
expect(shortWindow).toContain('min-height: 200px;')
|
||||
})
|
||||
|
||||
it('expands search within the row and disables its width animation for reduced motion', () => {
|
||||
const slot = stylesheet.match(/\.sidebar-search-slot\s*\{([^}]*)\}/u)?.[1]
|
||||
expect(slot).toContain('margin-left: auto;')
|
||||
expect(slot).toContain('transition: width var(--motion-normal) ease-out;')
|
||||
expect(stylesheet).toMatch(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.sidebar-search-slot\s*\{\s*transition: none;/u)
|
||||
const responsive = stylesheet.split('@media (max-width: 1020px) {')[1]?.split('@media')[0]
|
||||
expect(responsive).not.toMatch(/\.(?:new-chat|sidebar-search),/u)
|
||||
})
|
||||
|
||||
it('floats conversation actions outside list layout with viewport limits', () => {
|
||||
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
|
||||
expect(rule).toContain('position: fixed;')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type RefObject } from 'react'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
|
||||
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
|
||||
@@ -36,7 +36,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
|
||||
})}
|
||||
<div role="separator" />
|
||||
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
|
||||
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
|
||||
<LayoutGrid size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
|
||||
</button>
|
||||
</AnchoredMenu>
|
||||
}
|
||||
|
||||
@@ -80,6 +80,74 @@ function createMessages(): Message[] {
|
||||
}
|
||||
|
||||
describe('ChatTimeline', () => {
|
||||
it.each(['cancelled', 'failed'] as const)('renders saved %s status and offers editing only with usable input', (terminalStatus) => {
|
||||
const props = {
|
||||
...callbacks, artifactById: new Map(), conversationId: 'terminal',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1
|
||||
}
|
||||
const message: Message = { id: 'reply', role: 'assistant', content: 'Existing reply',
|
||||
state: 'error', terminalStatus, status: 'Terminal detail', createdAt: 1 }
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const status = screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' })
|
||||
expect(status.classList.contains('message__status--error')).toBe(terminalStatus === 'failed')
|
||||
expect(screen.getByText('Existing reply')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
|
||||
for (const extra of [
|
||||
{ contextCompression: { state: 'completed' as const, estimatedBeforeTokens: 20_000, estimatedAfterTokens: 8_000 } },
|
||||
{ knowledgeRetrieval: { mode: 'always' as const, state: 'succeeded' as const, libraryCount: 1,
|
||||
resultCount: 2, durationMs: 20, usedChannels: ['fts' as const], warnings: [] } }
|
||||
]) {
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, ...extra }]} />)
|
||||
expect(screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' }))
|
||||
.toHaveAttribute('aria-live', terminalStatus === 'cancelled' ? 'polite' : 'assertive')
|
||||
}
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent=" " />)
|
||||
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent="Original prompt" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }))
|
||||
expect(callbacks.onRetry).toHaveBeenLastCalledWith('Original prompt')
|
||||
})
|
||||
|
||||
it.each(['请求已取消', 'Provider failed'])('deduplicates only the exact legacy fallback shape (%s) without removing reply text', (detail) => {
|
||||
const props = { ...callbacks, artifactById: new Map(), conversationId: 'legacy',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1 }
|
||||
const message: Message = { id: 'old', role: 'assistant', content: detail,
|
||||
state: 'error', status: detail, createdAt: 1 }
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1)
|
||||
expect(screen.getByText(detail).closest('.message__content')).not.toBeNull()
|
||||
const blocks: Message['blocks'] = [
|
||||
{ id: 'reasoning', type: 'reasoning', content: 'Preserved reasoning' },
|
||||
{ id: 'fallback', type: 'text', content: message.content }
|
||||
]
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks }]} />)
|
||||
expect(screen.getAllByText(detail)).toHaveLength(1)
|
||||
// A body that merely quotes or includes the status is never stripped.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, content: `The message says ${detail}` }]} />)
|
||||
expect(screen.getByText(`The message says ${detail}`)).toBeVisible()
|
||||
const role = detail === '请求已取消' ? 'status' : 'alert'
|
||||
expect(screen.getByRole(role, { name: detail }).classList.contains('message__status--error')).toBe(role === 'alert')
|
||||
// Explicit failed events must override even the old cancellation labels.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, terminalStatus: 'failed' }]} />)
|
||||
expect(screen.getByRole('alert', { name: detail })).toHaveClass('message__status--error')
|
||||
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
|
||||
// Content and blocks disagree: do not treat this as the old fallback.
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: [{ id: 'real', type: 'text', content: 'Real output' }] }]} />)
|
||||
expect(screen.getByText('Real output')).toBeVisible()
|
||||
expect(screen.getByRole(role, { name: detail })).toBeVisible()
|
||||
// Multiple text blocks or a non-final text block are not the old fallback.
|
||||
for (const nextBlocks of [
|
||||
[...blocks, { id: 'later', type: 'reasoning' as const, content: 'Later reasoning' }],
|
||||
[{ id: 'earlier', type: 'text' as const, content: 'Earlier output' }, ...blocks]
|
||||
]) {
|
||||
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: nextBlocks }]} />)
|
||||
expect(screen.getByRole(role, { name: detail })).toBeVisible()
|
||||
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
|
||||
const onAddToNote = vi.fn()
|
||||
const messages: Message[] = [
|
||||
@@ -793,6 +861,97 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses subagent derivations during streaming and invalidates only changed dependencies', () => {
|
||||
const props = {
|
||||
...callbacks, artifactById: new Map(), conversationId: 'subagent-stream',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, totalMessageCount: 1
|
||||
}
|
||||
const subagents: NonNullable<Message['subagents']> = ['a', 'b', 'c', 'd'].map(id => ({
|
||||
childTaskId: id, expertId: id, expertName: `Expert ${id}`,
|
||||
routingMode: 'native', state: 'running'
|
||||
}))
|
||||
const makeBlocks = (content: string): NonNullable<Message['blocks']> => [
|
||||
{ id: 'b', type: 'subagent', childTaskId: 'b' },
|
||||
{ id: 'missing', type: 'subagent', childTaskId: 'missing' },
|
||||
{ id: 'text', type: 'text', content },
|
||||
{ id: 'a', type: 'subagent', childTaskId: 'a' }
|
||||
]
|
||||
let message: Message = {
|
||||
id: 'stream', role: 'assistant', content: 'Initial', createdAt: 0,
|
||||
state: 'streaming', subagents, blocks: makeBlocks('Initial')
|
||||
}
|
||||
const map = vi.spyOn(subagents, 'map')
|
||||
const filter = vi.spyOn(subagents, 'filter')
|
||||
const blockFilters = [vi.spyOn(message.blocks!, 'filter')]
|
||||
// Question IDs also filter blocks; count only passes returning subagent markers.
|
||||
const counts = () => ({
|
||||
map: map.mock.calls.length,
|
||||
ordered: blockFilters.reduce((count, probe) => count + probe.mock.results.filter(
|
||||
result => result.type === 'return' && result.value[0]?.type === 'subagent'
|
||||
).length, 0),
|
||||
unordered: filter.mock.calls.length
|
||||
})
|
||||
try {
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
const cardNames = () => Array.from(view.container.querySelectorAll(
|
||||
'.subagent-status-card__heading strong'
|
||||
), element => element.textContent)
|
||||
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
|
||||
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
message = { ...message, status: `Streaming ${index}` }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getByRole('status', { name: `Streaming ${index}` })).toBeVisible()
|
||||
}
|
||||
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const content = `Streamed block ${index}`
|
||||
message = { ...message, content, blocks: makeBlocks(content) }
|
||||
blockFilters.push(vi.spyOn(message.blocks!, 'filter'))
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.getByText(content)).toBeVisible()
|
||||
expect(markdownRenderProbe).toHaveBeenLastCalledWith(content)
|
||||
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
|
||||
}
|
||||
expect(counts()).toEqual({ map: 1, ordered: 4, unordered: 4 })
|
||||
|
||||
// Both anchored lookup and unanchored fallback must receive new child data.
|
||||
message = { ...message, subagents: subagents.map(child => ({
|
||||
...child, expertName: `Updated ${child.childTaskId}`, state: 'completed',
|
||||
output: `Result ${child.childTaskId}`
|
||||
})) }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d'])
|
||||
for (const card of view.container.querySelectorAll<HTMLDetailsElement>('.subagent-status-card')) {
|
||||
expect(card).toHaveClass('subagent-status-card--completed')
|
||||
fireEvent.click(card.querySelector('summary')!)
|
||||
fireEvent(card, new Event('toggle'))
|
||||
const id = card.querySelector('strong')!.textContent!.slice(-1)
|
||||
expect(within(card).getByText(`Result ${id}`)).toBeVisible()
|
||||
}
|
||||
|
||||
// Move a fallback child into the ordered blocks without duplicating it.
|
||||
message = { ...message, blocks: [{ id: 'd', type: 'subagent', childTaskId: 'd' }] }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c'])
|
||||
for (const blocks of [undefined, []]) {
|
||||
message = { ...message, blocks }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d'])
|
||||
}
|
||||
message = { ...message, subagents: undefined }
|
||||
view.rerender(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(cardNames()).toEqual([])
|
||||
} finally {
|
||||
map.mockRestore()
|
||||
filter.mockRestore()
|
||||
blockFilters.forEach(probe => probe.mockRestore())
|
||||
}
|
||||
})
|
||||
|
||||
it('shows every parallel expert output in its own expandable card', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
|
||||
export type ToolActivity = ConversationToolActivity
|
||||
|
||||
@@ -61,6 +62,7 @@ export type Message = {
|
||||
displayCaptureTruncated?: boolean
|
||||
createdAt: number
|
||||
state: 'streaming' | 'complete' | 'error'
|
||||
terminalStatus?: ConversationMessage['terminalStatus']
|
||||
status?: string
|
||||
runtimeChecklist?: RuntimeChecklist
|
||||
contextCompression?: ConversationMessage['contextCompression']
|
||||
@@ -606,20 +608,20 @@ function ChatMessageRowView({
|
||||
const compressionMarkers =
|
||||
message.contextCompressions ??
|
||||
(message.contextCompression ? [message.contextCompression] : [])
|
||||
const subagentsById = new Map(
|
||||
const subagentsById = useMemo(() => new Map(
|
||||
(message.subagents ?? []).map((subagent) => [
|
||||
subagent.childTaskId,
|
||||
subagent
|
||||
])
|
||||
)
|
||||
const orderedSubagentIds = new Set(
|
||||
), [message.subagents])
|
||||
const orderedSubagentIds = useMemo(() => new Set(
|
||||
message.blocks
|
||||
?.filter((block) => block.type === 'subagent')
|
||||
.map((block) => block.childTaskId)
|
||||
)
|
||||
const unorderedSubagents = message.subagents?.filter(
|
||||
), [message.blocks])
|
||||
const unorderedSubagents = useMemo(() => message.subagents?.filter(
|
||||
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
|
||||
)
|
||||
), [message.subagents, orderedSubagentIds])
|
||||
const compressionLabel = (
|
||||
compression: ConversationContextCompressionMarker
|
||||
): string =>
|
||||
@@ -706,7 +708,19 @@ function ChatMessageRowView({
|
||||
subagent.state === 'queued' || subagent.state === 'running')
|
||||
const activeCompression = compressionMarkers.find((marker) =>
|
||||
marker.state === 'compressing')
|
||||
const statusText = message.role === 'assistant' && message.state === 'streaming'
|
||||
// Old snapshots may contain the fallback status as the entire reply. Keep
|
||||
// the body (it could be genuine output) and omit only the duplicate status.
|
||||
const duplicateLegacyStatus = message.role === 'assistant' &&
|
||||
message.state === 'error' && message.terminalStatus === undefined &&
|
||||
Boolean(message.status) && message.content === message.status &&
|
||||
(!message.blocks?.length || (
|
||||
message.blocks.at(-1)?.type === 'text' &&
|
||||
message.blocks.every(block => block.type !== 'text' ||
|
||||
(block === message.blocks?.at(-1) && block.content === message.status))
|
||||
))
|
||||
const failed = message.state === 'error' && !isCancelledMessage(message)
|
||||
const statusText = duplicateLegacyStatus ? undefined
|
||||
: message.role === 'assistant' && message.state === 'streaming'
|
||||
? question
|
||||
? t('chat.status.waitingForAnswer')
|
||||
: message.approval
|
||||
@@ -1233,21 +1247,21 @@ function ChatMessageRowView({
|
||||
aria-atomic="true"
|
||||
aria-label={statusText}
|
||||
aria-live={
|
||||
message.knowledgeRetrieval || compressionMarkers.length > 0
|
||||
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
|
||||
? undefined
|
||||
: message.state === 'error'
|
||||
: failed
|
||||
? 'assertive'
|
||||
: 'polite'
|
||||
}
|
||||
className={
|
||||
message.state === 'error'
|
||||
failed
|
||||
? 'message__status message__status--error'
|
||||
: 'message__status'
|
||||
}
|
||||
role={
|
||||
message.knowledgeRetrieval || compressionMarkers.length > 0
|
||||
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
|
||||
? undefined
|
||||
: message.state === 'error'
|
||||
: failed
|
||||
? 'alert'
|
||||
: 'status'
|
||||
}
|
||||
@@ -1259,7 +1273,7 @@ function ChatMessageRowView({
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{canRetry && (
|
||||
{canRetry && Boolean(retryContent?.trim()) && (
|
||||
<button
|
||||
className="message-retry"
|
||||
onClick={() => {
|
||||
|
||||
@@ -55,6 +55,13 @@ describe('compact notes', () => {
|
||||
expect(screen.queryByText('Text to add')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Global')).not.toBeInTheDocument()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
|
||||
expect(screen.queryByText(/excluding reasoning/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'What gets saved' }))
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('excluding reasoning')
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
|
||||
expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A')
|
||||
expect(document.getElementById('compact-note-validation')).not.toBeInTheDocument()
|
||||
@@ -162,11 +169,28 @@ describe('compact notes', () => {
|
||||
expect(preview).toHaveValue(oversized)
|
||||
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
|
||||
expect(screen.getByText(/Each entry supports up to 500 KB/)).toBeVisible()
|
||||
expect(preview).toHaveAccessibleDescription(/Each entry supports up to 500 KB/)
|
||||
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
|
||||
fireEvent.change(preview, { target: { value: 'Within capacity' } })
|
||||
expect(screen.getByRole('button', { name: 'Add to note' })).toBeEnabled()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps new-note validation beside the title without initial footer instructions', async () => {
|
||||
render(<Harness />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'New note' }))
|
||||
const title = screen.getByRole('textbox', { name: 'Note title' })
|
||||
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Create note' })).toBeDisabled()
|
||||
fireEvent.change(title, { target: { value: 'x'.repeat(101) } })
|
||||
expect(title).toHaveAccessibleDescription('Use a title of up to 100 characters.')
|
||||
expect(title).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
|
||||
fireEvent.change(title, { target: { value: 'Valid title' } })
|
||||
expect(screen.queryByText('Use a title of up to 100 characters.')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create note' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('preserves text when the target disappears and requires a new selection', async () => {
|
||||
render(<Harness />)
|
||||
fireEvent.click(screen.getByText('Capture'))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
|
||||
import type { useMagicNoteDraft } from './use-magic-note-draft'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import './magic-notes-panel.css'
|
||||
|
||||
const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail }))
|
||||
@@ -149,8 +150,8 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
|
||||
setSelectedNoteId('')
|
||||
requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus())
|
||||
}
|
||||
const validationMessage = tooLong ? t('capture.tooLong') : draft && !valid ? t('capture.required') : undefined
|
||||
const validationId = [validationMessage && 'compact-note-validation', saveError && 'compact-note-save-error'].filter(Boolean).join(' ') || undefined
|
||||
const validationId = tooLong ? 'compact-note-validation' : undefined
|
||||
const titleTooLong = Boolean(draft?.newNote && draft.title.trim().length > 100)
|
||||
const list = (
|
||||
<>
|
||||
<label htmlFor="compact-note-search">{t('capture.search')}</label>
|
||||
@@ -202,13 +203,18 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
|
||||
{draft && !quickDraft ? (
|
||||
<>
|
||||
{draft.source && <div className="magic-note-source">
|
||||
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
|
||||
<p>{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}</p>
|
||||
<div className="magic-note-panel__source-heading">
|
||||
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
|
||||
<InlineHelp label={t('capture.scopeHelp')}>
|
||||
{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}
|
||||
</InlineHelp>
|
||||
</div>
|
||||
{draft.incomplete && <p>{t('capture.incomplete')}</p>}
|
||||
</div>}
|
||||
<textarea ref={preview} id="compact-note-text" aria-label={t('capture.content')} value={draft.text} disabled={saving}
|
||||
aria-describedby={validationId} aria-invalid={tooLong || undefined}
|
||||
onChange={event => setDraft({ ...draft, text: event.target.value })} />
|
||||
{tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
|
||||
<h3>{t('capture.target')}</h3>
|
||||
<SegmentedControl ariaLabel={t('capture.target')} disabled={saving}
|
||||
value={draft.newNote ? 'new' : 'existing'}
|
||||
@@ -218,8 +224,9 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
|
||||
<>
|
||||
<label htmlFor="compact-note-title">{t('capture.title')}</label>
|
||||
<input ref={title} id="compact-note-title" value={draft.title} disabled={saving}
|
||||
aria-describedby={validationId} aria-invalid={draft.title.trim().length > 100 || undefined}
|
||||
aria-describedby={titleTooLong ? 'compact-note-title-error' : undefined} aria-invalid={titleTooLong || undefined}
|
||||
onChange={event => setDraft({ ...draft, title: event.target.value })} />
|
||||
{titleTooLong && <p id="compact-note-title-error">{t('capture.titleTooLong')}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -264,14 +271,16 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
|
||||
text: event.target.value, initialText: '', title: '', initialTitle: '', targetId: currentDetail.id, newNote: false
|
||||
})} />
|
||||
</>}
|
||||
{validationMessage && <p id="compact-note-validation">{validationMessage}</p>}
|
||||
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
|
||||
<div className="magic-note-panel__actions">
|
||||
{draft && <button className="secondary-button" type="button" disabled={saving}
|
||||
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
|
||||
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
|
||||
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
|
||||
</button>
|
||||
{quickDraft && tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
|
||||
<div className="magic-note-panel__submit">
|
||||
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
|
||||
<div className="magic-note-panel__actions">
|
||||
{draft && <button className="secondary-button" type="button" disabled={saving}
|
||||
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
|
||||
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
|
||||
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
InlineMarkdown,
|
||||
MarkdownRenderer
|
||||
MarkdownRenderer,
|
||||
normalizeLatexDelimiters
|
||||
} from './MarkdownRenderer'
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
@@ -31,6 +32,26 @@ vi.mock('mermaid', () => ({
|
||||
}))
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
it.each([
|
||||
['# Title\n\n**bold** `code`\n', '# Title\n\n**bold** `code`\n'],
|
||||
['plain \\', 'plain \\'],
|
||||
['partial \\(x', 'partial $x'],
|
||||
['partial x\\)', 'partial x$'],
|
||||
['\\(x\\)', '$$\nx\n$$'],
|
||||
['\\[x\\]', '$$\nx\n$$'],
|
||||
['$x$', '$$\nx\n$$'],
|
||||
['`\\(code\\)` then \\(x\\)', '`\\(code\\)` then $x$'],
|
||||
['~~~tex\n$x$\n\\(x\\)\n~~~', '~~~tex\n$x$\n\\(x\\)\n~~~']
|
||||
])('preserves normalization across fast paths: %j', (content, expected) => {
|
||||
expect(normalizeLatexDelimiters(content)).toBe(expected)
|
||||
})
|
||||
|
||||
it('preserves CRLF conversion inside code and standalone HTML without math', () => {
|
||||
expect(normalizeLatexDelimiters('```txt\r\na\r\n```')).toBe('```txt\na\n```')
|
||||
expect(normalizeLatexDelimiters('<section>\r\na\r\n</section>')).toBe('<section>\na\n</section>')
|
||||
expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
app: { onPreviewEscape: vi.fn((listener: () => void) => {
|
||||
|
||||
@@ -134,6 +134,9 @@ const wholeMarkdownFence =
|
||||
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
|
||||
|
||||
function replaceLatexDelimiters(line: string): string {
|
||||
if (!line.includes('\\(') && !line.includes('\\)')) {
|
||||
return line
|
||||
}
|
||||
let output = ''
|
||||
let index = 0
|
||||
let codeDelimiterLength = 0
|
||||
@@ -171,7 +174,11 @@ function replaceLatexDelimiters(line: string): string {
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizeLatexDelimiters(content: string): string {
|
||||
export function normalizeLatexDelimiters(content: string): string {
|
||||
// Without math markers or CRLF, every line would be returned unchanged.
|
||||
if (!/[$\\\r]/u.test(content)) {
|
||||
return content
|
||||
}
|
||||
let fence:
|
||||
| {
|
||||
character: '`' | '~'
|
||||
|
||||
@@ -639,7 +639,6 @@ function BrowserToolbar({
|
||||
type="submit"
|
||||
>
|
||||
<ArrowRight aria-hidden="true" size={13} />
|
||||
<span>{t('sidebar.browser.toolbar.go')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={
|
||||
|
||||
@@ -18,6 +18,17 @@ const message: Message = {
|
||||
}
|
||||
|
||||
describe('RuntimeChecklistStrip', () => {
|
||||
it('uses the structured cancellation result when no status text was saved', () => {
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[
|
||||
{ ...message, state: 'error', terminalStatus: 'cancelled' }
|
||||
]} />)
|
||||
expect(screen.getByRole('button', { name: /执行清单/ })).toHaveTextContent('任务已取消')
|
||||
rerender(<RuntimeChecklistStrip messages={[
|
||||
{ ...message, state: 'error', terminalStatus: 'failed' }
|
||||
]} />)
|
||||
expect(screen.getByRole('button', { name: /执行清单/ })).not.toHaveTextContent('任务已取消')
|
||||
})
|
||||
|
||||
it('is read-only, preserves collapse and focus during full replacements, and retains terminal item states', () => {
|
||||
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
|
||||
const toggle = screen.getByRole('button', { name: /执行清单/ })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } f
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
import './runtime-checklist.css'
|
||||
|
||||
const statusIcons = {
|
||||
@@ -34,7 +35,8 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
|
||||
const completed = items.reduce((count, item) => count + Number(item.status === 'completed'), 0)
|
||||
const current = items.find(item => item.status === 'in_progress')
|
||||
const result = message.state === 'streaming' ? undefined : message.status || t(
|
||||
message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
|
||||
isCancelledMessage(message) ? 'chat.status.taskCancelled'
|
||||
: message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
|
||||
)
|
||||
const summary = t('runtimeChecklist.count', { completed, total: items.length })
|
||||
return (
|
||||
|
||||
@@ -473,17 +473,19 @@ export function WorkspaceFilesPanel({
|
||||
setDiff(undefined)
|
||||
setActionError('')
|
||||
}}><ChevronLeft size={14} />{t('files.backToChanges')}</button>
|
||||
<strong>{diff.path}</strong>
|
||||
<div className="workspace-files__diff-heading">
|
||||
<strong>{diff.path}</strong>
|
||||
<div className="workspace-files__toolbar">
|
||||
{!changedByPath.get(diff.path)?.status.includes('D') && <button type="button" className="secondary-button" onClick={() => onOpenFile(diff.path)}>{t('files.viewCurrentFile')}</button>}
|
||||
<button type="button" className="secondary-button" disabled={!diff.value && !diff.error} onClick={() => openDiff(diff.path)}>{t(!diff.value && !diff.error ? 'sidebar.workspace.refreshing' : 'sidebar.workspace.refresh')}</button>
|
||||
<button type="button" className="secondary-button" onClick={() => void copyPath(diff.path)}>{t('sidebar.workspace.copyPath')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="workspace-files__toolbar">
|
||||
{!changedByPath.get(diff.path)?.status.includes('D') && <button type="button" className="secondary-button" onClick={() => onOpenFile(diff.path)}>{t('files.viewCurrentFile')}</button>}
|
||||
<button type="button" className="secondary-button" disabled={!diff.value && !diff.error} onClick={() => openDiff(diff.path)}>{t(!diff.value && !diff.error ? 'sidebar.workspace.refreshing' : 'sidebar.workspace.refresh')}</button>
|
||||
<button type="button" className="secondary-button" onClick={() => void copyPath(diff.path)}>{t('sidebar.workspace.copyPath')}</button>
|
||||
</div>
|
||||
{diff.error ? <p role="alert">{diff.error}<button type="button" onClick={() => openDiff(diff.path)}>{t('files.retry')}</button></p>
|
||||
: !diff.value ? <p>{t('files.reading')}</p> : <>
|
||||
{diff.value.stagedPatch && <><h4>{t('files.staged')}</h4>{renderPatch(diff.value.stagedPatch)}</>}
|
||||
{diff.value.patch && <><h4>{t('files.unstaged')}</h4>{renderPatch(diff.value.patch)}</>}
|
||||
{diff.value.patch && <>{diff.value.stagedPatch && <h4>{t('files.unstaged')}</h4>}{renderPatch(diff.value.patch)}</>}
|
||||
{!diff.value.patch && !diff.value.stagedPatch && <p>{t('files.noDiff')}</p>}
|
||||
{diff.value.truncated && <p>{t('files.diffTruncated')}</p>}
|
||||
</>}
|
||||
|
||||
@@ -205,5 +205,18 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sidebar-footer--applications > .nav-item {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
padding-inline: var(--space-2);
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-footer--applications > .nav-item > span {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-footer__divider {
|
||||
flex: 0 0 1px;
|
||||
height: var(--space-6);
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const app = {
|
||||
priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' }
|
||||
},
|
||||
applications: {
|
||||
menuLabel: 'Apps',
|
||||
alwaysShown: 'Always shown', optional: 'Optional',
|
||||
manage: 'Manage Apps',
|
||||
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge is always shown; Supervisor can be enabled or disabled.',
|
||||
@@ -81,6 +82,7 @@ export const app = {
|
||||
label: 'Main sidebar',
|
||||
newConversation: 'New conversation',
|
||||
searchLabel: 'Search conversations',
|
||||
closeSearch: 'Close and clear search',
|
||||
searchPlaceholder: 'Search titles or messages',
|
||||
recent: 'Recent conversations',
|
||||
localWorkspace: 'Local workspace',
|
||||
@@ -389,7 +391,7 @@ export const app = {
|
||||
graph: 'Graph'
|
||||
}
|
||||
},
|
||||
retry: 'Edit and send again',
|
||||
retry: 'Edit again',
|
||||
copyMessage: 'Copy this response',
|
||||
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
@@ -407,6 +409,7 @@ export const app = {
|
||||
savingImage: 'Image generated; saving the result',
|
||||
taskCompleted: 'Task completed',
|
||||
taskFailed: 'Task failed',
|
||||
taskCancelled: 'Task cancelled',
|
||||
runtimeCompleted: 'Agent Runtime completed its response',
|
||||
activityIncomplete:
|
||||
'The parent request ended before this activity reported completion',
|
||||
@@ -488,7 +491,8 @@ export const app = {
|
||||
placeholder: 'Message GoodBuddy…',
|
||||
imagePlaceholder: 'Describe the image you want to generate…',
|
||||
keyboardHint:
|
||||
'Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text',
|
||||
'Enter to send · Shift+Enter for a new line · {{pasteShortcut}} to paste',
|
||||
newConversationHint: '{{shortcut}} for a new conversation',
|
||||
addContent: 'Add content',
|
||||
addAttachment: 'Add attachment',
|
||||
attachmentProgress: {
|
||||
@@ -545,7 +549,7 @@ export const app = {
|
||||
removeAria: 'Remove “{{title}}” from the send queue',
|
||||
actionFailed: 'Failed to update the send queue'
|
||||
},
|
||||
shortcut: 'Quick access: ',
|
||||
shortcut: '{{shortcut}} for quick access',
|
||||
context: {
|
||||
confirmedTokenCount: 'Latest call {{used}}',
|
||||
tokenCount: 'Estimated latest call ≈{{used}}',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const magicNotes = {
|
||||
conversationScope: 'Saves all user and assistant text at capture time, including earlier history, excluding reasoning, tool logs, images, and attachment files.',
|
||||
incomplete: 'This reply is incomplete. The preview includes its available text.', wait: 'Wait for the reply to finish or stop generation before saving.',
|
||||
tooLong: 'Each entry supports up to 500 KB of text. Shorten the content before saving.',
|
||||
required: 'Enter text and choose a note, or enter a new note title (up to 100 characters).',
|
||||
scopeHelp: 'What gets saved', titleTooLong: 'Use a title of up to 100 characters.',
|
||||
saving: 'Saving...', deletedTarget: 'The target note was deleted. Choose another note; your draft is preserved.',
|
||||
user: 'User', assistant: 'Assistant', loading: 'Loading the full conversation...'
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ export const app = {
|
||||
priority: { low: '低优先级', medium: '中优先级', high: '高优先级' }
|
||||
},
|
||||
applications: {
|
||||
menuLabel: '应用',
|
||||
alwaysShown: '始终显示', optional: '可选',
|
||||
manage: '管理应用',
|
||||
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库始终显示,监督者可启用或关闭。',
|
||||
@@ -78,6 +79,7 @@ export const app = {
|
||||
label: '主侧栏',
|
||||
newConversation: '新建对话',
|
||||
searchLabel: '搜索对话',
|
||||
closeSearch: '关闭并清除搜索',
|
||||
searchPlaceholder: '搜索标题或消息',
|
||||
recent: '最近会话',
|
||||
localWorkspace: '本地工作区',
|
||||
@@ -372,7 +374,7 @@ export const app = {
|
||||
graph: '图谱'
|
||||
}
|
||||
},
|
||||
retry: '重新编辑并发送',
|
||||
retry: '重新编辑',
|
||||
copyMessage: '复制此回复',
|
||||
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
|
||||
scrollToBottom: '到底部',
|
||||
@@ -390,6 +392,7 @@ export const app = {
|
||||
savingImage: '图片已生成,正在保存结果',
|
||||
taskCompleted: '任务执行完成',
|
||||
taskFailed: '任务执行失败',
|
||||
taskCancelled: '任务已取消',
|
||||
runtimeCompleted: 'Agent Runtime 已完成响应',
|
||||
activityIncomplete: '父请求已结束,但该活动未报告完成状态',
|
||||
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
|
||||
@@ -469,7 +472,8 @@ export const app = {
|
||||
placeholder: '给 GoodBuddy 发消息…',
|
||||
imagePlaceholder: '描述你想生成的图片…',
|
||||
keyboardHint:
|
||||
'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本',
|
||||
'Enter 发送 · Shift+Enter 换行 · {{pasteShortcut}} 粘贴',
|
||||
newConversationHint: '{{shortcut}} 新建对话',
|
||||
addContent: '添加内容',
|
||||
addAttachment: '添加附件',
|
||||
attachmentProgress: {
|
||||
@@ -522,7 +526,7 @@ export const app = {
|
||||
removeAria: '从待发送队列删除“{{title}}”',
|
||||
actionFailed: '待发送队列操作失败'
|
||||
},
|
||||
shortcut: '快捷唤起:',
|
||||
shortcut: '{{shortcut}} 快捷唤起',
|
||||
context: {
|
||||
confirmedTokenCount: '本次调用 {{used}}',
|
||||
tokenCount: '本次调用估算 ≈{{used}}',
|
||||
|
||||
@@ -10,7 +10,7 @@ export const magicNotes = {
|
||||
conversationScope: '保存范围:触发时完整对话的用户与助手文字正文,不含推理、工具日志、图片和附件文件。',
|
||||
incomplete: '这条回答尚未完成,预览保留已有正文。', wait: '请等待回答完成或停止生成后再保存。',
|
||||
tooLong: '每条记录的文字不能超过 500 KB,请缩减内容后保存。',
|
||||
required: '请输入正文并选择目标笔记,或填写新笔记标题(最多 100 字)。',
|
||||
scopeHelp: '保存内容说明', titleTooLong: '笔记标题最多 100 字。',
|
||||
saving: '正在保存…', deletedTarget: '目标笔记已删除,请重新选择;草稿已保留。',
|
||||
user: '用户', assistant: '助手', loading: '正在读取完整对话…'
|
||||
},
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
.magic-note-panel__preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
|
||||
.magic-note-panel__footer { border-top: 1px solid var(--border-subtle); border-bottom: 0; }
|
||||
.magic-note-panel__footer p { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
|
||||
.magic-note-panel__source-heading { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.magic-note-panel__source-heading > span { min-width: 0; }
|
||||
.magic-note-panel__submit { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.magic-note-panel__submit > p { flex: 1 1 200px; min-width: 0; margin: 0; }
|
||||
.magic-note-panel__submit > .magic-note-panel__actions { margin-left: auto; }
|
||||
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
|
||||
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
|
||||
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConversationMessage } from '../../shared/assistant-contracts'
|
||||
|
||||
export function isCancelledMessage(message: Pick<ConversationMessage,
|
||||
'state' | 'terminalStatus' | 'status'>): boolean {
|
||||
if (message.state !== 'error') return false
|
||||
if (message.terminalStatus !== undefined) return message.terminalStatus === 'cancelled'
|
||||
// Released snapshots lack terminalStatus. Only recognize exact old labels;
|
||||
// every new terminal event supplies the structured value above.
|
||||
return message.status === '请求已取消' || message.status === '已取消' ||
|
||||
message.status === '远端 Runtime 请求已取消'
|
||||
}
|
||||
+129
-62
@@ -2467,11 +2467,6 @@
|
||||
}
|
||||
|
||||
@container magic-notes-page (max-width: 720px) {
|
||||
.magic-note-composer > footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.magic-note-entry__delete {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
@@ -2586,10 +2581,9 @@ textarea:focus-visible {
|
||||
|
||||
.sidebar > .brand,
|
||||
.sidebar > .project-switcher,
|
||||
.sidebar > .new-chat,
|
||||
.sidebar > .sidebar-search,
|
||||
.sidebar > .sidebar-conversation-controls,
|
||||
.sidebar > .primary-nav,
|
||||
.sidebar > .conversation-list,
|
||||
.sidebar > .sidebar-conversations,
|
||||
.sidebar > .sidebar-footer {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -2701,8 +2695,12 @@ textarea:focus-visible {
|
||||
.project-switcher__row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
}
|
||||
|
||||
.project-switcher__row > .icon-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-switcher__picker {
|
||||
@@ -3489,14 +3487,52 @@ textarea:focus-visible {
|
||||
align-self: auto;
|
||||
}
|
||||
|
||||
.sidebar-conversation-controls {
|
||||
display: flex;
|
||||
flex: 0 0 44px;
|
||||
height: 44px;
|
||||
min-width: 0;
|
||||
margin-bottom: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-search-slot {
|
||||
width: 36px;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
transition: width var(--motion-normal) ease-out;
|
||||
}
|
||||
|
||||
.sidebar-conversation-controls--searching .sidebar-search-slot {
|
||||
width: calc(100% - 36px - var(--space-2));
|
||||
}
|
||||
|
||||
.sidebar-conversation-controls--searching .sidebar-search-slot:only-child {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-search-slot .sidebar-search-trigger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar-search-slot {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.new-chat {
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
overflow: hidden;
|
||||
min-width: 36px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--accent-solid);
|
||||
border-radius: var(--radius-control);
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--accent-solid);
|
||||
color: var(--text-on-accent);
|
||||
cursor: pointer;
|
||||
@@ -3510,31 +3546,30 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.new-chat span {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-chat kbd {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-on-accent);
|
||||
font-size: var(--font-caption);
|
||||
.new-chat > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-conversation-controls--searching .new-chat {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
margin-block: var(--space-1);
|
||||
align-items: center;
|
||||
padding: 9px 11px;
|
||||
padding-right: 40px;
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
margin-bottom: var(--space-4);
|
||||
background: var(--surface-raised);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -3546,7 +3581,8 @@ textarea:focus-visible {
|
||||
|
||||
.sidebar-search input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
width: calc(100% - 40px - var(--space-2));
|
||||
margin-left: var(--space-2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
@@ -3558,16 +3594,12 @@ textarea:focus-visible {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-search > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-search .sidebar-search__clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-2);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
right: var(--space-1);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -3575,7 +3607,7 @@ textarea:focus-visible {
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
@@ -3648,13 +3680,45 @@ textarea:focus-visible {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.sidebar-conversations {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
min-width: 248px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding-top: var(--space-4);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.sidebar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-conversations {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: none) and (pointer: fine) {
|
||||
.conversation-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-control) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-entry + .conversation-entry {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
@@ -3806,7 +3870,8 @@ textarea:focus-visible {
|
||||
|
||||
.sidebar-footer {
|
||||
min-width: 248px;
|
||||
padding-top: 12px;
|
||||
padding-top: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
@@ -4638,6 +4703,23 @@ button > svg {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-files__diff-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.workspace-files__diff-heading > strong {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.workspace-files__diff-heading > .workspace-files__toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.assistant-sidebar__body--browser {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -5274,7 +5356,8 @@ button > svg {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button {
|
||||
.assistant-sidebar__browser-tool-button,
|
||||
.assistant-sidebar__browser-go {
|
||||
width: var(--control-height);
|
||||
padding: 0;
|
||||
flex: 0 0 var(--control-height);
|
||||
@@ -5305,14 +5388,6 @@ button > svg {
|
||||
flex-basis: calc(100% - var(--control-height) - var(--space-1));
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go {
|
||||
width: var(--control-height);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-address input {
|
||||
@@ -5332,11 +5407,6 @@ button > svg {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-go {
|
||||
padding: 0 var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.assistant-sidebar__browser-tool-button:hover:not(:disabled),
|
||||
.assistant-sidebar__browser-go:hover:not(:disabled) {
|
||||
border-color: var(--accent-selected);
|
||||
@@ -7914,11 +7984,6 @@ button > svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.composer-meta__shortcut {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-context-compact {
|
||||
display: inline-flex;
|
||||
min-width: var(--composer-context-compact-width);
|
||||
@@ -12513,7 +12578,13 @@ textarea.field-control {
|
||||
}
|
||||
|
||||
.conversation-pin {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -16432,7 +16503,6 @@ textarea.field-control {
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.sidebar-search,
|
||||
.settings-section,
|
||||
.settings-tabs,
|
||||
.assistant-sidebar__row,
|
||||
@@ -16536,7 +16606,6 @@ textarea.field-control {
|
||||
.runtime-picker__menu > strong,
|
||||
.runtime-picker__menu > button > small,
|
||||
.message__meta span,
|
||||
.message__status,
|
||||
.settings-panel__description,
|
||||
.settings-tabs button small,
|
||||
.settings-section__title small,
|
||||
@@ -16793,8 +16862,6 @@ textarea.field-control {
|
||||
|
||||
.brand,
|
||||
.project-switcher,
|
||||
.new-chat,
|
||||
.sidebar-search,
|
||||
.primary-nav,
|
||||
.conversation-list,
|
||||
.sidebar-footer {
|
||||
|
||||
@@ -395,6 +395,7 @@ export const conversationMessageSchema = z
|
||||
displayCaptureTruncated: z.boolean().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
terminalStatus: z.enum(['failed', 'cancelled']).optional(),
|
||||
status: z.string().max(4_000).optional(),
|
||||
contextCompression:
|
||||
conversationContextCompressionMarkerSchema.optional(),
|
||||
|
||||
@@ -498,7 +498,7 @@ export type RuntimeConversationCompactInput = z.infer<
|
||||
|
||||
export const runtimeConversationCompactResultSchema = z
|
||||
.object({
|
||||
provider: z.enum(['opencode', 'continue']),
|
||||
provider: z.enum(['opencode', 'continue', 'model']),
|
||||
strategy: z.enum(['native', 'goodbuddy-summary']),
|
||||
compacted: z.boolean(),
|
||||
detail: z.string().trim().min(1).max(500),
|
||||
|
||||
@@ -810,7 +810,9 @@ describe('release build arguments', () => {
|
||||
)
|
||||
|
||||
expect(packageJson.build.files).toEqual([
|
||||
'out/**/*',
|
||||
'out/main/**/*',
|
||||
'out/preload/**/*',
|
||||
'out/renderer/**/*',
|
||||
'package.json',
|
||||
'!node_modules/npm{,/**/*}'
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -13,6 +13,27 @@ const { getFileMatchers, copyFiles } = require('app-builder-lib/out/fileMatcher'
|
||||
const { verifyOpenCodeConfig, verifyHarnessBundleImports } = require('../build/verify-desktop-runtimes.cjs')
|
||||
|
||||
describe('desktop Runtime packaging', () => {
|
||||
it('ships only production output even when validation builds remain in out', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-production-output-'))
|
||||
const output = join(root, 'output')
|
||||
const included = ['out/main/index.js', 'out/main/chunks/shared.js', 'out/main/obsidian-mcpvault/server.js', 'out/preload/index.cjs', 'out/renderer/index.html', 'package.json']
|
||||
const excluded = ['out/chat-paste-validation/renderer/index.html', 'out/another-probe/main.js']
|
||||
try {
|
||||
for (const file of [...included, ...excluded]) {
|
||||
mkdirSync(dirname(join(root, file)), { recursive: true })
|
||||
writeFileSync(join(root, file), file)
|
||||
}
|
||||
await copyFiles(getFileMatchers({ files: packageJson.build.files }, 'files', output, {
|
||||
defaultSrc: root, globalOutDir: output, customBuildOptions: {},
|
||||
macroExpander: (value: string) => value
|
||||
}))
|
||||
for (const file of included) expect(existsSync(join(output, file)), file).toBe(true)
|
||||
for (const file of excluded) expect(existsSync(join(output, file)), file).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('runs packaged Windows Runtime checks in CI before removing the unpacked output', () => {
|
||||
const release = readFileSync(join(process.cwd(), 'build', 'build-release.cjs'), 'utf8')
|
||||
const smoke = readFileSync(join(process.cwd(), 'build', 'run-packaged-deepseek-harness-smoke.cjs'), 'utf8')
|
||||
|
||||
@@ -60,7 +60,7 @@ it('captures messages and full history from App through real Electron preload, I
|
||||
process.stdout.write(`Magic notes capture Electron evidence: ${JSON.stringify(result)}\n`)
|
||||
expect(result.error, output).toBeUndefined()
|
||||
expect(result.dangerButtonStyles.map((style: { theme: string }) => style.theme)).toEqual(['light', 'dark'])
|
||||
expect(result).toMatchObject({ existingSaved: true, newSaved: true, fullHistoryCount: 502,
|
||||
expect(result).toMatchObject({ sidebarSearch: 6, existingSaved: true, newSaved: true, fullHistoryCount: 502,
|
||||
sourceOpened: true, workspaceOpened: true, keyboard: true, narrowPanel: true,
|
||||
sqliteReopened: true, sourceSurvivesDeletion: true, modelAttempts: 0 })
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('renders compact capture footers and responsive save errors in Electron across locales and themes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-footer-'))
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'notes-footer', configureServer(server) {
|
||||
server.middlewares.use('/footer.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/footer.html',
|
||||
'<html><body><script type="module" src="/tests/support/magic-notes-footer-regression.tsx"></script></body></html>'))
|
||||
})
|
||||
} }],
|
||||
server: { host: '127.0.0.1', port: 0, watch: null },
|
||||
optimizeDeps: { entries: ['tests/support/magic-notes-footer-regression.tsx'] }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const driver = join(directory, 'driver.cjs')
|
||||
await writeFile(driver, `
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const trace = message => fs.appendFileSync(${JSON.stringify(join(directory, 'driver.log'))}, message + '\\n');
|
||||
trace('Starting Electron');
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1');
|
||||
// Keep animation-frame measurements running when other test windows cover this one.
|
||||
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
|
||||
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
|
||||
app.whenReady().then(async () => {
|
||||
trace('Electron ready');
|
||||
const win = new BrowserWindow({ show: true, width: 900, height: 720, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
|
||||
const js = async code => {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([win.webContents.executeJavaScript(code), new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Renderer timeout: ' + code)), 15000);
|
||||
})]);
|
||||
} catch (error) { throw new Error(code + ': ' + error.message); }
|
||||
finally { clearTimeout(timeout); }
|
||||
};
|
||||
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
const wait = async code => { for (let i = 0; i < 400; i++) {
|
||||
if (await js(code)) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
} throw new Error('Timeout: ' + code); };
|
||||
const click = async selector => {
|
||||
const r = await js('document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect().toJSON()');
|
||||
win.focus(); win.webContents.focus();
|
||||
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
|
||||
await settle();
|
||||
};
|
||||
const resize = async width => {
|
||||
win.show(); win.focus();
|
||||
win.setContentSize(width, 720);
|
||||
await wait('innerWidth === ' + width); await settle();
|
||||
};
|
||||
const measure = () => js(
|
||||
'(() => {' +
|
||||
'const footer = document.querySelector(".magic-note-panel__footer"), style = getComputedStyle(footer);' +
|
||||
'const rect = selector => document.querySelector(selector)?.getBoundingClientRect().toJSON();' +
|
||||
'return { footer: footer.getBoundingClientRect().toJSON(), actions: rect(".magic-note-panel__footer .magic-note-panel__actions"),' +
|
||||
'cancel: rect(".magic-note-panel__footer .secondary-button"), save: rect(".magic-note-panel__footer .primary-button"),' +
|
||||
'error: rect("#compact-note-save-error"), paddingRight: parseFloat(style.paddingRight),' +
|
||||
'chrome: parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth),' +
|
||||
'background: style.backgroundColor, overflow: document.documentElement.scrollWidth > innerWidth }; })()');
|
||||
const aligned = (m, context) => {
|
||||
assert(Math.abs(m.save.right - (m.footer.right - m.paddingRight)) <= 1, context + ': right alignment ' + JSON.stringify(m));
|
||||
assert(Math.abs(m.cancel.y - m.save.y) <= 1, context + ': buttons stay on one row');
|
||||
assert(m.cancel.right <= m.save.x && m.cancel.x >= m.footer.x, context + ': buttons fit without overlap');
|
||||
assert(m.save.bottom <= m.footer.bottom && m.footer.bottom <= 720, context + ': footer remains visible');
|
||||
assert(!m.overflow, context + ': no horizontal overflow');
|
||||
};
|
||||
const screenshot = async name => {
|
||||
if (process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR) {
|
||||
fs.writeFileSync(require('node:path').join(process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR, name + '.png'), (await win.webContents.capturePage()).toPNG());
|
||||
}
|
||||
};
|
||||
const results = [];
|
||||
for (const locale of ['en-US', 'zh-CN']) for (const theme of ['light', 'dark']) for (const kind of ['message', 'conversation']) {
|
||||
const context = locale + '/' + theme + '/' + kind;
|
||||
trace(context + ': navigating');
|
||||
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'footer.html')} + '?locale=' + locale + '&theme=' + theme + '&kind=' + kind);
|
||||
trace(context + ': loaded');
|
||||
await resize(900);
|
||||
trace(context + ': resized');
|
||||
await wait('!!document.querySelector("#compact-note-text") && !document.querySelector(".magic-note-panel__scroll [role=status]")');
|
||||
trace(context + ': rendered');
|
||||
await js('document.fonts.ready'); await settle();
|
||||
trace(context + ': fonts ready');
|
||||
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context);
|
||||
assert.equal(await js('document.body.dataset.saveCount'), '0', context);
|
||||
assert(await js('document.querySelector(".magic-note-panel__footer .primary-button").disabled'), context);
|
||||
assert.equal(await js('document.querySelector(".magic-note-panel__footer p")'), null, context + ': no initial footer paragraph');
|
||||
const initial = [];
|
||||
for (const width of [900, 300]) {
|
||||
trace(context + ': initial ' + width);
|
||||
await resize(width);
|
||||
const m = await measure(); aligned(m, context + '/' + width);
|
||||
assert.equal(m.footer.width, width, context + ': actual panel width');
|
||||
assert(Math.abs(m.footer.height - m.chrome - m.actions.height) <= 1, context + ': compact footer ' + JSON.stringify(m));
|
||||
assert(m.footer.height <= 65, context + ': bounded initial footer height');
|
||||
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context + ': scope hidden');
|
||||
await click('.magic-note-panel__source-heading .inline-help');
|
||||
await wait('!!document.querySelector("[role=tooltip]")');
|
||||
assert(await js('document.querySelector("[role=tooltip]").checkVisibility()'), context);
|
||||
assert.equal(await js('document.querySelector("[role=tooltip]").textContent'), await js('document.body.dataset.scope'), context + ': correct capture scope');
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||
await wait('!document.querySelector("[role=tooltip]")');
|
||||
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context);
|
||||
assert.equal((await measure()).footer.height, m.footer.height, context + ': help does not expand footer');
|
||||
initial.push({ width, height: m.footer.height, saveWidth: m.save.width });
|
||||
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-initial-' + width);
|
||||
}
|
||||
assert(Math.abs(initial[0].saveWidth - initial[1].saveWidth) <= 1, context + ': content-sized button');
|
||||
await resize(900);
|
||||
trace(context + ': saving');
|
||||
await click('.magic-note-panel__scroll .segmented-control button:last-child');
|
||||
await wait('!!document.querySelector("#compact-note-title")');
|
||||
await click('.magic-note-panel__footer .primary-button');
|
||||
await wait('!!document.querySelector("#compact-note-save-error") && !document.querySelector(".magic-note-panel__footer .primary-button").disabled');
|
||||
assert.equal(await js('document.body.dataset.saveCount'), '1', context + ': real save handler reaches storage');
|
||||
assert.equal(await js('document.querySelector("#compact-note-save-error").textContent'), 'Storage unavailable. Please try again.', context);
|
||||
assert.equal(await js('document.querySelector("#compact-note-save-error").getAttribute("role")'), 'alert', context);
|
||||
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context + ': failed save preserves draft');
|
||||
const wide = await measure(); aligned(wide, context + '/wide-error');
|
||||
assert(Math.abs((wide.error.y + wide.error.height / 2) - (wide.actions.y + wide.actions.height / 2)) <= 1, context + ': error and actions share row');
|
||||
assert(wide.error.right <= wide.actions.x, context + ': error does not overlap actions');
|
||||
assert.equal(wide.footer.height, initial[0].height, context + ': wide error adds no footer height');
|
||||
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-900');
|
||||
await resize(300);
|
||||
const narrow = await measure(); aligned(narrow, context + '/narrow-error');
|
||||
trace(context + ': narrow error measured');
|
||||
assert(narrow.actions.y >= narrow.error.bottom + 7, context + ': error wraps above buttons ' + JSON.stringify(narrow));
|
||||
assert(narrow.footer.height > wide.footer.height, context + ': wrapped footer grows');
|
||||
assert(Math.abs(narrow.save.width - wide.save.width) <= 1, context + ': error does not stretch button');
|
||||
assert(await js('(() => { const e = document.querySelector("#compact-note-save-error"); return e.checkVisibility() && e.scrollWidth <= e.clientWidth && e.scrollHeight <= e.clientHeight; })()'), context + ': error is not clipped');
|
||||
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-300');
|
||||
results.push({ locale, theme, kind, initial, wideHeight: wide.footer.height, narrowHeight: narrow.footer.height,
|
||||
wideErrorY: wide.error.y, wideActionsY: wide.actions.y, narrowErrorBottom: narrow.error.bottom,
|
||||
narrowActionsY: narrow.actions.y, rightInset: narrow.footer.right - narrow.save.right, background: wide.background });
|
||||
}
|
||||
assert.notEqual(results[0].background, results[2].background, 'Real theme styles applied');
|
||||
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify(results));
|
||||
app.exit(0);
|
||||
}).catch(error => { trace(error.stack || String(error)); console.error(error); app.exit(1); });
|
||||
`)
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => { timedOut = true; child.kill() }, 90000)
|
||||
try {
|
||||
const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once('close', (code, signal) => resolve({ code, signal }))
|
||||
child.once('error', reject)
|
||||
})
|
||||
output += await readFile(join(directory, 'driver.log'), 'utf8').catch(() => '')
|
||||
expect(timedOut, `Electron footer exceeded 90s (exit=${code}, signal=${signal}).\n${output}`).toBe(false)
|
||||
expect(signal, output).toBeNull()
|
||||
expect(code, output).toBe(0)
|
||||
} finally { clearTimeout(timeout) }
|
||||
const results = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
|
||||
console.info('Notes footer Electron geometry:', JSON.stringify(results, null, 2))
|
||||
expect(results).toHaveLength(8)
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
}, 120000)
|
||||
@@ -74,6 +74,8 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
await open(); await js('document.fonts.ready'); win.focus(); win.webContents.focus();
|
||||
const editor = '.magic-note-composer .magic-note-editor__content .ql-editor';
|
||||
const emptyHeight = (await rect(editor)).height;
|
||||
const saveButton = '.magic-note-composer > footer > .primary-button';
|
||||
const desktopSaveWidth = (await rect(saveButton)).width;
|
||||
assert(emptyHeight >= 110 && emptyHeight <= 125, 'Empty composer height: ' + emptyHeight);
|
||||
assert.equal(await js('document.querySelector("#magic-notes-title").textContent'), 'Layout note');
|
||||
assert.equal(await js('document.querySelector(".page-header__description")'), null);
|
||||
@@ -113,6 +115,10 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
|
||||
const narrowStream = (await rect(stream)).width;
|
||||
await click('#magic-notes-index-toggle'); assert.equal((await rect(stream)).width, narrowStream);
|
||||
const narrowSave = await rect(saveButton), narrowFooter = await rect('.magic-note-composer > footer');
|
||||
const footerPaddingRight = await js('parseFloat(getComputedStyle(document.querySelector(".magic-note-composer > footer")).paddingRight)');
|
||||
assert(Math.abs(narrowSave.width - desktopSaveWidth) <= 1, 'Narrow Save button retains its content width: ' + JSON.stringify({ desktopSaveWidth, narrowSave, narrowFooter }));
|
||||
assert(Math.abs(narrowFooter.x + narrowFooter.width - footerPaddingRight - narrowSave.x - narrowSave.width) <= 1, 'Narrow Save button stays right-aligned within footer padding');
|
||||
await screenshot('magic-notes-narrow');
|
||||
win.setContentSize(1280, 800); await settle(); await settle();
|
||||
assert.equal((await rect(index)).width, withoutAi);
|
||||
@@ -161,7 +167,7 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
await wait('!!document.querySelector("#magic-library-switch")');
|
||||
await click('#magic-library-switch');
|
||||
assert.equal((await rect('#magic-todo-list')).width, todoWidth);
|
||||
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, persisted: true }));
|
||||
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ emptyHeight, grownHeight, dragged, withoutAi, constrained, narrowStream, desktopSaveWidth, narrowSave, narrowFooter, footerPaddingRight, persisted: true }));
|
||||
app.exit(0);
|
||||
}).catch(error => { console.error(error); app.exit(1); });
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it.skipIf(!process.env.GB_COMPACT_LIVE_SETTINGS)('uses manual compaction and restores it with two real text requests', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-compact-live-'))
|
||||
try {
|
||||
const main = join(directory, 'main.cjs')
|
||||
await build({ entryPoints: ['tests/support/manual-compaction-live.ts'], outfile: main, bundle: true,
|
||||
platform: 'node', format: 'cjs', external: ['electron'], plugins: [{ name: 'external-packages', setup(builder) {
|
||||
builder.onResolve({ filter: /^[^./]/ }, async (args) => {
|
||||
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
|
||||
const result = await builder.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
|
||||
return result.errors.length ? { errors: result.errors } : { path: result.path, external: true }
|
||||
})
|
||||
} }] })
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_COMPACT_LIVE_DIRECTORY: directory }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [main], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += String(data) })
|
||||
child.stderr.on('data', data => { output += String(data) })
|
||||
const timeout = setTimeout(() => child.kill(), 100000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
|
||||
process.stdout.write(output)
|
||||
expect(code, output).toBe(0)
|
||||
} finally { clearTimeout(timeout) }
|
||||
} finally { await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) }
|
||||
}, 110000)
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('compacts direct model context from App through preload, IPC, HTTP and SQLite', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-compaction-'))
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'compaction', configureServer(server) {
|
||||
server.middlewares.use('/compact.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/compact.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], server: { host: '127.0.0.1', port: 0, watch: null },
|
||||
optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'], include: ['fabric', 'quill', 'quill/blots/block', 'quill-delta', 'jspdf', 'pdfjs-dist', 'react', 'react-dom/client', 'react-i18next'] }
|
||||
})
|
||||
try {
|
||||
const driver = join(directory, 'main.mjs')
|
||||
await build({ entryPoints: ['tests/support/manual-compaction-main.ts'], outfile: driver,
|
||||
bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
|
||||
{ name: 'external-packages', setup(builder) {
|
||||
builder.onResolve({ filter: /^[^./]/ }, async args => {
|
||||
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
|
||||
const result = await builder.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
|
||||
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
|
||||
})
|
||||
} }
|
||||
] })
|
||||
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' })
|
||||
await server.listen()
|
||||
const bootstrap = join(directory, 'bootstrap.cjs')
|
||||
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_COMPACT_DIRECTORY: directory, GB_COMPACT_URL: server.resolvedUrls!.local[0] + 'compact.html' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 100000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
|
||||
expect(code, output).toBe(0)
|
||||
expect(output).toContain('"summaryRestored":true')
|
||||
process.stdout.write(output)
|
||||
} finally { clearTimeout(timeout) }
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}, 120000)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
@@ -12,9 +12,29 @@ it('copies DS dependencies and nested assets through the actual builder matcher'
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-dsh-packaging-'))
|
||||
const config = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const resource = config.build.extraResources.find((entry: { to: string }) => entry.to.startsWith('runtimes/dsh'))
|
||||
const files = ['@deepseek-ai/dsh/lib/bin.js', '@deepseek-ai/dsh/node_modules/example/index.js', '@deepseek-ai/dsh-web/dist/index.html']
|
||||
const files = [
|
||||
'@deepseek-ai/dsh/lib/bin.js',
|
||||
'@deepseek-ai/dsh/node_modules/example/index.js',
|
||||
'@deepseek-ai/dsh-web/dist/index.html',
|
||||
'@deepseek-ai/dsh-web/config.yml',
|
||||
'@deepseek-ai/dsh-web/LICENSE',
|
||||
'native/addon.node',
|
||||
'native/helper.exe',
|
||||
'native/helper.dll',
|
||||
'assets/data.map'
|
||||
]
|
||||
const excluded = [
|
||||
'@deepseek-ai/dsh/lib/bin.js.map',
|
||||
'@deepseek-ai/dsh/node_modules/example/index.d.ts',
|
||||
'example/index.d.mts',
|
||||
'example/index.d.cts',
|
||||
'example/index.d.ts.map',
|
||||
'example/index.mjs.map',
|
||||
'example/index.cjs.map',
|
||||
'native/helper.pdb'
|
||||
]
|
||||
try {
|
||||
for (const file of files) {
|
||||
for (const file of [...files, ...excluded]) {
|
||||
const source = join(root, '.runtime-resources/dsh-x64/node_modules', file)
|
||||
mkdirSync(dirname(source), { recursive: true })
|
||||
writeFileSync(source, file)
|
||||
@@ -30,6 +50,9 @@ it('copies DS dependencies and nested assets through the actual builder matcher'
|
||||
for (const file of files) {
|
||||
expect(readFileSync(join(destination, 'runtimes/dsh/node_modules', file), 'utf8')).toBe(file)
|
||||
}
|
||||
for (const file of excluded) {
|
||||
expect(existsSync(join(destination, 'runtimes/dsh/node_modules', file)), file).toBe(false)
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { createServer } from 'vite'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('measures real streaming Markdown and preserves every prefix against the original normalizer', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-streaming-markdown-'))
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [{
|
||||
name: 'streaming-markdown-instrumentation', enforce: 'pre',
|
||||
async load(id) {
|
||||
if (!id.endsWith('MarkdownRenderer.tsx?streaming-original')) return
|
||||
const source = await readFile(id.split('?')[0]!, 'utf8')
|
||||
return source.replace(/(?:export )?function replaceLatexDelimiters[\s\S]*?(?=function unwrapMarkdownFence)/u,
|
||||
'import { originalNormalize as normalizeLatexDelimiters } from "/tests/support/streaming-markdown-corpus.ts"\n\n')
|
||||
},
|
||||
transform(source, id) {
|
||||
if (id.split('?')[0]!.endsWith('/MarkdownRenderer.tsx')) {
|
||||
return source + '\nexport { normalizeLatexDelimiters as benchmarkNormalize }\n'
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/streaming-markdown.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await server.transformIndexHtml('/streaming-markdown.html',
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/support/streaming-markdown-perf.tsx"></script></body></html>'))
|
||||
})
|
||||
}
|
||||
}, react()],
|
||||
server: { host: '127.0.0.1', port: 0, watch: null }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_MARKDOWN_URL: server.resolvedUrls!.local[0] + 'streaming-markdown.html',
|
||||
GOODBUDDY_MARKDOWN_DIRECTORY: directory }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/streaming-markdown-driver.mjs'), driver)
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 150_000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.once('exit', resolve)
|
||||
child.once('error', reject)
|
||||
})
|
||||
expect(code, output).toBe(0)
|
||||
process.stdout.write(output.split(/\r?\n/u).filter(line => line.startsWith('Markdown ')).join('\n') + '\n')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
if (child.exitCode === null) child.kill()
|
||||
}
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
@@ -8,6 +8,7 @@ import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
import { verifySidebarSearch } from './sidebar-search-probe'
|
||||
|
||||
const directory = process.env.GB_CAPTURE_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
@@ -84,6 +85,7 @@ app.whenReady().then(async () => {
|
||||
win.webContents.focus()
|
||||
await wait('document.hasFocus()', 'visible capture window focus')
|
||||
await wait('document.body.innerText.includes("CAPTURE_HISTORY_501_END")', 'App persisted history')
|
||||
evidence.sidebarSearch = await verifySidebarSearch(win)
|
||||
assert.equal(await run('document.body.innerText.includes("CAPTURE_HISTORY_0_END")'), false, 'old history must initially be folded')
|
||||
// The last assistant reply is rendered by the real ChatTimeline.
|
||||
await run(`[...document.querySelectorAll('button')].filter(e => e.getAttribute('aria-label') === 'Add to note' || e.title === 'Add to note').at(-1).click()`)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { DesktopApi } from '../../src/shared/contracts'
|
||||
import type { MagicNoteSource } from '../../src/shared/magic-notes-contracts'
|
||||
import { MagicNotesPanel } from '../../src/renderer/src/MagicNotesPanel'
|
||||
import { useMagicNoteDraft } from '../../src/renderer/src/use-magic-note-draft'
|
||||
import i18n from '../../src/renderer/src/i18n'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const parameters = new URLSearchParams(location.search)
|
||||
await i18n.changeLanguage(parameters.get('locale') === 'zh-CN' ? 'zh-CN' : 'en-US')
|
||||
document.documentElement.dataset.theme = parameters.get('theme') === 'dark' ? 'dark' : 'light'
|
||||
const source: MagicNoteSource = {
|
||||
kind: parameters.get('kind') === 'conversation' ? 'conversation' : 'message',
|
||||
conversationId: 'footer-conversation', messageIds: ['footer-message'],
|
||||
capturedAt: '2026-09-26T00:00:00Z', conversationTitle: 'Footer discussion', projectName: 'Footer project'
|
||||
}
|
||||
document.body.dataset.scope = i18n.t(`capture.${source.kind === 'message' ? 'messageScope' : 'conversationScope'}`, { ns: 'magicNotes' })
|
||||
document.body.dataset.saveCount = '0'
|
||||
window.goodbuddy = {
|
||||
magicNotes: {
|
||||
search: async () => [],
|
||||
onChanged: () => () => {},
|
||||
create: async () => {
|
||||
document.body.dataset.saveCount = String(Number(document.body.dataset.saveCount) + 1)
|
||||
throw new Error('Storage unavailable. Please try again.')
|
||||
}
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
|
||||
function Harness(): React.JSX.Element {
|
||||
const state = useMagicNoteDraft()
|
||||
const { setDraft } = state
|
||||
useLayoutEffect(() => {
|
||||
setDraft({ text: 'Captured text', initialText: 'Captured text', title: 'Footer discussion',
|
||||
initialTitle: 'Footer discussion', targetId: '', newNote: false, source })
|
||||
}, [setDraft])
|
||||
return <>
|
||||
<MagicNotesPanel state={state} active commentMode="after-save-manual" onNotify={() => {}}
|
||||
onOpenWorkspace={() => {}} onOpenSource={async () => 'opened'} onCancelCapture={() => {}} />
|
||||
{state.confirmation}
|
||||
</>
|
||||
}
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.style.height = '100vh'
|
||||
document.body.append(host)
|
||||
createRoot(host).render(<Harness />)
|
||||
@@ -0,0 +1,93 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
|
||||
import { createModelProfileRuntime } from '../../src/main/agent/create-runtime'
|
||||
import { applyRuntimeSelection } from '../../src/main/agent/runtime-selection'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
|
||||
let calls = 0
|
||||
let stage = 'settings'
|
||||
void app.whenReady().then(async () => {
|
||||
const directory = process.env.GB_COMPACT_LIVE_DIRECTORY!
|
||||
const source = process.env.GB_COMPACT_LIVE_SETTINGS!
|
||||
const original = readFileSync(source)
|
||||
const snapshot = join(directory, 'settings-snapshot.json')
|
||||
writeFileSync(snapshot, original)
|
||||
const store = new RuntimeSettingsStore(snapshot, {
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: () => { throw new Error('Read-only probe') },
|
||||
decrypt: value => safeStorage.decryptString(value)
|
||||
}, process.env)
|
||||
const settings = await store.getResolvedSettings().finally(() => unlinkSync(snapshot))
|
||||
stage = 'profile'
|
||||
const profile = settings.modelProfiles.find(item => item.protocol !== 'openai-images-generations' &&
|
||||
(item.authentication === 'none' || Boolean(item.apiKey)))
|
||||
assert(profile && profile.protocol !== 'openai-images-generations')
|
||||
console.log(JSON.stringify({ credentialReadable: Boolean(profile.apiKey), authentication: profile.authentication,
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable() }))
|
||||
const runtimeSelection = { provider: 'model' as const, profileId: profile.id }
|
||||
const selected = applyRuntimeSelection(settings, runtimeSelection).settings
|
||||
selected.contextCompression = { ...(selected.contextCompression ?? defaultRuntimeSettings.contextCompression), enabled: false, modelSource: { kind: 'current' } }
|
||||
selected.workspacePath = directory
|
||||
const originalFetch = globalThis.fetch
|
||||
const payloads: string[] = []
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert(calls < 2, 'Two-call budget exceeded')
|
||||
const body = JSON.parse(String(init?.body))
|
||||
assert.equal(body.tools?.length ?? 0, 0)
|
||||
payloads.push(JSON.stringify(body.messages ?? body.input))
|
||||
calls++
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
const history = [
|
||||
{ role: 'user' as const, content: 'Remember the project codename is Cedar. Reply briefly.' },
|
||||
{ role: 'assistant' as const, content: 'The project codename is Cedar.' },
|
||||
{ role: 'user' as const, content: 'The next meeting is Tuesday.' },
|
||||
{ role: 'assistant' as const, content: 'Meeting: Tuesday.' }
|
||||
]
|
||||
const historyMessageIds = history.map(() => crypto.randomUUID())
|
||||
const runtime = createModelProfileRuntime(directory, selected, { ...profile, maximumOutputTokens: 1024 })
|
||||
stage = 'compact'
|
||||
const signal = AbortSignal.timeout(90000)
|
||||
const result = await runtime.compactConversation({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), runtimeSelection,
|
||||
history, historyMessageIds
|
||||
}, signal)
|
||||
assert.equal(result.result.compacted, true)
|
||||
const state = result.result.contextCompressionState!
|
||||
assert.equal(state.coveredMessageCount, 2)
|
||||
await runtime.dispose()
|
||||
// A new Runtime must consume the serialized state, without relying on an in-memory cache.
|
||||
const next = createModelProfileRuntime(directory, selected, { ...profile, maximumOutputTokens: 1024 })
|
||||
stage = 'restore'
|
||||
let answer = ''
|
||||
try {
|
||||
for await (const event of next.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), runtimeSelection,
|
||||
prompt: 'What is the project codename? Reply with that word only.', history, historyMessageIds,
|
||||
contextCompressionState: JSON.parse(JSON.stringify(state))
|
||||
}, signal)) {
|
||||
assert.notEqual(event.type, 'error', 'Runtime returned an error')
|
||||
if (event.type === 'text') answer += event.delta
|
||||
}
|
||||
} finally { await next.dispose() }
|
||||
stage = 'summary-payload'
|
||||
assert(payloads[1]!.includes(JSON.stringify(state.summary).slice(1, -1)))
|
||||
stage = 'history-payload'
|
||||
assert(!payloads[1]!.includes(history[0]!.content))
|
||||
assert(payloads[1]!.includes(history[2]!.content))
|
||||
stage = 'answer'
|
||||
assert.match(answer, /Cedar/i)
|
||||
stage = 'settings-unchanged'
|
||||
assert(readFileSync(source).equals(original))
|
||||
console.log(JSON.stringify({ realCallCount: calls, compacted: true, coveredMessageCount: state.coveredMessageCount,
|
||||
summaryRestoredInNewRuntime: true, recentHistoryRetained: true, answerCorrect: true, sourceSettingsUnchanged: true }))
|
||||
app.exit(0)
|
||||
}).catch((error: unknown) => {
|
||||
console.error(JSON.stringify({ realCallCount: calls, failed: true, stage,
|
||||
errorType: error instanceof Error ? error.name : 'unknown',
|
||||
location: error instanceof Error ? error.stack?.split('\n')[1]?.replace(/\([^)]*\)/g, '(redacted)') : undefined }))
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
|
||||
import { createModelProfileRuntime } from '../../src/main/agent/create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
|
||||
const directory = process.env.GB_COMPACT_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
const payloads: string[] = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk
|
||||
payloads.push(body)
|
||||
response.setHeader('Content-Type', 'text/event-stream')
|
||||
const content = payloads.length === 1 ? 'Project codename: Cedar.' : 'Cedar'
|
||||
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
assert(address && typeof address !== 'string')
|
||||
const settingsStore = new RuntimeSettingsStore(join(directory, 'runtime.json'), {
|
||||
isAvailable: () => false, encrypt: () => { throw new Error('No credentials') }, decrypt: () => { throw new Error('No credentials') }
|
||||
}, {})
|
||||
const profileId = randomUUID()
|
||||
await settingsStore.update({ ...defaultRuntimeSettings, apiKey: { action: 'keep' }, provider: 'model', workspacePath: directory,
|
||||
deepseekHarnessModelSource: { kind: 'platform' },
|
||||
modelProfiles: [{ id: profileId, name: 'Local text fixture', baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
modelName: 'selected-text', protocol: 'openai-chat-completions', authentication: 'none',
|
||||
imageGenerationQuality: 'auto', apiKey: { action: 'keep' } }],
|
||||
defaultModelProfileId: profileId,
|
||||
contextCompression: { ...defaultRuntimeSettings.contextCompression, enabled: false }
|
||||
})
|
||||
const settings = await settingsStore.getResolvedSettings()
|
||||
const runtime = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!)
|
||||
const win = new BrowserWindow({ show: true, width: 1280, height: 900, webPreferences: {
|
||||
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false,
|
||||
preload: join(directory, 'preload.cjs')
|
||||
} })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
const project = database.listProjects()[0]!
|
||||
const header = { id: randomUUID(), title: 'Manual compaction', projectId: project.id,
|
||||
runtimeSelection: { provider: 'model' as const, profileId }, updatedAt: Date.now() }
|
||||
const messages = ['Remember the project codename is Cedar.', 'Codename recorded.', 'Meeting is Tuesday.', 'Tuesday recorded.']
|
||||
.map((content, index) => ({ id: randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const,
|
||||
content, state: 'complete' as const, createdAt: Date.now() + index }))
|
||||
database.saveLocalConversations([{ header, messages }])
|
||||
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await applicationSettings.update({ checkUpdatesOnStartup: false })
|
||||
// Only unrelated App services are fixtures; compaction, model HTTP and persistence use production code.
|
||||
const dispose = registerIpcHandlers(win, runtime, 'CommandOrControl+Shift+Space', settingsStore,
|
||||
{} as never, { clear() {}, cancelImport() {}, getDraft: () => [] } as never,
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never, database,
|
||||
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
||||
async function wait(check: () => Promise<unknown> | unknown, label: string): Promise<void> {
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (await check()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error(`Timed out: ${label}`)
|
||||
}
|
||||
try {
|
||||
await win.loadURL(process.env.GB_COMPACT_URL!)
|
||||
const button = `[...document.querySelectorAll('button')].find(e => e.textContent.trim() === 'Compact context')`
|
||||
await wait(() => run(`Boolean(${button}) && !${button}.disabled`), 'manual button with auto disabled')
|
||||
await run(`${button}.click()`)
|
||||
await wait(() => database.getConversation(header.id).contextCompressionState, 'summary persisted by App')
|
||||
const saved = database.getConversation(header.id)
|
||||
assert.equal(saved.contextCompressionState?.coveredMessageCount, 2)
|
||||
assert.equal(saved.contextCompressionState?.summary, 'Project codename: Cedar.')
|
||||
assert.deepEqual(saved.messages.map(message => message.content), messages.map(message => message.content))
|
||||
assert.equal(payloads.length, 1)
|
||||
assert.equal(JSON.parse(payloads[0]!).model, 'selected-text')
|
||||
assert(payloads[0]!.includes(messages[0]!.content))
|
||||
assert(!payloads[0]!.includes(messages[2]!.content))
|
||||
await win.reload()
|
||||
await wait(() => run(`document.body.innerText.includes('Tuesday recorded.')`), 'reload persisted conversation')
|
||||
const restored = await run<{ contextCompressionState: typeof saved.contextCompressionState }>(
|
||||
`window.goodbuddy.conversations.get(${JSON.stringify(header.id)})`)
|
||||
assert.deepEqual(restored.contextCompressionState, saved.contextCompressionState)
|
||||
// A fresh production Runtime consumes the state returned across the real preload boundary.
|
||||
const next = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!)
|
||||
try {
|
||||
for await (const event of next.run({ requestId: randomUUID(), conversationId: header.id,
|
||||
runtimeSelection: header.runtimeSelection, prompt: 'What is the codename?',
|
||||
history: messages.map(({ role, content }) => ({ role, content })), historyMessageIds: messages.map(({ id }) => id),
|
||||
contextCompressionState: restored.contextCompressionState
|
||||
}, AbortSignal.timeout(10000))) {
|
||||
assert.notEqual(event.type, 'error')
|
||||
}
|
||||
} finally { await next.dispose() }
|
||||
assert.equal(payloads.length, 2)
|
||||
assert(payloads[1]!.includes(saved.contextCompressionState!.summary))
|
||||
assert(!payloads[1]!.includes(messages[0]!.content))
|
||||
assert(payloads[1]!.includes(messages[2]!.content))
|
||||
console.log(JSON.stringify({ appClick: true, ipc: true, sqliteSaved: true, summaryRestored: true, httpCalls: payloads.length, externalModelCalls: 0 }))
|
||||
} finally {
|
||||
await dispose()
|
||||
await runtime.dispose()
|
||||
database.close()
|
||||
win.destroy()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
}
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
export async function verifySidebarSearch(win: BrowserWindow): Promise<number> {
|
||||
const run = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
|
||||
const wait = async (code: string): Promise<void> => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (await run(code)) return
|
||||
await setTimeout(25)
|
||||
}
|
||||
throw new Error(`Sidebar search timeout: ${code}`)
|
||||
}
|
||||
const click = async (selector: string): Promise<void> => {
|
||||
const point = await run<{ x: number; y: number }>(`(() => {
|
||||
const e = document.querySelector(${JSON.stringify(selector)}), r = e.getBoundingClientRect();
|
||||
if (!e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))) throw new Error('Control obscured: ' + ${JSON.stringify(selector)});
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||
}
|
||||
const measure = `(() => {
|
||||
const row = document.querySelector('.sidebar-conversation-controls').getBoundingClientRect();
|
||||
const slot = document.querySelector('.sidebar-search-slot').getBoundingClientRect();
|
||||
const create = document.querySelector('.new-chat');
|
||||
return { top: document.querySelector('.conversation-list').getBoundingClientRect().top,
|
||||
right: slot.right, width: slot.width, height: row.height, overflow: create.scrollWidth - create.clientWidth,
|
||||
createWidth: create.getBoundingClientRect().width };
|
||||
})()`
|
||||
type Bounds = { top: number; right: number; width: number; height: number; overflow: number; createWidth: number }
|
||||
let observations = 0
|
||||
const originalWidth = await run<string>('document.querySelector(".sidebar").style.getPropertyValue("--primary-sidebar-width")')
|
||||
win.webContents.debugger.attach('1.3')
|
||||
try {
|
||||
await run('document.fonts.ready')
|
||||
await wait('document.querySelector(".composer textarea")?.matches(":placeholder-shown")')
|
||||
assert.equal(await run(`(async () => {
|
||||
const input = document.querySelector('.composer textarea');
|
||||
const info = await window.goodbuddy.app.getInfo();
|
||||
return input.placeholder.split('\\n').length === 3 &&
|
||||
input.placeholder.includes((info.platform === 'darwin' ? 'Command' : 'Ctrl') + '+N for a new conversation') &&
|
||||
(!info.shortcut || input.placeholder.includes(info.shortcut + ' for quick access')) &&
|
||||
!document.querySelector('.composer-meta__shortcut');
|
||||
})()`), true)
|
||||
win.show()
|
||||
win.focus()
|
||||
win.webContents.focus()
|
||||
await wait('document.hasFocus()')
|
||||
await click('.composer textarea')
|
||||
await wait('document.activeElement?.matches(".composer textarea")')
|
||||
await win.webContents.insertText('Placeholder visibility check')
|
||||
await wait('document.querySelector(".composer textarea").value === "Placeholder visibility check"')
|
||||
assert.equal(await run('document.querySelector(".composer textarea").matches(":placeholder-shown")'), false)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] })
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Backspace' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Backspace' })
|
||||
await wait('document.querySelector(".composer textarea").matches(":placeholder-shown")')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
for (const width of [220, 420, 280]) {
|
||||
win.setSize(width === 280 ? 760 : 1280, width === 280 ? 560 : 900)
|
||||
await wait(width === 280 ? 'window.innerWidth < 900' : 'window.innerWidth >= 900')
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
if (await run('document.querySelector(".sidebar").classList.contains("sidebar--closed")')) {
|
||||
await click('.sidebar-toggle')
|
||||
}
|
||||
await run(`document.documentElement.dataset.theme = '${theme}'; document.querySelector('.sidebar').style.setProperty('--primary-sidebar-width', '${width}px')`)
|
||||
await wait('!document.querySelector(".sidebar").classList.contains("sidebar--closed")')
|
||||
await run('Promise.all(document.querySelector(".sidebar").getAnimations().map(animation => animation.finished))')
|
||||
const before = await run<Bounds>(measure)
|
||||
assert.equal(await run(`(() => {
|
||||
const button = document.querySelector('.new-chat');
|
||||
const label = getComputedStyle(button.querySelector('span'));
|
||||
return !!button.querySelector('.lucide-message-square-plus') &&
|
||||
getComputedStyle(button).justifyContent === 'flex-start' &&
|
||||
label.fontSize === '13px' && label.fontWeight === '600';
|
||||
})()`), true)
|
||||
assert.ok(before.overflow <= 1, `New conversation text overflow at ${width}: ${JSON.stringify(before)}`)
|
||||
await click('.sidebar-search-trigger')
|
||||
await wait('document.activeElement?.matches(".sidebar-search input")')
|
||||
await run(`new Promise((resolve, reject) => {
|
||||
const sample = () => {
|
||||
const search = document.querySelector('.sidebar-search').getBoundingClientRect();
|
||||
const top = document.querySelector('.conversation-list').getBoundingClientRect().top;
|
||||
if (Math.abs(search.right - ${before.right}) >= 0.01 || top !== ${before.top}) {
|
||||
reject(new Error('Search edge or list moved during expansion')); return;
|
||||
}
|
||||
if (document.querySelector('.sidebar-search-slot').getAnimations().length) requestAnimationFrame(sample);
|
||||
else resolve(true);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
})`)
|
||||
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
|
||||
const after = await run<Bounds>(measure)
|
||||
assert.equal(await run(`(() => {
|
||||
const button = document.querySelector('.new-chat');
|
||||
const icon = button.querySelector('.lucide-message-square-plus').getBoundingClientRect();
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return Math.abs(icon.x + icon.width / 2 - bounds.x - bounds.width / 2) < 1;
|
||||
})()`), true)
|
||||
assert.ok(Math.abs(after.right - before.right) < 0.01, 'stable right edge within floating-point rounding')
|
||||
assert.equal(after.top, before.top)
|
||||
assert.equal(after.height, before.height)
|
||||
assert.ok(after.width > before.width)
|
||||
await win.webContents.insertText('Capture discussion')
|
||||
await click('.conversation-item')
|
||||
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
if (await run('document.querySelector(".sidebar").classList.contains("sidebar--closed")')) {
|
||||
await click('.sidebar-toggle')
|
||||
await wait('!document.querySelector(".sidebar").classList.contains("sidebar--closed")')
|
||||
await run('Promise.all(document.querySelector(".sidebar").getAnimations().map(animation => animation.finished))')
|
||||
}
|
||||
assert.equal(await run('document.querySelector(".sidebar-search input").value'), 'Capture discussion')
|
||||
await click('.sidebar-search input')
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
|
||||
await wait('document.activeElement?.matches(".sidebar-search-trigger")')
|
||||
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
|
||||
assert.equal((await run<Bounds>(measure)).top, before.top)
|
||||
await click('.sidebar-search-trigger')
|
||||
await wait('document.activeElement?.matches(".sidebar-search input")')
|
||||
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
|
||||
assert.equal(await run('document.querySelector(".sidebar-search input").value'), '')
|
||||
await click('.sidebar-search__clear')
|
||||
await wait('document.activeElement?.matches(".sidebar-search-trigger")')
|
||||
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
|
||||
observations++
|
||||
}
|
||||
}
|
||||
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
|
||||
assert.equal(await run('getComputedStyle(document.querySelector(".sidebar-search-slot")).transitionProperty'), 'none')
|
||||
return observations
|
||||
} finally {
|
||||
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [] })
|
||||
win.webContents.debugger.detach()
|
||||
win.setSize(1280, 900)
|
||||
await run(`document.querySelector('.sidebar').style.setProperty('--primary-sidebar-width', ${JSON.stringify(originalWidth)}); document.documentElement.dataset.theme = 'light'`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Frozen pre-optimization implementation: an independent streaming equivalence oracle.
|
||||
function originalReplace(line: string): string {
|
||||
let output = ''
|
||||
let index = 0
|
||||
let codeDelimiterLength = 0
|
||||
while (index < line.length) {
|
||||
if (line[index] === '`') {
|
||||
let runLength = 1
|
||||
while (line[index + runLength] === '`') runLength += 1
|
||||
if (codeDelimiterLength === 0 || codeDelimiterLength === runLength) {
|
||||
codeDelimiterLength = codeDelimiterLength === 0 ? runLength : 0
|
||||
}
|
||||
output += line.slice(index, index + runLength)
|
||||
index += runLength
|
||||
continue
|
||||
}
|
||||
if (codeDelimiterLength === 0 && line[index] === '\\' &&
|
||||
line[index - 1] !== '\\' && (line[index + 1] === '(' || line[index + 1] === ')')) {
|
||||
output += '$'
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
output += line[index]
|
||||
index += 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function originalNormalize(content: string): string {
|
||||
let fence: { character: '`' | '~'; length: number } | undefined
|
||||
return content.split(/\r?\n/u).map(line => {
|
||||
if (fence) {
|
||||
const closingFence = new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`, 'u')
|
||||
if (closingFence.test(line)) fence = undefined
|
||||
return line
|
||||
}
|
||||
const openingFence = /^ {0,3}(`{3,}|~{3,})/u.exec(line)
|
||||
if (openingFence) {
|
||||
const marker = openingFence[1]!
|
||||
fence = { character: marker[0] as '`' | '~', length: marker.length }
|
||||
return line
|
||||
}
|
||||
const standaloneDollar = /^ {0,3}\$(?!\$)(.+?)(?<!\\)\$\s*$/u.exec(line)
|
||||
if (standaloneDollar) return `$$\n${standaloneDollar[1]}\n$$`
|
||||
const standaloneParentheses = /^ {0,3}\\\(\s*(.*?)\s*\\\)\s*$/u.exec(line)
|
||||
if (standaloneParentheses) return `$$\n${standaloneParentheses[1]}\n$$`
|
||||
const singleLineDisplay = /^ {0,3}\\\[\s*(.*?)\s*\\\]\s*$/u.exec(line)
|
||||
if (singleLineDisplay) return `$$\n${singleLineDisplay[1]}\n$$`
|
||||
if (/^ {0,3}\\\[\s*$/u.test(line)) return '$$'
|
||||
if (/^ {0,3}\\\]\s*$/u.test(line)) return '$$'
|
||||
return originalReplace(line)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
export const equivalenceCorpus = [
|
||||
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
|
||||
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
|
||||
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
|
||||
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
|
||||
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
|
||||
'```markdown\r\n# Title\r\n\r\n\\(x\\)\r\n```',
|
||||
'<html>\r\n<body><p>\\(x\\)</p></body>\r\n</html>',
|
||||
'```html\r\n<section><strong>bold</strong></section>\r\n```',
|
||||
'<section>**raw**\r\ntext</section>',
|
||||
'partial \\(x + \\) then `code \\( and `` and \\[\n\\',
|
||||
'\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n'
|
||||
]
|
||||
|
||||
const prose = '# Streaming answer\n\n' + (
|
||||
'A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' +
|
||||
'- First item\n- Second item\n\n> A quoted explanation.\n\n'
|
||||
).repeat(48)
|
||||
export const performanceCorpus = [
|
||||
{ name: 'prose', content: prose },
|
||||
{ name: 'mixed', content: (prose.slice(0, 1200) + '\n\\(x^2 + y^2\\)\n\n' +
|
||||
'```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n').repeat(5) },
|
||||
{ name: 'crlf', content: prose.replaceAll('\n', '\r\n') }
|
||||
]
|
||||
|
||||
const longSection = [
|
||||
'## Deployment review\n',
|
||||
'The service processes incoming records in order and stores the last completed sequence. ' +
|
||||
'A restart resumes from that sequence so operators can inspect partial progress without repeating successful work. ' +
|
||||
'Keep **validation results** next to the source record and link the [runbook](https://example.com/runbook) in the report.\n',
|
||||
'Before deployment, compare the proposed configuration with the active settings. ' +
|
||||
'Check the database connection, confirm the worker count, and run a small representative batch. ' +
|
||||
'Record the elapsed time and any rejected records before increasing concurrency.\n',
|
||||
'- Validate required fields before writing.\n- Commit each completed batch.\n- Review rejected records separately.\n',
|
||||
'```ts\nasync function processBatch(records: Record<string, string>[]) {\n' +
|
||||
' for (const record of records) {\n const label = record.name ?? "unnamed"\n' +
|
||||
' await store.save({ ...record, label })\n }\n}\n```\n',
|
||||
'| Check | Expected result | Follow-up |\n| --- | --- | --- |\n' +
|
||||
'| Validation | Required fields present | Review rejected rows |\n' +
|
||||
'| Persistence | Completed batches stored | Resume from checkpoint |\n' +
|
||||
'| Throughput | Stable processing time | Inspect slow operations |\n',
|
||||
'> Keep the original input available until the final reconciliation has completed.\n\n'
|
||||
].join('\n')
|
||||
const longBody = longSection.repeat(Math.floor(102_400 / longSection.length))
|
||||
// ASCII makes character and UTF-8 byte lengths identical: exactly 100 KiB.
|
||||
export const longAnswerCorpus = longBody + 'Final review notes. '.repeat(100)
|
||||
.slice(0, 102_400 - longBody.length)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
app.setPath('userData', join(process.env.GOODBUDDY_MARKDOWN_DIRECTORY, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors = []
|
||||
win.webContents.on('console-message', event => {
|
||||
if (event.level === 'error') errors.push(event.message)
|
||||
})
|
||||
await win.loadURL(process.env.GOODBUDDY_MARKDOWN_URL)
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (await win.webContents.executeJavaScript('typeof window.streamingMarkdown === "function"')) break
|
||||
await setTimeout(25)
|
||||
}
|
||||
const result = await win.webContents.executeJavaScript('window.streamingMarkdown()')
|
||||
assert(result.prefixesChecked > 500)
|
||||
assert.equal(result.domChecks, result.prefixesChecked * 2)
|
||||
assert.equal(result.longAnswer.bytes, 102_400)
|
||||
assert.equal(result.longAnswer.prefixLengths.length, 6)
|
||||
assert.equal(result.longAnswer.samplesPerVariant, 18)
|
||||
assert.equal(result.longAnswer.domChecks, 24)
|
||||
assert.equal(result.longAnswer.finalDomEquivalent, true)
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('Markdown ' + JSON.stringify({ electron: process.versions.electron,
|
||||
chromium: process.versions.chrome, ...result }))
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,149 @@
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import * as current from '../../src/renderer/src/MarkdownRenderer'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import { equivalenceCorpus, longAnswerCorpus, originalNormalize, performanceCorpus } from './streaming-markdown-corpus'
|
||||
import '../../src/renderer/src/fonts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
type Instrumented = typeof current & { benchmarkNormalize: (content: string) => string }
|
||||
const normalize = (current as Instrumented).benchmarkNormalize
|
||||
const host = document.getElementById('root')!
|
||||
host.className = 'markdown-content'
|
||||
host.style.cssText = 'width: 800px; padding: 16px'
|
||||
const root = createRoot(host)
|
||||
function snapshot(container = host): string {
|
||||
const clone = container.cloneNode(true) as HTMLElement
|
||||
const ids = new Map<string, string>()
|
||||
// Separate component instances receive different React useId values.
|
||||
// Canonicalize only those IDs/references; retain all content and iframe srcdoc.
|
||||
for (const element of clone.querySelectorAll('*')) {
|
||||
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby']) {
|
||||
const value = element.getAttribute(name)
|
||||
if (value && /^_r_[a-z0-9]+_$/u.test(value)) {
|
||||
if (!ids.has(value)) ids.set(value, `react-id-${ids.size}`)
|
||||
element.setAttribute(name, ids.get(value)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone.innerHTML
|
||||
}
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1] }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const originalPath = '/src/renderer/src/MarkdownRenderer.tsx?streaming-original'
|
||||
const original = await import(/* @vite-ignore */ originalPath) as Instrumented
|
||||
const render = (Component: typeof current.MarkdownRenderer, content: string, renderHtml = false) => {
|
||||
flushSync(() => root.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<Component renderHtml={renderHtml}>{content}</Component>
|
||||
</UiLocaleProvider>))
|
||||
}
|
||||
// Warm fonts, styles, React, Markdown, and KaTeX before recording samples.
|
||||
render(current.MarkdownRenderer, performanceCorpus[1]!.content)
|
||||
await document.fonts.ready
|
||||
const results = []
|
||||
for (const corpus of performanceCorpus) {
|
||||
const prefixes = Array.from({ length: 24 }, (_, i) => corpus.content.slice(0, Math.ceil(corpus.content.length * (i + 1) / 24)))
|
||||
const renderTimes: number[] = []
|
||||
const normalizeTimes: number[] = []
|
||||
let checksum = 0
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
for (const prefix of prefixes) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
// Include the synchronous DOM commit and Chromium style/layout, not frame waiting.
|
||||
void host.offsetHeight
|
||||
if (round > 0) renderTimes.push(performance.now() - start)
|
||||
}
|
||||
}
|
||||
// Batch sub-millisecond calls to exceed Chromium timer resolution.
|
||||
for (let round = 0; round < 4; round++) {
|
||||
for (const prefix of prefixes) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 100; i++) checksum += normalize(prefix).length
|
||||
if (round > 0) normalizeTimes.push((performance.now() - start) / 100)
|
||||
}
|
||||
}
|
||||
results.push({ corpus: corpus.name, characters: corpus.content.length, samples: renderTimes.length,
|
||||
renderMs: stats(renderTimes), normalizeMs: stats(normalizeTimes), checksum })
|
||||
}
|
||||
const originalHost = document.createElement('div')
|
||||
originalHost.className = host.className
|
||||
originalHost.style.cssText = host.style.cssText
|
||||
document.body.append(originalHost)
|
||||
const originalRoot = createRoot(originalHost)
|
||||
const longSamples = { original: [] as number[], current: [] as number[] }
|
||||
const prefixLengths = Array.from({ length: 6 }, (_, i) => Math.ceil(longAnswerCorpus.length * (i + 1) / 6))
|
||||
let longDomChecks = 0
|
||||
try {
|
||||
// One warm-up sweep, three measured sweeps: 48 renders total, on persistent roots.
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
for (const [index, length] of prefixLengths.entries()) {
|
||||
const prefix = longAnswerCorpus.slice(0, length)
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Long normalizer mismatch: ${length}`)
|
||||
const order = (round + index) % 2 === 0
|
||||
? ['original', 'current'] as const : ['current', 'original'] as const
|
||||
for (const variant of order) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
if (variant === 'original') {
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void originalHost.offsetHeight
|
||||
} else {
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
void host.offsetHeight
|
||||
}
|
||||
if (round > 0) longSamples[variant].push(performance.now() - start)
|
||||
}
|
||||
// Compare outside timing, including the complete 100 KiB answer on every sweep.
|
||||
if (snapshot() !== snapshot(originalHost)) throw new Error(`Long DOM mismatch: ${length}`)
|
||||
longDomChecks++
|
||||
}
|
||||
}
|
||||
if (!host.querySelector('h2') || !host.querySelector('pre code') || !host.querySelector('table')) {
|
||||
throw new Error('Long-answer prose/code/table formatting missing')
|
||||
}
|
||||
} finally {
|
||||
flushSync(() => originalRoot.unmount())
|
||||
originalHost.remove()
|
||||
}
|
||||
const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length,
|
||||
prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length,
|
||||
originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current),
|
||||
rawRenderMs: longSamples, domChecks: longDomChecks, finalDomEquivalent: true }
|
||||
let prefixesChecked = 0
|
||||
let domChecks = 0
|
||||
for (const content of equivalenceCorpus) {
|
||||
for (let end = 0; end <= content.length; end++) {
|
||||
const prefix = content.slice(0, end)
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`)
|
||||
prefixesChecked++
|
||||
for (const renderHtml of [false, true]) {
|
||||
render(original.MarkdownRenderer, prefix, renderHtml)
|
||||
const expected = snapshot()
|
||||
render(current.MarkdownRenderer, prefix, renderHtml)
|
||||
if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`)
|
||||
domChecks++
|
||||
}
|
||||
}
|
||||
}
|
||||
render(current.MarkdownRenderer, '# Live\n\n**formatted** and \\(x\\)')
|
||||
if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) {
|
||||
throw new Error('Real Markdown formatting missing')
|
||||
}
|
||||
return { results, longAnswer, prefixesChecked, domChecks }
|
||||
}
|
||||
|
||||
Object.assign(window, { streamingMarkdown: run })
|
||||
@@ -0,0 +1,94 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||
import { createDefaultModelRuntime } from '../../src/main/agent/create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../src/shared/contracts'
|
||||
import { ContextManager } from '../../src/main/context-manager'
|
||||
|
||||
const directory = process.env.GB_CAPTURE_DIRECTORY!
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.whenReady().then(async () => {
|
||||
let calls = 0
|
||||
let scenario = 'empty'
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk /* Drain the real model request. */ }
|
||||
calls++
|
||||
if (scenario === 'failed') { res.writeHead(400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Probe failure' } })); return }
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write(`data: ${JSON.stringify({ id: 'probe', choices: [{ index: 0, delta: { role: 'assistant', content: scenario === 'partial' ? 'PARTIAL_REPLY' : '' }, finish_reason: null }] })}\n\n`)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const modelBaseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/v1`
|
||||
const settings = { ...defaultRuntimeSettings, workspacePath: directory, defaultModelProfileId: '', modelProfiles: [], embeddingConnections: [], modelBaseUrl,
|
||||
modelName: 'terminal-probe', modelProtocol: 'openai-chat-completions' as const, modelAuthentication: 'none' as const }
|
||||
const runtime = createDefaultModelRuntime(directory, settings)
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800, webPreferences: {
|
||||
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload: join(directory, 'preload.cjs')
|
||||
} })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite')); database.initialize(directory)
|
||||
const appSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||
await appSettings.update({ checkUpdatesOnStartup: false, heartbeatEnabled: false })
|
||||
const dispose = registerIpcHandlers(win, runtime, 'CommandOrControl+Shift+Space',
|
||||
{ getResolvedSettings: async () => settings, getPublicSettings: async () => settings } as never,
|
||||
{ getEnabledBuiltinMcpServerIds: async () => [] } as never, new ContextManager(),
|
||||
{ snapshot: () => ({ libraries: [], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as never,
|
||||
database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, appSettings,
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ getPending: async () => undefined } as never)
|
||||
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
|
||||
async function wait(script: string) {
|
||||
for (let i = 0; i < 300; i++) { if (await run(script)) return; await new Promise(resolve => setTimeout(resolve, 50)) }
|
||||
throw new Error(`Timeout ${script}: ${await run('document.body.innerText')}`)
|
||||
}
|
||||
const row = 'document.querySelectorAll(".message--assistant")[document.querySelectorAll(".message--assistant").length-1]'
|
||||
try {
|
||||
await win.loadURL(process.env.GB_CAPTURE_URL!)
|
||||
for (scenario of ['empty', 'partial', 'failed']) {
|
||||
const expectedCalls = calls + 1
|
||||
await wait('!!document.querySelector(".composer textarea")')
|
||||
await run(`(() => { const e=document.querySelector('.composer textarea'); const set=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set; set.call(e,${JSON.stringify('Probe ')}+${JSON.stringify(scenario)});e.dispatchEvent(new Event('input',{bubbles:true})); })()`)
|
||||
await wait('!document.querySelector(".send-button").disabled')
|
||||
await run('document.querySelector(".send-button").click()')
|
||||
for (let i = 0; calls < expectedCalls && i < 200; i++) await new Promise(resolve => setTimeout(resolve, 50))
|
||||
assert.equal(calls, expectedCalls)
|
||||
if (scenario !== 'failed') {
|
||||
if (scenario === 'partial') await wait(`${row}.textContent.includes('PARTIAL_REPLY')`)
|
||||
await wait('!!document.querySelector(".send-button--stop")')
|
||||
await run('document.querySelector(".send-button--stop").click()')
|
||||
}
|
||||
const role = scenario === 'failed' ? 'alert' : 'status'
|
||||
await wait(`${row}.querySelector('.message__status')?.getAttribute('role') === '${role}' && !!${row}.querySelector('.message-retry')`)
|
||||
const status = await run<string>(`${row}.querySelector('.message__status').textContent`)
|
||||
assert.equal(await run(`${row}.querySelectorAll('.message__content').length`), scenario === 'partial' ? 1 : 0)
|
||||
assert.equal(await run(`${row}.textContent.split(${JSON.stringify(status)}).length - 1`), 1)
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await run(`document.documentElement.dataset.theme='${theme}'`)
|
||||
assert.equal(await run(`${row}.querySelector('.message__status').classList.contains('message__status--error')`), scenario === 'failed')
|
||||
const colors = await run<string[]>(`(() => { const e=${row}.querySelector('.message__status'), c=document.createElement('span'); c.style.color='var(${scenario === 'failed' ? '--danger' : '--text-muted'})';e.append(c); const result=[getComputedStyle(e).color,getComputedStyle(c).color];c.remove();return result })()`)
|
||||
assert.equal(colors[0], colors[1])
|
||||
}
|
||||
await run(`${row}.querySelector('.message-retry').click()`)
|
||||
assert.equal(await run('document.activeElement === document.querySelector(".composer textarea")'), true)
|
||||
assert.equal(await run('document.querySelector(".composer textarea").value'), `Probe ${scenario}`)
|
||||
assert.equal(calls, expectedCalls)
|
||||
let saved
|
||||
for (let i = 0; i < 200; i++) {
|
||||
saved = database.listConversations()[0]?.messages.at(-1)
|
||||
if (saved?.terminalStatus === (scenario === 'failed' ? 'failed' : 'cancelled')) break
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
assert.equal(saved?.terminalStatus, scenario === 'failed' ? 'failed' : 'cancelled')
|
||||
assert.equal(saved?.content, scenario === 'partial' ? 'PARTIAL_REPLY' : '')
|
||||
database.close(); database.initialize(directory)
|
||||
await win.loadURL(process.env.GB_CAPTURE_URL!)
|
||||
await wait(`${row}?.querySelector('.message__status')?.getAttribute('role') === '${role}'`)
|
||||
assert.equal(await run(`${row}.querySelectorAll('.message__content').length`), scenario === 'partial' ? 1 : 0)
|
||||
}
|
||||
console.log(JSON.stringify({ nativeElectron: true, productionIpc: true, sqliteReopened: true, themes: ['light', 'dark'], scenarios: 3, deterministicHttpCalls: calls, paidCalls: 0 }))
|
||||
} finally { dispose(); database.close(); await runtime.dispose(); server.closeAllConnections(); server.close(); win.destroy() }
|
||||
}).then(() => app.exit(0)).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('validates terminal UI through production model HTTP, IPC, persistence and reload', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-terminal-probe-'))
|
||||
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'), plugins: [react(), {
|
||||
name: 'terminal-probe', configureServer(server) { server.middlewares.use('/capture.html', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html'); res.end(await server.transformIndexHtml('/capture.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/magic-notes-capture-renderer.tsx"></script></body></html>'))
|
||||
}) }
|
||||
}], server: { host: '127.0.0.1', port: 0, watch: null }, optimizeDeps: { entries: ['tests/support/magic-notes-capture-renderer.tsx'] } })
|
||||
try {
|
||||
const driver = join(directory, 'main.mjs')
|
||||
await build({ entryPoints: ['tests/support/terminal-status-probe-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
|
||||
{ name: 'no-channels', setup(build) {
|
||||
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'probe' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'probe' }, () => ({ contents: 'export const startEnvironmentChannels=()=>[];' }))
|
||||
} },
|
||||
{ name: 'external-packages', setup(build) { build.onResolve({ filter: /^[^./]/ }, async args => {
|
||||
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
|
||||
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
|
||||
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
|
||||
}) } }
|
||||
] })
|
||||
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
|
||||
await build({ entryPoints: ['src/main/execution-stats-worker.ts'], outfile: join(directory, 'out/main/execution-stats-worker.js'), bundle: true, platform: 'node', format: 'cjs' })
|
||||
await server.listen()
|
||||
const bootstrap = join(directory, 'bootstrap.cjs')
|
||||
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(e=>{console.error(e);require('electron').app.exit(1)})`)
|
||||
const env = { ...process.env, GB_CAPTURE_DIRECTORY: directory, GB_CAPTURE_URL: server.resolvedUrls!.local[0] + 'capture.html' }
|
||||
delete (env as NodeJS.ProcessEnv).ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''; child.stdout.on('data', data => { output += data }); child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 100000)
|
||||
try { expect(await new Promise(resolve => child.once('exit', resolve)), output).toBe(0); console.log(output) }
|
||||
finally { clearTimeout(timeout) }
|
||||
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
|
||||
}, 120000)
|
||||
Reference in New Issue
Block a user