fix: keep question order and streamline contextual help
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Keep interactive questions and answered cards at their original position in
conversation history, including remote reception, recovery, and reopening.
Preserve legacy history placement without a database migration.

Move supplementary guidance into shared accessible contextual-help popovers
across pages and settings. Add keyboard, modal, accessibility, and Electron
regressions, plus bounded opt-in live canvas-analysis validation.

Remove unused Agent, Runtime, and in-memory graph-search helpers, obsolete
mocks and browser copy; reuse the existing package-size formatter. Keep
production execution paths, protocol schemas, and released data migrations.

Validation: targeted cleanup tests, typecheck, lint, shared Linux Host
attach/read/re-attach, and added help/Electron checks passed. Final full-suite
verification passed 4,858 tests with 67 skipped and zero failures (412 files
passed, 9 skipped). Focused help checks passed 54 tests, including Electron
style comparisons and Chromium accessibility descriptions. Details and
validation boundaries are recorded in feature docs.

Release note: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
This commit is contained in:
mesalogo
2026-09-20 01:26:36 +08:00
parent bf1d34e897
commit 9836a4cc9d
89 changed files with 1961 additions and 1111 deletions
+21 -6
View File
@@ -177,7 +177,8 @@
`PageHeader` 统一接收以下内容:
- `title`:必填,简短名词或任务名称。
- `description`:可选,一行说明当前页面能做什么,不重复标题。
- `description`:可选,需要常显的简短上下文,不重复标题或帮助内容。
- `help`:可选,补充用途和操作说明,通过标题同行的 `InlineHelp` 按需展开,规则见 6.14。
- `scope`:涉及范围的数据页面必填,使用 `ScopeBadge`。
- `primaryAction`:可选,页面唯一主操作。
- `secondaryActions`:可选,最多两个直接显示,其余进入更多菜单。
@@ -402,8 +403,8 @@
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
- 共享锚定菜单 `AnchoredMenu` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer
绘制,避免被 Modal 或滚动容器遮挡。菜单挂在触发按钮所属 Modal 内,通知跟随当前活动
- 共享锚定菜单 `AnchoredMenu`、同行帮助 `InlineHelp` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer
绘制,避免被 Modal 或滚动容器遮挡。菜单和帮助挂在触发按钮所属 Modal 内,通知跟随当前活动
Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
@@ -473,6 +474,20 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
### 6.14 InlineHelp 同行帮助
页面、区块和字段的补充用途、格式及操作说明统一收进标题或标签旁的 `InlineHelp` 问号按钮。
`PageHeader` 与 `SettingsSectionHeader` 通过 `help` 接收这类内容;`description` 保留需要常显的上下文,两处不重复说明。描述缺省或为空时不渲染段落,不保留空行。
当前范围、状态、字段错误、操作进度、风险、费用和影响用户决策的限制仍就地可见。混合文案先拆分,再将补充说明移入帮助。
- 标题或标签与问号使用共享 `inline-help-label` 同行排列,不为帮助另占说明行;字段标签通过 `htmlFor` 关联控件,帮助按钮放在 `label` 外,避免点击帮助触发输入或开关。
- 悬停或键盘聚焦按钮时展开;指针可移入浮层阅读。点击(含 Enter、Space 激活)固定展开,再次点击关闭;未固定时,指针和焦点均离开后关闭。
- Escape、点击外部或焦点移到外部关闭帮助。Escape 优先关闭帮助,不同时关闭所属 Modal;展开和关闭不主动转移焦点,不建立焦点陷阱。
- 按钮提供与标题或标签对应的可访问名称、`aria-expanded`,展开时通过 `aria-controls`、`aria-describedby` 关联 `role="tooltip"` 内容。提供稳定帮助 ID 时,关闭状态保留同 ID 的隐藏说明,展开时由浮层替换,确保字段的 `aria-describedby` 始终有唯一目标。持久标签与必要提示不得依赖浮层。
- SSH 密码字段常显认证用途和验证成功后加密保存的说明;Main 处理及不写入命令行、日志、普通设置的细节收进同行帮助。应用常驻说明与关闭后果使用独立翻译键,关闭后果保持常显,不按标点拆分翻译文本。
- 浮层复用 `FloatingPortal`,遵循 6.10 的 Modal 归属规则。宽度最多 `360px`,四周保留 `16px` 视口边距;空间不足时翻转或调整位置,长内容在浮层内滚动,窗口及滚动变化后重新定位。
- 问号使用共享 `28px × 28px` 点击区域、焦点环与主题令牌。不得另做原生 `title` 长说明或页面专属帮助浮层。
## 7. 交互状态
所有可交互组件必须实现:
@@ -725,13 +740,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 笔记总览与待办使用一个普通按钮切换,放在页头“新建笔记”按钮左边。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。
- 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。
- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;新建文字记录的操作提示收进类型选择旁的 `InlineHelp`。
- 新记录可选择“文字 / 画布”;已保存画布提供分页阅读和“展开编辑画布”。画布使用 PeopleLib 的 Fabric 分页批注与 Quill 跨页正文,支持纸张模板、增删页、笔迹、高亮、整对象橡皮、选择及移动缩放旋转、浮动文字、图片和 PDF 底版。画布有固定页面边界;已保存记录、待办来源和编辑态默认容纳完整当前页及工具栏,高度随当前页实际显示尺寸变化,纵向由外层笔记流滚动。多页仍属于一条记录,通过页码翻页;窄窗口工具栏换行,纸张仅在自身视口内横向滚动,不撑宽外层页面。
- 画布通过“保存记录 / 保存修改”手动持久化,离开或切换记录类型前沿用未保存草稿确认。撤销/重做作用于当前批注页或 Quill 正文模式,增删页和 PDF 导入不属于全局撤销历史。删除非空批注页或 PDF 页需要在五秒内再次点击确认。
- 画布编辑与只读工具栏均提供缩小、当前百分比、放大和“适应宽度”。默认 100%,范围 25%–300%;加减每次调整 25 个百分点,点击百分比还原 100%。画布区域内 Ctrl/Command + 滚轮连续缩放,普通滚轮和 Shift 横滚保持原有方向与边界传递。适应宽度按实际视口计算,窗口或分栏调整后自动更新;翻页保留固定比例或适应宽度策略。缩放仅影响显示,整页高度与横向滚动范围随之变化,不新增内层纵向滚动,也不改变正文分页、保存内容、导出尺寸或未保存状态。控件沿用画布的主题、焦点环与提示,窄屏整组换行,适应宽度通过 `aria-pressed` 和选中样式表达状态。
- “导入 PDF”保留页面并提取原生文字供搜索与 AI 使用,扫描页不自动 OCR。“导出 PDF”将底版、正文和批注逐页合成为栅格 PDF,不保留可搜索文字层;当前 UI 无 PNG 下载按钮,页面 PNG 捕获仅作为程序接口提供。
- 画布新建与编辑态不显示常驻分析提示。操作放在编辑区右上角:新建与类型选择同排,顺序为“分析画布草稿、保存记录”;编辑放在记录头部,顺序为“分析画布草稿、取消、保存修改”。保存为最右侧主按钮,窄屏允许换行;底部不重复操作,新建不增加取消按钮。`immediate` 仍通过按钮手动分析,不随笔画自动请求;`after-save-auto` 在手动保存后分析。设置读取或页面捕获失败不阻断保存,保存成功后通过应用通知说明自动分析失败,可稍后重新分析;模型分析失败也不回滚已保存内容。
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题旁的问号图标通过现有 `title` 提示展示说明,不常驻整段文字;下拉框与帮助图标均通过 `aria-describedby` 关联屏幕阅读器说明。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。
- 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。
- 默认进入笔记总览,不自动打开第一篇笔记。笔记以自适应卡片网格平铺,卡片显示标题、最多两行内容预览、记录数和本地化更新时间。点击笔记进入详情,详情不保留内部左列表;“返回总览”恢复搜索、滚动位置和入口焦点。新建入口在笔记总览与待办页可见,创建成功后进入笔记详情。
@@ -777,7 +792,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目(技术预览)”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
@@ -1,5 +1,25 @@
# 工作栏实现与验证进度
## 2026-09-19 问答位置持久化
- Main 在收到问题时保存消息位置块,而不是等回答成功才追加;前台及重启恢复的实时待答
快照、带来源的语义事件投影共用同一去重函数。答案写入不移动位置块,旧消息不补造历史
顺序;规则见[Runtime 交互边界](./runtime-interactions.md#运行与失败)。未改变 schema。
- `npm exec -- vitest run src/main/assistant/assistant-database.test.ts src/main/ipc.test.ts`
通过 227 项,覆盖真实 SQLite 的问题/正文/工具交错、重复和重放、回答/跳过、完成及
重新打开,以及三个设置重载入口的并行远程问题恢复。
`npm exec -- tsc --noEmit -p tsconfig.node.json` 通过。
- 共享 Linux x64 Host 使用已保存凭据与固定 Host Key,经 LAN 路由运行当前源码 Agent、
Desktop Runtime 适配器和 Main SQLite 投影,使用真实 OpenCode `1.18.29`。首轮暴露实时
问题没有语义来源的实际分支,使用 1 次真实文本模型请求后停止;补齐 Main 接收入口后,
最终一轮 2 次请求,验证回答前位置块已经落库,回答后为 `tool → question → text`,
完成重开后消息完全一致。合计真实文本模型调用 **3 次**,每次最多 1024 输出 token,
每轮有限调用预算与 120 秒请求超时;图片模型调用 **0 次**。
- 临时复验脚本为既有批准临时目录中的 `question-order-host-build.cjs`、
`question-order-host-run.cjs`,最终日志为 `question-order-host-live.log`。
两轮均停止测试 daemon,所属进程剩余 0,移除本轮专属 Host 目录及上传文件。
该记录只覆盖 Main/Agent 路径,不代替 Renderer 视觉验收或最终全量验证。
## 2026-09-19 远程 CN 完整桌面验证
默认联合开发包已包含 OC/CN,并通过桌面导入、验签、传输至共享 Linux x64、安装及
@@ -29,6 +29,10 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包
- 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、
自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
- 新消息在提问到达时,将只含 `questionId` 的问题位置块插入当时的消息块序列;
待答和回答后的卡片使用同一位置,后续正文、工具输出不再排到问题前面。重复问题不追加
或移动位置块。位置块不保存待答表单;旧会话没有位置块时保留原有问答展示,不推测或
回填历史顺序。该变更复用既有消息元数据,不升级数据库 schema。
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
- 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个
@@ -38,6 +42,8 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包
- 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答
列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。
回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。
Main 在前台与恢复请求收到实时问题时立即保存位置块;实时待答快照没有语义事件来源,
不伪造来源或追加 transcript。带来源的提问事件仍通过既有事务投影并按问题 ID 去重。
Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用
新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止,
不使用替换后的模型汇总旧结果。详细实现见
+8
View File
@@ -16,6 +16,14 @@
## 验证证据
### 2026-09-20 清理孤立的内存图谱搜索
- 删除仅剩测试调用的 `searchGraph`、专属评分/遍历辅助函数、搜索上限及测试 fixture。
实际检索仍通过 `KnowledgeDatabase.hybridSearchWithDiagnostics` 与 `graphSearchScored`;
图谱抽取、归一化、合并、数据库结构和已发布迁移均保持不变。
- `graph-extractor.test.ts` 与 `knowledge-database.test.ts` 合计 55 项通过,保留合并时
丢弃失去端点关系的回归;没有调用外部模型。
### 2026-09-13 知识库布局与自动名称修复
- 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。
+35
View File
@@ -1,5 +1,40 @@
# Magic Notes Progress
## 2026-09-20: Shared Inline Help
The canvas page-count question mark now uses `InlineHelp`, superseding the native
`title` mechanism recorded below. The overview description, comment-direction
help and new text-entry hints also use the shared component. Interaction rules
are maintained in [the UI design system](../../../UI-DESIGN.md#614-inlinehelp-同行帮助).
The final verification ran `npm test -- --reporter=default --reporter=json
--outputFile.json=<temporary-report>`: **412 files passed, 9 skipped; 4,858 tests
passed, 67 skipped, no failures**, in 1,016.17 seconds. The JSON report confirms
`success: true`. `npm run typecheck` and `npm run lint` passed. The focused help,
application, SSH, Skills and MCP run also passed **6 files / 54 tests**. SSH password
help now keeps authentication and encrypted-storage consequences visible; Skills
and MCP headers no longer retain an empty description paragraph. Pin help and
disable consequences use separate Chinese and English translation keys.
`tests/inline-help.electron.test.ts` renders production `ApplicationCenter` /
`ApplicationSettingsView`, `SettingsPanel`, retrieval and activity components with
fixture data. Native Electron input verified hover, focus, click pinning, Escape
isolation, outside dismissal and Modal ownership. Chromium AX checks confirmed
unique description IDs and accessible text before, during and after expansion.
Light/dark checks cover 1280x800 and 640x480 consumer views, plus 1000x700 and
360x320 shared-component views. Computed styles and captured screenshots confirm
that canvas-count and pin help match model settings: 28px buttons, 14px icons,
4px label gaps, zero center offset, identical button colors/margins and tooltip
typography/padding/borders in each theme. No CSS difference was reproduced, so
this pass adds comparison assertions without changing styles.
The focused run retained 28 screenshots and `evidence.json` under the temporary
`inline-help-final` artifact directory selected by `GOODBUDDY_HELP_ARTIFACTS`.
This validates real Electron rendering with fixture services, not a packaged
full App or live provider. Help actions triggered zero consumer operations and
zero model calls. The full suite also passed both production canvas-analysis
Electron cases with substituted model output; live-provider checks remain opt-in.
## 2026-09-19: Compact Page Count Setting
- The page-count select now uses the existing `.field` settings control styling.
+4 -2
View File
@@ -24,6 +24,8 @@
该值。请求体说明明确列出直连、Continue 和受管远程 OpenCode 支持,并说明本地
OpenCode 与 DeepSeek Harness 不使用该值。
两项说明使用标签同行的 `InlineHelp`,交互遵循[同行帮助规范](../../../UI-DESIGN.md#614-inlinehelp-同行帮助)。下列警告及字段错误保持常显。
同一区域还必须说明:
- Runtime、协议和认证生成的字段优先。
@@ -32,7 +34,7 @@ OpenCode 与 DeepSeek Harness 不使用该值。
## 无障碍与键盘
- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。
- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。帮助关闭时保留隐藏说明,展开时替换为同 ID 的浮层,避免关联丢失或 ID 重复。
- 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。
- 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。
- 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。
@@ -49,7 +51,7 @@ Runtime 入口恢复跟随。该展示规则不改变模型解析、远程 Agent
DeepSeek Harness 的 `platform` 选项命名为“管理员环境优先,回退 GoodBuddy 兼容连接”,
不能沿用 OpenCode/Continue 的“Runtime 自有配置”标签。来源规则以
[Harness 模型配置](../deepseek-harness/technical-design.md#121-配置来源)为准。
帮助文字与选择器通过 `aria-describedby` 关联,并明确此选项不加载用户 profile 或
帮助文字收进标签同行的 `InlineHelp`,与选择器通过 `aria-describedby` 持续关联,并明确此选项不加载用户 profile 或
配置文件;无兼容 GoodBuddy 连接时仍可选择管理员环境来源。
概览使用 Main 提供的[平台来源投影](./technical-design.md#9-harness-平台来源公开投影),
@@ -359,6 +359,10 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
未发布进程复用源码补齐 Desktop 重启后的多任务并行附加、实时待答快照及 Main 答案
持久化,详见[恢复与终态投影](../assistant-workbar/runtime-process-reuse-technical-design.md#63-恢复与终态投影);
不把该待答投影当作 durable transcript 或旧版本已经支持的行为。
Desktop Main 接收实时问题时保存其消息位置,回答后不移动到后续输出之后;实时待答
快照不伪造语义来源。位置去重、旧会话边界见
[Runtime 交互边界](../assistant-workbar/runtime-interactions.md#运行与失败),
当前源码的真实 Host 证据见[问答位置持久化](../assistant-workbar/progress.md#2026-09-19-问答位置持久化)。
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
@@ -851,6 +855,23 @@ goodbuddy-agent diagnostics --installation-id <installationId>
信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和
payload 仍按原始签名字节及 SHA-256 验证。
## 2026-09-20 冗余代码清理验证
- 删除没有生产调用的结构化命令 runner、两个进程身份辅助函数、占位 peer provider、
平台查询辅助函数和五个旧 Runtime 请求构造/摘要辅助函数。保留实际 CLI、进程检查器、
请求 schema、原生 peer 校验及包签名验证,不改变 Ask/Execute 或协议行为。
- 清理后的当前源码 Agent、Main attach transport 和 protocol client 在共享 Linux x64
Host 上通过固定 Host Key 连接,完成启动、状态/能力读取、只读工作区打开及真实文件读取,
断开后再次连接并重复读取。验证没有使用模拟 Agent 或旧安装包的 Agent 实现。
- 本轮测试进程剩余 0;专属 Host 目录、上传文件及本地隔离 profile 已删除。
文本和图片模型请求均为 **0 次**。这是未使用代码删除后的启动/连接验证,不是新增的
Runtime 模型调用、安装更新或其他架构验收。
- 临时证据为 `redundancy-cleanup-host-{build,run}.cjs` 与
`redundancy-cleanup-host-live.log`;定向回归 230 项通过,typecheck、lint 通过。
- 同期全量 `npm test` 为 4,844 通过、67 跳过、11 失败;失败来自并行修改中的五个
Renderer 帮助提示/焦点测试套件。更新后的五个套件连同更新设置大小展示回归共 64 项
定向复跑全部通过;未把这次复跑记为又一轮全量通过。
## 发布前完整回归(不能替代开发验证)
以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次
-11
View File
@@ -465,14 +465,3 @@ function mergeProtocolMethods(
}
return { ...workspaceMethods, ...runtimeMethods }
}
export class UnsupportedPeerIdentityProvider
implements UnixPeerIdentityProvider
{
async getPeerIdentity(): Promise<never> {
throw new AgentUnsupportedError(
'A certified SO_PEERCRED helper is unavailable',
'peer-identity-unavailable'
)
}
}
-1
View File
@@ -29,7 +29,6 @@ export * from './runtime-acp-backend'
export * from './runtime-owner-registry'
export * from './runtime-process-identity'
export * from './semantic-prompt-store'
export * from './structured-command-runner'
export * from './workspace-git-service'
export * from './workspace-path-access'
export * from './workspace-protocol-methods'
@@ -1,5 +1,4 @@
import {
lstatSync,
readFileSync,
readlinkSync,
realpathSync
@@ -115,25 +114,6 @@ export class LinuxProcessInspector {
}
}
export function currentLinuxProcessIdentity(
options: LinuxProcessInspectorOptions = {}
): LinuxProcessIdentity {
const identity = new LinuxProcessInspector(options).inspect(process.pid)
if (identity === undefined) {
throw new Error('Current Linux process identity is unavailable')
}
return identity
}
export function canonicalExecutablePath(path: string): string {
const normalized = assertAbsoluteManagedPath(resolve(path))
const stat = lstatSync(normalized)
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new Error('Agent executable must be a regular file')
}
return assertAbsoluteManagedPath(realpathSync(normalized))
}
function parseStarttime(contents: string): string {
const close = contents.lastIndexOf(')')
if (close < 2 || contents[close + 1] !== ' ') {
@@ -1,182 +0,0 @@
import { spawn } from 'node:child_process'
export type SpawnResult = {
exitCode: number
stdout: string
stderr: string
signal?: NodeJS.Signals
terminationReason?: 'timeout' | 'aborted'
}
export type StructuredCommandOptions = {
env?: Readonly<NodeJS.ProcessEnv>
signal?: AbortSignal
timeoutMs?: number
terminationGraceMs?: number
}
export interface StructuredCommandRunner {
run(
executable: string,
args: readonly string[],
options?: StructuredCommandOptions
): Promise<SpawnResult>
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000
const MAXIMUM_COMMAND_TIMEOUT_MS = 120_000
const DEFAULT_TERMINATION_GRACE_MS = 1_000
const MAXIMUM_TERMINATION_GRACE_MS = 10_000
export class NodeStructuredCommandRunner implements StructuredCommandRunner {
async run(
executable: string,
args: readonly string[],
options: StructuredCommandOptions = {}
): Promise<SpawnResult> {
const timeoutMs = boundedDuration(
options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
'Command timeout',
MAXIMUM_COMMAND_TIMEOUT_MS
)
const terminationGraceMs = boundedDuration(
options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS,
'Command termination grace',
MAXIMUM_TERMINATION_GRACE_MS
)
if (options.signal?.aborted === true) {
return {
exitCode: -1,
stdout: '',
stderr: '',
terminationReason: 'aborted'
}
}
return await new Promise<SpawnResult>((resolveResult) => {
const child = spawn(executable, [...args], {
shell: false,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
...(options.env === undefined ? {} : { env: options.env })
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let stdoutBytes = 0
let stderrBytes = 0
const maximumBytes = 64 * 1024
let settled = false
let terminationReason: SpawnResult['terminationReason']
const timeout = setTimeout(() => terminate('timeout'), timeoutMs)
let escalation: NodeJS.Timeout | undefined
let closeDeadline: NodeJS.Timeout | undefined
let spawnError: Error | undefined
child.stdout.on('data', (chunk: Buffer) => {
if (stdoutBytes < maximumBytes) {
const bounded = chunk.subarray(0, maximumBytes - stdoutBytes)
stdout.push(bounded)
stdoutBytes += bounded.byteLength
}
})
child.stderr.on('data', (chunk: Buffer) => {
if (stderrBytes < maximumBytes) {
const bounded = chunk.subarray(0, maximumBytes - stderrBytes)
stderr.push(bounded)
stderrBytes += bounded.byteLength
}
})
const finish = (
exitCode: number | null,
signal: NodeJS.Signals | null
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (escalation !== undefined) {
clearTimeout(escalation)
}
if (closeDeadline !== undefined) {
clearTimeout(closeDeadline)
}
options.signal?.removeEventListener('abort', abort)
const capturedStderr = Buffer.concat(stderr).toString('utf8')
resolveResult({
exitCode: exitCode ?? -1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr:
capturedStderr ||
(spawnError === undefined ? '' : spawnError.message),
...(signal === null ? {} : { signal }),
...(terminationReason === undefined ? {} : { terminationReason })
})
}
const terminate = (
reason: NonNullable<SpawnResult['terminationReason']>
): void => {
if (settled || terminationReason !== undefined) {
return
}
terminationReason = reason
if (child.exitCode !== null || child.signalCode !== null) {
return
}
child.kill('SIGTERM')
escalation = setTimeout(() => {
if (
settled ||
child.exitCode !== null ||
child.signalCode !== null
) {
return
}
child.kill('SIGKILL')
closeDeadline = setTimeout(
() => finish(child.exitCode, child.signalCode),
terminationGraceMs
)
}, terminationGraceMs)
}
const abort = (): void => terminate('aborted')
child.once('error', (error) => {
spawnError = error
})
child.once('close', finish)
options.signal?.addEventListener('abort', abort, { once: true })
if (options.signal?.aborted === true) {
abort()
}
})
}
}
export function formatSpawnFailure(
command: string,
result: SpawnResult
): string {
const status =
result.terminationReason === 'timeout'
? 'timed out'
: result.terminationReason === 'aborted'
? 'was cancelled'
: result.signal !== undefined
? `terminated by ${result.signal}`
: `exited with status ${result.exitCode}`
return `${command} ${status}: ${boundedError(result.stderr)}`
}
function boundedDuration(
value: number,
name: string,
maximum: number
): number {
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
throw new Error(`${name} must be between 1 and ${maximum} milliseconds`)
}
return value
}
function boundedError(error: string): string {
const normalized = error.replaceAll(/\s+/gu, ' ').trim()
return normalized.slice(0, 1000) || 'unknown error'
}
@@ -4131,6 +4131,73 @@ describe('AssistantDatabase', () => {
database.close()
})
it.each([false, true])('keeps remote questions in arrival order through replay and reopen (legacy=%s)', async legacy => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-question-order-'))
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
try {
const project = database.createSshProject(validatedSshProjectWrite())
const conversationId = randomUUID()
const taskId = randomUUID()
const assistantMessageId = randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, projectId: project.id, title: 'Question order', updatedAt: Date.now() },
messages: []
}])
database.createTask({
id: taskId, projectId: project.id, conversationId, title: 'Question order',
instructions: 'Ask then acknowledge', workMode: 'execute',
remoteRecovery: { recoverable: true, currentUserMessageId: randomUUID(), currentAssistantMessageId: assistantMessageId }
})
if (legacy) {
const raw = new DatabaseSync(databasePath)
try {
raw.prepare("UPDATE messages SET metadata_json = json_remove(metadata_json, '$.blocks') WHERE id = ?").run(assistantMessageId)
} finally { raw.close() }
}
const append = (sequence: string, event: Parameters<AssistantDatabase['appendRemoteConversationTaskEventOnce']>[0]['event']) =>
database.appendRemoteConversationTaskEventOnce({
taskId, conversationId, assistantMessageId, bindingId: 'question-binding',
operationId: 'question-operation', semanticSequence: sequence, eventIndex: 0, event
})
const question = {
requestId: taskId, type: 'question' as const, questionId: 'first-question',
questions: [{ header: 'Choice', question: 'Choose one', options: [{ label: 'Yes', description: 'Continue' }], multiple: false, custom: false }]
}
expect(append('1', question)).toBe(true)
const initialBlocks = database.getConversation(conversationId).messages[1]!.blocks
expect(initialBlocks).toEqual(legacy ? undefined : [expect.objectContaining({ type: 'question', questionId: question.questionId })])
expect(append('1', question)).toBe(false)
expect(append('2', { requestId: taskId, type: 'text', delta: 'After first. ' })).toBe(true)
expect(append('3', { requestId: taskId, type: 'tool', callId: 'read-1', name: 'read', state: 'running', summary: 'Read fixture' })).toBe(true)
expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true)
expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true)
expect(append('4', { ...question, questionId: 'second-question' })).toBe(true)
// A fresh semantic envelope for the same live question must not move its marker.
expect(append('5', question)).toBe(true)
expect(append('6', { requestId: taskId, type: 'text', delta: 'After second.' })).toBe(true)
const answer = { questionId: question.questionId, skipped: false, questions: question.questions.map(item => ({ ...item, answer: ['Yes'] })) }
expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true)
expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true)
expect(database.recordRemoteTaskQuestionAnswer(taskId, { questionId: 'second-question', skipped: true, questions: question.questions })).toBe(true)
const beforeTerminal = database.getConversation(conversationId).messages[1]!
expect(beforeTerminal.blocks?.map(block => block.type)).toEqual(legacy ? undefined : ['question', 'text', 'tool', 'question', 'text'])
if (!legacy) expect(beforeTerminal.blocks?.[0]).toEqual(initialBlocks?.[0])
expect(append('7', { requestId: taskId, type: 'done' })).toBe(true)
const completed = database.getConversation(conversationId).messages[1]!
expect(completed.answeredQuestions).toHaveLength(2)
expect(completed.content).toBe('After first. After second.')
expect(completed.state).toBe('complete')
expect(completed.blocks?.filter(block => block.type === 'question')).toEqual(beforeTerminal.blocks?.filter(block => block.type === 'question'))
database.close()
database.initialize('C:\\Workspace')
expect(append('1', question)).toBe(false)
expect(database.getConversation(conversationId).messages[1]).toEqual(completed)
} finally { database.close() }
})
it('rolls back a recovered event when its message update fails', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-remote-event-atomic-')
+27
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
import { DatabaseSync } from 'node:sqlite'
import { statSync } from 'node:fs'
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
@@ -1383,6 +1384,11 @@ function reduceRecoveredAgentEvent(
blocks,
status: undefined
}
} else if (event.type === 'question') {
next = {
...message,
blocks: appendConversationQuestionBlock(message.blocks, event.questionId)
}
} else if (event.type === 'checklist') {
next = { ...message, runtimeChecklist: event.checklist }
} else if (event.type === 'status') {
@@ -5099,6 +5105,27 @@ export class AssistantDatabase {
})
}
recordRemoteTaskQuestionArrival(taskIdInput: string, questionId: string): boolean {
const taskId = assistantIdSchema.parse(taskIdInput)
const database = this.requireDatabase()
const row = database.prepare(
`SELECT m.id, m.metadata_json
FROM tasks t JOIN messages m
ON m.id = t.current_assistant_message_id
AND m.conversation_id = t.conversation_id AND m.request_id = t.id
WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'`
).get(taskId) as { id: string; metadata_json: string } | undefined
if (!row) return false
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
const blocks = appendConversationQuestionBlock(metadata.blocks, questionId)
if (blocks !== metadata.blocks) {
metadata.blocks = blocks
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?')
.run(JSON.stringify(metadata), row.id)
}
return true
}
recordRemoteTaskQuestionAnswer(
taskIdInput: string,
answerInput: ConversationAnsweredQuestion
-7
View File
@@ -1,4 +1,3 @@
import type { ChannelInboundText } from '../../shared/channel-contracts'
import type { ChannelExecutor } from './channel-driver'
import { ChannelService } from './channel-service'
import {
@@ -222,9 +221,3 @@ export function startEnvironmentChannels(
})
return services
}
export function isReadOnlyChannelMessage(
message: ChannelInboundText
): boolean {
return message.workMode === 'ask'
}
+3 -2
View File
@@ -1361,8 +1361,6 @@ vi.mock('./assistant/heartbeat-service', () => ({
vi.mock('./agent/create-runtime', () => runtimeFactoryMocks)
vi.mock('./channels/channel-env', () => ({
isReadOnlyChannelMessage: (message: { workMode: string }) =>
message.workMode === 'ask',
startEnvironmentChannels: vi.fn(
(options: { executor: typeof channelMocks.executor }) => {
channelMocks.executor = options.executor
@@ -5143,6 +5141,7 @@ describe('registerIpcHandlers agent terminal state', () => {
hasRemoteResponseTextAfterToolFailure: vi.fn<AssistantDatabase['hasRemoteResponseTextAfterToolFailure']>(() => false),
getRemoteTaskActivityStates: vi.fn<AssistantDatabase['getRemoteTaskActivityStates']>(() => ({ tools: new Map(), subagents: new Map() })),
endRecoverableRemoteTask: vi.fn(),
recordRemoteTaskQuestionArrival: vi.fn<AssistantDatabase['recordRemoteTaskQuestionArrival']>(),
recordRemoteTaskQuestionAnswer: vi.fn<AssistantDatabase['recordRemoteTaskQuestionAnswer']>(),
getTask: vi.fn<AssistantDatabase['getTask']>(),
updateTaskStatus: vi.fn(),
@@ -7013,6 +7012,7 @@ describe('registerIpcHandlers agent terminal state', () => {
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database))
harness.assistantDatabase.recordRemoteTaskQuestionArrival.mockImplementation(database.recordRemoteTaskQuestionArrival.bind(database))
harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation(
(...args) => database.recordRemoteTaskQuestionAnswer(...args)
)
@@ -7059,6 +7059,7 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(reopened.getTask(task.taskId).status).toBe('completed')
expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({
id: task.currentAssistantMessageId, state: 'complete',
blocks: [expect.objectContaining({ type: 'question', questionId: task.taskId })],
answeredQuestions: [{
questionId: task.taskId, skipped: index === 0,
questions: [{
+2
View File
@@ -2036,6 +2036,7 @@ export function registerIpcHandlers(
)) {
if (rawEvent.type === 'question' || rawEvent.type === 'question-resolved') {
if (rawEvent.type === 'question') {
assistantDatabase.recordRemoteTaskQuestionArrival(task.taskId, rawEvent.questionId)
pendingAgentQuestions.set(rawEvent.questionId, {
requestId: task.taskId, runtime: recoveredRuntime, question: rawEvent
})
@@ -4798,6 +4799,7 @@ export function registerIpcHandlers(
toolStates.delete(publicEvent.runtimeCallId)
}
if (publicEvent.type === 'question') {
assistantDatabase.recordRemoteTaskQuestionArrival(request.requestId, publicEvent.questionId)
pendingAgentQuestions.set(publicEvent.questionId, {
requestId: request.requestId,
runtime: selectedRuntime,
+2 -90
View File
@@ -7,7 +7,6 @@ import {
extractKnowledgeGraph,
mergeKnowledgeGraphs,
normalizeEntityAlias,
searchGraph,
validateModelGraph,
type GraphChunk,
type KnowledgeGraph
@@ -867,9 +866,9 @@ describe('extraction strategies', () => {
})
})
describe('graph merge and search', () => {
describe('graph merge', () => {
const evidence = {
chunkId: 'search',
chunkId: 'merge',
quote: 'evidence',
start: 0,
end: 8,
@@ -883,27 +882,6 @@ describe('graph merge and search', () => {
name: 'API Gateway',
type: 'service',
aliases: ['gateway'],
evidence: [{ ...evidence, confidence: 0.9 }]
},
{
id: 'users',
name: 'User Service',
type: 'service',
aliases: [],
evidence: [evidence]
},
{
id: 'database',
name: 'User Database',
type: 'database',
aliases: [],
evidence: [{ ...evidence, confidence: 0.6 }]
},
{
id: 'unrelated',
name: 'Billing',
type: 'service',
aliases: [],
evidence: [evidence]
}
],
@@ -913,77 +891,11 @@ describe('graph merge and search', () => {
sourceId: 'api',
targetId: 'users',
type: 'calls',
evidence: [{ ...evidence, confidence: 0.95 }]
},
{
id: 'users-db',
sourceId: 'users',
targetId: 'database',
type: 'uses',
evidence: [{ ...evidence, confidence: 0.8 }]
},
{
id: 'orphan',
sourceId: 'api',
targetId: 'missing',
type: 'calls',
evidence: [evidence]
}
]
}
it('ranks exact/alias matches, traverses adjacency, and returns a bounded subgraph', () => {
const result = searchGraph(graph, 'gateway', {
maximumEntities: 2,
maximumRelations: 1,
maximumDepth: 2
})
expect(result.matchedEntityIds[0]).toBe('api')
expect(result.entities.map((entity) => entity.id)).toEqual(['api', 'users'])
expect(result.relations.map((relation) => relation.id)).toEqual([
'api-users'
])
expect(searchGraph(graph, 'not found')).toEqual({
entities: [],
relations: [],
matchedEntityIds: []
})
})
it('never exceeds global search limits even when callers request more', () => {
const entities = Array.from(
{ length: GRAPH_LIMITS.maximumSearchEntities + 10 },
(_, index) => ({
id: `node-${index}`,
name: `node ${index}`,
type: 'node',
aliases: [],
evidence: [evidence]
})
)
const largeGraph: KnowledgeGraph = {
entities,
relations: entities.slice(1).map((entity, index) => ({
id: `edge-${index}`,
sourceId: entities[0]?.id ?? '',
targetId: entity.id,
type: 'links',
evidence: [evidence]
}))
}
const result = searchGraph(largeGraph, 'node', {
maximumEntities: 10_000,
maximumRelations: 10_000
})
expect(result.entities.length).toBeLessThanOrEqual(
GRAPH_LIMITS.maximumSearchEntities
)
expect(result.relations.length).toBeLessThanOrEqual(
GRAPH_LIMITS.maximumSearchRelations
)
})
it('discards relations whose endpoints disappear during merge', () => {
const merged = mergeKnowledgeGraphs(
{ entities: [graph.entities[0]!], relations: [graph.relations[0]!] },
-161
View File
@@ -21,8 +21,6 @@ export const GRAPH_LIMITS = {
maximumRelations: 400,
maximumFieldLength: 120,
maximumQuoteLength: 500,
maximumSearchEntities: 50,
maximumSearchRelations: 100,
maximumWarnings: 20,
maximumWarningLength: 240
} as const
@@ -91,16 +89,6 @@ export interface ExtractKnowledgeGraphOptions {
modelRetryBackoffMilliseconds?: number
}
export interface GraphSearchOptions {
maximumEntities?: number
maximumRelations?: number
maximumDepth?: number
}
export interface GraphSearchResult extends KnowledgeGraph {
matchedEntityIds: string[]
}
const emptyGraph = (): KnowledgeGraph => ({ entities: [], relations: [] })
const modelEvidenceSchema = z
@@ -1003,152 +991,3 @@ export async function extractKnowledgeGraph(
warnings: [...context.warnings].slice(0, GRAPH_LIMITS.maximumWarnings)
}
}
function bestEvidenceConfidence(evidence: readonly GraphEvidence[]): number {
return evidence.reduce(
(maximum, item) => Math.max(maximum, item.confidence),
0
)
}
function entityMatchScore(entity: GraphEntity, query: string): number {
const key = normalizeEntityAlias(entity.name)
const type = normalizeEntityAlias(entity.type)
const aliases = entity.aliases.map(normalizeEntityAlias)
if (key === query || aliases.includes(query)) {
return 100
}
if (key.startsWith(query) || aliases.some((alias) => alias.startsWith(query))) {
return 80
}
if (key.includes(query) || aliases.some((alias) => alias.includes(query))) {
return 60
}
if (type.includes(query)) {
return 30
}
return 0
}
export function searchGraph(
graph: KnowledgeGraph,
query: string,
options: GraphSearchOptions = {}
): GraphSearchResult {
const normalizedQuery = normalizeEntityAlias(query)
if (!normalizedQuery) {
return { ...emptyGraph(), matchedEntityIds: [] }
}
const maximumEntities = Math.max(
1,
Math.min(
options.maximumEntities ?? 20,
GRAPH_LIMITS.maximumSearchEntities
)
)
const maximumRelations = Math.max(
0,
Math.min(
options.maximumRelations ?? 40,
GRAPH_LIMITS.maximumSearchRelations
)
)
const maximumDepth = Math.max(0, Math.min(options.maximumDepth ?? 1, 3))
const entitiesById = new Map(
graph.entities
.slice(0, GRAPH_LIMITS.maximumEntities)
.map((entity) => [entity.id, entity])
)
const validRelations = graph.relations
.slice(0, GRAPH_LIMITS.maximumRelations)
.filter(
(relation) =>
entitiesById.has(relation.sourceId) &&
entitiesById.has(relation.targetId)
)
const scored = [...entitiesById.values()]
.map((entity) => ({
entity,
score: entityMatchScore(entity, normalizedQuery)
}))
.filter((item) => item.score > 0)
.sort(
(left, right) =>
right.score - left.score ||
bestEvidenceConfidence(right.entity.evidence) -
bestEvidenceConfidence(left.entity.evidence) ||
left.entity.name.localeCompare(right.entity.name)
)
const matchedEntityIds = scored
.slice(0, maximumEntities)
.map((item) => item.entity.id)
const selected = new Set(matchedEntityIds)
let frontier = new Set(matchedEntityIds)
for (
let depth = 0;
depth < maximumDepth && selected.size < maximumEntities;
depth += 1
) {
const candidates = new Map<string, number>()
for (const relation of validRelations) {
const neighbor = frontier.has(relation.sourceId)
? relation.targetId
: frontier.has(relation.targetId)
? relation.sourceId
: undefined
if (neighbor && !selected.has(neighbor)) {
candidates.set(
neighbor,
Math.max(
candidates.get(neighbor) ?? 0,
bestEvidenceConfidence(relation.evidence)
)
)
}
}
const next = [...candidates]
.sort(
([leftId, leftScore], [rightId, rightScore]) =>
rightScore - leftScore ||
(entitiesById.get(leftId)?.name ?? '').localeCompare(
entitiesById.get(rightId)?.name ?? ''
)
)
.slice(0, maximumEntities - selected.size)
.map(([id]) => id)
frontier = new Set(next)
for (const id of next) {
selected.add(id)
}
}
const entities = [...selected]
.map((id) => entitiesById.get(id))
.filter((entity): entity is GraphEntity => entity !== undefined)
const relations = validRelations
.filter(
(relation) =>
selected.has(relation.sourceId) && selected.has(relation.targetId)
)
.sort(
(left, right) =>
Number(matchedEntityIds.includes(right.sourceId)) +
Number(matchedEntityIds.includes(right.targetId)) -
Number(matchedEntityIds.includes(left.sourceId)) -
Number(matchedEntityIds.includes(left.targetId)) ||
bestEvidenceConfidence(right.evidence) -
bestEvidenceConfidence(left.evidence) ||
left.id.localeCompare(right.id)
)
.slice(0, maximumRelations)
const connected = new Set(
relations.flatMap((relation) => [relation.sourceId, relation.targetId])
)
return {
entities: entities.filter(
(entity) =>
matchedEntityIds.includes(entity.id) || connected.has(entity.id)
),
relations,
matchedEntityIds
}
}
@@ -41,7 +41,7 @@ vi.mock('electron', () => ({
contextBridge: { exposeInMainWorld: (_name: string, api: DesktopApi) => { bridge.api = api } }
}))
// Disable unrelated environment integrations; persistence, schemas and handlers are real.
vi.mock('../channels/channel-env', () => ({ startEnvironmentChannels: () => [], isReadOnlyChannelMessage: () => true }))
vi.mock('../channels/channel-env', () => ({ startEnvironmentChannels: () => [] }))
const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
function canvas(): MagicNoteCanvasContent {
+14 -1
View File
@@ -77,6 +77,17 @@ function makeTokenUsage(): TokenUsageSummary {
}
describe('ActivityPanel', () => {
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: '活动时间线' }))
const help = screen.getByRole('button', { name: '活动时间线' })
expect(help.closest('[role="tab"]')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(help)
expect(screen.getByRole('tooltip')).toHaveTextContent('所有轨道共享同一执行顺序')
expect(screen.getByRole('tab', { name: '活动时间线' })).toHaveAttribute('aria-selected', 'true')
})
afterEach(async () => {
cleanup()
await i18n.changeLanguage('zh-CN')
@@ -106,6 +117,7 @@ describe('ActivityPanel', () => {
screen.getByRole('heading', { level: 1, name: 'Run history' })
).toBeInTheDocument()
expect(screen.queryByText('RUN HISTORY')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Run history' }))
expect(
screen.getByText(
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
@@ -705,8 +717,9 @@ describe('ActivityPanel', () => {
).getByText('G')
).toBeInTheDocument()
expect(within(timeline).getByText('项目:项目甲')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '活动时间线' }))
expect(
within(timeline).getByText(
screen.getByText(
'所有轨道共享同一执行顺序,节点按发生时间依次展开;点击节点可查看身份和活动详情。'
)
).toBeInTheDocument()
+6 -4
View File
@@ -1,6 +1,7 @@
import { Activity, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
AssistantProject,
TokenUsageSummary
@@ -694,7 +695,7 @@ export function ActivityPanel({
className="activity-panel"
>
<PageHeader
description={t('header.description')}
help={t('header.description')}
headingId="activity-panel-title"
icon={<Activity size={20} />}
scope={{ kind: 'all-projects' }}
@@ -822,9 +823,10 @@ export function ActivityPanel({
aria-label={t('timeline.ariaLabel')}
className="activity-tracks"
>
<p className="activity-tracks__description">
{t('timeline.description')}
</p>
<div className="inline-help-label">
<strong>{t('tabs.timeline')}</strong>
<InlineHelp label={t('tabs.timeline')}>{t('timeline.description')}</InlineHelp>
</div>
<ul
aria-label={t('timeline.legendAriaLabel')}
className="activity-tracks__legend"
+66 -1
View File
@@ -1620,7 +1620,12 @@ describe("App", () => {
vi.mocked(api.conversations.list).mockResolvedValue([{
...saved, updatedAt: saved.updatedAt + 120_000,
messages: saved.messages.map((message) => message.role === "assistant"
? { ...message, state: "complete", content: "Persisted terminal result" } : message),
? {
...message, state: "complete", content: "Persisted terminal result",
blocks: [...(message.blocks ?? []), {
id: crypto.randomUUID(), type: "text" as const, content: "Persisted terminal result",
}],
} : message),
}]);
act(() => conversationQueueChangeListener?.(request.conversationId!));
await screen.findByText("Persisted terminal result");
@@ -11819,6 +11824,66 @@ describe("App", () => {
);
});
it("keeps interactive questions in event order after a late answer response and history reload", async () => {
const response = deferred<void>();
vi.mocked(api.agent.respondQuestion).mockReturnValueOnce(response.promise);
const view = render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
target: { value: "Question order regression" },
});
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
const question: Extract<AgentEvent, { type: "question" }> = {
requestId, type: "question", questionId: "ordered-question",
questions: [{
header: "休息日", question: "你想怎样安排?",
options: [{ label: "探索美食", description: "" }], multiple: false, custom: false,
}],
};
act(() => {
agentListener?.({ requestId, type: "text", delta: "Before question" });
agentListener?.(question);
agentListener?.(question);
});
expect(document.querySelectorAll(".agent-question-card")).toHaveLength(1);
fireEvent.click(screen.getByRole("radio", { name: "探索美食" }));
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalledWith("ordered-question", [["探索美食"]]));
act(() => {
agentListener?.({
requestId, type: "tool", callId: "after-question", name: "after_question",
state: "completed", summary: "after_question",
});
agentListener?.({ requestId, type: "text", delta: "Selection received" });
agentListener?.({ requestId, type: "done" });
});
await act(async () => response.resolve());
const assertOrder = () => {
const card = screen.getByText("问题与回答").closest(".agent-question-review")!;
expect(card.closest(".message-blocks")).not.toBeNull();
expect(screen.getByText("Before question").compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(card.compareDocumentPosition(screen.getByText("Selection received")) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
};
await waitFor(assertOrder);
let persisted: ConversationSnapshot | undefined;
await waitFor(() => {
const saved = vi.mocked(api.conversations.saveLocal).mock.calls
.flatMap(([batch]) => batch)
.filter(item => item.messages.some(message => message.answeredQuestions?.some(answer => answer.questionId === question.questionId)))
.at(-1);
expect(saved).toBeDefined();
const message = saved!.messages.find(message => message.answeredQuestions?.length)!;
expect(message.blocks?.map(block => block.type)).toEqual(["text", "question", "tool", "text"]);
expect(message.blocks?.filter(block => block.type === "question")).toHaveLength(1);
persisted = { ...saved!.header, messages: saved!.messages };
});
view.unmount();
vi.mocked(api.conversations.list).mockResolvedValue([persisted!]);
render(<App />);
await waitFor(assertOrder);
});
it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => {
let reject!: (error: Error) => void;
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((_resolve, no) => { reject = no; }));
+2
View File
@@ -206,6 +206,7 @@ import {
} from "./CustomTaskDialog";
import { ConversationTaskStrip } from "./ConversationTaskStrip";
import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip";
import { appendConversationQuestionBlock } from "../../shared/conversation-question-blocks";
import { ConversationInputQueue } from "./ConversationInputQueue";
import { OverflowMarquee } from "./OverflowMarquee";
import { findTaskSchedule } from "./TaskScheduleActions";
@@ -5082,6 +5083,7 @@ function App(): React.JSX.Element {
return {
...message,
status: undefined,
blocks: appendConversationQuestionBlock(message.blocks, event.questionId),
pendingQuestions: [...(message.pendingQuestions ?? []), event],
};
});
+36 -6
View File
@@ -6,6 +6,7 @@ import {
within,
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { changeUiLocale } from './i18n'
import {
applicationSettingsSchema,
defaultApplicationNavigation,
@@ -40,9 +41,34 @@ const pinnedSettings = {
pinned: { 'magic-notes': true, 'local-inference': true },
},
}
afterEach(cleanup)
afterEach(async () => {
cleanup()
await changeUiLocale('zh-CN')
})
describe('Application Center', () => {
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
await changeUiLocale(locale)
const onUpdate = vi.fn(async () => true)
const { container } = render(<ApplicationSettingsView id="magic-notes" settings={settings} pending={false} onUpdate={onUpdate} />)
const pinLabel = locale === 'zh-CN' ? '常驻左侧菜单' : 'Pin to sidebar'
const explanation = locale === 'zh-CN' ? '常驻只控制左侧捷径。' : 'Pinning only controls the sidebar shortcut.'
const consequence = locale === 'zh-CN' ? /不停止已有请求或后台服务/ : /without stopping existing requests or background services/
expect(screen.getByText(explanation)).not.toBeVisible()
expect(screen.getByText(consequence)).toBeVisible()
const help = screen.getByRole('button', { name: pinLabel })
expect(container.querySelector('label .inline-help, button .inline-help, summary .inline-help')).toBeNull()
const pin = screen.getByRole('switch', { name: pinLabel })
expect(pin).toHaveAccessibleDescription(explanation)
const checked = (pin as HTMLInputElement).checked
fireEvent.click(help)
expect(onUpdate).not.toHaveBeenCalled()
expect((pin as HTMLInputElement).checked).toBe(checked)
expect(screen.getByRole('tooltip')).toHaveTextContent(explanation)
expect(screen.getByRole('tooltip')).not.toHaveTextContent(consequence)
expect(pin).toHaveAccessibleDescription(explanation)
})
it('returns from compact settings through header navigation without losing the search', () => {
render(<ApplicationCenter {...props()} />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } })
@@ -282,13 +308,17 @@ describe('Application Center', () => {
const pageCount = screen.getByRole('combobox', { name: '发送画布页数' })
expect(pageCount).toHaveValue('1')
expect(within(pageCount).getAllByRole('option')).toHaveLength(8)
expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量'))
expect(pageCount.parentElement).toHaveClass('field')
const help = screen.getByRole('button', { name: '发送画布页数' })
expect(help).toHaveClass('icon-button')
expect(help).toHaveAccessibleDescription(help.getAttribute('title')!)
expect(help).toHaveAttribute('title', expect.stringContaining('不是笔记记录数量'))
expect(document.getElementById('magic-note-canvas-page-count-help')).toHaveClass('sr-only')
expect(help).toHaveClass('inline-help')
expect(help).not.toHaveAttribute('title')
expect(help.closest('label, summary')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(help)
expect(pageCount).toHaveValue('1')
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量'))
expect(screen.getByRole('tooltip')).toHaveAttribute('id', 'magic-note-canvas-page-count-help')
fireEvent.change(pageCount, { target: { value: '8' } })
expect(onUpdate).toHaveBeenLastCalledWith({ magicNoteCanvasPageCount: 8 })
fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' }))
+27 -28
View File
@@ -10,7 +10,6 @@ import {
ArrowDown,
ArrowLeft,
ArrowUp,
CircleHelp,
Cpu,
HeartPulse,
Library,
@@ -19,6 +18,7 @@ import {
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import {
defaultApplicationNavigation,
type ApplicationSettings,
@@ -117,24 +117,28 @@ export function ApplicationSettingsView({
/>
<span>{t('applications.enable')}</span>
</label>
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={navigation.pinned[id]}
disabled={pending}
onChange={(event) =>
void onUpdate({
applicationNavigation: {
...navigation,
pinned: { ...navigation.pinned, [id]: event.target.checked },
},
})
}
/>
<span>{t('applications.pin')}</span>
</label>
<p className="settings-notice">{t('applications.enableHelp')}</p>
<div className="inline-help-label">
<label className="toggle-row">
<input
role="switch"
type="checkbox"
checked={navigation.pinned[id]}
aria-describedby="application-pin-help"
disabled={pending}
onChange={(event) =>
void onUpdate({
applicationNavigation: {
...navigation,
pinned: { ...navigation.pinned, [id]: event.target.checked },
},
})
}
/>
<span>{t('applications.pin')}</span>
</label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>
<p className="settings-notice">{t('applications.disableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
@@ -161,12 +165,10 @@ export function ApplicationSettingsView({
<div className="platform-feature-option application-settings__compact-option">
<div className="application-settings__label-with-help">
<label htmlFor="magic-note-canvas-page-count">{t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}</label>
<button type="button" className="icon-button"
aria-label={t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}
aria-describedby="magic-note-canvas-page-count-help"
title={t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}>
<CircleHelp size={14} aria-hidden="true" />
</button>
<InlineHelp label={t('platformFeatures.magicNotes.canvasPageCount', { ns: 'settingsSections' })}
id="magic-note-canvas-page-count-help">
{t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}
</InlineHelp>
</div>
<div className="field">
<select id="magic-note-canvas-page-count" disabled={pending} value={settings.magicNoteCanvasPageCount ?? 1}
@@ -176,9 +178,6 @@ export function ApplicationSettingsView({
</select>
</div>
</div>
<span id="magic-note-canvas-page-count-help" className="sr-only">
{t('platformFeatures.magicNotes.canvasPageCountHelp', { ns: 'settingsSections' })}
</span>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode', {
+12 -9
View File
@@ -1,6 +1,7 @@
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
@@ -201,14 +202,17 @@ export function BrandingSettingsSection({
ref={fileInputRef}
type="file"
/>
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={13} />
{t('appearance.branding.logo.select')}
</button>
<span className="inline-help-label">
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={13} />
{t('appearance.branding.logo.select')}
</button>
<InlineHelp label={t('appearance.branding.logo.select')}>{t('appearance.branding.logo.help')}</InlineHelp>
</span>
{draft.logoDataUrl && (
<button
className="secondary-button"
@@ -227,7 +231,6 @@ export function BrandingSettingsSection({
{t('appearance.branding.logo.useDefault')}
</button>
)}
<small>{t('appearance.branding.logo.help')}</small>
</div>
{error && (
@@ -6,7 +6,8 @@ import {
repairChannelRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
export function channelProjectDraft(
project: ProjectCreateInput,
@@ -107,22 +108,23 @@ export function ChannelProjectSettingsFields({
selection={value.runtimeSelection}
selectionMode="channel"
/>
<ProjectWorkModeFields
<fieldset className="project-work-mode">
<legend><span className="inline-help-label">{t('channels.project.defaultMode')}<InlineHelp label={t('channels.project.defaultMode')}>{t('channels.project.overrideHelp')}</InlineHelp></span></legend>
<SegmentedControl
ariaLabel={t('channels.project.defaultModeAriaLabel', {
name: value.name
})}
disabled={disabled}
help={t('channels.project.overrideHelp')}
labels={{
ask: t('channels.project.modes.ask'),
execute: t('channels.project.modes.execute')
}}
legend={t('channels.project.defaultMode')}
options={[
{ value: 'ask', label: t('channels.project.modes.ask') },
{ value: 'execute', label: t('channels.project.modes.execute') }
]}
onChange={(defaultWorkMode) =>
onChange({ ...value, defaultWorkMode })
}
value={value.defaultWorkMode}
/>
</fieldset>
<p className="channel-project-settings__risk">
{value.defaultWorkMode === 'execute'
? t('channels.project.executeRisk')
+6 -4
View File
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import { createPortal } from 'react-dom'
import QRCode from 'qrcode'
import type {
@@ -687,7 +688,10 @@ function WeixinChannelEditor({
<article className="capability-card channel-settings-card">
<div className="capability-card__header">
<div>
<strong>{t('channels.tabs.weixin')}</strong>
<span className="inline-help-label">
<strong>{t('channels.tabs.weixin')}</strong>
<InlineHelp label={t('channels.tabs.weixin')} id="channel-weixin-behavior-help">{t('channels.weixin.behaviorHelp')}</InlineHelp>
</span>
<small>
{settings.bindingConfigured
? t('channels.weixin.bindingSaved', {
@@ -712,6 +716,7 @@ function WeixinChannelEditor({
checked={enabled}
disabled={!settings.bindingConfigured}
id="channel-weixin-enabled"
aria-describedby="channel-weixin-behavior-help"
onChange={(event) =>
onEnabledChange(event.target.checked)
}
@@ -755,9 +760,6 @@ function WeixinChannelEditor({
{t('channels.weixin.disconnectHelp')}
</small>
)}
<small>
{t('channels.weixin.behaviorHelp')}
</small>
</article>
<ChannelProjectCard
+78
View File
@@ -80,6 +80,84 @@ function createMessages(): Message[] {
}
describe('ChatTimeline', () => {
it('keeps a question before later tools and text when its answer arrives and history reopens', () => {
const props = {
artifactById: new Map(), conversationId: 'question-order',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
}
const question = {
requestId: 'request', type: 'question' as const, questionId: 'question-1',
questions: [{
header: '休息日', question: '你想怎样安排?',
options: [{ label: '探索美食', description: '' }], multiple: false, custom: false
}]
}
const message: Message = {
id: 'ordered-message', role: 'assistant', content: '提问前\n选择结果已收到',
createdAt: 0, state: 'streaming', pendingQuestions: [question],
blocks: [
{ id: 'before', type: 'text', content: '提问前' },
{ id: 'question', type: 'question', questionId: question.questionId },
{ id: 'tool', type: 'tool', tool: { callId: 'call-1', name: 'after_question', state: 'completed', summary: 'after_question' } },
{ id: 'after', type: 'text', content: '选择结果已收到' }
]
}
const view = render(<ChatTimeline {...props} messages={[message]} />)
const assertOrder = (questionSelector: string) => {
const blocks = view.container.querySelector('.message-blocks')!
const card = blocks.querySelector(questionSelector)!
expect(card).not.toBeNull()
expect(screen.getByText('提问前').compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(card.compareDocumentPosition(blocks.querySelector('.tool-execution-list')!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(card.compareDocumentPosition(screen.getByText('选择结果已收到')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
}
assertOrder('.agent-question-card')
const completed: Message = {
...message, state: 'complete', pendingQuestions: undefined,
answeredQuestions: [{ questionId: question.questionId, questions: [{
...question.questions[0]!, answer: ['探索美食']
}] }]
}
view.rerender(<ChatTimeline {...props} messages={[completed]} />)
assertOrder('.agent-question-review')
expect(screen.getAllByText('问题与回答')).toHaveLength(1)
view.unmount()
const reopened = render(<ChatTimeline {...props} messages={[JSON.parse(JSON.stringify(completed))]} />)
const card = reopened.container.querySelector('.agent-question-review')!
expect(card.closest('.message-blocks')).not.toBeNull()
expect(card.compareDocumentPosition(screen.getByText('选择结果已收到')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
it('uses question marker order rather than answer-array order and retains unanchored legacy answers', () => {
const review = (questionId: string) => ({
questionId, skipped: true,
questions: [{ header: questionId, question: `${questionId}?`, options: [], multiple: false, custom: true }]
})
const view = render(<ChatTimeline
artifactById={new Map()} conversationId="multiple-questions"
hiddenMessageCount={0} isUnusedConversation={false} locale="zh-CN"
messageStartIndex={0} {...callbacks} retryContent="" totalMessageCount={1}
messages={[{
id: 'message', role: 'assistant', content: 'Between', state: 'complete', createdAt: 0,
blocks: [
{ id: 'q1', type: 'question', questionId: 'first' },
{ id: 'text', type: 'text', content: 'Between' },
{ id: 'q2', type: 'question', questionId: 'second' }
],
answeredQuestions: [review('second'), review('first'), review('legacy')]
}]}
/>)
const cards = [...view.container.querySelectorAll('.agent-question-review')]
expect(cards).toHaveLength(3)
expect(cards.map(card => card.querySelector('.agent-question-review__question span')!.textContent)).toEqual([
'first', 'second', 'legacy'
])
expect(cards[0]!.compareDocumentPosition(screen.getByText('Between')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(screen.getByText('Between').compareDocumentPosition(cards[1]!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(cards[2]!.closest('.message-blocks')).toBeNull()
})
it.each([
['read', 'OpenCode 工具:read', false],
['read', 'Continue 工具:read', false],
+61 -54
View File
@@ -112,12 +112,21 @@ type MessageBlockRenderItem =
id: string
childTaskIds: string[]
}
| {
kind: 'question'
id: string
questionId: string
}
function groupMessageBlocks(
blocks: ConversationMessageBlock[]
): MessageBlockRenderItem[] {
const items: MessageBlockRenderItem[] = []
for (const block of blocks) {
if (block.type === 'question') {
items.push({ kind: 'question', id: block.id, questionId: block.questionId })
continue
}
if (block.type === 'subagent') {
const previous = items.at(-1)
if (previous?.kind === 'subagents') {
@@ -437,7 +446,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
: tool
)} />
) : item.kind === 'subagents' ? null
) : item.kind === 'subagents' || item.kind === 'question' ? null
: item.block.type === 'reasoning' ? (
<MessageReasoning
key={item.block.id}
@@ -643,6 +652,52 @@ function ChatMessageRowView({
const question = message.pendingQuestions?.[0]
const questionTask = question?.childTaskId ? subagentsById.get(question.childTaskId) : undefined
const questionFormId = `agent-question-${message.id}`
const orderedQuestionIds = useMemo(() => new Set(
message.blocks?.filter(block => block.type === 'question').map(block => block.questionId)
), [message.blocks])
const answeredQuestionsById = useMemo(() => new Map(
message.answeredQuestions?.map(review => [review.questionId, review])
), [message.answeredQuestions])
const renderQuestion = (questionId: string): React.JSX.Element | null => {
if (question?.questionId === questionId) {
return (
<AgentQuestionCard
id={questionFormId}
pendingCount={message.pendingQuestions?.length}
taskTitle={questionTask?.reason ??
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
key={questionId}
onReject={() => onRespondQuestion(conversationId, message.id, questionId)}
onSubmit={(answers) => onRespondQuestion(conversationId, message.id, questionId, answers)}
value={question}
/>
)
}
const review = answeredQuestionsById.get(questionId)
if (!review) return null
return (
<div className="agent-question-review" key={questionId}>
<header>
<CircleHelp aria-hidden="true" size={16} />
<strong>{t('chat.questionReview.title')}</strong>
</header>
{review.questions.map((item, index) => (
<div className="agent-question-review__item" key={`${questionId}:${index}`}>
<p className="agent-question-review__question">
<span>{item.header}</span>
{item.question}
</p>
<p className="agent-question-review__answer">
<span>{t('chat.questionReview.answerLabel')}</span>
{review.skipped || !item.answer?.length
? t('chat.questionReview.skipped')
: item.answer.join('、')}
</p>
</div>
))}
</div>
)
}
const activeTool = message.tools?.find((tool) =>
tool.state === 'pending' || tool.state === 'running')
const activeSubagent = message.subagents?.some((subagent) =>
@@ -788,7 +843,8 @@ function ChatMessageRowView({
{message.blocks && message.blocks.length > 0 ? (
<div className="message-blocks">
{groupMessageBlocks(message.blocks).map((item) =>
item.kind === 'tools' ? (
item.kind === 'question' ? renderQuestion(item.questionId)
: item.kind === 'tools' ? (
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
) : item.kind === 'subagents' ? (
<SubagentStatusList
@@ -1149,58 +1205,9 @@ function ChatMessageRowView({
)}
</div>
)}
{question && (
<AgentQuestionCard
id={questionFormId}
pendingCount={message.pendingQuestions?.length}
taskTitle={questionTask?.reason ??
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
key={question.questionId}
onReject={() =>
onRespondQuestion(
conversationId,
message.id,
question.questionId
)
}
onSubmit={(answers) =>
onRespondQuestion(
conversationId,
message.id,
question.questionId,
answers
)
}
value={question}
/>
)}
{message.answeredQuestions?.map((review) => (
<div className="agent-question-review" key={review.questionId}>
<header>
<CircleHelp aria-hidden="true" size={16} />
<strong>{t('chat.questionReview.title')}</strong>
</header>
{review.questions.map((question, index) => (
<div
className="agent-question-review__item"
key={`${review.questionId}:${index}`}
>
<p className="agent-question-review__question">
<span>{question.header}</span>
{question.question}
</p>
<p className="agent-question-review__answer">
<span>{t('chat.questionReview.answerLabel')}</span>
{review.skipped ||
!question.answer ||
question.answer.length === 0
? t('chat.questionReview.skipped')
: question.answer.join('、')}
</p>
</div>
))}
</div>
))}
{question && !orderedQuestionIds.has(question.questionId) && renderQuestion(question.questionId)}
{message.answeredQuestions?.filter(review => !orderedQuestionIds.has(review.questionId))
.map(review => renderQuestion(review.questionId))}
{message.role === 'assistant' && message.imageContextNotice && (
<p className="message__image-context-note">
{t(`chat.images.contextNotice.${message.imageContextNotice}`)}
+20 -3
View File
@@ -26,6 +26,23 @@ function futureLocalDateTime(): string {
}
describe('CustomTaskDialog', () => {
it('keeps descriptions accessible and opens help outside field labels', () => {
const onClose = vi.fn()
render(<CustomTaskDialog currentConversationAvailable={false} onClose={onClose} onCreate={vi.fn()} projectName="Project" workspaceLabel="Workspace" />)
const dialog = screen.getByRole('dialog', { name: '新建定制任务' })
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)).toHaveClass('sr-only')
const name = screen.getByRole('textbox', { name: '任务名称' })
expect(document.getElementById(name.getAttribute('aria-describedby')!)).toHaveClass('sr-only')
const help = screen.getByRole('button', { name: '任务名称' })
expect(help.closest('label')).toBeNull()
fireEvent.click(help)
expect(screen.getByRole('tooltip')).toHaveTextContent(document.getElementById('custom-task-name-help')!.textContent!)
fireEvent.keyDown(help, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(dialog).toBeInTheDocument()
})
it('keeps dialog spacing and form controls inside their layout bounds', () => {
expect(stylesheet).toMatch(/--space-5:\s*20px;/u)
expect(stylesheet).toMatch(
@@ -72,7 +89,7 @@ describe('CustomTaskDialog', () => {
expect(screen.getByLabelText('关联会话')).toHaveValue('current')
expect(screen.getByLabelText('任务内容')).toHaveFocus()
fireEvent.change(screen.getByLabelText('任务名称'), {
fireEvent.change(screen.getByRole('textbox', { name: '任务名称' }), {
target: { value: '每周项目总结' }
})
fireEvent.change(screen.getByLabelText('任务内容'), {
@@ -190,7 +207,7 @@ describe('CustomTaskDialog', () => {
expect(screen.queryByRole('option', { name: '当前项目会话' })).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: ' 自定义名称 ' } })
fireEvent.change(screen.getByRole('textbox', { name: '任务名称' }), { target: { value: ' 自定义名称 ' } })
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ conversationId: otherId, title: '自定义名称' }),
@@ -256,7 +273,7 @@ describe('CustomTaskDialog', () => {
const create = screen.getByRole('button', { name: '创建任务' })
create.focus()
fireEvent.keyDown(create, { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭新建定制任务' })).toHaveFocus()
expect(screen.getByRole('button', { name: '新建定制任务' })).toHaveFocus()
unmount()
expect(opener).toHaveFocus()
opener.remove()
+16 -7
View File
@@ -8,6 +8,7 @@ import type {
} from '../../shared/assistant-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import './custom-task-dialog.css'
export type CustomTaskDestination = 'current' | 'new'
@@ -175,10 +176,13 @@ export function CustomTaskDialog({
<ClockFading aria-hidden="true" size={14} />
{t('customTask.eyebrow')}
</span>
<h2 id="custom-task-title">{t('customTask.title')}</h2>
<p id="custom-task-description">
<div className="inline-help-label">
<h2 id="custom-task-title">{t('customTask.title')}</h2>
<InlineHelp label={t('customTask.title')}>{t('customTask.description')}</InlineHelp>
</div>
<span className="sr-only" id="custom-task-description">
{t('customTask.description')}
</p>
</span>
</div>
<button
aria-label={t('customTask.close')}
@@ -218,9 +222,14 @@ export function CustomTaskDialog({
)}
</label>
<label className="custom-task-dialog__field">
<span id="custom-task-name-label">{t('customTask.fields.name')}</span>
<div className="custom-task-dialog__field">
<div className="inline-help-label">
<label htmlFor="custom-task-name" id="custom-task-name-label">{t('customTask.fields.name')}</label>
<small>{t('applications.optional')}</small>
<InlineHelp label={t('customTask.fields.name')}>{t('customTask.nameHelp')}</InlineHelp>
</div>
<input
id="custom-task-name"
aria-labelledby="custom-task-name-label"
aria-describedby="custom-task-name-help"
maxLength={120}
@@ -228,8 +237,8 @@ export function CustomTaskDialog({
placeholder={generatedTitle}
value={title}
/>
<small id="custom-task-name-help">{t('customTask.nameHelp')}</small>
</label>
<span className="sr-only" id="custom-task-name-help">{t('customTask.nameHelp')}</span>
</div>
<label className="custom-task-dialog__field">
<span id="custom-task-destination-label">{t('customTask.fields.destination')}</span>
@@ -38,6 +38,7 @@ import {
import { modelOperationPercent } from './model-download-presentation'
import { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { HttpOcrSettings } from './HttpOcrSettings'
import { DocumentResultPreview } from './DocumentResultPreview'
import { AnchoredMenu } from './AnchoredMenu'
@@ -621,10 +622,12 @@ export function DocumentParsingSettingsSection({
<div className="settings-section__title">
<FileText aria-hidden="true" size={17} />
<div>
<span className="inline-help-label">
<strong id="document-parsing-workflows-title">
{t('documentParsing.workflows.title')}
</strong>
<small>{t('documentParsing.workflows.description')}</small>
<InlineHelp label={t('documentParsing.workflows.title')}>{t('documentParsing.workflows.description')}</InlineHelp>
</span>
</div>
</div>
<div className="document-parsing-grid">
@@ -721,10 +724,12 @@ export function DocumentParsingSettingsSection({
<div className="settings-section__title settings-section__title--actions">
<ScanText aria-hidden="true" size={17} />
<div>
<span className="inline-help-label">
<strong id="document-parsing-ocr-title">
{t('documentParsing.ocr.title')}
</strong>
<small>{t('documentParsing.ocr.description')}</small>
<InlineHelp label={t('documentParsing.ocr.title')}>{t('documentParsing.ocr.description')}</InlineHelp>
</span>
</div>
<button
className="secondary-button"
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { InlineHelp } from './InlineHelp'
import { useTranslation } from 'react-i18next'
import { ChevronDown, Database, Eye, EyeOff, FolderOpen, RefreshCw, X } from 'lucide-react'
import type { KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
@@ -347,7 +348,7 @@ export function ExternalInstanceManager({ instances, libraries, onClose, onChang
<Field label={t('external.address')}><input type="url" required value={draft.baseUrl} aria-describedby={/^http:\/\//i.test(draft.baseUrl) ? `${keyId}-transport` : undefined} onChange={event => change({ baseUrl: event.currentTarget.value })} /></Field>
{/^http:\/\//i.test(draft.baseUrl) && <p className="external-knowledge__help" id={`${keyId}-transport`}>{t('external.transportNote')}</p>}
<fieldset className="external-knowledge__group">
<legend>{t('external.authentication')}</legend>
<legend><span className="inline-help-label">{t('external.authentication')}<InlineHelp label={t('external.authentication')}>{t('external.credentialNote')}</InlineHelp></span></legend>
{draft.id && <Field label={t('external.credentialAction')}><select value={draft.credential.action} onChange={event => { setShowKey(false); change({ credential: event.currentTarget.value === 'replace' ? { action: 'replace', value: '' } : { action: event.currentTarget.value as 'keep' | 'clear' } }) }}>
{draft.id && <option value="keep">{t('external.keep')}</option>}<option value="replace">{t('external.replace')}</option>{draft.id && <option value="clear">{t('external.clear')}</option>}
</select></Field>}
@@ -357,7 +358,7 @@ export function ExternalInstanceManager({ instances, libraries, onClose, onChang
<button type="button" className="icon-button" aria-label={t(showKey ? 'external.hideKey' : 'external.showKey')} title={t(showKey ? 'external.hideKey' : 'external.showKey')} aria-pressed={showKey} onClick={() => setShowKey(value => !value)}>{showKey ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
</div>}
<p className="external-knowledge__help" id={`${keyId}-note`}>{t('external.credentialNote')}</p>
<span className="sr-only" id={`${keyId}-note`}>{t('external.credentialNote')}</span>
{draft.credential.action === 'clear' && <label><input type="checkbox" checked={clearConfirmed} onChange={event => setClearConfirmed(event.currentTarget.checked)} />{t('external.clearConfirm')}</label>}
</fieldset>
<div className="external-knowledge__fields">
+14
View File
@@ -153,6 +153,7 @@ describe('HeartbeatCenter', () => {
it('renders English interface copy while preserving heartbeat content', async () => {
await i18n.changeLanguage('en-US')
render(<HeartbeatCenter {...createProps()} />)
fireEvent.click(screen.getByRole('button', { name: 'Smart Heartbeat' }))
expect(
screen.getByRole('heading', {
@@ -327,6 +328,7 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 2, name: '智能心跳' })
).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: '智能心跳' })[1]!)
expect(
screen.getByText('心跳计划按时只读运行,不调用工具。')
).toBeInTheDocument()
@@ -425,6 +427,18 @@ describe('HeartbeatCenter', () => {
)
})
it('keeps scope choices visible while showing help for the selected scope', () => {
render(<HeartbeatCenter {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
const help = screen.getByRole('button', { name: '回顾范围' })
expect(help.closest('label, [role="tab"]')).toBeNull()
fireEvent.click(help)
expect(screen.getByRole('tooltip')).toHaveTextContent('回顾所有可用项目中的有界对话与任务')
fireEvent.click(screen.getByRole('button', { name: '指定项目' }))
expect(screen.getByRole('tooltip')).toHaveTextContent('一次运行共同回顾所选项目')
expect(screen.getByRole('checkbox', { name: '默认项目' })).toBeVisible()
})
it('creates one heartbeat plan for multiple selected projects', async () => {
const onCreate = vi.fn(async () => {})
const secondProject = {
+1 -1
View File
@@ -414,7 +414,7 @@ export function HeartbeatCenter({
</>
)
}
description={t('center.description')}
help={t('center.description')}
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={heartbeatScope}
+18 -11
View File
@@ -1,6 +1,7 @@
import { HeartPulse, Pencil } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import {
heartbeatCreateSchema,
type AssistantHeartbeatConfig,
@@ -179,11 +180,13 @@ export function HeartbeatSettings({
return (
<div className="heartbeat-settings">
<div className="heartbeat-settings__intro">
<h2>
<HeartPulse size={15} />
{t('settings.title')}
</h2>
<p>{t('settings.description')}</p>
<div className="inline-help-label">
<h2>
<HeartPulse size={15} />
{t('settings.title')}
</h2>
<InlineHelp label={t('settings.title')}>{t('settings.description')}</InlineHelp>
</div>
</div>
<div className="heartbeat-settings__editor">
<div className="heartbeat-settings__editor-heading">
@@ -212,7 +215,16 @@ export function HeartbeatSettings({
/>
</label>
<fieldset className="heartbeat-settings__scope">
<legend>{t('settings.scope.legend')}</legend>
<legend>
<span className="inline-help-label">
{t('settings.scope.legend')}
<InlineHelp label={t('settings.scope.legend')}>
{scopeKind === 'global'
? t('settings.scope.globalHelp')
: t('settings.scope.projectsHelp')}
</InlineHelp>
</span>
</legend>
<SegmentedControl
ariaLabel={t('settings.scope.ariaLabel')}
disabled={pendingAction !== undefined}
@@ -229,11 +241,6 @@ export function HeartbeatSettings({
]}
value={scopeKind}
/>
<p>
{scopeKind === 'global'
? t('settings.scope.globalHelp')
: t('settings.scope.projectsHelp')}
</p>
{scopeKind === 'projects' && (
<div className="heartbeat-settings__project-list">
{selectableProjects.length === 0 ? (
+89
View File
@@ -0,0 +1,89 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { InlineHelp } from './InlineHelp'
import { PageHeader } from './WorkspacePrimitives'
import { SettingsSectionHeader } from './SettingsPrimitives'
afterEach(() => { cleanup(); vi.useRealTimers() })
it('preserves externally referenced descriptions while closed and never duplicates their IDs', () => {
render(<>
<input aria-label="Context size" aria-describedby="context-description" defaultValue="128" />
<InlineHelp label="Context help" id="context-description">Keep this model context size.</InlineHelp>
</>)
const input = screen.getByRole('textbox')
const trigger = screen.getByRole('button', { name: 'Context help' })
const check = (): void => {
expect(input).toHaveAccessibleDescription('Keep this model context size.')
expect(trigger).toHaveAccessibleDescription('Keep this model context size.')
expect(document.querySelectorAll('#context-description')).toHaveLength(1)
expect(input).toHaveValue('128')
}
check()
expect(document.getElementById('context-description')).not.toBeVisible()
fireEvent.click(trigger)
check()
expect(screen.getByRole('tooltip')).toBeVisible()
fireEvent.keyDown(trigger, { key: 'Escape' })
check()
expect(document.getElementById('context-description')).not.toBeVisible()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('supports hover transfer, focus, pinned clicks, outside dismissal and isolated Escape', () => {
vi.useFakeTimers()
const parentEscape = vi.fn()
render(<section role="dialog" aria-modal="true" onKeyDown={parentEscape}>
<button>Outside</button>
<InlineHelp label="Context" id="context-help">Long help text</InlineHelp>
</section>)
const trigger = screen.getByRole('button', { name: 'Context' })
const outside = screen.getByText('Outside')
outside.focus()
fireEvent.mouseEnter(trigger)
expect(outside).toHaveFocus()
expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('tooltip'))
expect(trigger).toHaveAttribute('aria-describedby', 'context-help')
fireEvent.mouseLeave(trigger)
fireEvent.mouseEnter(screen.getByRole('tooltip'))
act(() => vi.advanceTimersByTime(150))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.mouseLeave(screen.getByRole('tooltip'))
act(() => vi.advanceTimersByTime(150))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
act(() => trigger.focus())
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.mouseLeave(trigger)
act(() => vi.advanceTimersByTime(150))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(trigger)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(parentEscape).not.toHaveBeenCalled()
expect(trigger).toHaveFocus()
fireEvent.click(trigger)
fireEvent.pointerDown(outside)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(trigger)
act(() => outside.focus())
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('adds optional help beside both header titles without replacing descriptions', () => {
render(<>
<PageHeader headingId="page" title="Page" help="Page help" description="Page description" />
<SettingsSectionHeader headingId="section" title="Section" help="Section help" description="Section description" />
</>)
for (const title of ['Page', 'Section']) {
const trigger = screen.getByRole('button', { name: title })
expect(trigger.parentElement).toHaveClass('inline-help-label')
expect(trigger.closest('h1, h2')).toHaveTextContent(title)
fireEvent.click(trigger)
expect(screen.getByRole('tooltip')).toHaveTextContent(`${title} help`)
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(screen.getByText(`${title} description`)).toBeInTheDocument()
}
})
+114
View File
@@ -0,0 +1,114 @@
import { CircleHelp } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { FloatingPortal } from './FloatingPortal'
import './inline-help.css'
export function InlineHelp({ label, children, id }: {
label: string
children: ReactNode
id?: string
}): React.JSX.Element {
const generatedId = useId()
const contentId = id ?? generatedId
const anchorRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const pinned = useRef(false)
const hovered = useRef(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const [open, setOpen] = useState(false)
const cancelClose = (): void => { clearTimeout(timer.current) }
const close = (): void => {
cancelClose()
pinned.current = false
setOpen(false)
}
const leave = (): void => {
hovered.current = false
cancelClose()
timer.current = setTimeout(() => {
if (!pinned.current && !hovered.current && document.activeElement !== anchorRef.current &&
!panelRef.current?.contains(document.activeElement)) setOpen(false)
}, 120)
}
const enter = (): void => {
hovered.current = true
cancelClose()
setOpen(true)
}
const dismiss = useEffectEvent(close)
const position = useEffectEvent(() => {
const panel = panelRef.current
const anchor = anchorRef.current
if (!panel || !anchor) return
const viewport = window.visualViewport
const left = viewport?.offsetLeft ?? 0
const top = viewport?.offsetTop ?? 0
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const panelWidth = Math.max(0, Math.min(360, width - 32))
panel.style.width = `${panelWidth}px`
panel.style.maxHeight = `${Math.max(0, height - 32)}px`
const panelHeight = panel.getBoundingClientRect().height
const below = rect.bottom + 8
const y = below + panelHeight <= top + height - 16 ? below : rect.top - 8 - panelHeight
panel.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - panelWidth - 16))}px`
panel.style.top = `${Math.max(top + 16, Math.min(y, top + height - panelHeight - 16))}px`
})
useLayoutEffect(() => { if (open) position() })
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
if (!open) return
const inside = (target: EventTarget | null): boolean => target instanceof Node &&
!!(anchorRef.current?.contains(target) || panelRef.current?.contains(target))
const outside = (event: Event): void => { if (!inside(event.target)) dismiss() }
const keydown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
dismiss()
}
const reposition = (): void => position()
const observer = new ResizeObserver(reposition)
if (panelRef.current) observer.observe(panelRef.current)
if (anchorRef.current) observer.observe(anchorRef.current)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
// Run before modal/document handlers, including when hover did not move focus.
window.addEventListener('keydown', keydown, true)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
window.visualViewport?.addEventListener('resize', reposition)
window.visualViewport?.addEventListener('scroll', reposition)
return () => {
observer.disconnect()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
window.removeEventListener('keydown', keydown, true)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
window.visualViewport?.removeEventListener('resize', reposition)
window.visualViewport?.removeEventListener('scroll', reposition)
}
}, [open])
return <>
<button type="button" className="inline-help" ref={anchorRef} aria-label={label}
aria-expanded={open} aria-controls={open ? contentId : undefined} aria-describedby={open || id ? contentId : undefined}
onMouseEnter={enter} onMouseLeave={leave} onFocus={() => { cancelClose(); setOpen(true) }} onBlur={leave}
onClick={event => {
event.stopPropagation()
if (pinned.current) close()
else { cancelClose(); pinned.current = true; setOpen(true) }
}}>
<CircleHelp size={14} aria-hidden="true" />
</button>
{/* Referenced hidden text remains an accessible description without a second visible copy. */}
{!open && id && <span id={contentId} hidden>{children}</span>}
{open && <FloatingPortal anchorRef={anchorRef}>
<div className="inline-help__content" id={contentId} role="tooltip" ref={panelRef}
onMouseEnter={enter} onMouseLeave={leave} onFocus={cancelClose} onBlur={leave}>
{children}
</div>
</FloatingPortal>}
</>
}
@@ -106,6 +106,27 @@ describe('KnowledgeRetrievalWorkbench', () => {
).not.toHaveAttribute('open')
})
it('consolidates static help while keeping unavailable channels and validation visible', () => {
render(<KnowledgeRetrievalWorkbench {...createProps({ graphAvailable: false })} />)
openAdvancedSettings()
const help = screen.getByRole('button', { name: '通道融合占比(合计 100%)' })
expect(help.closest('label, summary')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(help)
expect(screen.getAllByRole('tooltip')).toHaveLength(1)
expect(screen.getByRole('tooltip')).toHaveTextContent('按相对占比参与融合;可用通道建议合计 100%')
expect(screen.getAllByText('按相对占比参与融合;可用通道建议合计 100%')).toHaveLength(1)
expect(screen.getByText('当前知识库未启用图谱,此权重暂不生效。')).toBeVisible()
expect(screen.getByRole('tooltip')).not.toHaveTextContent('未启用')
fireEvent.keyDown(help, { key: 'Escape' })
const adjacent = screen.getByRole('spinbutton', { name: '相邻分块数' })
fireEvent.change(adjacent, { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: '测试检索' }))
expect(screen.getByText('相邻分块数必须是 0 至 2 的整数。')).toBeVisible()
expect(adjacent).toHaveAccessibleDescription('相邻分块数必须是 0 至 2 的整数。')
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => {
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(false)
@@ -24,6 +24,7 @@ import {
} from '../../shared/knowledge-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
export type KnowledgeRetrievalChannel =
SharedKnowledgeRetrievalChannel
@@ -458,9 +459,9 @@ export function KnowledgeRetrievalWorkbench({
className="knowledge-workbench-section"
>
<div className="knowledge-workbench-section__heading">
<div>
<div className="inline-help-label">
<h3 id={`${titleId}-query`}>{t('retrieval.query.title')}</h3>
<p>{t('retrieval.query.help')}</p>
<InlineHelp label={t('retrieval.query.title')}>{t('retrieval.query.help')}</InlineHelp>
</div>
<span className="knowledge-character-count">
{t('retrieval.query.count', { count: query.length })}
@@ -587,11 +588,11 @@ export function KnowledgeRetrievalWorkbench({
>
<div className="knowledge-workbench-settings__group-heading">
<span>1</span>
<div>
<div className="inline-help-label">
<h4 id={`${titleId}-recall-settings`}>
{t('retrieval.settings.groups.recall.title')}
</h4>
<p>{t('retrieval.settings.groups.recall.description')}</p>
<InlineHelp label={t('retrieval.settings.groups.recall.title')}>{t('retrieval.settings.groups.recall.description')}</InlineHelp>
</div>
</div>
<div className="knowledge-workbench-settings__grid knowledge-workbench-settings__grid--three">
@@ -655,9 +656,10 @@ export function KnowledgeRetrievalWorkbench({
</label>
</div>
<div className="knowledge-workbench-settings__weights">
<span className="knowledge-workbench-settings__subheading">
{t('retrieval.settings.channelWeights')}
</span>
<div className="inline-help-label">
<span className="knowledge-workbench-settings__subheading">{t('retrieval.settings.channelWeights')}</span>
<InlineHelp label={t('retrieval.settings.channelWeights')}>{t('retrieval.settings.weightHelp')}</InlineHelp>
</div>
<div className="knowledge-workbench-settings__grid knowledge-workbench-settings__grid--three">
{(
weightKeys
@@ -682,13 +684,11 @@ export function KnowledgeRetrievalWorkbench({
/>
<span aria-hidden="true">%</span>
</span>
<small>
{(showSettingsValidation && validationErrors[key] || key === 'graphWeight' && !graphAvailable) && <small>
{showSettingsValidation && validationErrors[key]
? validationErrors[key]
: key === 'graphWeight' && !graphAvailable
? t('retrieval.settings.graphUnavailable')
: t('retrieval.settings.weightHelp')}
</small>
: t('retrieval.settings.graphUnavailable')}
</small>}
</label>
))}
</div>
@@ -701,11 +701,11 @@ export function KnowledgeRetrievalWorkbench({
>
<div className="knowledge-workbench-settings__group-heading">
<span>2–4</span>
<div>
<div className="inline-help-label">
<h4 id={`${titleId}-output-settings`}>
{t('retrieval.settings.groups.output.title')}
</h4>
<p>{t('retrieval.settings.groups.output.description')}</p>
<InlineHelp label={t('retrieval.settings.groups.output.title')}>{t('retrieval.settings.groups.output.description')}</InlineHelp>
</div>
</div>
<div className="field">
@@ -794,9 +794,14 @@ export function KnowledgeRetrievalWorkbench({
: t('retrieval.settings.contextBudgetHelp')}
</small>
</label>
<label className="field">
<span>{t('retrieval.settings.adjacentCount')}</span>
<div className="field">
<div className="inline-help-label">
<label htmlFor={`${titleId}-adjacent-count`}>{t('retrieval.settings.adjacentCount')}</label>
<InlineHelp label={t('retrieval.settings.adjacentCount')}>{t('retrieval.settings.adjacentCountHelp')}</InlineHelp>
</div>
<input
id={`${titleId}-adjacent-count`}
aria-describedby={showSettingsValidation && validationErrors.adjacentChunkCount ? `${titleId}-adjacent-error` : undefined}
aria-invalid={
showSettingsValidation &&
Boolean(validationErrors.adjacentChunkCount)
@@ -817,13 +822,8 @@ export function KnowledgeRetrievalWorkbench({
: draftSettings.adjacentChunkCount
}
/>
<small>
{showSettingsValidation &&
validationErrors.adjacentChunkCount
? validationErrors.adjacentChunkCount
: t('retrieval.settings.adjacentCountHelp')}
</small>
</label>
{showSettingsValidation && validationErrors.adjacentChunkCount && <small id={`${titleId}-adjacent-error`}>{validationErrors.adjacentChunkCount}</small>}
</div>
</div>
</section>
</div>
+12 -2
View File
@@ -298,6 +298,13 @@ describe('KnowledgeWorkspace', () => {
)
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }))
const advancedHelp = screen.getByRole('button', { name: '高级设置' })
const advancedDetails = screen.getByText('高级设置').closest('details')!
expect(advancedHelp.closest('summary, label, button button')).toBeNull()
fireEvent.click(advancedHelp)
expect(screen.getByRole('tooltip')).toHaveTextContent('仅在需要托管副本或知识图谱时调整。')
expect(advancedDetails).not.toHaveAttribute('open')
fireEvent.keyDown(advancedHelp, { key: 'Escape' })
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '客户研究' }
})
@@ -305,6 +312,9 @@ describe('KnowledgeWorkspace', () => {
target: { value: '访谈与反馈' }
})
fireEvent.click(screen.getByText('高级设置'))
fireEvent.click(advancedHelp)
expect(advancedDetails).toHaveAttribute('open')
fireEvent.keyDown(advancedHelp, { key: 'Escape' })
fireEvent.click(screen.getByLabelText(/引用原文件/))
expect(
screen.getByRole('switch', { name: /启用知识图谱/u })
@@ -1502,10 +1512,10 @@ describe('KnowledgeWorkspace', () => {
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
expect(screen.getByRole('region', { name: '知识图谱画布' }).parentElement).toHaveClass(
'knowledge-graph'
)
expect(screen.getByLabelText('知识图谱画布').parentElement)
expect(screen.getByRole('region', { name: '知识图谱画布' }).parentElement)
.not.toHaveClass('knowledge-graph--with-details')
expect(screen.getByLabelText('实体详情')).toHaveClass(
'knowledge-graph__detail'
+22 -14
View File
@@ -43,6 +43,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { createPortal } from 'react-dom'
import { InlineHelp } from './InlineHelp'
import type { TFunction } from 'i18next'
import type {
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
@@ -663,6 +664,8 @@ function CreateLibraryWizard({
const [description, setDescription] = useState('')
const [storageMode, setStorageMode] =
useState<KnowledgeStorageMode>('reference')
const [advancedOpen, setAdvancedOpen] = useState(false)
const advancedId = useId()
const [graphEnabled, setGraphEnabled] = useState(false)
const [graphStrategy, setGraphStrategy] =
useState<KnowledgeGraphStrategy>('rules')
@@ -730,14 +733,21 @@ function CreateLibraryWizard({
value={description}
/>
</label>
<details className="knowledge-create__advanced">
<summary>
<div className="inline-help-label">
<details className="knowledge-create__advanced" open={advancedOpen}>
<summary aria-controls={advancedOpen ? advancedId : undefined} onClick={(event) => {
event.preventDefault()
setAdvancedOpen(!advancedOpen)
}}>
<span>
<strong>{t('create.advanced')}</strong>
<small>{t('create.advancedDescription')}</small>
</span>
</summary>
<div className="knowledge-create__advanced-content">
</details>
<InlineHelp label={t('create.advanced')}>{t('create.advancedDescription')}</InlineHelp>
</div>
{advancedOpen && (
<div className="knowledge-create__advanced-content" id={advancedId}>
<fieldset className="knowledge-create__storage">
<legend>
{t('fields.storageMode')}
@@ -809,7 +819,7 @@ function CreateLibraryWizard({
</label>
)}
</div>
</details>
)}
{error && (
<p
aria-live="polite"
@@ -2470,11 +2480,11 @@ function KnowledgeSettingsView({
>
{mode === 'index' && (
<section className="knowledge-settings__section">
<div>
<div className="inline-help-label">
<h3>{t('settings.retrieval.title')}</h3>
<p className="knowledge-section-description">
<InlineHelp label={t('settings.retrieval.title')}>
{t('settings.retrieval.description')}
</p>
</InlineHelp>
</div>
<div className="knowledge-settings__actions">
<button
@@ -2734,13 +2744,13 @@ function KnowledgeSettingsView({
aria-labelledby="knowledge-ontology-settings-title"
className="knowledge-settings__ontology knowledge-settings__section"
>
<div>
<div className="inline-help-label">
<h3 id="knowledge-ontology-settings-title">
{t('settings.ontology.title')}
</h3>
<p className="knowledge-section-description">
<InlineHelp label={t('settings.ontology.title')}>
{t('settings.ontology.description')}
</p>
</InlineHelp>
</div>
{library.ontologyRebuildRequired && (
<p className="knowledge-settings__rebuild-note" role="status">
@@ -3889,6 +3899,7 @@ function GraphView({
<RotateCcw aria-hidden="true" size={15} />
{t('graph.fitView')}
</button>
<InlineHelp label={t('graph.canvasAriaLabel')}>{t('graph.interactionHint')}</InlineHelp>
{reextractError && (
<span
className="knowledge-graph__toolbar-error"
@@ -3946,9 +3957,6 @@ function GraphView({
})}
</span>
</div>
<p className="knowledge-graph__interaction-hint">
{t('graph.interactionHint')}
</p>
{visibleRelations.length === 0 ? (
<p className="knowledge-graph__panel-empty">
{t('graph.visibleRelations.empty')}
+8 -3
View File
@@ -49,7 +49,10 @@ describe('LocalInferencePage', () => {
it('keeps the service toolbar responsive', async () => {
render(<LocalInferencePage onClose={vi.fn()} />)
await screen.findByRole('heading', { name: '向量生成' })
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
expect(screen.queryByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '本机推理监控' }))
expect(screen.getByRole('tooltip')).toHaveTextContent('每 5 秒刷新 · CPU 100% = 一个逻辑核心')
const toolbar = screen.getByRole('button', { name: '刷新状态' }).closest<HTMLElement>('.local-inference-toolbar')!
expect(toolbar).toHaveClass('local-inference-toolbar')
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
@@ -138,10 +141,12 @@ describe('LocalInferencePage', () => {
render(<LocalInferencePage onClose={onClose} />)
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
await screen.findByRole('heading', { name: '向量生成' })
fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理监控' }), { key: 'Tab', shiftKey: true })
const help = screen.getByRole('button', { name: '本机推理监控' })
help.focus()
fireEvent.keyDown(help, { key: 'Tab', shiftKey: true })
expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' })
expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus()
expect(help).toHaveFocus()
const stop = screen.getByRole('button', { name: '停止服务' })
stop.focus()
fireEvent.click(stop)
+1 -1
View File
@@ -122,13 +122,13 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
}}>
<div className="custom-task-dialog__header">
<PageHeader headingId="local-inference-title" title="本机推理监控" scope={{ kind: 'global' }}
help={enabled ? '每 5 秒刷新 · CPU 100% = 一个逻辑核心' : undefined}
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理监控" title="关闭本机推理监控" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
</div>
<div className="local-inference-modal__body">
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong></div>}
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
<div className="local-inference-toolbar">
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
<div className="local-inference-actions"><button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings().catch((reason: unknown) => setOperationError(String(reason)))}>模型与连接设置</button><button className="secondary-button" type="button" disabled={refreshing || busy} onClick={() => setReload((value) => value + 1)}>刷新状态</button></div>
</div>
{error && <div className="local-inference-feedback" role="alert"><p>{snapshot ? '状态刷新失败;以下为上次成功读取的状态,操作已禁用。' : '无法读取本机推理状态。'} {error}</p><button type="button" className="secondary-button" onClick={() => setReload((value) => value + 1)}>重试</button></div>}
@@ -161,6 +161,23 @@ async function openNote(title = detail.title): Promise<void> {
await screen.findByDisplayValue(title)
}
it.each(['immediate', 'after-save-auto', 'after-save-manual'] as const)('shows complete text composer help for %s without triggering analysis', async (mode) => {
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: mode })
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
const help = screen.getByRole('button', { name: '新记录内容' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(help.closest('label, button button, [role="tab"]')).toBeNull()
fireEvent.click(help)
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent('输入 -、1.、[ ] 或 [x] 后按空格可创建列表')
expect(tooltip).toHaveTextContent(mode === 'immediate' ? '停止输入 5 秒后自动评论' : '也可粘贴或拖入本地文件')
if (mode === 'after-save-auto') expect(tooltip).toHaveTextContent('保存记录后,AI 会自动评论。')
expect(screen.getByRole('button', { name: '保存记录' })).toBeVisible()
expect(analyzeDraft).not.toHaveBeenCalled()
expect(analyze).not.toHaveBeenCalled()
})
function back(): void {
fireEvent.click(screen.getByRole('button', { name: '返回总览' }))
}
+23 -17
View File
@@ -31,6 +31,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import { createPortal } from 'react-dom'
import type {
MagicNoteAnalysisOptions,
@@ -1991,7 +1992,7 @@ export function MagicNotesWorkspace({
)}
</>
}
description={detailView ? undefined : t('page.description')}
help={detailView ? undefined : t('page.description')}
headingId="magic-notes-title"
icon={<Sparkles size={20} />}
scope={{ kind: 'global' }}
@@ -2397,21 +2398,24 @@ export function MagicNotesWorkspace({
<section aria-label={t('comments.paneLabel')} className="magic-todo-comments">
<strong>{t('comments.paneLabel')}</strong>
<div className="magic-notes-ai-controls">
<label>
<span>{t('comments.directionLabel')}</span>
<div>
<div className="inline-help-label">
<label htmlFor="magic-todo-comment-direction">{t('comments.directionLabel')}</label>
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
</div>
<select
id="magic-todo-comment-direction"
aria-label={t('comments.directionAriaLabel')}
onChange={(event) => setCommentDirection(event.target.value as MagicNoteCommentDirection)}
value={commentDirection}
>
{commentDirections.map((direction) => <option key={direction.value} value={direction.value}>{direction.label}</option>)}
</select>
</label>
</div>
<button className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeTodo(selectedTodo.id)} type="button">
<Bot aria-hidden="true" size={14} />
{t(busy === `analyze-todo-${selectedTodo.id}` ? 'actions.analyzing' : selectedTodo.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
</button>
<small>{t('comments.directionHelp')}</small>
</div>
{liveAnalysis && busy === `analyze-todo-${selectedTodo.id}` && (
<div className="magic-notes-ai-live" aria-live="polite">
@@ -2549,6 +2553,12 @@ export function MagicNotesWorkspace({
<div className="magic-note-composer__header">
<div className="magic-note-entry-type" role="group" aria-label={t('canvas.entryType')}>
{(['text', 'canvas'] as const).map((value) => <button key={value} type="button" className="secondary-button" aria-pressed={entryType === value} disabled={Boolean(busy) || entryType === value} onClick={() => void requestDraftSwitch({ kind: 'entry-type', value })}>{t(`canvas.${value}`)}</button>)}
{entryType === 'text' && <InlineHelp label={t('notes.newEntryLabel')}>
<p>{commentMode === 'immediate'
? t('notes.composerImmediateHint')
: t('notes.composerRichTextHint')}</p>
{commentMode === 'after-save-auto' && <p>{t('comments.autoHint')}</p>}
</InlineHelp>}
</div>
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
@@ -2606,12 +2616,7 @@ export function MagicNotesWorkspace({
{validation.message}
</p>
)}
{entryType === 'text' && <footer>
<span>
{commentMode === 'immediate'
? t('notes.composerImmediateHint')
: t('notes.composerRichTextHint')}
</span>
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
<button
className="primary-button"
disabled={Boolean(busy)}
@@ -2882,9 +2887,13 @@ export function MagicNotesWorkspace({
id="magic-notes-ai-pane"
>
<div className="magic-notes-ai-controls">
<label>
<span>{t('comments.directionLabel')}</span>
<div>
<div className="inline-help-label">
<label htmlFor="magic-note-comment-direction">{t('comments.directionLabel')}</label>
<InlineHelp label={t('comments.directionLabel')}>{t('comments.directionHelp')}</InlineHelp>
</div>
<select
id="magic-note-comment-direction"
aria-label={t('comments.directionAriaLabel')}
onChange={(event) =>
setCommentDirection(
@@ -2899,10 +2908,7 @@ export function MagicNotesWorkspace({
</option>
))}
</select>
</label>
<small>
{t('comments.directionHelp')}
</small>
</div>
</div>
{liveAnalysis?.format === 'structured' ? (
<p className="magic-notes-muted" role="status">
@@ -0,0 +1,20 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { McpSettingsSection } from './McpSettingsSection'
import i18n from './i18n'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('moves the MCP introduction to title help and retains the custom server notice', async () => {
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
await waitFor(() => expect(getSnapshot).toHaveBeenCalledOnce())
expect(screen.getByRole('heading', { name: i18n.t('integrations:mcp.title') }).closest('header')?.querySelector('p')).toBeNull()
expect(screen.queryByText(i18n.t('integrations:mcp.description'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.title') }))
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('integrations:mcp.description'))
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: i18n.t('integrations:mcp.tabs.custom') }))
expect(screen.getByText(i18n.t('integrations:mcp.customNotice'))).toBeVisible()
})
+1 -1
View File
@@ -409,7 +409,7 @@ export function McpSettingsSection({
</button>
) : undefined
}
description={t('mcp.description')}
help={t('mcp.description')}
error={!editor ? error : undefined}
headingLevel={3}
headingId="mcp-settings-heading"
@@ -13,6 +13,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
ApplicationSettings,
ModelDownloadSource
@@ -553,12 +554,12 @@ export function PlatformFeaturesSettingsSection({
>
<article className="capability-card">
<div className="capability-card__header">
<div>
<span className="inline-help-label">
<strong>{t('platformFeatures.shortcut.title')}</strong>
<small>
<InlineHelp label={t('platformFeatures.shortcut.title')}>
{t('platformFeatures.shortcut.description')}
</small>
</div>
</InlineHelp>
</span>
</div>
{shortcutDraft && shortcutSnapshot ? (
<>
@@ -660,14 +661,14 @@ export function PlatformFeaturesSettingsSection({
{settings && (
<article className="capability-card">
<div className="capability-card__header">
<div>
<span className="inline-help-label">
<strong>
{t('platformFeatures.conversationHtml.title')}
</strong>
<small>
<InlineHelp label={t('platformFeatures.conversationHtml.title')}>
{t('platformFeatures.conversationHtml.description')}
</small>
</div>
</InlineHelp>
</span>
</div>
<label className="toggle-row">
<input
@@ -690,16 +691,16 @@ export function PlatformFeaturesSettingsSection({
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<span className="inline-help-label">
<strong>
{t('platformFeatures.modelDownloadSource.title')}
</strong>
<small>
<InlineHelp label={t('platformFeatures.modelDownloadSource.title')}>
{t(
'platformFeatures.modelDownloadSource.description'
)}
</small>
</div>
</InlineHelp>
</span>
</div>
<fieldset className="model-download-source">
<legend className="sr-only">
@@ -762,16 +763,16 @@ export function PlatformFeaturesSettingsSection({
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<span className="inline-help-label">
<strong>
{t('platformFeatures.remoteProjects.title')}
</strong>
<small>
<InlineHelp label={t('platformFeatures.remoteProjects.title')}>
{t(
'platformFeatures.remoteProjects.description'
)}
</small>
</div>
</InlineHelp>
</span>
</div>
<label className="toggle-row">
<input
+2 -2
View File
@@ -1430,7 +1430,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
within(dialog).getByRole('option', { name: /Build host/u })
).toBeInTheDocument()
)
expect(within(dialog).getByLabelText('SSH 主机')).toBeDisabled()
expect(within(dialog).getByRole('combobox', { name: 'SSH 主机' })).toBeDisabled()
expect(within(dialog).getByLabelText('远端工作目录')).toBeDisabled()
fireEvent.click(
within(dialog).getByRole('button', {
@@ -1512,7 +1512,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
/此主机尚未完成 Host Key 和连接验证/u
)
).toBeInTheDocument()
expect(within(dialog).getByLabelText('SSH 主机')).toHaveValue(hostId)
expect(within(dialog).getByRole('combobox', { name: 'SSH 主机' })).toHaveValue(hostId)
expect(
within(dialog).getByRole('option', {
name: /Build host.*需要验证/u
+9 -7
View File
@@ -56,6 +56,7 @@ import type { ProjectActivityCountsProps } from './ProjectActivity'
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
import { ChannelIcon } from './ChannelIcon'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { displayErrorMessage } from './error-message'
type ProjectSwitcherProps = {
@@ -1582,11 +1583,15 @@ export function ProjectSwitcher({
</>
) : (
<div className="remote-project-fields">
<label>
<span>
<div className="field">
<div className="inline-help-label">
<label htmlFor="project-remote-host">
{t('projectSwitcher.remote.fields.host')}
</span>
</label>
<InlineHelp label={t('projectSwitcher.remote.fields.host')}>{t('projectSwitcher.remote.hostHelp')}</InlineHelp>
</div>
<select
id="project-remote-host"
aria-label={t(
'projectSwitcher.remote.fields.host'
)}
@@ -1638,9 +1643,6 @@ export function ProjectSwitcher({
)
})}
</select>
<small>
{t('projectSwitcher.remote.hostHelp')}
</small>
{remoteHostId &&
selectedRemoteHostReadiness && (
<small
@@ -1657,7 +1659,7 @@ export function ProjectSwitcher({
)}
</small>
)}
</label>
</div>
<label>
<span>
{t('projectSwitcher.remote.fields.root')}
@@ -0,0 +1,21 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
const onClose = vi.fn()
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
render(<QueuedAttachmentsDialog itemId="queued" onClose={onClose} />)
expect(await screen.findByText('此输入没有附件。')).toBeVisible()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
const help = screen.getByRole('button', { name: '入队时的附件' })
fireEvent.click(help)
expect(screen.getByRole('tooltip')).toHaveTextContent('这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。')
fireEvent.keyDown(help, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '关闭附件预览' }))
expect(onClose).toHaveBeenCalledOnce()
})
+2 -2
View File
@@ -4,6 +4,7 @@ import type { ContextAttachment } from '../../shared/contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { AttachmentResultButton } from './AttachmentResultButton'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { InlineHelp } from './InlineHelp'
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
const close = useRef<HTMLButtonElement>(null)
@@ -22,8 +23,7 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose() }
}}>
<header><strong>入队时的附件</strong><button type="button" className="secondary-button" ref={close} onClick={onClose}>关闭附件预览</button></header>
<p>这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。</p>
<header><span className="inline-help-label"><strong>入队时的附件</strong><InlineHelp label="入队时的附件">这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。</InlineHelp></span><button type="button" className="secondary-button" ref={close} onClick={onClose}>关闭附件预览</button></header>
{error && <p role="alert">{error}</p>}
{!items && !error && <p role="status">正在读取附件</p>}
{items?.length === 0 && <p>此输入没有附件。</p>}
+15 -12
View File
@@ -2,6 +2,7 @@ import { Bot, Plus, Save, Trash2 } from 'lucide-react'
import type { TFunction } from 'i18next'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
AssistantExpert,
ExpertCreateInput
@@ -378,9 +379,13 @@ export function RolePromptSettingsSection({
})}
</small>
</label>
<label className="field">
<span>{t('roles.fields.modelConnection')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor="role-model-profile">{t('roles.fields.modelConnection')}</label>
<InlineHelp label={t('roles.fields.modelConnection')} id="role-model-profile-help">{t('roles.fields.modelHelp')}</InlineHelp>
</span>
<select
id="role-model-profile"
aria-describedby={
selectedModelProfileAvailable
? 'role-model-profile-help'
@@ -408,9 +413,6 @@ export function RolePromptSettingsSection({
</option>
))}
</select>
<small id="role-model-profile-help">
{t('roles.fields.modelHelp')}
</small>
{!selectedModelProfileAvailable && (
<small
className="field-error"
@@ -424,10 +426,14 @@ export function RolePromptSettingsSection({
: t('roles.fields.modelFallback')}
</small>
)}
</label>
<label className="field">
<span>{t('roles.fields.routingKeywords')}</span>
</div>
<div className="field">
<span className="inline-help-label">
<label htmlFor="role-routing-keywords">{t('roles.fields.routingKeywords')}</label>
<InlineHelp label={t('roles.fields.routingKeywords')} id="role-routing-keywords-help">{t('roles.fields.routingHelp')}</InlineHelp>
</span>
<textarea
id="role-routing-keywords"
aria-describedby={
routingKeywordsError
? 'role-routing-keywords-error role-routing-keywords-help'
@@ -446,9 +452,6 @@ export function RolePromptSettingsSection({
rows={3}
value={draft.routingKeywordsText}
/>
<small id="role-routing-keywords-help">
{t('roles.fields.routingHelp')}
</small>
{routingKeywordsError && (
<small
className="field-error"
@@ -458,7 +461,7 @@ export function RolePromptSettingsSection({
{routingKeywordsError}
</small>
)}
</label>
</div>
<div className="role-prompt-detail__actions">
{draft.id ? (
<DestructiveConfirmActions
@@ -16,6 +16,7 @@ import {
Trash2
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import {
runtimeCustomizationLimits,
type ContinueConfigurationPreset,
@@ -669,12 +670,19 @@ export const RuntimeCustomizationSection = forwardRef<
) : null}
{provider === 'opencode' && settings && snapshot ? (
<label
<div
aria-busy={saving}
className="field runtime-customization-editor"
>
<span>{t('runtime.customization.opencode.defaultAgent')}</span>
<span className="inline-help-label">
<label htmlFor="opencode-default-agent">{t('runtime.customization.opencode.defaultAgent')}</label>
<InlineHelp label={t('runtime.customization.opencode.defaultAgent')} id="opencode-default-agent-help">
{t('runtime.customization.opencode.agentDescription')}
</InlineHelp>
</span>
<select
id="opencode-default-agent"
aria-describedby="opencode-default-agent-help"
aria-label={t(
'runtime.customization.opencode.defaultAgent'
)}
@@ -705,10 +713,7 @@ export const RuntimeCustomizationSection = forwardRef<
</option>
))}
</select>
<small>
{t('runtime.customization.opencode.agentDescription')}
</small>
</label>
</div>
) : null}
{provider === 'continue' && settings && snapshot ? (
+25 -12
View File
@@ -1120,7 +1120,7 @@ describe('SettingsPanel runtime files', () => {
configurable: true,
value: DecodableBrandImage
})
fireEvent.change(screen.getByLabelText('选择 Logo'), {
fireEvent.change(screen.getByLabelText('选择 Logo', { selector: 'input' }), {
target: {
files: [
new File(
@@ -1304,13 +1304,11 @@ describe('SettingsPanel runtime files', () => {
/>
)
const contextWindow = await screen.findByLabelText(
'上下文上限(可选)'
)
const contextWindow = await screen.findByRole('spinbutton', { name: '上下文上限(可选)' })
expect(contextWindow).toHaveValue(null)
expect(contextWindow).toHaveAttribute('min', '32')
fireEvent.change(contextWindow, { target: { value: '256' } })
const maximumOutput = screen.getByLabelText('Anthropic 最大输出')
const maximumOutput = screen.getByRole('spinbutton', { name: 'Anthropic 最大输出' })
expect(maximumOutput).toHaveValue(32)
expect(maximumOutput).toHaveAttribute('min', '1')
fireEvent.change(maximumOutput, { target: { value: '48' } })
@@ -2770,6 +2768,12 @@ describe('SettingsPanel runtime files', () => {
name: '启用 Subagent 智能路由'
})
expect(smartRouting).not.toBeChecked()
expect(screen.getByText(/仅在 Ask 模式/)).not.toBeVisible()
expect(smartRouting).toHaveAccessibleDescription(/仅在 Ask 模式/)
const routingHelp = screen.getByRole('button', { name: 'Subagent 智能路由' })
expect(routingHelp.closest('label, summary')).toBeNull()
fireEvent.click(routingHelp)
expect(smartRouting).not.toBeChecked()
expect(screen.getByText(/仅在 Ask 模式/)).toHaveTextContent(
'自动选择 1 位专家'
)
@@ -3170,6 +3174,8 @@ describe('SettingsPanel runtime files', () => {
expect(screen.getByText(locale === 'zh-CN'
? `未配置完整的管理员预置;已保存配置中的实际回退:${resolvedName}。`
: `No complete administrator preset; actual fallback in saved settings: ${resolvedName}.`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: locale === 'zh-CN'
? 'OpenAI 兼容模型连接' : 'OpenAI-compatible model connection' }))
expect(source).toHaveAccessibleDescription(/Harness/)
})
@@ -3435,7 +3441,7 @@ describe('SettingsPanel runtime files', () => {
render(<RuntimeCustomizationTestHarness provider="opencode" />)
const agent = await screen.findByLabelText('默认 Agent')
const agent = await screen.findByRole('combobox', { name: '默认 Agent' })
expect(agent).toHaveValue('planner')
expect(
screen.queryByText('OpenCode 原生能力已就绪')
@@ -4180,12 +4186,18 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByLabelText('自定义请求头')
const body = screen.getByLabelText('自定义请求体')
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
const body = screen.getByRole('textbox', { name: '自定义请求体' })
expect(headers).toHaveValue('{}')
expect(body).toHaveValue('{}')
expect(headers).toHaveAttribute('maxlength')
expect(body).toHaveAttribute('maxlength')
const bodyHelp = screen.getByRole('button', { name: '自定义请求体' })
expect(bodyHelp.closest('label, summary')).toBeNull()
expect(screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)).not.toBeVisible()
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
fireEvent.click(bodyHelp)
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
expect(
screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
).toBeInTheDocument()
@@ -4238,7 +4250,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByLabelText('自定义请求头')
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
fireEvent.change(headers, {
target: { value: '{"Authorization":"replacement"}' }
})
@@ -4901,7 +4913,7 @@ describe('SettingsPanel runtime files', () => {
})
expect(rerankSwitch).not.toBeChecked()
fireEvent.click(rerankSwitch)
fireEvent.change(screen.getByLabelText('重排接口 URL'), {
fireEvent.change(screen.getByRole('textbox', { name: '重排接口 URL' }), {
target: { value: 'https://rerank.example/v1/rerank' }
})
fireEvent.change(screen.getByLabelText('模型名称'), {
@@ -5262,6 +5274,7 @@ describe('SettingsPanel runtime files', () => {
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('文档写作')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
expect(
screen.getByText(
'支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
@@ -5946,7 +5959,7 @@ describe('SettingsPanel runtime files', () => {
)
)
fireEvent.change(screen.getByLabelText('路由关键词'), {
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
target: { value: 'x' }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
@@ -5955,7 +5968,7 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
expect(updateExpert).toHaveBeenCalledTimes(1)
fireEvent.change(screen.getByLabelText('路由关键词'), {
fireEvent.change(screen.getByRole('textbox', { name: '路由关键词' }), {
target: {
value: ' TypeScript,代码 审查\nTYPESCRIPT '
}
+106 -81
View File
@@ -17,6 +17,7 @@ import {
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import { createPortal } from 'react-dom'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type {
@@ -2195,10 +2196,10 @@ export function SettingsPanel({
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<SunMoon size={17} />
<div>
<span className="inline-help-label">
<strong>{t('appearance.theme.title')}</strong>
<small>{t('appearance.theme.description')}</small>
</div>
<InlineHelp label={t('appearance.theme.title')}>{t('appearance.theme.description')}</InlineHelp>
</span>
</div>
<div
aria-label={t('appearance.theme.ariaLabel')}
@@ -2242,10 +2243,10 @@ export function SettingsPanel({
</div>
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<div>
<span className="inline-help-label">
<strong>{t('appearance.language.title')}</strong>
<small>{t('appearance.language.description')}</small>
</div>
<InlineHelp label={t('appearance.language.title')}>{t('appearance.language.description')}</InlineHelp>
</span>
</div>
<div
aria-label={t('appearance.language.ariaLabel')}
@@ -2308,10 +2309,10 @@ export function SettingsPanel({
<div className="settings-section">
<div className="settings-section__title">
<FolderOpen size={17} />
<div>
<span className="inline-help-label">
<strong>{t('runtime.workspace.title')}</strong>
<small>{t('runtime.workspace.description')}</small>
</div>
<InlineHelp label={t('runtime.workspace.title')}>{t('runtime.workspace.description')}</InlineHelp>
</span>
</div>
<label className="field">
<span>{t('runtime.workspace.directoryLabel')}</span>
@@ -2836,9 +2837,15 @@ export function SettingsPanel({
runtime: 'DeepSeek Harness'
})}
/>
<label className="field">
<span>{t('runtime.deepseekHarness.connection')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor="deepseek-harness-connection">{t('runtime.deepseekHarness.connection')}</label>
<InlineHelp label={t('runtime.deepseekHarness.connection')} id="deepseek-harness-connection-description">
{t('runtime.deepseekHarness.connectionDescription')}
</InlineHelp>
</span>
<select
id="deepseek-harness-connection"
aria-label={`DeepSeek Harness ${t(
'runtime.deepseekHarness.connection'
)}`}
@@ -2869,12 +2876,7 @@ export function SettingsPanel({
</option>
))}
</select>
<small id="deepseek-harness-connection-description">
{t(
'runtime.deepseekHarness.connectionDescription'
)}
</small>
</label>
</div>
<details className="settings-section">
<summary>{t('runtime.advanced')}</summary>
<p className="settings-panel__description">
@@ -3342,9 +3344,16 @@ export function SettingsPanel({
{t('model.profile.supportsImageInputDescription')}
</small>
</div>
<label className="field">
<span>{t('model.profile.contextWindow')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-context-window-${profile.id}`}>{t('model.profile.contextWindow')} (K tokens)</label>
<InlineHelp label={t('model.profile.contextWindow')} id={`model-context-window-help-${profile.id}`}>
{t('model.profile.contextWindowDescription')}
</InlineHelp>
</span>
<input
id={`model-context-window-${profile.id}`}
aria-describedby={`model-context-window-help-${profile.id}`}
aria-label={t('model.profile.contextWindow')}
inputMode="numeric"
max={10_000}
@@ -3365,14 +3374,18 @@ export function SettingsPanel({
: profile.contextWindowTokens / 1_000
}
/>
<small>
{t('model.profile.contextWindowDescription')}
</small>
</label>
</div>
{profile.protocol === 'anthropic-messages' && (
<label className="field">
<span>{t('model.profile.maximumOutput')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-maximum-output-${profile.id}`}>{t('model.profile.maximumOutput')} (K tokens)</label>
<InlineHelp label={t('model.profile.maximumOutput')} id={`model-maximum-output-help-${profile.id}`}>
{t('model.profile.maximumOutputDescription')}
</InlineHelp>
</span>
<input
id={`model-maximum-output-${profile.id}`}
aria-describedby={`model-maximum-output-help-${profile.id}`}
aria-label={t('model.profile.maximumOutput')}
inputMode="numeric"
max={10_000}
@@ -3392,18 +3405,22 @@ export function SettingsPanel({
defaultAnthropicMaximumOutputTokens) / 1_000
}
/>
<small>
{t('model.profile.maximumOutputDescription')}
</small>
</label>
</div>
)}
</>
)}
{profile.protocol ===
'openai-images-generations' && (
<label className="field">
<span>{t('model.profile.imageQuality')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-image-quality-${profile.id}`}>{t('model.profile.imageQuality')}</label>
<InlineHelp label={t('model.profile.imageQuality')} id={`model-image-quality-help-${profile.id}`}>
{t('model.profile.imageQualityDescription')}
</InlineHelp>
</span>
<select
id={`model-image-quality-${profile.id}`}
aria-describedby={`model-image-quality-help-${profile.id}`}
aria-label={t(
'model.profile.imageQualityAriaLabel',
{ name: modelProfileDisplayName(profile) }
@@ -3429,14 +3446,17 @@ export function SettingsPanel({
{t('model.profile.quality.high')}
</option>
</select>
<small>
{t('model.profile.imageQualityDescription')}
</small>
</label>
</div>
)}
<label className="field">
<span>{t('model.profile.requestHeaders')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-request-headers-${profile.id}`}>{t('model.profile.requestHeaders')}</label>
<InlineHelp label={t('model.profile.requestHeaders')} id={`model-request-headers-help-${profile.id}`}>
{t('model.profile.requestHeadersDescription')}
</InlineHelp>
</span>
<textarea
id={`model-request-headers-${profile.id}`}
aria-describedby={`model-request-headers-help-${profile.id}`}
aria-label={t('model.profile.requestHeaders')}
aria-invalid={
@@ -3453,20 +3473,21 @@ export function SettingsPanel({
spellCheck={false}
value={profile.requestHeadersText}
/>
<small
id={`model-request-headers-help-${profile.id}`}
>
{t('model.profile.requestHeadersDescription')}
</small>
{!requestHeadersValid && (
<small className="field-error" role="alert">
{t('model.profile.requestHeadersInvalid')}
</small>
)}
</label>
<label className="field">
<span>{t('model.profile.requestBody')}</span>
</div>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-request-body-${profile.id}`}>{t('model.profile.requestBody')}</label>
<InlineHelp label={t('model.profile.requestBody')} id={`model-request-body-help-${profile.id}`}>
{t('model.profile.requestBodyDescription')}
</InlineHelp>
</span>
<textarea
id={`model-request-body-${profile.id}`}
aria-describedby={`model-request-body-help-${profile.id}`}
aria-label={t('model.profile.requestBody')}
aria-invalid={
@@ -3483,17 +3504,12 @@ export function SettingsPanel({
spellCheck={false}
value={profile.requestBodyText}
/>
<small
id={`model-request-body-help-${profile.id}`}
>
{t('model.profile.requestBodyDescription')}
</small>
{!requestBodyValid && (
<small className="field-error" role="alert">
{t('model.profile.requestBodyInvalid')}
</small>
)}
</label>
</div>
<p className="settings-warning">
{t('model.profile.requestCustomizationWarning')}
</p>
@@ -3734,9 +3750,14 @@ export function SettingsPanel({
/>
<span>{t('model.rerank.enabled')}</span>
</label>
<label className="field">
<span>{t('model.rerank.endpoint')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor="model-rerank-endpoint">{t('model.rerank.endpoint')}</label>
<InlineHelp label={t('model.rerank.endpoint')} id="model-rerank-endpoint-help">{t('model.rerank.endpointDescription')}</InlineHelp>
</span>
<input
id="model-rerank-endpoint"
aria-describedby="model-rerank-endpoint-help"
aria-label={t('model.rerank.endpoint')}
disabled={!knowledgeRerankEnabled}
inputMode="url"
@@ -3746,8 +3767,7 @@ export function SettingsPanel({
placeholder="https://api.cohere.com/v1/rerank"
value={knowledgeRerankEndpoint}
/>
<small>{t('model.rerank.endpointDescription')}</small>
</label>
</div>
<label className="field">
<span>{t('model.rerank.modelName')}</span>
<input
@@ -3837,21 +3857,24 @@ export function SettingsPanel({
<>
<div className="settings-section">
<div className="field">
<label className="toggle-row">
<input
checked={contextCompression.enabled}
onChange={(event) =>
setContextCompression((current) => ({
...current,
enabled: event.target.checked
}))
}
role="switch"
type="checkbox"
/>
<span>{t('contextControl.enabled')}</span>
</label>
<small>{t('contextControl.enabledDescription')}</small>
<div className="inline-help-label">
<label className="toggle-row">
<input
aria-describedby="context-compression-help"
checked={contextCompression.enabled}
onChange={(event) =>
setContextCompression((current) => ({
...current,
enabled: event.target.checked
}))
}
role="switch"
type="checkbox"
/>
<span>{t('contextControl.enabled')}</span>
</label>
<InlineHelp label={t('contextControl.enabled')} id="context-compression-help">{t('contextControl.enabledDescription')}</InlineHelp>
</div>
<small>{t('contextControl.usageNotice')}</small>
</div>
</div>
@@ -3914,9 +3937,14 @@ export function SettingsPanel({
})}
</small>
</label>
<label className="field">
<span>{t('contextControl.summaryModel')}</span>
<div className="field">
<span className="inline-help-label">
<label htmlFor="context-summary-model">{t('contextControl.summaryModel')}</label>
<InlineHelp label={t('contextControl.summaryModel')} id="context-summary-model-help">{t('contextControl.summaryModelDescription')}</InlineHelp>
</span>
<select
id="context-summary-model"
aria-describedby="context-summary-model-help"
aria-label={t('contextControl.summaryModel')}
disabled={!contextCompression.enabled}
onChange={(event) =>
@@ -3951,8 +3979,7 @@ export function SettingsPanel({
</option>
))}
</select>
<small>{t('contextControl.summaryModelDescription')}</small>
</label>
</div>
<p className="settings-panel__description">
{t('contextControl.fixedTarget')}
</p>
@@ -4125,12 +4152,13 @@ export function SettingsPanel({
<>
<div className="settings-section subagent-routing-settings">
<div className="settings-section__title">
<div>
<span className="inline-help-label">
<strong>{t('roles.smartRouting.title')}</strong>
<small>
<InlineHelp label={t('roles.smartRouting.title')} id="subagent-smart-routing-help">
{t('roles.smartRouting.description')}
</small>
</div>
<p>{t('roles.smartRouting.help')}</p>
</InlineHelp>
</span>
</div>
<label className="toggle-row">
<input
@@ -4144,9 +4172,6 @@ export function SettingsPanel({
/>
<span>{t('roles.smartRouting.enabled')}</span>
</label>
<small id="subagent-smart-routing-help">
{t('roles.smartRouting.help')}
</small>
</div>
<RolePromptSettingsSection
defaultModelProfileId={savedRoleDefaultModelProfileId}
+6 -3
View File
@@ -9,6 +9,7 @@ import {
type SettingsCategoryId
} from './settings-categories'
import { translateSettingsWarning } from './settings-warnings'
import { InlineHelp } from './InlineHelp'
export function SettingsWarningList({
warnings
@@ -62,13 +63,15 @@ export function SettingsSectionHeader({
description,
error,
headingLevel = 2,
help,
headingId,
title
}: {
actions?: ReactNode
description: string
description?: string
error?: string
headingLevel?: 2 | 3
help?: ReactNode
headingId: string
title: string
}): React.JSX.Element {
@@ -76,8 +79,8 @@ export function SettingsSectionHeader({
return (
<header className="settings-category-header">
<div className="settings-category-header__content">
<Heading id={headingId}>{title}</Heading>
<p>{description}</p>
<Heading id={headingId}>{help != null ? <span className="inline-help-label">{title}<InlineHelp label={title}>{help}</InlineHelp></span> : title}</Heading>
{description && <p>{description}</p>}
</div>
{actions && (
<div className="settings-category-header__actions">
@@ -0,0 +1,20 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import i18n from './i18n'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('moves runtime support to title help while keeping assignment notice and skill descriptions visible', async () => {
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot: vi.fn().mockResolvedValue({ skills: [
{ id: 'skill', name: 'Skill', source: 'builtin', enabled: true, description: 'Actual skill description', tags: [], assignments: [] }
] }) } })
render(<SkillsSettingsSection />)
expect(await screen.findByText('Actual skill description')).toBeVisible()
expect(screen.getByRole('heading', { name: 'Skills' }).closest('header')?.querySelector('p')).toBeNull()
expect(screen.getByText(i18n.t('settingsSections:skills.notice'))).toBeVisible()
expect(screen.queryByText(i18n.t('settingsSections:skills.description'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('settingsSections:skills.description'))
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
})
+1 -1
View File
@@ -93,7 +93,7 @@ export function SkillsSettingsSection(): React.JSX.Element {
</button>
</>
}
description={t('skills.description')}
help={t('skills.description')}
error={error}
headingLevel={3}
headingId="skills-settings-heading"
+13 -4
View File
@@ -20,6 +20,7 @@ import type {
} from '../../shared/ssh-host-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { displayErrorMessage } from './error-message'
import { InlineHelp } from './InlineHelp'
type HostDraft = {
name: string
@@ -121,6 +122,7 @@ export function SshHostDialog({
const titleId = useId()
const descriptionId = useId()
const formErrorId = useId()
const passwordId = useId()
const api = window.goodbuddy.sshHosts
const targetUnchanged = targetMatchesHost(draft, host)
const canKeepPassword =
@@ -652,9 +654,16 @@ export function SshHostDialog({
)}
{(!canKeepPassword ||
passwordAction === 'replace') && (
<label className="custom-task-dialog__field">
<span>{t('sshHosts.fields.password')}</span>
<div className="custom-task-dialog__field">
<div className="inline-help-label">
<label htmlFor={passwordId}>{t('sshHosts.fields.password')}</label>
<InlineHelp label={t('sshHosts.fields.password')} id={`${passwordId}-help`}>
{t('sshHosts.passwordHelp')}
</InlineHelp>
</div>
<input
id={passwordId}
aria-describedby={`${passwordId}-notice ${passwordId}-help`}
autoComplete="new-password"
maxLength={
SSH_HOST_LIMITS.maximumPasswordLength
@@ -667,8 +676,8 @@ export function SshHostDialog({
type="password"
value={password}
/>
<small>{t('sshHosts.passwordHelp')}</small>
</label>
<small id={`${passwordId}-notice`}>{t('sshHosts.passwordNotice')}</small>
</div>
)}
</>
) : (
@@ -300,7 +300,19 @@ describe('SshHostsSettingsSection', () => {
expect(validateAndSave).not.toHaveBeenCalled()
expect(screen.getByText(new RegExp(fingerprint))).toBeInTheDocument()
fireEvent.change(screen.getByLabelText(/^SSH 密码/u), {
const passwordInput = screen.getByLabelText(/^SSH 密码/u, { selector: 'input' })
const passwordHelp = screen.getByRole('button', { name: 'SSH 密码' })
expect(passwordHelp.closest('label')).toBeNull()
expect(screen.getByText(/密码用于 SSH 认证/)).toBeVisible()
expect(screen.getByText(/由 Main 进程处理密码/)).not.toBeVisible()
fireEvent.click(passwordHelp)
expect(screen.getByRole('tooltip')).toHaveTextContent('不会写入命令行、日志或普通设置文件')
expect(passwordInput).toHaveValue('')
expect(validateAndSave).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(passwordInput).toHaveAccessibleDescription(/密码用于 SSH 认证.*由 Main 进程处理密码/)
fireEvent.change(passwordInput, {
target: { value: 'private password' }
})
fireEvent.click(
@@ -390,7 +402,7 @@ describe('SshHostsSettingsSection', () => {
openNewHostDialog()
fillConnectionDetails()
await inspectAndConfirmFirstKey()
const password = screen.getByLabelText(/^SSH 密码/u)
const password = screen.getByLabelText(/^SSH 密码/u, { selector: 'input' })
fireEvent.change(password, {
target: { value: 'wrong password' }
})
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
LocalToolEnvironmentSettings,
LocalToolEnvironmentSnapshot,
@@ -340,9 +341,9 @@ export function ToolEnvironmentSettingsSection({
return (
<article className="tool-environment-card">
<div className="tool-environment-card__header">
<div>
<div className="inline-help-label">
<h3>{t(`toolEnvironment.runtimes.${kind}.title`)}</h3>
<p>{t(`toolEnvironment.runtimes.${kind}.description`)}</p>
<InlineHelp label={t(`toolEnvironment.runtimes.${kind}.title`)}>{t(`toolEnvironment.runtimes.${kind}.description`)}</InlineHelp>
</div>
<button
className="secondary-button"
@@ -731,9 +732,9 @@ export function ToolEnvironmentSettingsSection({
)}
<section className="tool-environment-card">
<div className="tool-environment-card__header">
<div>
<div className="inline-help-label">
<h3>{t('toolEnvironment.downloadSource.title')}</h3>
<p>{t('toolEnvironment.downloadSource.description')}</p>
<InlineHelp label={t('toolEnvironment.downloadSource.title')}>{t('toolEnvironment.downloadSource.description')}</InlineHelp>
</div>
<button
className="secondary-button"
@@ -61,6 +61,11 @@ describe('UpdateSettingsSection', () => {
name: 'GoodBuddy-0.9.0-windows-x64-setup.exe',
size: 1024 * 1024,
sha256: 'a'.repeat(64)
},
{
name: 'GoodBuddy-0.9.0-windows-x64-portable.zip',
size: 1.5 * 1024 * 1024 * 1024,
sha256: 'b'.repeat(64)
}
]
}
@@ -106,7 +111,7 @@ describe('UpdateSettingsSection', () => {
name: '检查更新源'
})
const startupRow = startup.closest('label')
const sourceRow = source.closest('label')
const sourceRow = source.closest('.field')
expect(source).toHaveValue('github')
expect(sourceRow).toHaveClass('update-settings__source')
expect(
@@ -142,6 +147,8 @@ describe('UpdateSettingsSection', () => {
expect(
screen.getByText('GoodBuddy-0.9.0-windows-x64-setup.exe')
).toBeInTheDocument()
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
expect(screen.getByText('1.5 GB')).toBeInTheDocument()
const feedbackTrigger = screen.getByRole('button', {
name: '提交反馈'
+11 -12
View File
@@ -5,6 +5,7 @@ import {
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
ApplicationSettings,
UpdateSource,
@@ -21,13 +22,7 @@ import {
displayNetworkAwareErrorMessage
} from './error-message'
import { FeedbackDialog } from './FeedbackDialog'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
import { formatModelPackageBytes } from './model-download-presentation'
export function UpdateSettingsSection({
onNotify
@@ -233,9 +228,14 @@ export function UpdateSettingsSection({
<span>{t('updates.checkOnStartup')}</span>
</label>
<label className="field update-settings__source">
<span>{t('updates.source.label')}</span>
<div className="field update-settings__source">
<span className="inline-help-label">
<label htmlFor="update-source">{t('updates.source.label')}</label>
<InlineHelp label={t('updates.source.label')} id="update-source-help">{t('updates.source.description')}</InlineHelp>
</span>
<select
id="update-source"
aria-describedby="update-source-help"
aria-label={t('updates.source.label')}
disabled={
!settings ||
@@ -256,8 +256,7 @@ export function UpdateSettingsSection({
{t('updates.source.options.mirror')}
</option>
</select>
<small>{t('updates.source.description')}</small>
</label>
</div>
<div className="update-settings__actions">
<button
@@ -309,7 +308,7 @@ export function UpdateSettingsSection({
{result.target.files.map((file) => (
<li key={file.name}>
<code>{file.name}</code>
<span>{formatBytes(file.size)}</span>
<span>{formatModelPackageBytes(file.size)}</span>
</li>
))}
</ul>
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
export type WorkspaceScope =
| { kind: 'global' }
@@ -142,6 +143,7 @@ export function PageHeader({
description,
eyebrow,
headingId,
help,
icon,
scope,
title
@@ -151,6 +153,7 @@ export function PageHeader({
description?: ReactNode
eyebrow?: string
headingId: string
help?: ReactNode
icon?: ReactNode
scope?: WorkspaceScope
title: string
@@ -169,7 +172,7 @@ export function PageHeader({
{icon}
</span>
)}
<h1 id={headingId}>{title}</h1>
<h1 id={headingId}>{help != null ? <span className="inline-help-label">{title}<InlineHelp label={title}>{help}</InlineHelp></span> : title}</h1>
{scope && <ScopeBadge scope={scope} />}
</div>
{description && (
+2 -1
View File
@@ -13,7 +13,8 @@ export const app = {
manage: 'Manage Apps',
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge and Smart Heartbeat are always shown and can be moved.',
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
pinHelp: 'Pinning only controls the sidebar shortcut.',
disableHelp: 'Disabling preserves data and pins without stopping existing requests or background services.',
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
@@ -38,8 +38,9 @@ export const settingsSections = {
keep: 'Keep saved password',
replace: 'Enter a new password'
},
passwordNotice: 'The password is used for SSH authentication and saved encrypted in system secure storage after validation succeeds.',
passwordHelp:
'The password is used only for this authentication attempt. After validation succeeds, the Main process writes it to system secure storage. It is never written to command-line arguments, logs, or regular settings files.',
'The Main process handles the password. It is never written to command-line arguments, logs, or regular settings files.',
credentialSources: {
none: 'Not configured',
encrypted: 'Encrypted by system secure storage',
@@ -436,10 +436,7 @@ export const workspace = {
ready: 'Browser ready',
failed: 'Browser operation failed',
stopped: 'Browser stopped'
},
frameAlt: 'Live Agent browser view',
noFrame: 'Could not capture the page',
waitingFrame: 'Waiting for the first page frame…'
}
},
errors: {
workspacePreview: 'Could not preview the workspace file',
+2 -1
View File
@@ -10,7 +10,8 @@ export const app = {
manage: '管理应用',
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库和智能心跳始终显示,位置可调整。',
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
pinHelp: '常驻只控制左侧捷径。',
disableHelp: '关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
@@ -33,8 +33,9 @@ export const settingsSections = {
keep: '保留已保存密码',
replace: '输入新密码'
},
passwordNotice: '密码用于 SSH 认证,验证成功后加密保存到系统安全存储。',
passwordHelp:
'密码仅用于本次认证,并在验证成功后由 Main 进程写入系统安全存储;不会写入命令行、日志或普通设置文件。',
'由 Main 进程处理密码,不会写入命令行、日志或普通设置文件。',
credentialSources: {
none: '尚未配置',
encrypted: '已由系统安全存储加密',
@@ -425,10 +425,7 @@ export const workspace = {
ready: '浏览器已就绪',
failed: '浏览器操作失败',
stopped: '浏览器已停止'
},
frameAlt: 'Agent 实时浏览器画面',
noFrame: '未能获取页面画面',
waitingFrame: '等待首个页面画面…'
}
},
errors: {
workspacePreview: '工作区文件预览失败',
+56
View File
@@ -0,0 +1,56 @@
.inline-help-label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
max-width: 100%;
vertical-align: middle;
}
.inline-help {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
vertical-align: middle;
-webkit-app-region: no-drag;
}
.inline-help:hover,
.inline-help[aria-expanded="true"] {
color: var(--accent);
background: var(--surface-subtle);
}
.inline-help:active { background: var(--accent-subtle); }
.inline-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.inline-help__content {
position: fixed;
box-sizing: border-box;
overflow: auto;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: var(--shadow-dialog);
font-family: var(--font-family-ui);
font-size: var(--font-body);
font-weight: 400;
line-height: 1.6;
text-align: start;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.inline-help__content > :first-child { margin-top: 0; }
.inline-help__content > :last-child { margin-bottom: 0; }
-6
View File
@@ -21,9 +21,3 @@ export function agentTargetKey(target: {
)) throw new Error(`Unsupported Agent target: ${target.platform}-${target.architecture}`)
return `${target.platform}-${target.architecture}` as AgentTargetKey
}
export function currentAgentPlatform(): AgentPlatform {
const platform = agentPlatformSchema.parse(process.platform)
agentTargetKey({ platform, architecture: process.arch })
return platform
}
+7
View File
@@ -264,6 +264,13 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
type: z.literal('subagent'),
childTaskId: assistantIdSchema
})
.strict(),
z
.object({
id: assistantIdSchema,
type: z.literal('question'),
questionId: z.string().min(1)
})
.strict()
])
@@ -0,0 +1,19 @@
import { expect, it } from 'vitest'
import { conversationMessageBlocksSchema } from './assistant-contracts'
import { appendConversationQuestionBlock } from './conversation-question-blocks'
it('appends one stable question marker without moving earlier content or duplicate questions', () => {
const text = { id: crypto.randomUUID(), type: 'text' as const, content: 'Before' }
const blocks = appendConversationQuestionBlock([text], 'question-1')!
expect(blocks[0]).toBe(text)
expect(blocks[1]).toMatchObject({ type: 'question', questionId: 'question-1' })
expect(appendConversationQuestionBlock(blocks, 'question-1')).toBe(blocks)
expect(conversationMessageBlocksSchema.parse(blocks)).toEqual(blocks)
expect(appendConversationQuestionBlock(blocks, 'question-2')?.map(block => block.type)).toEqual([
'text', 'question', 'question'
])
})
it('does not fabricate positions for legacy messages without blocks', () => {
expect(appendConversationQuestionBlock(undefined, 'question-1')).toBeUndefined()
})
@@ -0,0 +1,11 @@
import type { ConversationMessageBlock } from './assistant-contracts'
export function appendConversationQuestionBlock(
blocks: ConversationMessageBlock[] | undefined,
questionId: string
): ConversationMessageBlock[] | undefined {
if (!blocks || blocks.some(block => block.type === 'question' && block.questionId === questionId)) {
return blocks
}
return [...blocks, { id: crypto.randomUUID(), type: 'question', questionId }]
}
-7
View File
@@ -66,13 +66,6 @@ export const goodbuddyConfigToolCatalog = {
GoodBuddyConfigToolDefinition
>
export const goodbuddyConfigToolKeys = [
'capabilities',
'get',
'plan',
'apply'
] as const
export const goodbuddyConfigTools = [
goodbuddyConfigToolCatalog.capabilities,
goodbuddyConfigToolCatalog.get,
@@ -3,10 +3,6 @@ import { createOperationIdentity } from './agent-protocol/canonical'
import {
REMOTE_RUNTIME_LAUNCH_LIMITS,
assertDetachedRemoteRuntimeBundleDigest,
assertRemoteRuntimeOperationDigest,
createRemoteRuntimeQueryRequest,
createRemoteRuntimeStartRequest,
createRemoteRuntimeStopRequest,
detachedSignedRemoteRuntimeBundleSchema,
digestRemoteRuntimeBundleIdentity,
digestRemoteRuntimeBundleManifest,
@@ -86,30 +82,6 @@ const signedBundle = {
}
}
const commonPreparation = {
bindingId: 'binding-1',
operationId: 'operation-1',
requestId: 'request-1',
workMode: 'ask' as const,
controllerId: 'controller-1',
controllerGeneration: 1,
connectionGeneration: 1,
channelEpoch: '1',
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
workspaceIdentity: 'workspace-1',
agentInstallationId: 'installation-1',
runtimeId: 'runtime-1',
runtimeBundleDigest: digest,
runtimeAdapterDigest: digest,
deadlineAt,
budget: {
maximumInputBytes: 10,
maximumOutputBytes: 100
}
}
const startPayload = {
bindingId: 'binding-1',
requestId: 'request-1',
@@ -119,7 +91,10 @@ const startPayload = {
{ name: 'modelProfileId' as const, value: 'model-1' }
],
deadlineAt,
budget: commonPreparation.budget
budget: {
maximumInputBytes: 10,
maximumOutputBytes: 100
}
}
async function operationRequest<T>(
@@ -278,68 +253,14 @@ describe('remote Runtime launch contracts', () => {
).rejects.toThrow(/digest does not match/iu)
})
it('constructs the exact start operation identity and canonical digest', async () => {
const request = await createRemoteRuntimeStartRequest({
controllerId: 'controller-1',
operationId: 'start-operation-1',
prompt: commonPreparation,
adapterParameters: startPayload.adapterParameters,
deadlineAt
})
expect(request.identity.method).toBe('runtime/startPrompt')
expect(request.identity.scope).toEqual({
kind: 'run',
sessionId: 'binding-1',
requestId: 'request-1'
})
const expected = await createOperationIdentity({
controllerId: 'controller-1',
operationId: 'start-operation-1',
scope: request.identity.scope,
method: 'runtime/startPrompt',
payload: request.payload
})
expect(request.identity.payloadDigest).toBe(expected.payloadDigest)
expect(request.payload.workMode).toBe('ask')
await expect(
createRemoteRuntimeStartRequest({
controllerId: 'controller-1',
operationId: commonPreparation.operationId,
prompt: commonPreparation,
deadlineAt: '2031-01-01T00:00:00.000Z'
})
).rejects.toThrow(/prompt deadline/iu)
})
it('rejects caller digest mismatches', async () => {
it('validates start requests without adapter flags, paths or process-control fields', async () => {
const valid = await operationRequest(
'runtime/startPrompt',
'start-operation-1',
'request-1',
startPayload
)
const changedPayload = {
...startPayload,
runtimeId: 'runtime-2'
}
await expect(
assertRemoteRuntimeOperationDigest({
...valid,
payload: changedPayload
})
).rejects.toThrow(/digest does not match/iu)
await expect(
assertRemoteRuntimeOperationDigest(valid)
).resolves.toBeUndefined()
await expect(
assertRemoteRuntimeOperationDigest({
...valid,
identity: { ...valid.identity, payloadDigest: digest }
})
).rejects.toThrow(/digest does not match/iu)
})
it('rejects adapter flag and path tunneling plus all process-control fields', async () => {
expect(remoteRuntimeStartRequestSchema.parse(valid)).toEqual(valid)
for (const value of [
'--flag',
'/tmp/model',
@@ -347,24 +268,16 @@ describe('remote Runtime launch contracts', () => {
'models\\model',
'..'
]) {
await expect(
createRemoteRuntimeStartRequest({
controllerId: 'controller-1',
operationId: 'start-operation-1',
prompt: commonPreparation,
adapterParameters: [
{ name: 'modelProfileId', value }
],
deadlineAt
expect(() =>
remoteRuntimeStartRequestSchema.parse({
...valid,
payload: {
...startPayload,
adapterParameters: [{ name: 'modelProfileId', value }]
}
})
).rejects.toThrow()
).toThrow()
}
const valid = await operationRequest(
'runtime/startPrompt',
'start-operation-1',
'request-1',
startPayload
)
for (const forbidden of forbiddenLaunchFields) {
expect(() =>
remoteRuntimeStartRequestSchema.parse({
@@ -398,23 +311,19 @@ describe('remote Runtime launch contracts', () => {
reason: 'user-cancelled' as const,
deadlineAt
}
const stop = await createRemoteRuntimeStopRequest({
controllerId: 'controller-1',
operationId: 'stop-1',
payload: stopPayload
})
const stop = await operationRequest(
'runtime/stopPrompt', 'stop-1', stopPayload.requestId, stopPayload
)
expect(remoteRuntimeStopRequestSchema.parse(stop)).toEqual(stop)
const query = await createRemoteRuntimeQueryRequest({
controllerId: 'controller-1',
operationId: 'query-1',
payload: {
const query = await operationRequest(
'runtime/queryPrompt', 'query-1', 'query-request-1', {
launchId: 'launch-1',
bindingId: 'binding-1',
runtimeId: 'runtime-1',
startOperationId: 'start-operation-1',
requestId: 'query-request-1'
}
})
)
expect(remoteRuntimeQueryRequestSchema.parse(query)).toEqual(query)
expect(() =>
remoteRuntimeStopRequestSchema.parse({
+2 -111
View File
@@ -15,14 +15,8 @@ import {
utf8StringSchema,
type OperationIdentity
} from './agent-protocol/contracts'
import {
createOperationIdentity,
digestCanonicalOperation
} from './agent-protocol/canonical'
import {
remotePromptOperationPreparationSchema,
type RemotePromptOperationPreparation
} from './remote-agent-contracts'
import { digestCanonicalOperation } from './agent-protocol/canonical'
import { remotePromptOperationPreparationSchema } from './remote-agent-contracts'
export const REMOTE_RUNTIME_LAUNCH_LIMITS = {
maximumFiles: 10_000,
@@ -638,96 +632,6 @@ export type RemoteRuntimeQueryResult = z.infer<
typeof remoteRuntimeQueryResultSchema
>
export async function digestRemoteRuntimePreparation(
preparation: RemotePromptOperationPreparation
): Promise<string> {
const parsed = remotePromptOperationPreparationSchema.parse(preparation)
return digestCanonicalOperation({
method: 'runtime/preparePrompt',
scope: {
kind: 'run',
sessionId: parsed.bindingId,
requestId: parsed.requestId
},
payload: parsed
})
}
export async function createRemoteRuntimeStartRequest(input: {
controllerId: string
operationId: string
prompt: unknown
adapterParameters?: unknown
deadlineAt?: string
}): Promise<RemoteRuntimeStartRequest> {
const prompt = remotePromptOperationPreparationSchema.parse(input.prompt)
const deadlineAt = input.deadlineAt ?? prompt.deadlineAt
if (Date.parse(deadlineAt) > Date.parse(prompt.deadlineAt)) {
throw new Error('Runtime start deadline exceeds the prompt deadline')
}
const payload = remoteRuntimeStartPayloadSchema.parse({
bindingId: prompt.bindingId,
requestId: prompt.requestId,
workMode: prompt.workMode,
runtimeId: prompt.runtimeId,
adapterParameters: input.adapterParameters ?? [],
deadlineAt,
budget: prompt.budget
})
const identity = await createOperationIdentity({
controllerId: agentIdentifierSchema.parse(input.controllerId),
operationId: agentIdentifierSchema.parse(input.operationId),
scope: {
kind: 'run',
sessionId: prompt.bindingId,
requestId: prompt.requestId
},
method: 'runtime/startPrompt',
payload
})
return remoteRuntimeStartRequestSchema.parse({ identity, payload })
}
export async function createRemoteRuntimeStopRequest(input: {
controllerId: string
operationId: string
payload: unknown
}): Promise<RemoteRuntimeStopRequest> {
const payload = remoteRuntimeStopPayloadSchema.parse(input.payload)
const identity = await createOperationIdentity({
controllerId: input.controllerId,
operationId: input.operationId,
scope: {
kind: 'run',
sessionId: payload.bindingId,
requestId: payload.requestId
},
method: 'runtime/stopPrompt',
payload
})
return remoteRuntimeStopRequestSchema.parse({ identity, payload })
}
export async function createRemoteRuntimeQueryRequest(input: {
controllerId: string
operationId: string
payload: unknown
}): Promise<RemoteRuntimeQueryRequest> {
const payload = remoteRuntimeQueryPayloadSchema.parse(input.payload)
const identity = await createOperationIdentity({
controllerId: input.controllerId,
operationId: input.operationId,
scope: {
kind: 'run',
sessionId: payload.bindingId,
requestId: payload.requestId
},
method: 'runtime/queryPrompt',
payload
})
return remoteRuntimeQueryRequestSchema.parse({ identity, payload })
}
export async function digestRemoteRuntimeBundleManifest(
manifest: unknown
): Promise<string> {
@@ -775,19 +679,6 @@ export async function assertDetachedRemoteRuntimeBundleDigest(
return parsed
}
export async function assertRemoteRuntimeOperationDigest(
request: RemoteRuntimeStartRequest | RemoteRuntimeStopRequest | RemoteRuntimeQueryRequest
): Promise<void> {
const expectedDigest = await digestCanonicalOperation({
method: request.identity.method,
scope: request.identity.scope,
payload: request.payload
})
if (request.identity.payloadDigest !== expectedDigest) {
throw new Error('Runtime operation payload digest does not match')
}
}
function addOperationBindingIssues(
identity: OperationIdentity,
method: string,
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } 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('keeps inline help usable in real modals with mouse, keyboard and narrow windows', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-inline-help-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'inline-help', configureServer(server) {
server.middlewares.use('/inline-help.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/inline-help.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/inline-help-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_HELP_URL: server.resolvedUrls!.local[0] + 'inline-help.html',
GOODBUDDY_HELP_DIRECTORY: directory }
delete env.ELECTRON_RUN_AS_NODE
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/inline-help-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(), 60000)
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/).filter(line => line.startsWith('InlineHelp')).join('\n') + '\n')
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 90000)
+28 -7
View File
@@ -10,7 +10,9 @@ import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it.each([true, false])('analyzes selected canvas pages through production Electron UI and IPC (vision=%s)', async (supportsImageInput) => {
const live = process.env.GB_NOTES_LIVE === '1'
const livePageCounts = process.env.GB_NOTES_LIVE_PAGES === '8' ? [8] : [1, 8]
it.each(live ? [true] : [true, false])('analyzes selected canvas pages through production Electron UI and IPC (vision=%s)', async (supportsImageInput) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-analysis-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-analysis', configureServer(server) {
@@ -26,9 +28,9 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
await build({ entryPoints: ['tests/support/magic-notes-analysis-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'],
plugins: [{ name: 'isolated-services', setup(build) {
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'probe' }))
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'probe' }))
if (!live) build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'probe' }))
build.onLoad({ filter: /.*/, namespace: 'probe' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[]; export const isReadOnlyChannelMessage=()=>true;'
? 'export const startEnvironmentChannels=()=>[];'
: `export const createDefaultModelRuntime=()=>({ dispose:async()=>{}, releaseConversation:async()=>{}, async *run(request) {
(globalThis.analysisRequests ??= []).push({ prompt: request.prompt, imageCount: request.images?.length ?? 0 });
yield {requestId:request.requestId,type:'text',delta:'{"comments":[{"kind":"summary","content":"Canvas reviewed."}]}'};
@@ -45,17 +47,35 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
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_NOTES_PROBE_VISION: String(supportsImageInput), GB_NOTES_PROBE_DIRECTORY: directory, GB_NOTES_PROBE_URL: server.resolvedUrls!.local[0] + 'analysis.html' }
const env: NodeJS.ProcessEnv = { ...process.env, GB_NOTES_PROBE_VISION: String(supportsImageInput), GB_NOTES_PROBE_DIRECTORY: directory, GB_NOTES_PROBE_URL: server.resolvedUrls!.local[0] + 'analysis.html' + (live ? `?live=1&pages=${process.env.GB_NOTES_LIVE_PAGES ?? ''}` : '') }
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(), 60000)
const timeout = setTimeout(() => child.kill(), live ? 240000 : 60000)
try {
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
if (live) {
const evidence = JSON.parse(await readFile(join(directory, 'live-evidence.json'), 'utf8'))
expect(result.error).toBeUndefined()
expect(result.errors).toEqual([])
expect(result.runs.map((run: { pageCount: number }) => run.pageCount)).toEqual(livePageCounts)
expect(evidence.realCallCount).toBe(livePageCounts.length)
expect(evidence.originalSettingsUnchanged).toBe(true)
expect(evidence.requests.map((request: { imageCount: number }) => request.imageCount)).toEqual(livePageCounts)
for (const [index, run] of result.runs.entries()) {
expect(run.comments.every((comment: { inputMode: string }) => comment.inputMode === 'canvas-images')).toBe(true)
const comments = run.comments.map((comment: { content: string }) => comment.content).join('\n')
expect(comments).toContain(result.markers[0])
expect(comments).not.toContain(result.markers[8])
expect(evidence.requests[index].status).toBe(200)
for (const marker of result.markers) expect(evidence.requests[index].text).not.toContain(marker)
}
return
}
expect(result).toMatchObject({ errors: [], manualRevision: 1, editedRevision: 3, createdAnalyzed: true, todoAnalyzed: supportsImageInput ? 'canvas-images' : 'text-fallback' })
expect(result.requests).toHaveLength(6)
for (const request of result.requests.slice(0, 5)) {
@@ -68,6 +88,7 @@ it.each([true, false])('analyzes selected canvas pages through production Electr
expect(result.requests[5].imageCount).toBe(supportsImageInput ? 2 : 0)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
if (live) console.log(`Live notes evidence: ${directory}`)
else await rm(directory, { recursive: true, force: true })
}
}, 90000)
}, live ? 270000 : 90000)
+239
View File
@@ -0,0 +1,239 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
app.setPath('userData', join(process.env.GOODBUDDY_HELP_DIRECTORY, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1000, height: 700, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
const js = async code => {
try { return await win.webContents.executeJavaScript(code) }
catch (error) { throw new Error('Renderer script: ' + code, { cause: error }) }
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const wait = async code => {
for (let i = 0; i < 200; i++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error('Timeout: ' + code)
}
// scrollIntoView also scrolls overflow:hidden <details>, unlike a user's wheel.
const reveal = async selector => {
await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
for (let p = e.parentElement; p; p = p.parentElement) {
if (!/(auto|scroll)/.test(getComputedStyle(p).overflowY)) continue;
const r = e.getBoundingClientRect(), bounds = p.getBoundingClientRect();
p.scrollTop += r.top + r.height/2 - bounds.top - p.clientHeight/2;
} })()`)
await settle()
}
const mouse = async (selector, click = false) => {
await reveal(selector)
const point = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); const r = e.getBoundingClientRect();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
const parents = []; for (let p = e; p && parents.length < 9; p = p.parentElement) {
const b = p.getBoundingClientRect(); parents.push({ tag: p.tagName, class: p.className, inert: p.inert,
top: b.top, height: b.height, scrollTop: p.scrollTop, overflow: getComputedStyle(p).overflow, pointer: getComputedStyle(p).pointerEvents }); }
return { x, y, hit: e.contains(document.elementFromPoint(x,y)), target: document.elementFromPoint(x,y)?.outerHTML.slice(0, 250), parents }; })()`)
assert(point.hit, 'Occluded: ' + selector + ' ' + JSON.stringify(point))
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
if (click) {
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
}
await settle()
}
const key = async keyCode => {
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await settle()
}
await win.loadURL(process.env.GOODBUDDY_HELP_URL)
await wait('!!document.querySelector("#open")')
win.focus()
win.webContents.focus()
await settle()
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1000, 700], [360, 320]]) {
win.setContentSize(width, height)
await settle()
await mouse('#open', true)
await wait('!!document.querySelector("#outside")')
assert(await js('document.querySelector(".app-shell").inert'))
await mouse('[aria-label="Page"]')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.id === "outside"'), 'Hover stole focus')
assert(await js('document.querySelector("[aria-modal=true]").contains(document.querySelector("[role=tooltip]"))'))
await mouse('[role=tooltip]')
await setTimeout(160)
assert(await js('!!document.querySelector("[role=tooltip]")'), 'Hover transfer closed help')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
assert(await js('!!document.querySelector("[aria-modal=true]")'), `Hover Escape closed modal: ${theme} ${width}x${height}`)
await mouse('#outside')
await key('Tab')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.matches(".inline-help")'))
await key('Escape')
await key('Space')
await wait('!!document.querySelector("[role=tooltip]")')
await key('Space')
assert(await js('!document.querySelector("[role=tooltip]")'))
await mouse('[aria-label="Long help"]', true)
await wait('!!document.querySelector("#long-help[role=tooltip]")')
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await setTimeout(160)
assert(await js('!!document.querySelector("#long-help[role=tooltip]")'), 'Click did not pin help')
assert(await js(`(() => { const e = document.querySelector('#long-help'), r = e.getBoundingClientRect();
return r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15 && e.scrollWidth <= e.clientWidth; })()`), 'Help overflowed viewport')
win.webContents.sendInputEvent({ type: 'mouseDown', x: 5, y: 5, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: 5, y: 5, button: 'left', clickCount: 1 })
await settle()
assert(await js('!document.querySelector("[role=tooltip]")'))
await key('Escape')
await wait('!document.querySelector("[aria-modal=true]")')
assert(await js('document.activeElement.id === "open"'))
}
}
console.log('InlineHelp: hover/focus/click/Escape/outside/modal/narrow bounds passed in light and dark themes')
win.webContents.debugger.attach('1.3')
const evidence = []
const artifacts = process.env.GOODBUDDY_HELP_ARTIFACTS
if (artifacts) await mkdir(artifacts, { recursive: true })
let capturePrefix = ''
let captureIndex = 0
const snapshot = root => js(`(() => { const root = document.querySelector(${JSON.stringify(root)});
return JSON.stringify({ fields: Array.from(root.querySelectorAll('input, select, textarea')).map(e => [e.id, e.value, e.checked]),
details: Array.from(root.querySelectorAll('details')).map(e => e.open),
tabs: Array.from(root.querySelectorAll('[aria-selected], [aria-pressed]')).map(e => [e.textContent, e.getAttribute('aria-selected'), e.getAttribute('aria-pressed')]) }); })()`)
const description = async selector => {
const expected = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
const id = e.getAttribute('aria-describedby'); const targets = Array.from(document.querySelectorAll('[id]')).filter(e => e.id === id);
if (targets.length !== 1) throw new Error('Missing or duplicate description: ' + id);
return targets[0].textContent.replace(/\\s+/g, ' ').trim(); })()`)
assert(expected.length > 0)
const { root } = await win.webContents.debugger.sendCommand('DOM.getDocument')
const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector })
const { nodes } = await win.webContents.debugger.sendCommand('Accessibility.getPartialAXTree', { nodeId })
assert.equal(nodes[0].description?.value, expected, 'Chromium accessibility description: ' + selector)
}
const checkHelp = async (selector, root, describedControl) => {
await reveal(selector)
if (describedControl) await description(describedControl)
const before = await snapshot(root)
const geometry = await js(`(() => { const button = document.querySelector(${JSON.stringify(selector)});
const previous = button.previousSibling; const range = document.createRange(); range.selectNodeContents(previous);
const label = range.getBoundingClientRect(), r = button.getBoundingClientRect();
const style = getComputedStyle(button), svg = button.querySelector('svg'), icon = getComputedStyle(svg);
return { label: button.getAttribute('aria-label'), sameLine: Math.min(label.bottom, r.bottom) > Math.max(label.top, r.top),
appearance: Object.fromEntries(['width', 'height', 'padding', 'margin', 'color', 'backgroundColor', 'border', 'borderRadius'].map(key => [key, style[key]])),
icon: { width: icon.width, height: icon.height, color: icon.color },
gap: r.left - label.right, centerOffset: r.top + r.height / 2 - label.top - label.height / 2,
afterLabel: r.left >= label.right - 1, nested: !!button.closest('label, summary, [role=tab]') || !!button.parentElement.closest('button') }; })()`)
assert(geometry.sameLine && geometry.afterLabel, 'Help not beside label: ' + JSON.stringify(geometry))
assert(!geometry.nested, 'Help nested in another control: ' + geometry.label)
const focused = () => js('document.activeElement.tagName + ":" + document.activeElement.id + ":" + document.activeElement.getAttribute("aria-label")')
const beforeHover = await focused()
await mouse(selector)
await wait('!!document.querySelector("[role=tooltip]")')
assert.equal(await focused(), beforeHover, 'Consumer hover stole focus')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await js(`document.querySelector(${JSON.stringify(selector)}).focus()`)
await wait('!!document.querySelector("[role=tooltip]")')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await mouse(selector, true)
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`), 'Help click focused another input')
const panel = await js(`(() => { const e = document.querySelector('[role=tooltip]'), r = e.getBoundingClientRect();
const style = getComputedStyle(e);
const hit = document.elementFromPoint(r.x + r.width/2, r.y + Math.min(r.height/2, 20));
return { text: e.textContent, width: r.width, height: r.height, scrollHeight: e.scrollHeight, clientHeight: e.clientHeight,
appearance: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'padding', 'border', 'borderRadius'].map(key => [key, style[key]])),
inBounds: r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15,
wraps: e.scrollWidth <= e.clientWidth, hit: e.contains(hit), modal: !!e.closest('[aria-modal=true]') }; })()`)
assert(panel.text.trim().length > 0 && panel.inBounds && panel.wraps && panel.hit, 'Unreadable help: ' + JSON.stringify(panel))
if (root.includes('dialog') || root.includes('settings-panel') || root.includes('application-center')) assert(panel.modal)
if (describedControl) await description(describedControl)
assert.equal(await snapshot(root), before, 'Help click changed values, tabs or disclosure state')
if (artifacts) await writeFile(join(artifacts, `${capturePrefix}-${++captureIndex}.png`), (await win.webContents.capturePage()).toPNG())
await key('Escape')
assert(await js(`!document.querySelector('[role=tooltip]') && !!document.querySelector(${JSON.stringify(root)})`), 'Escape closed consumer')
assert.equal(await snapshot(root), before, 'Help dismissal changed consumer state')
if (describedControl) await description(describedControl)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await settle()
return { ...geometry, panelAppearance: panel.appearance, characters: panel.text.length, width: panel.width, height: panel.height }
}
const profileId = '00000000-0000-4000-8000-000000000001'
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1280, 800], [640, 480]]) {
capturePrefix = `${theme}-${width}x${height}`
captureIndex = 0
win.setContentSize(width, height)
await settle()
const checked = []
await mouse('#applications', true)
await wait('!!document.querySelector("#application-center")')
await mouse('[aria-label="魔法笔记 应用设置"]', true)
await wait('!!document.querySelector("#magic-note-canvas-page-count")')
assert.equal(await js('document.querySelector("#magic-note-canvas-page-count").value'), '3')
checked.push(await checkHelp('.inline-help[aria-label="常驻左侧菜单"]', '#application-center', '[aria-describedby="application-pin-help"][role=switch]'))
checked.push(await checkHelp('.inline-help[aria-label="发送画布页数"]', '#application-center', '#magic-note-canvas-page-count'))
await key('Escape')
await wait('!document.querySelector("#application-center")')
await mouse('#settings', true)
const context = '#model-context-window-' + profileId
await wait(`!!document.querySelector('${context}')`)
assert.equal(await js(`document.querySelector('${context}').value`), '128')
checked.push(await checkHelp('.inline-help[aria-label="上下文上限(可选)"]', '.settings-panel', context))
checked.push(await checkHelp('.inline-help[aria-label="自定义请求体"]', '.settings-panel', '#model-request-body-' + profileId))
for (const applicationHelp of checked.slice(0, 2)) {
assert.deepEqual(applicationHelp.appearance, checked[2].appearance, 'Application/settings help button styles differ')
assert.deepEqual(applicationHelp.icon, checked[2].icon, 'Application/settings help icons differ')
assert.deepEqual(applicationHelp.panelAppearance, checked[2].panelAppearance, 'Application/settings tooltip styles differ')
assert.equal(applicationHelp.gap, checked[2].gap, 'Application/settings label gaps differ')
assert.equal(applicationHelp.centerOffset, checked[2].centerOffset, 'Application/settings label alignment differs')
}
await key('Escape')
await wait('!document.querySelector(".settings-panel")')
await mouse('#retrieval', true)
await wait('!!document.querySelector(".knowledge-retrieval-workbench")')
const queryHelp = '.knowledge-workbench-section__heading .inline-help'
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
const disclosureHeight = await js('document.querySelector(".knowledge-workbench-settings").getBoundingClientRect().height')
await js('document.querySelector(".knowledge-workbench-settings > summary").focus({preventScroll: true})')
await key('Space')
assert(await js('document.querySelector(".knowledge-workbench-settings").open'))
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
assert.equal(await js('document.querySelector(".knowledge-retrieval-workbench textarea").value'), 'Preserve this query')
await key('Escape')
await wait('!document.querySelector(".knowledge-retrieval-workbench")')
await mouse('#activity', true)
await wait('!!document.querySelector(".page-header .inline-help")')
checked.push(await checkHelp('.page-header .inline-help', 'main'))
await mouse('#home', true)
evidence.push({ theme, viewport: `${width}x${height}`, retrievalDisclosureHeight: disclosureHeight, checked })
}
}
assert.equal(await js('document.documentElement.dataset.fixtureActions ?? "[]"'), '[]', 'Help triggered a consumer operation')
console.log('InlineHelp consumer evidence: ' + JSON.stringify(evidence))
console.log('InlineHelp consumer actions: 0; model calls: 0; Chromium AX descriptions passed closed/open/closed')
if (artifacts) await writeFile(join(artifacts, 'evidence.json'), JSON.stringify({ evidence, consumerActions: 0, modelCalls: 0 }, null, 2))
win.destroy()
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { InlineHelp } from '../../src/renderer/src/InlineHelp'
import { PageHeader } from '../../src/renderer/src/WorkspacePrimitives'
import { SettingsSectionHeader } from '../../src/renderer/src/SettingsPrimitives'
import { activateModalFocus, trapTabFocus } from '../../src/renderer/src/dialog-focus'
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
import { KnowledgeRetrievalWorkbench } from '../../src/renderer/src/KnowledgeRetrievalWorkbench'
import { ActivityPanel } from '../../src/renderer/src/ActivityPanel'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { defaultRuntimeSettings } from '../../src/shared/contracts'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
import { defaultKnowledgeRetrievalSettings } from '../../src/shared/knowledge-contracts'
import '../../src/renderer/src/styles.css'
const actions: string[] = []
const record = (action: string): void => {
actions.push(action)
document.documentElement.dataset.fixtureActions = JSON.stringify(actions)
}
const applicationSettings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false, magicNoteCanvasPageCount: 3,
})
const profileId = '00000000-0000-4000-8000-000000000001'
Object.defineProperty(window, 'goodbuddy', { value: {
settings: {
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
id: profileId, name: 'Help regression model', modelName: 'fixture-model', baseUrl: 'https://example.invalid',
protocol: 'anthropic-messages', authentication: 'none', imageGenerationQuality: 'auto',
apiKeyConfigured: false, credentialSource: 'none', contextWindowTokens: 128000, maximumOutputTokens: 16000,
requestHeaders: { 'x-fixture': 'preserve-me' }, requestBody: { temperature: 0.4 },
}], defaultModelProfileId: profileId, embeddingConnections: [],
opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, deepseekHarnessModelSource: { kind: 'platform' } }),
detectAgentRuntimes: async () => ({}),
updateRuntime: async () => { record('runtime-save'); throw new Error('Unexpected save') },
testModelConnection: async () => { record('model-call'); throw new Error('Model calls forbidden in help regression') },
},
} })
function Modal({ close }: { close: () => void }): React.JSX.Element {
const first = useRef<HTMLButtonElement>(null)
useEffect(() => activateModalFocus(() => first.current), [])
useEffect(() => {
const escape = (event: KeyboardEvent): void => { if (event.key === 'Escape') close() }
document.addEventListener('keydown', escape, true)
return () => document.removeEventListener('keydown', escape, true)
}, [close])
return createPortal(<div className="settings-backdrop">
<section role="dialog" aria-modal="true" aria-label="Help fixture" style={{ width: 'calc(100% - 32px)', maxWidth: 700,
maxHeight: 'calc(100% - 32px)', overflow: 'auto', padding: 16, background: 'var(--surface-raised)' }}
onKeyDown={event => trapTabFocus(event, event.currentTarget)}>
<button id="outside" ref={first}>Outside</button>
<PageHeader headingId="page" title="Page" help="Page help" />
<SettingsSectionHeader headingId="section" title="Section" description="Description" help="Section help" />
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<InlineHelp label="Long help" id="long-help">{'Long unbroken text: ' + 'abcdefghij'.repeat(150)}</InlineHelp>
</div>
</section>
</div>, document.body)
}
function Fixture(): React.JSX.Element {
const [view, setView] = useState('')
const [settings, setSettings] = useState(applicationSettings)
return <div className="app-shell"><div>
<button id="open" onClick={() => setView('isolated')}>Open modal</button>
<button id="applications" onClick={() => setView('applications')}>Applications</button>
<button id="settings" onClick={() => setView('settings')}>Settings</button>
<button id="retrieval" onClick={() => setView('retrieval')}>Retrieval</button>
<button id="activity" onClick={() => setView('activity')}>Activity</button>
<button id="home" onClick={() => setView('')}>Home</button>
</div>
{view === 'isolated' && <Modal close={() => setView('')} />}
{view === 'applications' && <ApplicationCenter settings={settings} pending={false} onClose={() => setView('')}
onOpen={() => record('application-open')} onRetry={() => record('application-retry')}
onUpdate={async patch => { record('application-update'); setSettings(current => ({ ...current, ...patch })); return true }} />}
{view === 'settings' && <SettingsPanel open initialCategory="model" projects={[]} onClose={() => setView('')}
onSaved={() => record('settings-saved')} onClearLocalData={async () => record('clear-data')}
onUpdateProject={async () => { throw new Error('Unexpected project update') }} />}
{view === 'retrieval' && <KnowledgeRetrievalWorkbench libraryName="Help regression library" initialQuery="Preserve this query"
settings={defaultKnowledgeRetrievalSettings} graphAvailable onClose={() => setView('')}
onTest={() => record('retrieval-test')} onSaveDefaults={() => record('retrieval-save')}
onOpenSource={() => record('source-open')} onViewContext={() => record('context-open')} />}
{view === 'activity' && <main style={{ width: '100%', minWidth: 0, padding: 16 }}><ActivityPanel records={[]}
tokenUsage={{ records: [], totals: { callCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } }}
onClear={() => record('activity-clear')} onOpenConversation={() => record('conversation-open')} /></main>}
</div>
}
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN"><Fixture /></UiLocaleProvider>)
@@ -0,0 +1,73 @@
import { createRoot } from 'react-dom/client'
import { useState } from 'react'
import { ApplicationSettingsView } from '../../src/renderer/src/ApplicationCenter'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contracts'
export async function liveNotesAnalysisRegression() {
const api = window.goodbuddy.magicNotes
const initialSettings = await window.goodbuddy.updates.getSettings()
if (initialSettings.magicNoteCanvasPageCount !== 1) throw new Error('Expected default 1 page')
const markers = ['KITE-41', 'FERN-72', 'MOON-36', 'WAVE-85', 'PINE-29', 'STAR-63', 'LIME-57', 'BIRD-94', 'WOLF-18']
const content: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas', pages: [], assets: [] }
for (const [index, marker] of markers.entries()) {
const canvas = document.createElement('canvas')
canvas.width = 700
canvas.height = 700
const context = canvas.getContext('2d')!
context.fillStyle = '#ffffff'
context.fillRect(0, 0, 700, 700)
context.fillStyle = '#172554'
context.font = 'bold 64px sans-serif'
context.fillText(`PAGE ${index + 1}`, 70, 180)
context.fillText(marker, 70, 310)
context.font = '32px sans-serif'
context.fillText('Visual inventory card', 70, 410)
content.assets.push({ id: `asset-${index + 1}`, name: `card-${index + 1}.png`, mimeType: 'image/png', dataUrl: canvas.toDataURL('image/png') })
content.pages.push({ id: `page-${index + 1}`, width: 794, height: 1123,
background: { type: 'template', template: 'blank' },
objects: [{ type: 'Image', canvasKind: 'image', assetId: `asset-${index + 1}`, originX: 'left', originY: 'top', left: 40, top: 40, width: 700, height: 700 }] })
}
const note = await api.create({ title: 'Isolated visual inventory' })
const created = await api.createEntry({ noteId: note.id, content })
const entry = async () => (await api.get(note.id)).entries.find(item => item.id === created.createdEntryId)!
const host = document.createElement('div')
document.body.append(host)
const errors: string[] = []
function Probe() {
const [settings, setSettings] = useState(initialSettings)
return <><ApplicationSettingsView id="magic-notes" settings={settings} pending={false} onUpdate={async updates => {
setSettings(await window.goodbuddy.updates.updateSettings(updates)); return true
}} /><MagicNotesWorkspace applicationSettings={settings} onNotify={notification => {
if (notification.tone === 'error') errors.push(notification.message)
}} /></>
}
createRoot(host).render(<Probe />)
const wait = async (predicate: () => unknown | Promise<unknown>) => {
for (let i = 0; i < 4000; i++) {
if (errors.length) throw new Error(errors.join('\n'))
if (await predicate()) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error('Live UI wait timed out')
}
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
const runs = []
const pageCounts = new URLSearchParams(location.search).get('pages') === '8' ? [8] : [1, 8]
for (const pageCount of pageCounts) {
if (pageCount === 8) {
const select = host.querySelector<HTMLSelectElement>('select[aria-describedby="magic-note-canvas-page-count-help"]')!
select.value = '8'
select.dispatchEvent(new Event('change', { bubbles: true }))
await wait(async () => (await window.goodbuddy.updates.getSettings()).magicNoteCanvasPageCount === 8)
}
const revision = (await entry()).revision
const button = () => [...host.querySelectorAll<HTMLButtonElement>('button')].find(node => ['Analyze with AI', 'Analyze again'].includes(node.textContent?.trim() ?? ''))
await wait(() => button() && !button()!.disabled)
button()!.click()
await wait(async () => (await entry()).revision > revision && (await entry()).comments.length)
runs.push({ pageCount, comments: (await entry()).comments })
}
return { errors, markers, runs, fixturePageCount: content.pages.length }
}
@@ -0,0 +1,85 @@
import { safeStorage } from 'electron'
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
// Read an encrypted snapshot so even corrupt-settings recovery cannot touch user data.
export async function prepareLiveNotesAnalysis(directory: string) {
const source = process.env.GB_NOTES_LIVE_SETTINGS!
const original = readFileSync(source)
const snapshot = join(directory, 'runtime-settings.snapshot.json')
writeFileSync(snapshot, original)
const store = new RuntimeSettingsStore(snapshot, {
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: () => { throw new Error('Live probe must not encrypt credentials') },
decrypt: value => safeStorage.decryptString(value)
}, {})
const settings = await store.getResolvedSettings().finally(() => unlinkSync(snapshot))
writeFileSync(join(directory, 'preflight.json'), JSON.stringify({
realCallCount: 0, model: settings.modelName, protocol: settings.modelProtocol,
supportsImageInput: settings.supportsImageInput,
authentication: settings.modelAuthentication,
credentialReadable: Boolean(settings.apiKey),
encryptionAvailable: safeStorage.isEncryptionAvailable(),
defaultModelProfileId: settings.defaultModelProfileId,
profiles: settings.modelProfiles.map(profile => ({ id: profile.id, model: profile.modelName,
supportsImageInput: profile.supportsImageInput, credentialReadable: Boolean(profile.apiKey) }))
}, null, 2))
if (!settings.supportsImageInput || !settings.modelName ||
(settings.modelAuthentication !== 'none' && !settings.apiKey)) {
throw new Error('Persisted default model lacks vision support or readable credentials')
}
settings.workspacePath = directory
const requests: { imageCount: number; images: string[]; text: string; toolCount: number; status?: number }[] = []
const save = () => writeFileSync(join(directory, 'live-evidence.json'), JSON.stringify({
model: settings.modelName, protocol: settings.modelProtocol,
realCallCount: requests.length,
originalSettingsUnchanged: readFileSync(source).equals(original), requests
}, null, 2))
const originalFetch = globalThis.fetch
const pageCounts = process.env.GB_NOTES_LIVE_PAGES === '8' ? [8] : [1, 8]
globalThis.fetch = async (input, init) => {
if (requests.length >= pageCounts.length) throw new Error('Live probe request budget exhausted')
const body = JSON.parse(String(init?.body))
const images: string[] = []
const texts: string[] = []
const visit = (value: unknown) => {
if (!value || typeof value !== 'object') return
const part = value as Record<string, unknown>
let data: string | undefined
if (part.type === 'input_image' && typeof part.image_url === 'string') data = part.image_url
if (part.type === 'image_url') data = (part.image_url as { url?: string })?.url
if (part.type === 'image' && (part.source as { type?: string })?.type === 'base64') {
const source = part.source as { media_type: string; data: string }
data = `data:${source.media_type};base64,${source.data}`
}
if (data) images.push(data)
if (typeof part.text === 'string') texts.push(part.text)
if (typeof part.content === 'string') texts.push(part.content)
for (const child of Object.values(part)) {
if (Array.isArray(child)) child.forEach(visit)
else if (child && typeof child === 'object') visit(child)
}
}
visit(body.input ?? body.messages)
const expected = pageCounts[requests.length]
if (images.length !== expected || (body.tools?.length ?? 0) !== 0) {
throw new Error(`Unexpected outbound payload: ${images.length} images, ${body.tools?.length ?? 0} tools`)
}
const observation = { imageCount: images.length, images: images.map((data, index) => {
const bytes = Buffer.from(data.slice(data.indexOf(',') + 1), 'base64')
const name = `request-${requests.length + 1}-page-${index + 1}.png`
writeFileSync(join(directory, name), bytes)
return `${name} sha256:${createHash('sha256').update(bytes).digest('hex')}`
}), text: texts.join('\n'), toolCount: body.tools?.length ?? 0, status: undefined as number | undefined }
requests.push(observation)
save()
const response = await originalFetch(input, init)
observation.status = response.status
save()
return response
}
save()
return { settings, save }
}
+23 -6
View File
@@ -1,14 +1,23 @@
import { app, BrowserWindow } from 'electron'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { dirname, 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 { prepareLiveNotesAnalysis } from './magic-notes-analysis-live'
const directory = process.env.GB_NOTES_PROBE_DIRECTORY!
const url = process.env.GB_NOTES_PROBE_URL!
const preload = join(directory, 'preload.cjs')
app.setPath('userData', join(directory, 'profile'))
if (process.env.GB_NOTES_LIVE === '1' && process.platform === 'win32') {
const localState = JSON.parse(readFileSync(join(dirname(process.env.GB_NOTES_LIVE_SETTINGS!), 'Local State'), 'utf8'))
mkdirSync(join(directory, 'profile'), { recursive: true })
writeFileSync(join(directory, 'profile', 'Local State'), JSON.stringify({ os_crypt: localState.os_crypt }))
}
app.on('will-quit', () => {
if (process.env.GB_NOTES_LIVE === '1') rmSync(join(directory, 'profile', 'Local State'), { force: true })
})
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1400, height: 1000, webPreferences: {
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload
@@ -16,16 +25,20 @@ app.whenReady().then(async () => {
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const supportsImageInput = process.env.GB_NOTES_PROBE_VISION === 'true'
const runtimeSettings = { workspacePath: directory, supportsImageInput,
const live = process.env.GB_NOTES_LIVE === '1' ? await prepareLiveNotesAnalysis(directory) : undefined
const runtimeSettings = live?.settings ?? { workspacePath: directory, supportsImageInput,
defaultModelProfileId: 'vision', modelProfiles: [{ id: 'vision', supportsImageInput }] }
const applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
await applicationSettings.update({ magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' })
await applicationSettings.update({ magicNoteCommentMode: live ? 'after-save-manual' : 'after-save-auto', magicNoteCommentFormat: 'structured' })
const dispose = registerIpcHandlers(win, { capability: 'text' } as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => runtimeSettings } as never,
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => live ? {
supportsImageInput: true, defaultModelProfileId: runtimeSettings.defaultModelProfileId,
modelProfiles: runtimeSettings.modelProfiles.map(profile => ({ id: profile.id, supportsImageInput: profile.supportsImageInput }))
} : runtimeSettings } as never,
{} as never, { clear() {}, cancelImport() {} } as never, {} as never, database,
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined,
applicationSettings)
win.webContents.on('console-message', event => console.log(event.message))
if (!live) win.webContents.on('console-message', event => console.log(event.message))
try {
await win.loadURL(url)
for (let i = 0; i < 400; i++) {
@@ -34,6 +47,10 @@ app.whenReady().then(async () => {
}
const result = await win.webContents.executeJavaScript('window.notesAnalysisRegression().catch(error => ({ error: error.stack, body: document.body.innerText }))')
writeFileSync(join(directory, 'result.json'), JSON.stringify({ ...result, requests: (globalThis as typeof globalThis & { analysisRequests: unknown[] }).analysisRequests }))
if (live) {
writeFileSync(join(directory, 'ui.png'), (await win.webContents.capturePage()).toPNG())
live.save()
}
} finally {
await dispose()
database.close()
@@ -8,9 +8,11 @@ import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contra
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
import { liveNotesAnalysisRegression } from './magic-notes-analysis-live-regression'
async function notesAnalysisRegression() {
await i18n.changeLanguage('en-US')
if (new URLSearchParams(location.search).get('live') === '1') return liveNotesAnalysisRegression()
const api = window.goodbuddy.magicNotes
const initialSettings = await window.goodbuddy.updates.getSettings()
if (initialSettings.magicNoteCanvasPageCount !== 1) throw new Error('Expected default page count 1')