feat: retain completed project activity until viewed
Keep successful background conversations in project activity until their chat is visible and loaded. Track completion in memory for the current client session; initial history, failures and cancellations do not create completion notices. Include whole-file companion changes already present in App and shared UI: preserve the workspace beneath Settings, show inline reply-copy feedback, and keep project settings inside the project menu. Include their component tests and documentation; navigation demo source changes remain unstaged. Validation: 77 focused activity tests, typecheck and lint passed; Electron component fixtures passed 112 checks. Full suite: 4365 passed, 3 failed, 67 skipped (offline inventory timeout and two heartbeat migration failures). Commit-time staged whitespace check passed; tests were not rerun. Release note: 后台会话完成后保留已完成提示,进入对应聊天后自动清除,避免错过任务结果。
This commit is contained in:
+9
-7
@@ -145,7 +145,7 @@
|
||||
| 变体 | 最大内容宽度 | 适用场景 | 页面映射 |
|
||||
| --- | --- | --- | --- |
|
||||
| `reading` | `820px` | 连续阅读、单列编辑、对话撰写 | 聊天正文与输入区 |
|
||||
| `standard` | `960px` | 常规列表、设置与表单 | 最近对话、设置 |
|
||||
| `standard` | `960px` | 常规列表与表单 | 最近对话 |
|
||||
| `dashboard` | `1040px` | 指标、卡片网格、宽表格与审计数据 | 智能心跳、活动记录 |
|
||||
| `master-detail` | 可用空间内流式铺开 | 左侧选择、右侧编辑或预览 | 知识库 |
|
||||
|
||||
@@ -285,7 +285,8 @@
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 项目选择器下方常驻跨项目活动汇总,空闲时显示“暂无活动”;项目计数与项目、会话级联菜单遵守
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
@@ -605,8 +606,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
右边缘与用户气泡右边缘对齐;两侧为头像保留独立安全边距,滚动条出现或工作栏缩放不得改变对齐。
|
||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
|
||||
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
|
||||
复制入口。
|
||||
原文,不包含推理过程、工具日志或引用元数据。复制成功后,当前按钮显示使用 `--success`
|
||||
的绿色对号,可访问名称和工具提示同步表示已复制,3 秒后恢复复制图标;重复复制成功重新
|
||||
计时,不显示成功通知。失败仍通过应用错误通知反馈。流式回复完成前不显示复制入口。
|
||||
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
|
||||
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
|
||||
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
|
||||
@@ -723,11 +725,11 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
### 13.8 设置中心
|
||||
|
||||
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
|
||||
- 设置中心以整窗遮罩上的大 Modal 打开,保留底层工作区、会话草稿和助手工作栏,不切换一级页面。固定标题区和右上角关闭按钮,左侧分类与右侧内容各自滚动。关闭后返回原工作区并恢复入口焦点。
|
||||
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
|
||||
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
|
||||
- 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。
|
||||
- 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。
|
||||
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);极窄窗口隐藏分类辅助说明,仍保留左右布局。内容区在可用空间内伸缩,最大宽度为 `960px`。
|
||||
- 设置 Modal 通过 Portal 挂载到 `document.body`,复用共享背景隔离与焦点恢复机制。打开后聚焦关闭按钮,Tab 在弹窗内循环;Escape 先交由嵌套弹窗处理,设置自身的 Escape 与关闭按钮共用未保存确认,确认出现后再按 Escape 返回编辑。点击遮罩不关闭;保存、测试和清理数据期间禁止关闭。每次打开重新挂载设置,模型和消息通道等直达入口使用本次指定分类,普通入口默认打开 Agent Runtime。
|
||||
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
|
||||
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”包括当前运行、待处理及本次客户端
|
||||
会话内已完成但未查看的会话;完成提示在实际进入聊天后清除,不累计历史完成总数。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
|
||||
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
|
||||
六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为
|
||||
演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。
|
||||
这些方案用于讨论最底部应用中心;其中商店页、商店应用安装与 F 方案属于未来交互参考,
|
||||
不纳入首期实现或验收。首期产品只提供内置应用;Demo 暂保留远期示例以便后续讨论。
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
六个方案共用本次页面会话内的配置,刷新后全部恢复默认,不持久化或修改真实设置。
|
||||
A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单独演示未来私有市场。
|
||||
模型服务、资源指标和工具内容均为演示数据,不执行真实安装、模型加载或进程操作。
|
||||
窄屏上下排列侧栏与主内容,应用页面隐藏输入区,对话页面显示输入区。
|
||||
模型管理与 F 方案均为计划预览,不代表正式功能已交付,也不纳入首期验收。
|
||||
底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、
|
||||
知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名
|
||||
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
|
||||
@@ -51,16 +52,31 @@ Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加
|
||||
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
|
||||
|
||||
六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用
|
||||
上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理”
|
||||
和“阅读空间”应用示例,支持仅安装或安装并常驻左侧菜单,常驻不表示后台运行。
|
||||
常驻应用在本次页面会话内跨方案保持一致;“恢复默认应用”重置安装与常驻配置。
|
||||
移除入口不会卸载应用或关闭当前页面,刷新后恢复默认。
|
||||
上下箭头排序、移除入口。魔法笔记、知识库、智能心跳及计划预览“本机模型”默认全部启用且常驻。
|
||||
取消常驻仅移除导航捷径,仍可从应用中心打开;关闭应用隐藏业务入口,应用中心保留设置与
|
||||
重新开启入口,并记住常驻偏好。当前应用页关闭后显示不可用状态及设置恢复按钮。
|
||||
知识库关闭时同步隐藏侧栏入口、会话选项中的知识范围和检索方式、已选知识标签;重新开启
|
||||
恢复原来的选择。以上操作不删除数据,也不表示停止后台任务或模型服务。
|
||||
“恢复默认应用”重置应用安装、启用、常驻和笔记设置,返回对话页;模型服务模拟状态保留至刷新。
|
||||
|
||||
魔法笔记提供应用中心“设置”、笔记页面“笔记设置”和全局设置演示入口,均打开同一设置
|
||||
面板,共用本次页面会话内的配置。面板沿用现有选项:显示未完成待办数量、AI 评论方式
|
||||
所有内置应用提供应用中心、应用页面和全局设置入口,均打开同一应用设置面板,分别控制
|
||||
启用与常驻。魔法笔记面板另含显示未完成待办数量、AI 评论方式
|
||||
(即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示
|
||||
魔法笔记入口”在原型中与“常驻左侧菜单”统一,不额外增加功能启停开关。侧栏待办数为
|
||||
固定的 3 项演示数据。修改即时生效于原型,不调用 AI 或写入真实应用设置。
|
||||
魔法笔记入口”在原型中与“常驻左侧菜单”统一。启用、常驻、待办数量显示默认开启;
|
||||
评论方式默认即时,评论形式默认长评 + 要点。侧栏待办数为固定的 3 项演示数据。
|
||||
修改即时生效于原型,不调用 AI 或写入真实应用设置。
|
||||
|
||||
本机模型主内容区展示紧凑服务列表,包含模型、用途、引擎、管理方式、状态、占用和使用方。
|
||||
受管进程模拟启动、停止、重启;外部服务仅提供连接配置预览;进程内模型模拟加载与释放。
|
||||
影响使用方的操作先显示就地确认,取消保留原状态;隐藏或关闭管理应用不改变服务状态。
|
||||
|
||||
F 提供私有市场 URL、模拟获取目录、模拟不可达与重试,不发起网络请求或切换公共来源。
|
||||
目录包含项目管理和阅读空间示例,可模拟安装或安装并常驻。YAML 文本支持导入预览和导出
|
||||
预览,明确标为 mock schema,仅识别示例中的七个扁平字段,尚非通用 YAML 解析器。
|
||||
导入列出依赖、外部资源和同标识冲突:缺少依赖或资源时不允许安装;内置应用不允许覆盖;
|
||||
已安装扩展须显式确认更新定义,保留启用与常驻偏好。导入新应用在目录中以标识显示,原始名称
|
||||
保留在定义及导出预览中。修改输入会使旧预览失效,取消不修改安装状态。
|
||||
导出仅显示定义文本,不写文件,不包含凭据、会话、笔记、知识数据、程序包或模型权重。
|
||||
|
||||
| 方案 | 讨论重点 |
|
||||
| --- | --- |
|
||||
@@ -69,7 +85,7 @@ Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加
|
||||
| C:应用预览 | 用卡片说明应用用途 |
|
||||
| D:搜索启动台 | 搜索打开能力,支持 Ctrl / Command K、方向键与 Enter |
|
||||
| E:常驻管理 | 已常驻应用优先排列,可取消常驻或打开 |
|
||||
| F:商店发现(未来) | 默认展开模拟商店,不纳入首期 |
|
||||
| F:私有市场预览(未来) | URL 模拟获取、YAML 导入导出与显式模拟安装,不纳入首期 |
|
||||
|
||||
D 参考 [Raycast](https://www.raycast.com/) 的搜索启动与扩展发现方式。早期方案参考过
|
||||
Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快捷区。
|
||||
|
||||
@@ -152,6 +152,7 @@ warming/loading/live/partial/error -> inactive
|
||||
| 工具打开决策表 | FR-5 至 FR-10 | US-C1 至 US-C7 |
|
||||
| 资源采样状态 | FR-9 | US-C6 |
|
||||
| 无障碍与窄窗口 | FR-13 | US-D1、US-D2 |
|
||||
| 活动聚合、完成通知与查看清除 | FR-14 | US-E1、US-E2、US-E3 |
|
||||
|
||||
## 7. 逻辑完整性评估
|
||||
|
||||
@@ -171,7 +172,7 @@ warming/loading/live/partial/error -> inactive
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按 Conversation ID 聚合,每条会话最多一行。
|
||||
数据来源为活动会话 ID、会话中的流式消息及待回答/待审批请求,以及全部可见的运行中或
|
||||
等待处理 Task;不能只取当前项目、当前搜索结果或每条会话的最新 Task。
|
||||
等待处理 Task,以及当前应用会话内的已完成未查看集合;不能只取当前项目、当前搜索结果或每条会话的最新 Task。
|
||||
|
||||
| 优先级(高到低) | 条件 | 汇总类别 |
|
||||
| --- | --- | --- |
|
||||
@@ -179,18 +180,30 @@ warming/loading/live/partial/error -> inactive
|
||||
| 待审批 | 消息有待审批请求 | 待处理 |
|
||||
| Task 等待处理 | Task 为 `waiting_approval`,尚无更具体的消息状态 | 待处理 |
|
||||
| 运行中 | 活动会话 ID、`streaming` 消息或运行中 Task | 运行中 |
|
||||
| 已完成 | 会话在已完成未查看集合中,且无更高优先级实时状态 | 已完成 |
|
||||
|
||||
同一会话只采用最高优先级状态;Task 的 `waiting_approval` 也可能对应提问,不能直接
|
||||
标成待审批。“待处理”和“运行中”数量互斥,全局总数为两者之和;项目计数使用相同结果。
|
||||
标成待审批。“待处理”“运行中”和“已完成”数量互斥,全局总数为三者之和;项目计数使用相同结果。
|
||||
项目归属优先取会话元数据,缺少会话时取 Task 元数据;无项目归属的活动只计入全局汇总。
|
||||
|
||||
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
|
||||
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
|
||||
历史排队项、仅待调度的 Task、终态 Task 和普通未读标记本身不构成活动。完成通知规则如下:
|
||||
|
||||
- 本地运行收到成功 `done`,或当前活动运行从持久化快照确认成功完成时,记录会话完成通知。
|
||||
- 后台顶层 Task 只有在本次应用会话中已观察到该任务,随后状态转为 `completed` 时才记录通知;
|
||||
初次载入或首次出现的已完成历史任务只建立基线,子任务不单独产生通知。
|
||||
- 失败、取消不产生完成通知。实时待处理或运行中状态优先;开始新运行时清除旧完成通知,
|
||||
已观察 Task 转为 `running` 或 `waiting_approval` 时也清除对应会话的旧通知。
|
||||
- 通知只保存在 Renderer 内存,不跨重载或重启恢复,也不从普通未读状态推导。
|
||||
- 仅当目标会话实际成为当前聊天、设置未打开且文档未隐藏时清除通知;普通侧栏导航和活动菜单
|
||||
导航采用同一查看条件。已在查看的会话完成后不保留通知,恢复可见聊天时也清除通知。
|
||||
- 展开活动项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
|
||||
没有实时状态且没有已完成未查看通知时才移除活动行。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内先列待处理会话,再列运行中会话,同类别保留来源顺序。
|
||||
项目计数。每个项目内依次列待处理、运行中、已完成会话,同类别保留来源顺序。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动全部结束后返回项目列表;全局活动结束后
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动行全部移除后返回项目列表;全局无活动行后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
|
||||
@@ -141,16 +141,18 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
||||
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有运行中或待处理会话时显示在项目选择器下方,无活动时整行隐藏。点击后先显示有活动会话的项目,
|
||||
用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有上述活动行时显示在项目选择器下方,无活动行时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随待处理或运行中数量变化。
|
||||
各项目的活动数量;级联菜单内的项目顺序不随各类活动数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。结束后仅有未读标记的
|
||||
会话不保留在活动汇总中;已完成未读面板不属于本项交付。
|
||||
会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。成功完成后保留“已完成”
|
||||
通知,直到用户实际查看该会话;从活动菜单或普通侧栏进入均可清除。设置覆盖下的聊天及
|
||||
隐藏文档不算查看。完成通知仅保留在当前应用会话内,初始任务历史不补发通知,失败和取消
|
||||
不产生完成通知;普通未读标记本身不构成活动。
|
||||
|
||||
## 6. 权限与安全
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# 项目活动级联实施进度
|
||||
|
||||
## 2026-09-16:设置改为大 Modal
|
||||
|
||||
对应 [US-A2](./user-stories.md#us-a2-从固定入口进入设置)。设置入口保留底层工作区,关闭后卸载
|
||||
设置内容;布局、关闭与分类直达规则见 [设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心)。
|
||||
|
||||
`App.test.tsx`、`SettingsPanel.test.tsx`、`WorkspacePrimitives.test.tsx` 和
|
||||
`browser-viewport-occlusion.test.ts` 定向回归共 389 项通过。`npm run lint` 通过。
|
||||
全量 `npm test` 完成时为 4,354 项通过、9 项失败、67 项跳过;失败涉及本地 Runtime 复用超时、
|
||||
心跳旧库迁移、远程恢复及 ProjectSwitcher。共享工作区存在并行修改,最终 `npm run typecheck`
|
||||
报错位于非本次修改的 `use-unviewed-completions.test.tsx`,未将全量检查记为通过。
|
||||
|
||||
Windows Electron 使用临时用户目录、当前 Renderer 源码和已有 Main/Preload 输出,验证窗口
|
||||
尺寸 `1440 × 960`、`1024 × 700`、`680 × 560`(系统缩放使实际 CSS 高宽略有偏差)。
|
||||
浅色与深色检查通过:整窗背景隔离、左右分栏、无内容横向溢出、遮罩点击不关闭、原生 Tab 与
|
||||
Escape、关闭后保留原知识库组件。截图保存在本轮临时验证目录;没有运行打包构建,也未单独
|
||||
驱动原生 `WebContentsView`,其遮挡规则通过共享检测测试验证。本次未修改 GoodBuddy Agent
|
||||
或桌面到 Agent 的执行路径,未额外发起真实模型请求。
|
||||
|
||||
## 2026-09-16:已完成保留至查看
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E3](./user-stories.md#us-e3-完成后保留至查看)。
|
||||
已核对当前工作区 `use-unviewed-completions.ts`、`conversation-activity.ts` 和 `App.tsx` 的
|
||||
完成通知接入:Renderer 会话内集合、后台 Task 转换、本地及持久化活动运行成功标记、
|
||||
三类计数,以及聊天、设置和文档可见性条件。行为定义见[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
活动汇总、完成通知 Hook、项目活动组件、项目选择器及 App 活动集成定向回归共 77 项通过。
|
||||
覆盖前台完成、后台完成、持久化完成补齐、失败和取消、普通会话入口及级联菜单进入、
|
||||
设置覆盖聊天、窗口隐藏、历史完成不提醒、查看后刷新不重复提示和下一轮完成。
|
||||
实际聊天详情加载完成后才清除提示。最终 `npm run typecheck` 与 `npm run lint` 通过。
|
||||
|
||||
全量 `npm test` 用 900 秒预算运行,762.20 秒结束:4,365 项通过、3 项失败、67 项跳过。
|
||||
失败为 `agent-package.test.ts` 离线依赖清单安装内部 60 秒超时,以及
|
||||
`heartbeat-database.test.ts` 两项迁移的 `duplicate column name: pinned`。
|
||||
共享工作区存在并行修改,此项未改动这些文件,不将全量检查记为通过。
|
||||
|
||||
Windows Electron 43.2.0 / Chromium 150 的当前组件 fixture 验证浅深主题、1280×800 和
|
||||
375×667、混合状态及仅完成状态,共 8 个场景、112 项检查通过,8 张 PNG 已审阅。
|
||||
检查包含完成图标、三类计数、原生方向键与 Space、进入回调及移除后的空闲隐藏。
|
||||
证据为临时目录 `activity-completion-0916-results.json` 和 `activity-completion-0916-review.md`。
|
||||
此项使用受控活动数据,未验证完整 Main/Preload 真实模型端到端;模型及知识库请求为 0。
|
||||
本次只修改 Renderer 展示与状态跟踪,本地和远程均消费已有事件/Task 更新,不改变 Agent 执行路径。
|
||||
|
||||
## 2026-09-14:菜单独立高度修正
|
||||
|
||||
生产菜单移除共用双栏表面,会话层独立定位到选中项目行。打开较长会话列表时,项目层的位置、
|
||||
|
||||
@@ -190,7 +190,9 @@ Renderer
|
||||
```
|
||||
|
||||
不能在设置按钮内部嵌套工具按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。
|
||||
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
|
||||
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
|
||||
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
|
||||
|
||||
系统工具按钮调用统一 `openSystemTool`:
|
||||
|
||||
@@ -408,8 +410,17 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
派生状态,界面行为见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`assistantTasks`、`completedConversationIds` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
- `use-unviewed-completions.ts` 在 Renderer 内存维护完成集合和上一轮 Task 状态;已观察到的
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
新运行及 Task 恢复运行或等待处理时清除旧通知。具体优先级和通知生命周期以活动逻辑为准。
|
||||
- `App` 仅在 `view === 'chat' && !settingsOpen` 时向 Hook 传入当前会话 ID;Hook 监听
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空才隐藏摘要。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
@@ -434,5 +445,6 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
设置离开检查通过后才提交项目、会话及界面清理状态。
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
验证记录见[项目活动级联实施进度](./progress.md)。本次只改 Renderer 展示与交互,远程
|
||||
Runtime、Agent 协议和桌面到 Agent 的执行路径未改变。
|
||||
完成通知的定向验证应覆盖本地成功、持久化活动运行成功、后台 Task 状态转换、初始历史、
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动行隐藏。
|
||||
验证记录见[项目活动级联实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
||||
| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 |
|
||||
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
|
||||
| 末位 | `Settings` | 设置 | 打开设置页面 |
|
||||
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal |
|
||||
|
||||
图标使用项目现有 Lucide 库。每个按钮采用统一的 `34px × 34px` 点击区域,内部图标建议
|
||||
`16px`。工具栏使用 `8px` 内边距和 `4px` 间隔;设置按钮通过自动空白靠右,不添加第二条
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
## 4. 应用与工具设置
|
||||
|
||||
设置中心新增“应用与工具”分类,采用 `standard` PageShell:
|
||||
设置中心新增“应用与工具”分类,位于设置 Modal 的右侧内容区;容器与键盘规则遵守 [设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心):
|
||||
|
||||
```text
|
||||
应用与工具
|
||||
@@ -227,7 +227,7 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮仅在有活动时显示在项目选择器下方,紧凑显示待处理与运行中数量,空闲时整行隐藏,
|
||||
- 全局汇总按钮仅在有活动行时显示在项目选择器下方,紧凑显示待处理、运行中与已完成数量,无活动行时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
@@ -237,10 +237,11 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
运行中会话之前,已完成未查看会话排在最后并显示“已完成”。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面和完成通知。完成通知在目标聊天
|
||||
实际可见后清除,从普通侧栏进入也相同;设置覆盖下的聊天或隐藏文档不触发清除。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
@@ -249,4 +250,4 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。活动全部结束时,关闭已打开菜单并隐藏摘要。
|
||||
高度并独立滚动。成功完成但尚未查看的会话继续保留;所有活动行移除后,关闭已打开菜单并隐藏摘要。
|
||||
|
||||
@@ -259,11 +259,31 @@
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Given 当前项目的活动全部结束
|
||||
- Given 当前项目已无待处理、运行中或已完成未查看会话
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
|
||||
### US-E3 完成后保留至查看
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),具体状态规则见[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- Given 用户正在其他会话或应用中工作
|
||||
- When 本次应用会话内的运行成功完成,或已观察到的后台任务转为完成
|
||||
- Then 活动会话保留为“已完成”,全局与项目的已完成计数同步更新
|
||||
- When 从活动菜单或普通侧栏实际进入该会话的可见聊天
|
||||
- Then 清除其完成通知;没有其他活动行时隐藏摘要并关闭菜单
|
||||
- Given 聊天被设置覆盖或文档隐藏
|
||||
- When 对应运行完成
|
||||
- Then 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知
|
||||
- Given 目标聊天已可见且未被设置覆盖
|
||||
- When 运行成功完成
|
||||
- Then 不保留该会话的完成通知
|
||||
- Given 初始加载已完成任务历史,或任务失败、取消
|
||||
- Then 不产生新的完成通知;重载或重启也不恢复上一应用会话的通知
|
||||
- Given 同一会话仍有待处理或运行中工作
|
||||
- Then 按实时状态优先展示,只计入一个类别
|
||||
|
||||
## 7. 后续需求场景
|
||||
|
||||
本节对应新增计划,不计入应用中心首期完成条件。
|
||||
|
||||
+185
-14
@@ -1301,7 +1301,7 @@ describe("App", () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task]);
|
||||
});
|
||||
|
||||
it("keeps cross-project task counts independent of initial and current search and clears on refresh", async () => {
|
||||
it("keeps cross-project counts independent of search and retains completion until opened", async () => {
|
||||
const initialTasks = deferred<AssistantTask[]>();
|
||||
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
|
||||
render(<App />);
|
||||
@@ -1326,6 +1326,10 @@ describe("App", () => {
|
||||
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
|
||||
expect(summary).not.toHaveTextContent("待处理");
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
await waitFor(() => expect(summary).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
@@ -1333,6 +1337,114 @@ describe("App", () => {
|
||||
.not.toHaveTextContent("个运行中");
|
||||
});
|
||||
|
||||
it.each((["foreground", "background"] as const).flatMap((location) =>
|
||||
(["done", "persisted"] as const).map((source) => ({ location, source })),
|
||||
))("acknowledges $location local success from $source only when its chat is viewed", async ({ location, source }) => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
const { container } = render(<App />);
|
||||
await screen.findByLabelText("更多会话操作 Current discussion");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Complete local activity" } });
|
||||
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(request.conversationId).toBe("activity-current");
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: "assistant", state: "streaming" }),
|
||||
]) }),
|
||||
])));
|
||||
const saved = vi.mocked(api.conversations.saveLocal).mock.calls.at(-1)![0]
|
||||
.find((conversation) => conversation.header.id === request.conversationId)!;
|
||||
if (location === "background") {
|
||||
selectProjectOption(otherProject.name);
|
||||
fireEvent.click(await screen.findByTitle("Exact background discussion"));
|
||||
await screen.findByText("Exact conversation content");
|
||||
}
|
||||
|
||||
if (source === "done") {
|
||||
act(() => {
|
||||
agentListener?.({ requestId: request.requestId, type: "text", delta: "Local completion result" });
|
||||
agentListener?.({ requestId: request.requestId, type: "done" });
|
||||
});
|
||||
} else {
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([
|
||||
{ ...saved.header, updatedAt: saved.header.updatedAt + 120_000,
|
||||
messages: saved.messages.map((message) => message.role === "assistant"
|
||||
? { ...message, state: "complete", content: "Local completion result" } : message) },
|
||||
...conversations.filter((conversation) => conversation.id !== request.conversationId),
|
||||
]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
}
|
||||
|
||||
if (location === "background") {
|
||||
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
|
||||
expect(summary).not.toHaveTextContent("运行中");
|
||||
expect(screen.getByText("Exact conversation content")).toBeVisible();
|
||||
fireEvent.click(summary);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(project.name, "u") }));
|
||||
expect(screen.getByRole("menu", { name: project.name })).toHaveTextContent("已完成");
|
||||
fireEvent.click(summary);
|
||||
expect(summary).toHaveTextContent("1 个已完成");
|
||||
selectProjectOption(project.name);
|
||||
fireEvent.click(await within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.findByTitle(saved.header.title));
|
||||
}
|
||||
expect(await screen.findByText("Local completion result")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
selectProjectOption(otherProject.name);
|
||||
await screen.findByTitle("Exact background discussion");
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["failed", "cancelled"] as const)("does not mark a background local run completed after %s", async (status) => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
render(<App />);
|
||||
await screen.findByLabelText("更多会话操作 Current discussion");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Unsuccessful local activity" } });
|
||||
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
selectProjectOption(otherProject.name);
|
||||
fireEvent.click(await screen.findByTitle("Exact background discussion"));
|
||||
await screen.findByText("Exact conversation content");
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中");
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "error", status, message: `Local activity ${status}`,
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Exact conversation content")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu", { name: "当前项目" }))
|
||||
.getByRole("menuitemradio", { name: new RegExp(project.name, "u") })).not.toHaveTextContent("已完成");
|
||||
});
|
||||
|
||||
it("retains completion for the selected conversation while Settings is open until chat is actually viewed", async () => {
|
||||
const currentTask = { ...task, projectId, conversationId: "activity-current" };
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([currentTask]);
|
||||
const { container } = render(<App />);
|
||||
const selected = (await within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.findByTitle("Current discussion")).closest("button")!;
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
|
||||
const settings = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...currentTask, status: "completed" }]);
|
||||
act(() => conversationQueueChangeListener?.(currentTask.conversationId));
|
||||
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
|
||||
expect(settings).toBeVisible();
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
fireEvent.click(within(settings).getByRole("button", { name: "关闭设置" }));
|
||||
await waitFor(() => expect(settings).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "current-task", projectId, conversationId: "activity-current", title: "Current task", status: "idle",
|
||||
@@ -1790,7 +1902,10 @@ describe("App", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Toggle assistant workspace"));
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Project settings"));
|
||||
fireEvent.click(projectTrigger);
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "Manage project Default project",
|
||||
}));
|
||||
const settingsDialog = screen.getByRole("dialog", {
|
||||
name: "Project settings",
|
||||
});
|
||||
@@ -2579,7 +2694,7 @@ describe("App", () => {
|
||||
expect(route).not.toHaveAttribute("hidden");
|
||||
});
|
||||
|
||||
it("enforces the workspace KeepAlive cap during rapid visits", async () => {
|
||||
it("keeps Settings outside the workspace KeepAlive cache", async () => {
|
||||
let now = 2_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => ++now);
|
||||
render(<App />);
|
||||
@@ -2597,7 +2712,8 @@ describe("App", () => {
|
||||
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
|
||||
expect(
|
||||
document.querySelector('[data-route="knowledge"]'),
|
||||
).not.toBeInTheDocument();
|
||||
).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-route="settings"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves title, message, and project filtering with deferred search", async () => {
|
||||
@@ -2794,7 +2910,7 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("region", {
|
||||
await screen.findByRole("dialog", {
|
||||
name: "Settings",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
@@ -3345,7 +3461,8 @@ describe("App", () => {
|
||||
expect.stringContaining("你好,我是 GoodBuddy"),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("回复已复制到剪贴板")).toBeVisible();
|
||||
expect(await screen.findByRole("button", { name: "回复已复制到剪贴板" })).toBeVisible();
|
||||
expect(screen.queryByText("回复已复制到剪贴板")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a desktop clipboard write failure", async () => {
|
||||
@@ -3359,6 +3476,7 @@ describe("App", () => {
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"无法访问剪贴板,请检查系统权限",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "复制此回复" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("flushes and opens an independent conversation branch with provenance badges", async () => {
|
||||
@@ -5007,7 +5125,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(knowledgeNavigation).toHaveFocus());
|
||||
});
|
||||
|
||||
it("restores focus outside Settings after closing the page", async () => {
|
||||
it("restores focus outside Settings after closing the modal", async () => {
|
||||
render(<App />);
|
||||
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
@@ -5022,6 +5140,40 @@ describe("App", () => {
|
||||
await waitFor(() => expect(settingsTrigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it.each([
|
||||
["chat", "对话"],
|
||||
["knowledge", "知识库"],
|
||||
["activity", "运行记录"],
|
||||
])("preserves the %s workspace underneath Settings", async (route, label) => {
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(composer, { target: { value: "Keep this workspace draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
const workspace = document.querySelector(`[data-route="${route}"]`)!;
|
||||
workspace.scrollTop = 120;
|
||||
const trigger = screen.getByRole("button", { name: /本地工作区/u });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(document.querySelector(".app-shell")).toHaveProperty("inert", true);
|
||||
expect(workspace).not.toHaveAttribute("hidden");
|
||||
expect(document.querySelector(`[data-route="${route}"]`)).toBe(workspace);
|
||||
fireEvent.mouseDown(dialog.parentElement!);
|
||||
fireEvent.click(dialog.parentElement!);
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "关闭设置" })).toHaveFocus();
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".app-shell")).toHaveProperty("inert", false);
|
||||
expect(workspace).not.toHaveAttribute("hidden");
|
||||
expect(workspace.scrollTop).toBe(120);
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toBe(composer);
|
||||
expect(composer).toHaveValue("Keep this workspace draft");
|
||||
});
|
||||
|
||||
it("focuses the Composer after closing automatically opened Settings", async () => {
|
||||
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
|
||||
id: "setup",
|
||||
@@ -8271,7 +8423,10 @@ describe("App", () => {
|
||||
"微信 ClawBot通道项目",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("项目设置"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: `管理项目 ${weixinProject.name}`,
|
||||
}));
|
||||
let dialog = screen.getByRole("dialog", { name: "项目设置" });
|
||||
const dialogDescription =
|
||||
within(dialog).getByLabelText("微信 ClawBot 项目说明");
|
||||
@@ -8350,7 +8505,10 @@ describe("App", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("项目设置"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: `管理项目 ${weixinProject.name}`,
|
||||
}));
|
||||
dialog = screen.getByRole("dialog", { name: "项目设置" });
|
||||
expect(within(dialog).getByLabelText("微信 ClawBot 项目说明")).toHaveValue(
|
||||
"从消息通道更新",
|
||||
@@ -9222,10 +9380,18 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep my image request' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '外观' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
|
||||
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep my image request');
|
||||
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
|
||||
expect(await screen.findByRole('tab', { name: 'Agent Runtime' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
|
||||
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(api.conversations.imageOperations.regenerate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -10378,7 +10544,10 @@ describe("App", () => {
|
||||
]);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("项目设置"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "管理项目 默认项目",
|
||||
}));
|
||||
let dialog = screen.getByRole("dialog", { name: "项目设置" });
|
||||
expect(within(dialog).getByLabelText("名称")).toHaveValue(project.name);
|
||||
expect(within(dialog).getByLabelText("根目录")).toHaveValue(
|
||||
@@ -10413,7 +10582,10 @@ describe("App", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("项目设置"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", {
|
||||
name: "管理项目 默认项目",
|
||||
}));
|
||||
dialog = screen.getByRole("dialog", { name: "项目设置" });
|
||||
expect(dialog).toHaveTextContent("不会删除磁盘上的项目目录或文件");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "删除项目" }));
|
||||
@@ -11405,10 +11577,9 @@ describe("App", () => {
|
||||
name: "设置中心",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: "设置中心" }),
|
||||
).toBeInTheDocument();
|
||||
screen.getByRole("dialog", { name: "设置中心" }),
|
||||
).toHaveAttribute("aria-modal", "true");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Agent Runtime" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
+47
-31
@@ -171,6 +171,7 @@ import {
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { deriveConversationActivity } from "./conversation-activity";
|
||||
import { useUnviewedCompletions } from "./use-unviewed-completions";
|
||||
import {
|
||||
RightAssistantSidebar,
|
||||
type AssistantSidebarTab,
|
||||
@@ -865,7 +866,7 @@ function ChatHistoryPane({
|
||||
conversationHtmlRenderingEnabled: boolean;
|
||||
conversation: Conversation;
|
||||
locale: TimeFormatLocale;
|
||||
onCopyMessage: (content: string, kind?: 'tool') => Promise<void>;
|
||||
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
|
||||
onDownloadImage: (item: ImageViewerItem) => void;
|
||||
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
|
||||
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
|
||||
@@ -2391,6 +2392,12 @@ function App(): React.JSX.Element {
|
||||
Record<string, Record<string, BrowserLiveState>>
|
||||
>({});
|
||||
const [view, setViewState] = useState<WorkspaceView>("chat");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const settingsOpenRef = useRef(false);
|
||||
const [settingsInitialCategory, setSettingsInitialCategory] =
|
||||
useState<SettingsCategoryId>();
|
||||
const [settingsInitialChannel, setSettingsInitialChannel] =
|
||||
useState<ProjectChannel>();
|
||||
const settingsEntryFocusRef = useRef<HTMLElement | undefined>(undefined);
|
||||
const settingsExitFocusRef = useRef<HTMLElement | undefined>(undefined);
|
||||
const settingsLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(
|
||||
@@ -2407,7 +2414,11 @@ function App(): React.JSX.Element {
|
||||
cachedConversationViewsRef.current = cachedConversationViews;
|
||||
}, [cachedConversationViews]);
|
||||
const commitView = useCallback((next: WorkspaceView): void => {
|
||||
const previous = viewRef.current;
|
||||
const wasSettingsOpen = settingsOpenRef.current;
|
||||
settingsOpenRef.current = false;
|
||||
setSettingsOpen(false);
|
||||
setSettingsInitialCategory(undefined);
|
||||
setSettingsInitialChannel(undefined);
|
||||
const now = Date.now();
|
||||
const runningConversationIds = new Set(
|
||||
[...activeRuns.current.values()].map((run) => run.conversationId),
|
||||
@@ -2444,7 +2455,7 @@ function App(): React.JSX.Element {
|
||||
}),
|
||||
);
|
||||
setViewState(next);
|
||||
if (previous === "settings" && next !== "settings") {
|
||||
if (wasSettingsOpen) {
|
||||
const preferred = settingsExitFocusRef.current;
|
||||
const returnTarget = settingsEntryFocusRef.current;
|
||||
settingsExitFocusRef.current = undefined;
|
||||
@@ -2468,7 +2479,7 @@ function App(): React.JSX.Element {
|
||||
const next =
|
||||
typeof update === "function" ? update(viewRef.current) : update;
|
||||
if (
|
||||
viewRef.current !== "settings" &&
|
||||
!settingsOpenRef.current &&
|
||||
next === "settings" &&
|
||||
!settingsEntryFocusRef.current?.isConnected
|
||||
) {
|
||||
@@ -2480,7 +2491,12 @@ function App(): React.JSX.Element {
|
||||
? activeElement
|
||||
: undefined;
|
||||
}
|
||||
if (viewRef.current === "settings" && next !== "settings") {
|
||||
if (next === "settings") {
|
||||
settingsOpenRef.current = true;
|
||||
setSettingsOpen(true);
|
||||
return;
|
||||
}
|
||||
if (settingsOpenRef.current) {
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (requestLeave) {
|
||||
requestLeave(() => commitView(next));
|
||||
@@ -2536,10 +2552,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
setActiveIdState(next);
|
||||
}, []);
|
||||
const [settingsInitialCategory, setSettingsInitialCategory] =
|
||||
useState<SettingsCategoryId>();
|
||||
const [settingsInitialChannel, setSettingsInitialChannel] =
|
||||
useState<ProjectChannel>();
|
||||
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
|
||||
const [
|
||||
conversationHtmlRenderingEnabled,
|
||||
@@ -2844,13 +2856,13 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const navigateFromSidebar = useCallback(
|
||||
(nextView: WorkspaceView, trigger: HTMLElement): void => {
|
||||
if (viewRef.current !== "settings" && nextView === "settings") {
|
||||
if (!settingsOpenRef.current && nextView === "settings") {
|
||||
settingsEntryFocusRef.current = trigger;
|
||||
} else if (viewRef.current === "settings" && nextView !== "settings") {
|
||||
} else if (settingsOpenRef.current && nextView !== "settings") {
|
||||
settingsExitFocusRef.current = trigger;
|
||||
}
|
||||
setView(nextView);
|
||||
if (narrowWindow) {
|
||||
if (narrowWindow && nextView !== "settings") {
|
||||
closeNarrowSidebar();
|
||||
}
|
||||
},
|
||||
@@ -3925,6 +3937,10 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
[projects, tWorkspace],
|
||||
);
|
||||
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
|
||||
useUnviewedCompletions(assistantTasks,
|
||||
view === "chat" && !settingsOpen && activeConversation && !activeConversation.messageSummary
|
||||
? activeId : undefined);
|
||||
const projectActivity = useMemo(
|
||||
() => deriveConversationActivity(
|
||||
conversations.map((conversation) => ({
|
||||
@@ -3938,14 +3954,15 @@ function App(): React.JSX.Element {
|
||||
name: projectNames.get(project.id) ?? project.name,
|
||||
})),
|
||||
tWorkspace("projectActivity.unassigned"),
|
||||
completedConversationIds,
|
||||
),
|
||||
[conversations, conversationTitles, assistantTasks, activeConversationIds,
|
||||
projects, projectNames, tWorkspace],
|
||||
projects, projectNames, tWorkspace, completedConversationIds],
|
||||
);
|
||||
const activityByConversationId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
projectActivity.activities.map((activity) => [
|
||||
projectActivity.activities.filter((activity) => activity.status !== "completed").map((activity) => [
|
||||
activity.conversationId,
|
||||
activity,
|
||||
]),
|
||||
@@ -5070,6 +5087,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
});
|
||||
activeRuns.current.delete(event.requestId);
|
||||
if (event.type === "done") markConversationCompleted(run.conversationId);
|
||||
setConversationActivity(run.conversationId, false);
|
||||
releaseConversationQueueAfterRun(run);
|
||||
flushConversationPersistenceAfterRenderRef.current = true;
|
||||
@@ -5083,6 +5101,7 @@ function App(): React.JSX.Element {
|
||||
setConversationActivity,
|
||||
updateMessage,
|
||||
updateRequestActivity,
|
||||
markConversationCompleted,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5307,6 +5326,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
if (state !== undefined && state !== "streaming") {
|
||||
activeRuns.current.delete(requestId);
|
||||
if (state === "complete") markConversationCompleted(run.conversationId);
|
||||
setConversationActivity(run.conversationId, false);
|
||||
releaseConversationQueueAfterRun(run);
|
||||
}
|
||||
@@ -5464,6 +5484,7 @@ function App(): React.JSX.Element {
|
||||
releaseConversationQueueAfterRun,
|
||||
retainedConversationDetailIds,
|
||||
setConversationActivity,
|
||||
markConversationCompleted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -6969,18 +6990,20 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const writeClipboardText = useCallback(
|
||||
async (content: string, successMessage: string): Promise<void> => {
|
||||
async (content: string, successMessage?: string): Promise<boolean> => {
|
||||
try {
|
||||
await window.goodbuddy.clipboard.writeText(content);
|
||||
notify({
|
||||
if (successMessage) notify({
|
||||
tone: "success",
|
||||
message: successMessage,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
notify({
|
||||
tone: "error",
|
||||
message: t("notices.clipboardUnavailable"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -7017,8 +7040,8 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const copyMessage = useCallback(
|
||||
(content: string, kind?: 'tool'): Promise<void> =>
|
||||
writeClipboardText(content, t(kind === 'tool' ? "chat.tools.copied" : "notices.messageCopied")),
|
||||
(content: string, kind?: 'tool'): Promise<boolean> =>
|
||||
writeClipboardText(content, kind === 'tool' ? t("chat.tools.copied") : undefined),
|
||||
[t, writeClipboardText],
|
||||
);
|
||||
|
||||
@@ -7433,6 +7456,7 @@ function App(): React.JSX.Element {
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
preparingConversations.current.add(conversationId);
|
||||
clearConversationCompleted(conversationId);
|
||||
setConversationActivity(conversationId, true);
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -8245,7 +8269,7 @@ function App(): React.JSX.Element {
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (viewRef.current === "settings" && requestLeave) {
|
||||
if (settingsOpenRef.current && requestLeave) {
|
||||
requestLeave(() => { void open(); });
|
||||
} else {
|
||||
void open();
|
||||
@@ -11214,9 +11238,7 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{(view === "settings" ||
|
||||
cachedWorkspaceViewKeys.has("settings")) && (
|
||||
<KeepAliveRoute active={view === "settings"} route="settings">
|
||||
{settingsOpen && (
|
||||
<RouteErrorBoundary
|
||||
key="settings"
|
||||
fallback={
|
||||
@@ -11226,9 +11248,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<RouteLoadingStatus label={t("route.loading")} />}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SettingsPanel
|
||||
appearanceTheme={appearanceTheme}
|
||||
brandingFallbackLogo={
|
||||
@@ -11259,9 +11279,7 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
onClearLocalData={clearLocalData}
|
||||
onClose={() => {
|
||||
setSettingsInitialCategory(undefined);
|
||||
setSettingsInitialChannel(undefined);
|
||||
commitView("chat");
|
||||
commitView(viewRef.current);
|
||||
}}
|
||||
onExpertsChanged={(experts) => {
|
||||
setAssistantExperts(experts);
|
||||
@@ -11299,13 +11317,11 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
onShortcutSettingsChanged={handleShortcutSettingsChanged}
|
||||
onUpdateProject={updateProject}
|
||||
open={view === "settings"}
|
||||
presentation="page"
|
||||
open={settingsOpen}
|
||||
projects={projects}
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{(view === "activity" ||
|
||||
cachedWorkspaceViewKeys.has("activity")) && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -38,7 +39,7 @@ vi.mock('./MarkdownRenderer', () => ({
|
||||
|
||||
const callbacks = {
|
||||
onArticleRef: vi.fn(),
|
||||
onCopyMessage: vi.fn(async () => undefined),
|
||||
onCopyMessage: vi.fn(async () => true),
|
||||
onDownloadImage: vi.fn(),
|
||||
onOpenCitationContext: vi.fn(async () => undefined),
|
||||
onOpenCitationSource: vi.fn(async () => undefined),
|
||||
@@ -483,7 +484,7 @@ describe('ChatTimeline', () => {
|
||||
).toHaveTextContent('每日状态')
|
||||
})
|
||||
|
||||
it('copies a completed assistant reply from its bottom action', () => {
|
||||
it('copies a completed assistant reply and resets its success icon three seconds after the latest copy', async () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'assistant-message',
|
||||
@@ -532,12 +533,26 @@ describe('ChatTimeline', () => {
|
||||
screen.getAllByRole('button', { name: '复制此回复' })
|
||||
).toHaveLength(1)
|
||||
|
||||
fireEvent.click(copyButton)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await act(async () => { fireEvent.click(copyButton) })
|
||||
|
||||
expect(callbacks.onCopyMessage).toHaveBeenCalledOnce()
|
||||
expect(callbacks.onCopyMessage).toHaveBeenCalledWith(
|
||||
'## Answer\n\nKeep the Markdown source.'
|
||||
)
|
||||
expect(callbacks.onCopyMessage).toHaveBeenCalledOnce()
|
||||
expect(callbacks.onCopyMessage).toHaveBeenCalledWith(
|
||||
'## Answer\n\nKeep the Markdown source.'
|
||||
)
|
||||
expect(copyButton).toHaveAccessibleName('回复已复制到剪贴板')
|
||||
expect(copyButton.querySelector('.message__copy-success')).not.toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(2000) })
|
||||
await act(async () => { fireEvent.click(copyButton) })
|
||||
await act(async () => { vi.advanceTimersByTime(2999) })
|
||||
expect(copyButton).toHaveAccessibleName('回复已复制到剪贴板')
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(copyButton).toHaveAccessibleName('复制此回复')
|
||||
expect(copyButton.querySelector('.message__copy-success')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows every parallel expert output in its own expandable card', () => {
|
||||
|
||||
@@ -185,7 +185,7 @@ function MessageReasoning({
|
||||
)
|
||||
}
|
||||
|
||||
type CopyContent = (content: string, kind?: 'tool') => Promise<void>
|
||||
type CopyContent = (content: string, kind?: 'tool') => Promise<boolean>
|
||||
|
||||
function ToolDetail({ label, content, formatJson = false, onCopy }: {
|
||||
label: string
|
||||
@@ -551,6 +551,18 @@ function ChatMessageRowView({
|
||||
retryContent
|
||||
}: ChatMessageRowProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
useEffect(() => () => clearTimeout(copyResetTimer.current), [])
|
||||
|
||||
const copyReply = async (): Promise<void> => {
|
||||
const success = await onCopyMessage(greeting ? t('conversation.greeting') : message.content)
|
||||
clearTimeout(copyResetTimer.current)
|
||||
setCopied(success)
|
||||
if (success) {
|
||||
copyResetTimer.current = setTimeout(() => setCopied(false), 3000)
|
||||
}
|
||||
}
|
||||
const imageArtifactIds = useMemo(() => [...new Set([
|
||||
...(message.artifactIds ?? []),
|
||||
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
|
||||
@@ -1220,19 +1232,15 @@ function ChatMessageRowView({
|
||||
Boolean(message.content.trim()) && (
|
||||
<div className="message__actions">
|
||||
<button
|
||||
aria-label={t('chat.copyMessage')}
|
||||
aria-label={t(copied ? 'notices.messageCopied' : 'chat.copyMessage')}
|
||||
className="icon-button"
|
||||
onClick={() =>
|
||||
void onCopyMessage(
|
||||
greeting
|
||||
? t('conversation.greeting')
|
||||
: message.content
|
||||
)
|
||||
}
|
||||
title={t('chat.copyMessage')}
|
||||
onClick={() => void copyReply()}
|
||||
title={t(copied ? 'notices.messageCopied' : 'chat.copyMessage')}
|
||||
type="button"
|
||||
>
|
||||
<Copy aria-hidden="true" size={15} />
|
||||
{copied
|
||||
? <Check aria-hidden="true" className="message__copy-success" size={15} />
|
||||
: <Copy aria-hidden="true" size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
|
||||
import { workspace } from './i18n/locales/en-US/workspace'
|
||||
import { workspace as chineseWorkspace } from './i18n/locales/zh-CN/workspace'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
|
||||
const i18n = createInstance()
|
||||
@@ -75,6 +76,58 @@ describe('ProjectActivity', () => {
|
||||
.toEqual(['Local', 'Remote', 'Same host', 'Other host', 'Channel', 'No project'])
|
||||
})
|
||||
|
||||
it('counts completed separately and orders attention, running, then completed within a project', () => {
|
||||
const completed: ConversationActivity = {
|
||||
conversationId: 'completed', projectId: 'remote', projectName: 'Remote', title: 'Release notes', status: 'completed'
|
||||
}
|
||||
render(view([completed, ...activities]))
|
||||
const trigger = open()
|
||||
expect(trigger).toHaveAccessibleName('All project activity: 3 need attention, 2 running, 1 completed')
|
||||
expect(Array.from(trigger.querySelectorAll('.project-activity__counts > span'), (count) => count.textContent))
|
||||
.toEqual(['3 need attention', '2 running', '1 completed'])
|
||||
const remote = screen.getByRole('menuitem', { name: 'Remote 2 need attention 1 running 1 completed' })
|
||||
fireEvent.click(remote)
|
||||
const rows = within(screen.getByRole('menu', { name: 'Remote' })).getAllByRole('menuitem')
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
'Choose target Waiting for your answer', 'Approve command Waiting for approval', 'Deploy Running', 'Release notes Completed'
|
||||
])
|
||||
expect(rows[3]!.querySelector('.project-activity__completed .lucide-check')).toBeInTheDocument()
|
||||
expect(rows[3]!.querySelector('.project-activity__attention')).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(rows[0]!, { key: 'End' })
|
||||
expect(rows[3]).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ language: 'en', resource: workspace, title: 'All project activity', count: '1 completed', status: 'Completed' },
|
||||
{ language: 'zh', resource: chineseWorkspace, title: '全项目活动', count: '1 个已完成', status: '已完成' }
|
||||
])('keeps completed-only activity navigable in $language and closes before opening the conversation', ({ language, resource, title, count, status }) => {
|
||||
const localized = createInstance()
|
||||
void localized.init({ lng: language, initImmediate: false, resources: { [language]: { workspace: resource } } })
|
||||
const onOpen = vi.fn(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
render(<I18nextProvider i18n={localized}>
|
||||
<ProjectActivity activities={[{
|
||||
conversationId: 'completed-only', projectId: 'remote', projectName: 'Remote', title: 'Release notes', status: 'completed'
|
||||
}]} onOpenConversation={onOpen} />
|
||||
</I18nextProvider>)
|
||||
const trigger = screen.getByRole('button', { name: new RegExp(title) })
|
||||
expect(within(trigger).getByText(count)).toBeInTheDocument()
|
||||
expect(trigger.querySelector('.project-activity__attention')).not.toBeInTheDocument()
|
||||
expect(trigger.querySelector('.project-activity__running')).not.toBeInTheDocument()
|
||||
expect(trigger.querySelector('.project-activity__completed .lucide-check')).toBeInTheDocument()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
const project = screen.getByRole('menuitem', { name: `Remote ${count}` })
|
||||
expect(project).toHaveFocus()
|
||||
fireEvent.keyDown(project, { key: 'ArrowRight' })
|
||||
const conversation = screen.getByRole('menuitem', { name: `Release notes ${status}` })
|
||||
expect(conversation).toHaveFocus()
|
||||
fireEvent.click(conversation)
|
||||
expect(onOpen).toHaveBeenCalledExactlyOnceWith('completed-only')
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('navigates arrows, Home/End, Enter and two-stage Escape before the sidebar listener', () => {
|
||||
render(view())
|
||||
const sidebarEscape = vi.fn()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -13,14 +13,15 @@ export type ProjectActivityProps = {
|
||||
onOpenConversation: (id: string) => void
|
||||
}
|
||||
|
||||
export type ProjectActivityCountsProps = { running: number; attention: number }
|
||||
export type ProjectActivityCountsProps = { running: number; attention: number; completed?: number }
|
||||
|
||||
export function ProjectActivityCounts({
|
||||
running,
|
||||
attention
|
||||
attention,
|
||||
completed = 0
|
||||
}: ProjectActivityCountsProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('workspace')
|
||||
if (!running && !attention) return null
|
||||
if (!running && !attention && !completed) return null
|
||||
return (
|
||||
<span className="project-activity__counts">
|
||||
{attention > 0 && (
|
||||
@@ -36,6 +37,12 @@ export function ProjectActivityCounts({
|
||||
{t('projectActivity.runningCount', { count: running })}
|
||||
</span>
|
||||
)}
|
||||
{completed > 0 && <>{' '}
|
||||
<span className="project-activity__completed">
|
||||
<Check aria-hidden="true" size={13} />
|
||||
{t('projectActivity.completedCount', { count: completed })}
|
||||
</span>
|
||||
</>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +61,7 @@ function ActivityMenu({
|
||||
const sessionsRef = useRef<HTMLDivElement>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const groups = useMemo(() => {
|
||||
const priority = { attention: 0, approval: 0, question: 0, running: 1, completed: 2 }
|
||||
const rows = new Map<string, { id: string; name: string; rows: ConversationActivity[] }>()
|
||||
// Keep source project order within local, SSH Host and channel groups.
|
||||
const ordered = new Map<string, AssistantProject[]>()
|
||||
@@ -77,8 +85,9 @@ function ActivityMenu({
|
||||
}
|
||||
return [...rows.values()].filter((group) => group.rows.length).map((group) => ({
|
||||
...group,
|
||||
rows: group.rows.sort((a, b) => Number(a.status === 'running') - Number(b.status === 'running')),
|
||||
running: group.rows.filter((row) => row.status === 'running').length
|
||||
rows: group.rows.sort((a, b) => priority[a.status] - priority[b.status]),
|
||||
running: group.rows.filter((row) => row.status === 'running').length,
|
||||
completed: group.rows.filter((row) => row.status === 'completed').length
|
||||
}))
|
||||
}, [activities, projects])
|
||||
const active = groups.find((group) => group.id === selected)
|
||||
@@ -202,7 +211,8 @@ function ActivityMenu({
|
||||
}}
|
||||
onClick={() => enter(group.id)}>
|
||||
<span className="project-activity__identity"><span>{group.name}</span>{' '}
|
||||
<ProjectActivityCounts running={group.running} attention={group.rows.length - group.running} />
|
||||
<ProjectActivityCounts running={group.running} completed={group.completed}
|
||||
attention={group.rows.length - group.running - group.completed} />
|
||||
</span><ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
))}
|
||||
@@ -219,7 +229,8 @@ function ActivityMenu({
|
||||
onOpenConversation(activity.conversationId)
|
||||
}}>
|
||||
<span className="project-activity__identity"><span>{activity.title}</span>{' '}
|
||||
<small className={`project-activity__${activity.status === 'running' ? 'running' : 'attention'}`}>
|
||||
<small className={`project-activity__${activity.status === 'completed' ? 'completed' : activity.status === 'running' ? 'running' : 'attention'}`}>
|
||||
{activity.status === 'completed' && <Check aria-hidden="true" size={13} />}
|
||||
{t(`projectActivity.status.${activity.status}`)}
|
||||
</small>
|
||||
</span>
|
||||
@@ -242,6 +253,8 @@ export function ProjectActivity({
|
||||
const id = useId()
|
||||
if ((!visible || activities.length === 0) && open) setOpen(false)
|
||||
const running = useMemo(() => activities.filter((activity) => activity.status === 'running').length, [activities])
|
||||
const completed = useMemo(() => activities.filter((activity) => activity.status === 'completed').length, [activities])
|
||||
const attention = activities.length - running - completed
|
||||
if (activities.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
@@ -249,7 +262,7 @@ export function ProjectActivity({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={open ? id : undefined}
|
||||
aria-label={`${t('projectActivity.title')}: ${[t('projectActivity.attentionCount', { count: activities.length - running }), t('projectActivity.runningCount', { count: running })].join(', ')}`}
|
||||
aria-label={`${t('projectActivity.title')}: ${[t('projectActivity.attentionCount', { count: attention }), t('projectActivity.runningCount', { count: running }), ...(completed > 0 ? [t('projectActivity.completedCount', { count: completed })] : [])].join(', ')}`}
|
||||
ref={triggerRef}
|
||||
className="project-activity__summary"
|
||||
onClick={(event) => {
|
||||
@@ -264,7 +277,7 @@ export function ProjectActivity({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ProjectActivityCounts attention={activities.length - running} running={running} />
|
||||
<ProjectActivityCounts attention={attention} running={running} completed={completed} />
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{open && visible && (
|
||||
|
||||
@@ -356,8 +356,15 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
describe('ProjectSwitcher runtime fields', () => {
|
||||
it.each(['新建项目', '项目设置'])('keeps %s outside the sidebar and restores focus after closing', (name) => {
|
||||
renderSwitcher()
|
||||
const trigger = screen.getByLabelText(name)
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: name === '项目设置' ? '当前项目' : name
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
if (name === '项目设置') {
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Local project'
|
||||
}))
|
||||
}
|
||||
const dialog = screen.getByRole('dialog', { name })
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
const body = dialog.querySelector('.project-create-card__body')!
|
||||
@@ -411,7 +418,10 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
|
||||
const { onUpdate } = renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('项目设置'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Local project'
|
||||
}))
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
fireEvent.change(
|
||||
within(dialog).getByLabelText('新对话默认 Runtime'),
|
||||
@@ -893,9 +903,14 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
}
|
||||
renderSwitcher(remoteProject, { remoteProjectsEnabled: false })
|
||||
|
||||
const settings = screen.getByLabelText('项目设置')
|
||||
expect(settings).toBeDisabled()
|
||||
fireEvent.click(settings)
|
||||
expect(screen.queryByRole('button', {
|
||||
name: '项目设置'
|
||||
})).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
expect(within(screen.getByRole('menu', { name: '当前项目' }))
|
||||
.queryByRole('menuitem', {
|
||||
name: '管理项目 Remote project'
|
||||
})).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '项目设置' })
|
||||
).not.toBeInTheDocument()
|
||||
@@ -1401,7 +1416,10 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
runtimeSelection: { provider: 'opencode' }
|
||||
}
|
||||
const { onUpdate } = renderSwitcher(remoteProject)
|
||||
fireEvent.click(screen.getByLabelText('项目设置'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Remote project'
|
||||
}))
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -1479,7 +1497,10 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
runtimeSelection: { provider: 'opencode' }
|
||||
}
|
||||
renderSwitcher(remoteProject)
|
||||
fireEvent.click(screen.getByLabelText('项目设置'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Remote project'
|
||||
}))
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
|
||||
expect(
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivitySummary } from './conversation-activity'
|
||||
import type { ProjectActivityCountsProps } from './ProjectActivity'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
@@ -60,7 +60,7 @@ import { displayErrorMessage } from './error-message'
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
activityByProjectId?: ConversationActivitySummary['byProjectId']
|
||||
activityByProjectId?: Record<string, ProjectActivityCountsProps>
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
@@ -272,7 +272,6 @@ export function ProjectSwitcher({
|
||||
const [remoteDirectoryListing, setRemoteDirectoryListing] =
|
||||
useState<SshDirectoryBrowseResult>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const settingsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerRef = useRef<HTMLDivElement>(null)
|
||||
const directoryBrowseRequestRef = useRef(0)
|
||||
@@ -281,7 +280,7 @@ export function ProjectSwitcher({
|
||||
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'settings' | 'picker' | undefined
|
||||
'create' | 'picker' | undefined
|
||||
>(undefined)
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
||||
@@ -788,8 +787,6 @@ export function ProjectSwitcher({
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
createButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'settings') {
|
||||
settingsButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'picker') {
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
@@ -852,10 +849,7 @@ export function ProjectSwitcher({
|
||||
setSettingsProjectId(undefined)
|
||||
}
|
||||
|
||||
const openProjectSettings = (
|
||||
project: AssistantProject,
|
||||
restoreTarget: 'settings' | 'picker'
|
||||
): void => {
|
||||
const openProjectSettings = (project: AssistantProject): void => {
|
||||
if (
|
||||
!remoteProjectsEnabled &&
|
||||
project.executionSpace.kind === 'ssh'
|
||||
@@ -894,7 +888,7 @@ export function ProjectSwitcher({
|
||||
? channelProjectDraft(nextDraft, runtimeSettings)
|
||||
: nextDraft
|
||||
)
|
||||
restoreFocusTarget.current = restoreTarget
|
||||
restoreFocusTarget.current = 'picker'
|
||||
setDialogMode('settings')
|
||||
}
|
||||
|
||||
@@ -1151,7 +1145,7 @@ export function ProjectSwitcher({
|
||||
{ name: projectDisplay.name }
|
||||
)}
|
||||
className="project-switcher__menu-settings"
|
||||
onClick={() => openProjectSettings(project, 'picker')}
|
||||
onClick={() => openProjectSettings(project)}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
@@ -1367,25 +1361,6 @@ export function ProjectSwitcher({
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('projectSwitcher.selector.settings')}
|
||||
className="icon-button"
|
||||
disabled={
|
||||
!activeProject ||
|
||||
(!remoteProjectsEnabled &&
|
||||
activeProject.executionSpace.kind === 'ssh')
|
||||
}
|
||||
onClick={() => {
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
openProjectSettings(activeProject, 'settings')
|
||||
}}
|
||||
ref={settingsButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{dialogMode && createPortal(
|
||||
<div
|
||||
|
||||
@@ -5229,6 +5229,43 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(await screen.findByRole('button', { name: '添加自定义' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('traps modal focus and protects dirty drafts on Escape', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={onClose}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const workspace = await screen.findByLabelText('默认工作区目录')
|
||||
const dialog = screen.getByRole('dialog', { name: '设置中心' })
|
||||
const closeButton = screen.getByRole('button', { name: '关闭设置' })
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const last = focusable[focusable.length - 1]!
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(closeButton).toHaveFocus()
|
||||
fireEvent.change(workspace, { target: { value: 'C:\\Escape draft' } })
|
||||
workspace.focus()
|
||||
fireEvent.keyDown(workspace, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: '继续编辑' }), { key: 'Escape' })
|
||||
await waitFor(() => expect(workspace).toHaveFocus())
|
||||
expect(workspace).toHaveValue('C:\\Escape draft')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(workspace, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃更改并关闭' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains a disabled or deleted image default and keeps drafts on save failure', async () => {
|
||||
const imageId = '00000000-0000-4000-8000-000000000099'
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type {
|
||||
AssistantExpert,
|
||||
AssistantProject,
|
||||
@@ -821,6 +823,8 @@ export function SettingsPanel({
|
||||
const runtimeCustomizationRef =
|
||||
useRef<RuntimeCustomizationSectionHandle>(null)
|
||||
const keepEditingRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const leaveTriggerRef = useRef<HTMLElement | undefined>(undefined)
|
||||
const handleRuntimeCustomizationDirtyChange = useCallback(
|
||||
(dirty: boolean): void => {
|
||||
@@ -1241,6 +1245,7 @@ export function SettingsPanel({
|
||||
}
|
||||
|
||||
const close = (trigger?: HTMLElement): void => {
|
||||
if (saving || testing || clearingLocalData) return
|
||||
if (hasUnsavedDrafts) {
|
||||
leaveTriggerRef.current =
|
||||
trigger ??
|
||||
@@ -1349,6 +1354,13 @@ export function SettingsPanel({
|
||||
keepEditingRef.current?.focus()
|
||||
}, [pendingLeave])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && presentation === 'modal') {
|
||||
return activateModalFocus(() => closeRef.current)
|
||||
}
|
||||
return undefined
|
||||
}, [open, presentation])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const requestLeave: SettingsLeaveRequester = (proceed) => {
|
||||
if (hasUnsavedDrafts) {
|
||||
@@ -2007,7 +2019,7 @@ export function SettingsPanel({
|
||||
? settings?.defaultModelProfileId
|
||||
: undefined
|
||||
|
||||
return (
|
||||
const panel = (
|
||||
<div
|
||||
className={
|
||||
presentation === 'page'
|
||||
@@ -2020,6 +2032,21 @@ export function SettingsPanel({
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal={presentation === 'modal' ? 'true' : undefined}
|
||||
className="settings-panel"
|
||||
ref={dialogRef}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
presentation !== 'modal' ||
|
||||
event.defaultPrevented ||
|
||||
(event.target as HTMLElement).closest('[role="dialog"]') !== dialogRef.current
|
||||
) return
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (pendingLeave) keepEditing()
|
||||
else close()
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
role={presentation === 'modal' ? 'dialog' : 'region'}
|
||||
>
|
||||
<div className="settings-panel__header">
|
||||
@@ -2029,6 +2056,8 @@ export function SettingsPanel({
|
||||
<button
|
||||
aria-label={t('center.close')}
|
||||
className="icon-button"
|
||||
disabled={saving || testing || clearingLocalData}
|
||||
ref={closeRef}
|
||||
onClick={(event) => close(event.currentTarget)}
|
||||
type="button"
|
||||
>
|
||||
@@ -4160,4 +4189,5 @@ export function SettingsPanel({
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
return presentation === 'modal' ? createPortal(panel, document.body) : panel
|
||||
}
|
||||
|
||||
@@ -547,22 +547,22 @@ describe('WorkspacePrimitives', () => {
|
||||
/\.settings-page \.settings-panel__header \.page-header\s*\{[^}]*padding-bottom:\s*var\(--space-4\);[^}]*border-bottom:\s*1px solid var\(--border-subtle\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-page \.settings-panel__body\s*\{[^}]*padding:\s*var\(--space-6\) var\(--page-gutter\) var\(--page-gutter\);[^}]*grid-template-columns:\s*220px minmax\(0,\s*1fr\);/u
|
||||
/\.settings-backdrop \.settings-panel__body\s*\{[^}]*padding:\s*var\(--space-6\) var\(--page-gutter\) var\(--page-gutter\);[^}]*grid-template-columns:\s*220px minmax\(0,\s*1fr\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/@media \(max-width: 1020px\)\s*\{[\s\S]*?\.settings-page \.settings-panel__body\s*\{[^}]*grid-template-columns:\s*196px minmax\(0,\s*1fr\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-page \.settings-tabs\s*\{[^}]*scrollbar-gutter:\s*auto;/u
|
||||
/\.settings-backdrop \.settings-tabs\s*\{[^}]*scrollbar-gutter:\s*auto;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-page \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
|
||||
/\.settings-backdrop \.settings-panel__content\s*\{[^}]*width:\s*min\(100%,\s*var\(--content-standard\)\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-page \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
|
||||
/\.settings-backdrop \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-page \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
/\.settings-backdrop \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ describe('deriveConversationActivity', () => {
|
||||
})),
|
||||
{ title: 'No conversation', status: 'running' }
|
||||
], new Set(), projects, 'Unknown')
|
||||
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0 })
|
||||
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0, completed: 0 })
|
||||
})
|
||||
|
||||
it('retains only unviewed completions and gives live work precedence', () => {
|
||||
const summary = deriveConversationActivity([
|
||||
{ id: 'done', title: 'Done', projectId: 'local', messages: [] },
|
||||
{ id: 'busy', title: 'Busy', projectId: 'local', messages: [] }
|
||||
], [{ conversationId: 'unloaded', projectId: 'remote', title: 'Remote done', status: 'completed' }],
|
||||
new Set(['busy']), projects, 'Unknown', new Set(['done', 'busy', 'unloaded']))
|
||||
expect(summary).toMatchObject({ running: 1, attention: 0, completed: 2,
|
||||
byProjectId: { local: { running: 1, completed: 1 }, remote: { completed: 1 } } })
|
||||
expect(summary.activities.map(row => row.status)).toEqual(['completed', 'running', 'completed'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,15 @@ export type ConversationActivity = {
|
||||
projectId?: string
|
||||
title: string
|
||||
projectName: string
|
||||
status: 'running' | 'approval' | 'question' | 'attention'
|
||||
status: 'running' | 'approval' | 'question' | 'attention' | 'completed'
|
||||
}
|
||||
|
||||
export type ConversationActivitySummary = {
|
||||
activities: ConversationActivity[]
|
||||
byProjectId: Record<string, { running: number; attention: number }>
|
||||
byProjectId: Record<string, { running: number; attention: number; completed: number }>
|
||||
running: number
|
||||
attention: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
export function deriveConversationActivity(
|
||||
@@ -28,16 +29,18 @@ export function deriveConversationActivity(
|
||||
}[],
|
||||
activeConversationIds: ReadonlySet<string>,
|
||||
projects: readonly { id: string; name: string }[],
|
||||
fallbackProjectName: string
|
||||
fallbackProjectName: string,
|
||||
completedConversationIds: ReadonlySet<string> = new Set()
|
||||
): ConversationActivitySummary {
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
|
||||
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
|
||||
const rows = new Map<string, ConversationActivity>()
|
||||
const priority = { running: 0, attention: 1, approval: 2, question: 3 }
|
||||
const priority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
|
||||
|
||||
for (const conversation of conversations) {
|
||||
let status: ConversationActivity['status'] | undefined =
|
||||
activeConversationIds.has(conversation.id) ? 'running' : undefined
|
||||
activeConversationIds.has(conversation.id) ? 'running'
|
||||
: completedConversationIds.has(conversation.id) ? 'completed' : undefined
|
||||
for (const message of conversation.messages) {
|
||||
const candidate = message.pendingQuestions?.length
|
||||
? 'question'
|
||||
@@ -62,11 +65,13 @@ export function deriveConversationActivity(
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval')) {
|
||||
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval'
|
||||
&& !completedConversationIds.has(task.conversationId))) {
|
||||
continue
|
||||
}
|
||||
// Tasks use waiting_approval for both approvals and questions.
|
||||
const status = task.status === 'waiting_approval' ? 'attention' : 'running'
|
||||
const status = task.status === 'waiting_approval' ? 'attention'
|
||||
: task.status === 'running' ? 'running' : 'completed'
|
||||
const existing = rows.get(task.conversationId)
|
||||
if (existing) {
|
||||
if (priority[status] > priority[existing.status]) existing.status = status
|
||||
@@ -87,14 +92,15 @@ export function deriveConversationActivity(
|
||||
activities: [...rows.values()],
|
||||
byProjectId: {},
|
||||
running: 0,
|
||||
attention: 0
|
||||
attention: 0,
|
||||
completed: 0
|
||||
}
|
||||
const counts = new Map<string, { running: number; attention: number }>()
|
||||
const counts = new Map<string, { running: number; attention: number; completed: number }>()
|
||||
for (const activity of summary.activities) {
|
||||
const category = activity.status === 'running' ? 'running' : 'attention'
|
||||
const category = activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'
|
||||
summary[category] += 1
|
||||
if (activity.projectId !== undefined) {
|
||||
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0 }
|
||||
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0, completed: 0 }
|
||||
project[category] += 1
|
||||
counts.set(activity.projectId, project)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ export const workspace = {
|
||||
projectActivity: {
|
||||
title: 'All project activity',
|
||||
idle: 'No activity',
|
||||
empty: 'No running conversations or conversations needing attention.',
|
||||
empty: 'No conversation activity.',
|
||||
back: 'Back to projects',
|
||||
close: 'Close project activity',
|
||||
unassigned: 'No project',
|
||||
attentionCount: '{{count}} need attention',
|
||||
runningCount: '{{count}} running',
|
||||
completedCount: '{{count}} completed',
|
||||
groups: { attention: 'Needs attention', running: 'Running' },
|
||||
status: {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
approval: 'Waiting for approval',
|
||||
question: 'Waiting for your answer',
|
||||
attention: 'Needs attention'
|
||||
|
||||
@@ -2,15 +2,17 @@ export const workspace = {
|
||||
projectActivity: {
|
||||
title: '全项目活动',
|
||||
idle: '暂无活动',
|
||||
empty: '暂无运行中或待处理会话。',
|
||||
empty: '暂无会话活动。',
|
||||
back: '返回项目列表',
|
||||
close: '关闭全项目活动',
|
||||
unassigned: '未归属项目',
|
||||
attentionCount: '{{count}} 个待处理',
|
||||
runningCount: '{{count}} 个运行中',
|
||||
completedCount: '{{count}} 个已完成',
|
||||
groups: { attention: '待处理', running: '运行中' },
|
||||
status: {
|
||||
running: '运行中',
|
||||
completed: '已完成',
|
||||
approval: '等待审批',
|
||||
question: '等待你的回答',
|
||||
attention: '待处理'
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
|
||||
.project-activity__attention { color: var(--warning); }
|
||||
.project-activity__running { color: var(--accent); }
|
||||
.project-activity__completed { color: var(--success); }
|
||||
|
||||
.project-activity__completed > svg {
|
||||
flex: 0 0 auto;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.project-switcher__project-heading {
|
||||
display: grid;
|
||||
@@ -129,6 +135,9 @@
|
||||
}
|
||||
|
||||
.project-activity__identity small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
|
||||
+63
-14
@@ -2558,7 +2558,7 @@ textarea:focus-visible {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.project-switcher__picker {
|
||||
@@ -5711,6 +5711,10 @@ button > svg {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message__copy-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.message__actions {
|
||||
display: flex;
|
||||
margin-top: var(--space-2);
|
||||
@@ -7861,7 +7865,7 @@ button > svg {
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
background: var(--overlay-backdrop);
|
||||
inset: 0;
|
||||
@@ -7877,10 +7881,11 @@ button > svg {
|
||||
|
||||
.settings-panel {
|
||||
display: grid;
|
||||
width: min(820px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 48px);
|
||||
width: min(1200px, calc(100vw - 2 * var(--space-6)));
|
||||
height: min(900px, calc(100vh - 2 * var(--space-6)));
|
||||
max-height: calc(100vh - 2 * var(--space-6));
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
@@ -7900,7 +7905,7 @@ button > svg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22px 24px 18px;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@@ -7918,6 +7923,11 @@ button > svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-backdrop .settings-panel__header .page-header {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-page .settings-panel__header .page-header {
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
@@ -8002,7 +8012,8 @@ button > svg {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.settings-page .settings-panel__body {
|
||||
.settings-page .settings-panel__body,
|
||||
.settings-backdrop .settings-panel__body {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
padding: var(--space-6) var(--page-gutter) var(--page-gutter);
|
||||
@@ -8010,7 +8021,7 @@ button > svg {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
justify-content: start;
|
||||
gap: 28px;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.platform-feature-option {
|
||||
@@ -8210,7 +8221,8 @@ button > svg {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
.settings-page .settings-tabs,
|
||||
.settings-backdrop .settings-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
@@ -8222,7 +8234,8 @@ button > svg {
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.settings-page .settings-panel__content {
|
||||
.settings-page .settings-panel__content,
|
||||
.settings-backdrop .settings-panel__content {
|
||||
width: min(100%, var(--content-standard));
|
||||
min-height: 0;
|
||||
padding-right: var(--space-1);
|
||||
@@ -8278,7 +8291,9 @@ button > svg {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs button {
|
||||
.settings-page .settings-tabs button,
|
||||
.settings-backdrop .settings-tabs button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 58px;
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
@@ -8286,19 +8301,22 @@ button > svg {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs button strong {
|
||||
.settings-page .settings-tabs button strong,
|
||||
.settings-backdrop .settings-tabs button strong {
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs button small {
|
||||
.settings-page .settings-tabs button small,
|
||||
.settings-backdrop .settings-tabs button small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs button[aria-selected='true'] {
|
||||
.settings-page .settings-tabs button[aria-selected='true'],
|
||||
.settings-backdrop .settings-tabs button[aria-selected='true'] {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -16342,7 +16360,20 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1020px), (max-height: 720px) {
|
||||
.settings-backdrop .settings-panel {
|
||||
width: calc(100vw - 2 * var(--space-4));
|
||||
height: calc(100vh - 2 * var(--space-4));
|
||||
max-height: calc(100vh - 2 * var(--space-4));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.settings-backdrop .settings-panel__body {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.sidebar {
|
||||
flex-basis: var(--primary-sidebar-width, 236px);
|
||||
}
|
||||
@@ -16422,6 +16453,24 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-backdrop .settings-panel__body {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-backdrop .settings-tabs button {
|
||||
min-height: 40px;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-backdrop .settings-tabs button small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-backdrop .settings-panel__header {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.release-notes-backdrop {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useUnviewedCompletions } from './use-unviewed-completions'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
describe('useUnviewedCompletions', () => {
|
||||
const task = { id: 'task', conversationId: 'chat', status: 'running' }
|
||||
|
||||
it('baselines history, ignores child work, and does not restore acknowledged completions on refresh', () => {
|
||||
const history = { ...task, status: 'completed' }
|
||||
const initialProps: {
|
||||
tasks: (typeof task & { parentTaskId?: string })[]; viewed?: string
|
||||
} = { tasks: [history] }
|
||||
const { result, rerender } = renderHook(({ tasks, viewed }) =>
|
||||
useUnviewedCompletions(tasks, viewed), { initialProps })
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
rerender({ tasks: [task, { ...task, id: 'child', parentTaskId: 'task' }] })
|
||||
rerender({ tasks: [task, { ...history, id: 'child', parentTaskId: 'task' }] })
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
rerender({ tasks: [history] })
|
||||
expect(result.current.completedConversationIds.has('chat')).toBe(true)
|
||||
rerender({ tasks: [history], viewed: 'chat' })
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
rerender({ tasks: [{ ...history }] })
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
rerender({ tasks: [task] })
|
||||
rerender({ tasks: [history] })
|
||||
expect(result.current.completedConversationIds.has('chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('retains completion in a hidden window until the selected chat becomes visible', () => {
|
||||
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
|
||||
const tasks: typeof task[] = []
|
||||
const { result } = renderHook(() => useUnviewedCompletions(tasks, 'chat'))
|
||||
act(() => result.current.markConversationCompleted('chat'))
|
||||
expect(result.current.completedConversationIds.has('chat')).toBe(true)
|
||||
visibility.mockReturnValue('visible')
|
||||
act(() => document.dispatchEvent(new Event('visibilitychange')))
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('clears an old completion before a new local run and ignores failed task transitions', () => {
|
||||
const { result, rerender } = renderHook(({ tasks }) => useUnviewedCompletions(tasks), {
|
||||
initialProps: { tasks: [task] }
|
||||
})
|
||||
act(() => result.current.markConversationCompleted('chat'))
|
||||
act(() => result.current.clearConversationCompleted('chat'))
|
||||
rerender({ tasks: [{ ...task, status: 'failed' }] })
|
||||
expect(result.current.completedConversationIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type Task = { id: string; conversationId?: string; parentTaskId?: string; status: string }
|
||||
|
||||
// Session-only notifications: initial task history is a baseline, not unread work.
|
||||
export function useUnviewedCompletions(tasks: readonly Task[], viewedConversationId?: string): {
|
||||
completedConversationIds: ReadonlySet<string>
|
||||
markConversationCompleted: (id: string) => void
|
||||
clearConversationCompleted: (id: string) => void
|
||||
} {
|
||||
const [completed, setCompleted] = useState<Set<string>>(() => new Set())
|
||||
const [previousTasks, setPreviousTasks] = useState(tasks)
|
||||
const [visible, setVisible] = useState(() => document.visibilityState !== 'hidden')
|
||||
useEffect(() => {
|
||||
const update = (): void => setVisible(document.visibilityState !== 'hidden')
|
||||
document.addEventListener('visibilitychange', update)
|
||||
return () => document.removeEventListener('visibilitychange', update)
|
||||
}, [])
|
||||
let next = completed
|
||||
if (previousTasks !== tasks) {
|
||||
const previous = new Map(previousTasks.map(task => [task.id, task.status]))
|
||||
next = new Set(completed)
|
||||
for (const task of tasks) {
|
||||
if (!task.conversationId || task.parentTaskId || previous.get(task.id) === task.status) continue
|
||||
if (task.status === 'completed' && previous.has(task.id)) next.add(task.conversationId)
|
||||
else if (task.status === 'running' || task.status === 'waiting_approval') next.delete(task.conversationId)
|
||||
}
|
||||
setPreviousTasks(tasks)
|
||||
}
|
||||
if (visible && viewedConversationId && next.has(viewedConversationId)) {
|
||||
next = new Set(next)
|
||||
next.delete(viewedConversationId)
|
||||
}
|
||||
if (next !== completed) setCompleted(next)
|
||||
const markConversationCompleted = useCallback((id: string) => {
|
||||
setCompleted(current => current.has(id) ? current : new Set(current).add(id))
|
||||
}, [])
|
||||
const clearConversationCompleted = useCallback((id: string) => {
|
||||
setCompleted(current => {
|
||||
if (!current.has(id)) return current
|
||||
const remaining = new Set(current)
|
||||
remaining.delete(id)
|
||||
return remaining
|
||||
})
|
||||
}, [])
|
||||
return { completedConversationIds: next, markConversationCompleted, clearConversationCompleted }
|
||||
}
|
||||
Reference in New Issue
Block a user