feat: add stable scheduled tasks

Scheduled tasks previously created separate visible work for each run and lacked one conversation-backed product identity. Custom tasks now create or reuse one stable Task and Conversation, reuse that identity across triggers, and write text results back with Task provenance.

Tasks default to Execute while preserving the configured Runtime, tool authorization, and high-risk approval boundaries. Schema v22 backfills existing schedules to stable Task and Conversation links without deleting historical runs.

The conversation list, conversation Task strip, and Task Center now expose the same Task, with localized status metadata, overflow-aware titles, and shared schedule controls.

Release note: 现在可以创建关联当前或新会话的定制计划任务;重复执行会复用同一 Task 并将文本结果回写会话,左侧会话列表和 Task Center 可直接查看和管理。
This commit is contained in:
mesalogo
2026-08-19 15:37:01 +08:00
parent 43e1d162dc
commit 993c439228
38 changed files with 5339 additions and 773 deletions
+3 -3
View File
@@ -33,8 +33,8 @@
- [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。
- [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。
- [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。
- [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Subagent 控制、后台 Job、Workflow/Hook、长任务与原生会话监督;当前会话只提供默认目标,用户可以改为固定其他 Run,Composer 只保留对当前消息生效的高频上下文选择 - [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 TaskJob/Run 保持内部,不形成树或独立操作对象
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在助手工作栏固定的 Runtime 栏目显示父子状态、取消入口和审计归属。 - [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在助手工作栏固定的 Runtime 栏目按 Task 聚合状态、取消入口和审计归属。
### Skills、MCP 与知识库 ### Skills、MCP 与知识库
@@ -57,7 +57,7 @@
### 工作管理、长期协作与工作流 ### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [ ] **Task Center 完善**(规划中):保留现有 Task Center,不先建设独立 Automation CenterTask 与唯一 Conversation 一对一,Task Center 只索引 Task,并补齐范围、状态、最近进展、需要关注和直接打开。普通 Conversation、Job、Run、工具步骤与心跳事项不作为顶层 Task。详见 [Task Center PRD](./docs/prd/task-and-job/task-center-prd.md)。 - [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带任务图标的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/prd/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/prd/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/prd/smart-heartbeat/smart-heartbeat-prd.md)。 - [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/prd/smart-heartbeat/smart-heartbeat-prd.md)。
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/prd/supervision/conversation-supervision-prd.md)。 - [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/prd/supervision/conversation-supervision-prd.md)。
+26 -6
View File
@@ -282,6 +282,15 @@
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle` - 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量;
展开后的每个 Task 子项使用任务图标标识身份,展开层级只到 Task,不展示 Job、Subjob 或 Run。
- 会话标题保持单行且不挤压时间或操作按钮。标题实际溢出时,悬停会话行才在标题的固定裁切
区域内平滑横向滑动以展示完整名称;移开后快速复位。未溢出标题不产生位移动效,
`prefers-reduced-motion` 下禁用滑动并保留完整标题提示。
- 新建 Task 后可以首次自动展开所属 Conversation;用户手动折叠后保持其选择,后台状态变化
只更新状态提示,不强制展开或抢占焦点。
- Task 子项的任务图标保持稳定,运行中、等待审批、失败和暂停同时使用本地化状态文字,
不能只靠图标颜色表达状态。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。 - 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 助手工作栏 ### 6.9 助手工作栏
@@ -292,7 +301,8 @@
- 默认固定提供 Task Center、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文九个标准栏目。 - 默认固定提供 Task Center、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文九个标准栏目。
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
与唯一 Conversation 一对一绑定,列表不得复制会话内容,也不得把 Job/Run 单独提升为 Task。 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
也不得把 Job/Run 提升为可导航 UI 对象。
- 其他可绑定目标的栏目独立支持“跟随当前上下文”和“固定到指定对象”。当前会话、项目和 Runtime 只提供默认目标,不能成为进入栏目或切换目标的前提。 - 其他可绑定目标的栏目独立支持“跟随当前上下文”和“固定到指定对象”。当前会话、项目和 Runtime 只提供默认目标,不能成为进入栏目或切换目标的前提。
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
@@ -491,7 +501,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
- 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 - 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
- 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。 - 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。
- OpenCode、Continue 和 DeepSeek Harness 后续的 Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。 - OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。 - 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
- Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。 - Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。
- Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。 - Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
@@ -509,6 +519,10 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 搜索、范围和时间筛选位于筛选工具栏。 - 搜索、范围和时间筛选位于筛选工具栏。
- 行项目统一显示标题、范围、最近更新时间和必要状态。 - 行项目统一显示标题、范围、最近更新时间和必要状态。
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。 - 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
- 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后
最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。
- Task 子项以任务图标开头,并显示名称、本地化的 Ask/Execute、计划摘要和聚合状态。点击
子项打开同一 Conversation 并定位 Task,不继续展开 Job/Run。
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。 - 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
### 13.3 知识库 ### 13.3 知识库
@@ -534,10 +548,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。 - 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。
- 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。 - 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。
- 每项显示名称、Global 或 Project 范围、状态、最近进展、最近真实活动时间及需要关注信息。 - 每项显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
- 点击列表项直接打开 Task Conversation,不显示第二份内容载体 最近真实活动时间及需要关注信息
- 点击列表项打开关联 Conversation 并定位该 Task,不显示第二份内容载体。
- 需要关注、进行中、已暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。 - 需要关注、进行中、已暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。
- 完整消息、长错误、活动和成果留在 Task Conversation、Runtime、活动记录和成果查看器中,不撑高列表。 - 完整消息留在 Conversation;长错误、工具、审批和成果按 Task 关联到 Runtime、活动记录和
成果查看器,不撑高列表,也不显示 Job/Run 树。
- “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前
ConversationTask Center 入口默认新 Conversation;选择必须持续可见。
- 创建 Modal 默认 Execute,并允许主动切换 Ask。Execute 持续显示实际 Runtime、Project、
工作目录、工具和审批摘要;不支持工具时不得静默降级。
### 13.6 运行记录 ### 13.6 运行记录
@@ -644,7 +664,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。 - [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
- [x] 智能心跳使用 `dashboard`,保留概览、建议、轨迹和计划,并在计划中支持 Global / 多 Project 范围。 - [x] 智能心跳使用 `dashboard`,保留概览、建议、轨迹和计划,并在计划中支持 Global / 多 Project 范围。
- [ ] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。 - [x] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。
- [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 - [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。 - [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。
- [ ] 文档解析设置统一聊天附件与知识库的解析预设、OCR 状态、转换状态、隐私限制和真实文件测试。 - [ ] 文档解析设置统一聊天附件与知识库的解析预设、OCR 状态、转换状态、隐私限制和真实文件测试。
@@ -15,8 +15,9 @@
GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分离的功能: GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分离的功能:
1. Scheduled Task 支持单次、每日和每周触发。一个 Task 绑定一条持续 Conversation 1. 当前 Schedule 支持单次、每日和每周触发,并在创建时绑定稳定产品级 Task 与真实
每次触发在其中创建 Job/Run 并保存进展和成果。 Conversation;重复触发复用同一身份,文本结果写回 Conversation,独立文件和图片保存为
Artifact。IANA 时区、Cron、事件触发、租约、重试和完整 Job/Run 抽象仍待实现。
2. 当前智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆, 2. 当前智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆,
生成摘要、记忆建议和后续任务。 生成摘要、记忆建议和后续任务。
3. 专家执行的 Job 支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。 3. 专家执行的 Job 支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。
@@ -92,8 +93,9 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
### 3.1 用户目标 ### 3.1 用户目标
- 在现有 Task Center 中找到 Scheduled、Event 和 Goal Task,并直接打开 Task Conversation - 在现有 Task Center 中找到 Scheduled、Event 和 Goal Task,并直接打开关联 Conversation
- 清楚知道 Task 的触发原因、当前目标、Job 状态、预算和停止条件 和对应 Task
- 清楚知道 Task 的触发原因、当前目标、聚合执行状态、预算和停止条件。
- 在一个工作台中观察多个候选运行,并追溯结论到原始证据。 - 在一个工作台中观察多个候选运行,并追溯结论到原始证据。
- 为重要会话启用独立监督,及时发现偏题、遗漏、矛盾、证据不足和风险。 - 为重要会话启用独立监督,及时发现偏题、遗漏、矛盾、证据不足和风险。
- 知道每条记忆属于哪个范围、从哪里产生、何时有效以及被哪些运行使用。 - 知道每条记忆属于哪个范围、从哪里产生、何时有效以及被哪些运行使用。
@@ -102,7 +104,8 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
### 3.2 产品目标 ### 3.2 产品目标
- 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。 - 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。
- 保持个 Task 只绑定一条 Conversation不为同一项工作建立第二份内容载体。 - 保持个 Task 只关联一条 Conversation同时允许一条 Conversation 承载多个 Task,不为
同一项工作建立第二份内容载体。
- 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。 - 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。
- 保持 Ask 只读,Execute 继续经过现有能力和审批控制。 - 保持 Ask 只读,Execute 继续经过现有能力和审批控制。
- 保持本地优先,应用退出后不虚假承诺后台持续执行。 - 保持本地优先,应用退出后不虚假承诺后台持续执行。
@@ -127,34 +130,36 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
以下模型是 Scheduled Task、Goal Task 和实验共享的技术基础,不要求新增独立 以下模型是 Scheduled Task、Goal Task 和实验共享的技术基础,不要求新增独立
Automation Center。`AutomationPlan` 是 Task 的计划配置,`Job` 是 Task 内执行单位, Automation Center。`AutomationPlan` 是 Task 的计划配置,`Job` 是 Task 内执行单位,
`Run` 是执行尝试。用户主要通过 Task Center Task Conversation 理解工作。智能心跳 `Run` 是执行尝试。用户主要通过 Task Center、左侧会话 Task 列表和关联 Conversation
理解工作;当前 UI 不展示 Job/Run 层级。智能心跳
不属于此模型。 不属于此模型。
### 5.1 核心实体 ### 5.1 核心实体
```text ```text
Task ── Conversation Conversation
AutomationPlan(可选) Task 0..N
├─ TriggerPolicy ├─ AutomationPlan(可选)
│ ├─ ObjectiveSet │ ├─ TriggerPolicy
│ ├─ ExecutionProtocol │ ├─ ObjectiveSet
│ ├─ BudgetPolicy │ ├─ ExecutionProtocol
│ ├─ ApprovalPolicy │ ├─ BudgetPolicy
│ ├─ SupervisorPolicy │ ├─ ApprovalPolicy
MemoryBinding SupervisorPolicy
└─ Job │ └─ MemoryBinding
Run Job
├─ Subjob ├─ Run
├─ Observation ├─ Subjob
├─ SupervisorDecision ├─ Observation
├─ Artifact ├─ SupervisorDecision
├─ Metric ├─ Artifact
└─ MemoryCandidate ├─ Metric
└─ MemoryCandidate
``` ```
| 实体 | 职责 | | 实体 | 职责 |
| --- | --- | | --- | --- |
| `Task` | 用户可见工作单位,与唯一 Conversation 一对一绑定 | | `Task` | 用户可见工作单位,只关联一条 ConversationConversation 可以承载多个 Task |
| `Job` | Task 内部一次步骤、触发、并行分支或委派执行 | | `Job` | Task 内部一次步骤、触发、并行分支或委派执行 |
| `Run` | Task/Job 的一次执行尝试和审计记录 | | `Run` | Task/Job 的一次执行尝试和审计记录 |
| `AutomationPlan` | Task 的可编辑计划配置,描述做什么、为何做、何时做和允许做什么 | | `AutomationPlan` | Task 的可编辑计划配置,描述做什么、为何做、何时做和允许做什么 |
@@ -180,8 +185,8 @@ Task ── Conversation
| `goal_loop` | 围绕目标重复执行“观察、计划、行动、评估” | | `goal_loop` | 围绕目标重复执行“观察、计划、行动、评估” |
| `experiment` | 生成隔离候选 Run,按统一协议评估和比较 | | `experiment` | 生成隔离候选 Run,按统一协议评估和比较 |
会话监督不是独立 Task。它是可附着到 Conversation、Task、Job/Run 或 Experiment 会话监督不是独立 Task。用户选择 Conversation、Task 或 Experiment 作为监督对象;
`SupervisorPolicy` 和监督会话 `SupervisorPolicy` 可以在内部观察所属 Job/Run 事件,但当前 UI 不把它们作为独立目标
### 5.3 运行快照 ### 5.3 运行快照
@@ -198,7 +203,8 @@ Task ── Conversation
- 预算和并发限制。 - 预算和并发限制。
- 审批策略。 - 审批策略。
运行开始后的设置变化只影响下一次 Run。用户可以查看当前 Run 与最新 Plan 的差异。 运行开始后的设置变化只影响下一次 Run。用户可以在 Task 执行记录中查看当次快照与最新
Plan 的差异,但 Run 不作为独立导航对象。
## 6. 统一状态模型 ## 6. 统一状态模型
@@ -250,28 +256,28 @@ inactive → observing → attention_required → paused → resolved
```text ```text
Trigger Trigger
→ AutomationCoordinator 声明 Run → AutomationCoordinator 在所属 Task 内声明 Job
RunQueue 按优先级和预算排队 ExecutionQueue 按优先级和预算排队
→ AutomationExecutor 在所属 Task 内创建或恢复 Job → AutomationExecutor 为 Job 创建或恢复 Run
→ Runtime 执行 → Runtime 执行
→ Supervisor 观察 → Supervisor 观察
→ Evaluator 计算指标 → Evaluator 计算指标
→ 结果、证据和候选记忆入库 → 结果、证据和候选记忆入库
→ 用户审查或后续 Run → 用户审查或后续执行
``` ```
`AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过 Job 和 `AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过 Job 和
Runtime 边界完成,Job 的用户可见结果汇入所属 Task Conversation。 Runtime 边界完成,用户可见结果通过所属 Task 汇入关联 Conversation。
### 7.2 优先级 ### 7.2 优先级
默认优先级从高到低: 默认优先级从高到低:
1. 用户正在等待的前台对话。 1. 用户正在等待的前台对话。
2. 用户手动启动的 Run 2. 用户手动启动的 Task 执行
3. 等待批准后恢复的 Run 3. 等待批准后恢复的 Task 执行
4. 到期 Scheduled Task 的 Job 4. 到期 Scheduled Task 的执行
5. 目标循环和实验 Run 5. 目标循环和实验执行
6. 心跳回顾、记忆巩固和维护。 6. 心跳回顾、记忆巩固和维护。
后台任务必须可被背压延后。延后记录为 `deferred`,不得丢失,也不得在系统恢复空闲时一次性 后台任务必须可被背压延后。延后记录为 `deferred`,不得丢失,也不得在系统恢复空闲时一次性
@@ -414,7 +420,7 @@ Runtime 边界完成,Job 的用户可见结果汇入所属 Task Conversation
监督统一进入应用级助手工作栏中固定且始终可访问的“监督”栏目,不再保留“独立可折叠右栏” 监督统一进入应用级助手工作栏中固定且始终可访问的“监督”栏目,不再保留“独立可折叠右栏”
和“动态新增页签”两种实现。栏目默认跟随当前上下文,用户可以固定到其他 Conversation、 和“动态新增页签”两种实现。栏目默认跟随当前上下文,用户可以固定到其他 Conversation、
Task、Job/Run 或实验 Run;详细范围与交互契约见 Task 或实验对象;详细范围与交互契约见
[通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
## 13. 安全与隐私 ## 13. 安全与隐私
@@ -433,7 +439,7 @@ Task、Job/Run 或实验 Run;详细范围与交互契约见
## 14. 可观测性 ## 14. 可观测性
个 Run 至少展示 次 Task 执行的内部 Job/Run 记录至少保存
- 触发来源和计划版本。 - 触发来源和计划版本。
- 计划目标和当前 `goalStatus` - 计划目标和当前 `goalStatus`
@@ -447,7 +453,8 @@ Task、Job/Run 或实验 Run;详细范围与交互契约见
- 产生的候选记忆或学习产物。 - 产生的候选记忆或学习产物。
- 重试、延后、中断和恢复原因。 - 重试、延后、中断和恢复原因。
不得只显示一个模糊的“自动化成功率”而隐藏失败 Run、跳过 Run 或无结论 Run。 UI 在 Task 下呈现上述信息的有界摘要和活动,不提供 Job/Run 树或独立导航。不得只显示一个
模糊的“自动化成功率”而隐藏失败、跳过或无结论的 Task 执行。
## 15. 建议的数据模型增量 ## 15. 建议的数据模型增量
@@ -474,8 +481,8 @@ experiment_runs
``` ```
现有 `schedules``schedule_runs``heartbeat_configs``heartbeat_runs` 现有 `schedules``schedule_runs``heartbeat_configs``heartbeat_runs`
`heartbeat_entries``tasks``runs` 不应一次性重写。Schedule 可渐进绑定一个持续 `heartbeat_entries``tasks``runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与
Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得 Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得
静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。 静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。
## 16. 分阶段实施 ## 16. 分阶段实施
@@ -484,8 +491,9 @@ Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳
- 固定 Task、Conversation、Job、Subjob、Run、Plan、Goal、Protocol、Supervisor、 - 固定 Task、Conversation、Job、Subjob、Run、Plan、Goal、Protocol、Supervisor、
Observation、Memory Candidate 等概念。 Observation、Memory Candidate 等概念。
- 明确 Task Conversation 一对一,Task Center 只是索引,不复制内容。 - 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center
- 为现有 Scheduled Task 和专家 Job 建立统一活动视图 索引边界,不复制内容
- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。
- 明确当前心跳保持独立,未来分区记忆尚待设计。 - 明确当前心跳保持独立,未来分区记忆尚待设计。
- 补充触发来源、运行版本、预算和读写范围展示。 - 补充触发来源、运行版本、预算和读写范围展示。
@@ -538,9 +546,12 @@ Task/Conversation,旧 child-task 字段可兼容映射到 Job/Subjob;心跳
## 18. 总体验收标准 ## 18. 总体验收标准
- [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。 - [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。
- [ ] Task Center 只索引 Task;每个 Task 只绑定一条 Conversation - [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以
关联多个 Task。
- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。
- [ ] Scheduled Task 的重复触发和并行 Job 不创建新的顶层 Task。 - [ ] Scheduled Task 的重复触发和并行 Job 不创建新的顶层 Task。
- [ ]自动 Run 都能解释触发原因、目标、范围、预算、状态和结果 - [ ]自动执行的内部 Run 都记录触发原因、目标、范围、预算、状态和结果,并在所属
Task 下提供有界可观测信息。
- [ ] Ask 自动化无法调用写工具或产生外部副作用。 - [ ] Ask 自动化无法调用写工具或产生外部副作用。
- [ ] Execute 自动化不能绕过现有审批、主机执行策略和能力控制。 - [ ] Execute 自动化不能绕过现有审批、主机执行策略和能力控制。
- [ ] 会话监督默认只评论,不能替用户发言或批准工具。 - [ ] 会话监督默认只评论,不能替用户发言或批准工具。
@@ -51,6 +51,9 @@
- 任何即将发送给模型的上下文都必须可见、可预览、可移除。 - 任何即将发送给模型的上下文都必须可见、可预览、可移除。
- 模型输出不等同于执行授权,工具权限由独立权限层判定。 - 模型输出不等同于执行授权,工具权限由独立权限层判定。
- 核心体验保持一致,受系统限制的能力采用渐进增强和明确降级。 - 核心体验保持一致,受系统限制的能力采用渐进增强和明确降级。
- 不静默替换用户选择的 Provider、模型、Runtime、数据处理位置、工作模式、权限范围或
质量档位。语义等价的内部恢复可以自动进行;涉及隐私、成本、能力或可感知质量的替代
路径必须明确显示并由用户决定。
- 安全、权限、更新签名和数据生命周期属于基础能力,不延期补做。 - 安全、权限、更新签名和数据生命周期属于基础能力,不延期补做。
### 2.5 当前非目标 ### 2.5 当前非目标
@@ -589,13 +592,15 @@
### 5.13 Task 与自动化 ### 5.13 Task 与自动化
每个 Task 与唯一 Conversation 一对一绑定;Task Center 只是 Task 的索引,不复制会话 每个 Task 只关联一条 Conversation,一条 Conversation 可以承载多个 Task;关联只增加
内容。内部步骤、委派、并行分支和定时触发使用 Job/Subjob,执行尝试使用 Run,它们留在 Task 身份和左侧行首展开入口,不改变 Conversation 类型或复制会话内容。内部步骤、委派、
所属 Task 并行分支和定时触发使用 Job/Subjob,执行尝试使用 Run,但当前 UI 只展示到 Task。
#### P1 功能 #### P1 功能
- 将多步工具调用保存为 Task。 - 将多步工具调用保存为 Task。
- 新建定制 Task 时选择当前或新 Conversation,默认 Execute,并明确显示 Runtime、Project、
工作目录、工具和审批范围。
- 执行前展示步骤计划、输入和权限。 - 执行前展示步骤计划、输入和权限。
- 逐步执行、暂停、取消和人工检查点。 - 逐步执行、暂停、取消和人工检查点。
- 失败重试和从安全检查点继续。 - 失败重试和从安全检查点继续。
@@ -622,6 +627,8 @@
- 系统通知和应用内通知。 - 系统通知和应用内通知。
- 保留 Task Center 作为 Task 入口,显示范围、状态、最近进展和需要关注信息。 - 保留 Task Center 作为 Task 入口,显示范围、状态、最近进展和需要关注信息。
- 左侧会话列表对关联 Task 显示行首展开按钮,展开后的 Task 子项使用任务图标,父行不重复
任务标签,展开层级只到 Task。
- 普通 Conversation、Job、Run、工具步骤和智能心跳记录不作为顶层 Task。 - 普通 Conversation、Job、Run、工具步骤和智能心跳记录不作为顶层 Task。
- 生成完成、任务完成、任务失败和等待确认。 - 生成完成、任务完成、任务失败和等待确认。
- 未读数量、全部已读和按类别过滤。 - 未读数量、全部已读和按类别过滤。
@@ -14,10 +14,10 @@
## 1. 背景 ## 1. 背景
GoodBuddy 已经在聊天右侧提供上下文、工作区、浏览器和成果面板,也已经具备 GoodBuddy 已经在聊天右侧提供上下文、工作区、浏览器和成果面板,也已经具备
Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家 Job 等基础能力。后续还 Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家执行等基础能力。后续还
计划增加: 计划增加:
- ConversationTask/Job Run 的独立监督。 - ConversationTask 和实验的独立监督。
- OpenCode、Continue 和 DeepSeek Harness 的 Runtime 生命周期监督。 - OpenCode、Continue 和 DeepSeek Harness 的 Runtime 生命周期监督。
- 用户可直接使用的终端和受管进程。 - 用户可直接使用的终端和受管进程。
- HTML 等成果的即时安全预览。 - HTML 等成果的即时安全预览。
@@ -87,8 +87,9 @@ Runtime
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。 - 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
- 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。 - 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。
- Task Center 是 Task 的单例应用级索引。每个 Task 与唯一 Conversation 一对一;Task - Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条
Center 不复制会话内容,也不显示普通 Conversation、Job、Run 或心跳事项。 Conversation 可以关联多个 TaskTask Center 不复制会话内容,也不显示普通 Conversation、
Job、Run 或心跳事项。
- 当前能力、连接、数据和空状态可以动态变化。 - 当前能力、连接、数据和空状态可以动态变化。
- 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。 - 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。
- 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。 - 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。
@@ -100,8 +101,8 @@ Runtime
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是 目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
使用门槛: 使用门槛:
- 监督默认选择当前会话,用户可以改选其他 Conversation、Task、Job/Run 或实验 Run - 监督默认选择当前会话,用户可以改选其他 Conversation、Task 或实验对象
- Runtime 默认跟随当前会话,用户可以固定到其他活动或历史 Run - Runtime 默认跟随当前会话或 Task;执行事件可以查看,但 Job/Run 不作为独立选择对象
- 终端默认使用当前项目执行空间,用户可以新建本机或远程终端。 - 终端默认使用当前项目执行空间,用户可以新建本机或远程终端。
- 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。 - 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。
- 成果和上下文默认使用当前范围,用户可以切换到项目、全局或其他允许范围。 - 成果和上下文默认使用当前范围,用户可以切换到项目、全局或其他允许范围。
@@ -210,7 +211,7 @@ Runtime
当前聊天右栏过渡实现保留 Task Center、上下文、工作区、浏览器和成果五个横向页签时,必须 当前聊天右栏过渡实现保留 Task Center、上下文、工作区、浏览器和成果五个横向页签时,必须
单行横向滚动,不能自动隐藏或缩写到不可辨认。Task Center 继续作为 Task 的现有入口; 单行横向滚动,不能自动隐藏或缩写到不可辨认。Task Center 继续作为 Task 的现有入口;
审批定位到所属 Task、Job 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属 审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属
“智能心跳”菜单入口。当前阶段不新增独立自动化中心。 “智能心跳”菜单入口。当前阶段不新增独立自动化中心。
### 6.3 工作栏尺寸 ### 6.3 工作栏尺寸
@@ -227,16 +228,22 @@ Runtime
Task Center 保留为工作栏中的稳定入口,并在现有基础上适度完善: Task Center 保留为工作栏中的稳定入口,并在现有基础上适度完善:
- 只索引 Task;每个 Task 与唯一 Conversation 一对一绑定 - 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task
- 显示名称、Global 或 Project 范围、状态、最近进展、真实活动时间和需要关注信息。 - 显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
- 点击 Task 直接打开其 Conversation 真实活动时间和需要关注信息
- 完整消息、Job、Run、工具、Subagent、审批和成果分别留在 Task Conversation、Runtime、活动记录和 - 点击 Task 打开其关联 Conversation 并定位 Task。
成果查看器中,不在窄栏复制。 - 完整消息留在 Conversation;工具、Subagent、审批、错误和成果按 Task 关联到 Runtime、
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
- 智能心跳的报告、建议、历史和配置不进入任务中心。 - 智能心跳的报告、建议、历史和配置不进入任务中心。
任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新 任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新
徽标和排序,但不能自动打开面板或抢占用户当前工作。 徽标和排序,但不能自动打开面板或抢占用户当前工作。
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
新建定制任务使用 Modal 选择当前或新
Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录、工具和审批摘要。
详细产品边界以 [Task Center PRD](../task-and-job/task-center-prd.md) 和 详细产品边界以 [Task Center PRD](../task-and-job/task-center-prd.md) 和
[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 为准。 [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 为准。
@@ -248,8 +255,8 @@ Task Center 保留为工作栏中的稳定入口,并在现有基础上适度
监督能力在目录中稳定可发现;用户打开面板实例后可以选择: 监督能力在目录中稳定可发现;用户打开面板实例后可以选择:
- 普通会话。 - 普通会话。
- Task 或 Job/Run - Task。
- 实验 Run 或实验整体 - 实验或实验结果
- 后续支持的文档分析和其他可监督对象。 - 后续支持的文档分析和其他可监督对象。
监督面板包含: 监督面板包含:
@@ -269,20 +276,21 @@ Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness
共同区域: 共同区域:
- Runtime、模型连接、会话或 Run 身份。 - Runtime、模型连接、Conversation 或 Task 身份。
- 活动请求、状态、耗时、用量和取消。 - 活动请求、状态、耗时、用量和取消。
- 工具、审批、问题、上下文压缩和错误。 - 工具、审批、问题、上下文压缩和错误。
- 跳转完整活动记录和持久设置。 - 跳转完整活动记录和持久设置。
可选区域: 可选区域:
- Subagent 父子关系和取消。 - Task 级委派状态和取消。
- 后台 Job 队列、进度、结果和终止。 - Task 级后台执行进度、结果和终止。
- Todo、Workflow 和 Hook 运行。 - Todo、Workflow 和 Hook 运行。
- 原生会话、暂停、恢复、压缩或释放。 - 原生会话、暂停、恢复、压缩或释放。
可选区域不可用时,用一段有操作路径的状态说明替代空卡片。用户可以在面板中切换 Runtime 可选区域不可用时,用一段有操作路径的状态说明替代空卡片。用户可以在面板中切换 Runtime
目标 Run,不要求先回到聊天 Composer。 Conversation / Task 目标,不要求先回到聊天 Composer。内部 Job/Run 事件按 Task
聚合,不提供 Job/Run 选择器、树、页面或独立操作菜单。
### 7.4 终端 ### 7.4 终端
@@ -353,7 +361,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
### 7.8 成果 ### 7.8 成果
成果面板统一显示全局、项目、Conversation、Task/Job Run 和监督显式产生的独立成果: 成果面板统一显示全局、项目、Conversation、Task 执行和监督显式产生的独立成果:
- Markdown、纯文本和 JSON。 - Markdown、纯文本和 JSON。
- 图片和图表。 - 图片和图表。
@@ -365,8 +373,9 @@ Artifact、自动化和监督生成的独立输出,以及用户手动导入或
升级前已经自动保存的普通对话 Markdown 可以从成果列表中隐藏,但不应通过升级迁移物理 升级前已经自动保存的普通对话 Markdown 可以从成果列表中隐藏,但不应通过升级迁移物理
删除用户数据库内容。 删除用户数据库内容。
用户可以切换范围、搜索、预览、查看来源、导出或打开关联对象。成果必须保留项目、会话、 用户可以切换范围、搜索、预览、查看来源、导出或打开关联对象。成果必须保留项目、
Conversation、Run、创建者、MIME、大小、校验值和时间等可用归属 Conversation、Task、内部 Run、创建者、MIME、大小、校验值和时间等可用归属;界面按
Task 呈现来源,不把 Run 作为导航对象。
#### HTML 即时预览 #### HTML 即时预览
@@ -388,8 +397,8 @@ Conversation、Run、创建者、MIME、大小、校验值和时间等可用归
- 浏览器、工作区文件和授权目录。 - 浏览器、工作区文件和授权目录。
- Runtime、监督或自动化显式绑定的其他上下文。 - Runtime、监督或自动化显式绑定的其他上下文。
每项显示来源、范围、大小、发送状态和用途。用户可以预览、移除或清空。固定到历史 Run 每项显示来源、范围、大小、发送状态和用途。用户可以预览、移除或清空。查看 Task 的历史
时,上下文只读展示不可变快照;跟随当前 Composer 时才允许编辑下一次请求的上下文。 执行上下文只读展示不可变快照;跟随当前 Composer 时才允许编辑下一次请求的上下文。
## 8. 范围和选择模型 ## 8. 范围和选择模型
@@ -410,7 +419,8 @@ type WorkbarCapabilityId =
type WorkbarTargetRef = type WorkbarTargetRef =
| { type: 'conversation'; id: string } | { type: 'conversation'; id: string }
| { type: 'run'; id: string } | { type: 'task'; id: string }
| { type: 'experiment'; id: string }
| { type: 'project'; id: string } | { type: 'project'; id: string }
| { type: 'workspace'; id: string } | { type: 'workspace'; id: string }
| { type: 'runtime-session'; id: string } | { type: 'runtime-session'; id: string }
@@ -510,7 +520,7 @@ type ManagedLifecycleState =
| 'interrupted' | 'interrupted'
``` ```
每个 Runtime 会话、Job、终端或受管进程公开: 每个 Runtime 会话、Task 级执行、终端或受管进程公开:
- GoodBuddy 受管 ID。 - GoodBuddy 受管 ID。
- 类型、来源和父子关系。 - 类型、来源和父子关系。
@@ -533,7 +543,8 @@ type ManagedControl =
``` ```
界面不能因为状态枚举中存在某个动作就假设所有 Runtime 都支持。Main 根据当前受管对象和 界面不能因为状态枚举中存在某个动作就假设所有 Runtime 都支持。Main 根据当前受管对象和
能力重新验证动作。 能力重新验证动作。内部 Job/Run 快照可以支持聚合与审计,但不能成为
`WorkbarTargetRef` 或独立 UI 对象。
## 11. 数据与契约建议 ## 11. 数据与契约建议
@@ -695,7 +706,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
- 远程租约、事件重放和幂等重连。 - 远程租约、事件重放和幂等重连。
- 更多远程系统和架构。 - 更多远程系统和架构。
- PDF、Office 和数据成果预览。 - PDF、Office 和数据成果预览。
- Conversation、Task/Job Run 和实验的完整监督。 - Conversation、Task 和实验的完整监督。
- 用户可导入导出工作栏布局和主机非敏感配置。 - 用户可导入导出工作栏布局和主机非敏感配置。
## 17. 验收标准 ## 17. 验收标准
@@ -714,7 +725,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
### 17.2 通用使用 ### 17.2 通用使用
- [ ] 没有项目时仍可创建终端、选择工作区、打开浏览器和查看成果。 - [ ] 没有项目时仍可创建终端、选择工作区、打开浏览器和查看成果。
- [ ] 监督可以作用于普通 Conversation、Task、Job/Run 和后续实验对象,不假设编程语境。 - [ ] 监督可以作用于普通 Conversation、Task 和后续实验对象,不假设编程语境。
- [ ] 工作区不是 Git 仓库时仍可浏览文件。 - [ ] 工作区不是 Git 仓库时仍可浏览文件。
- [ ] Runtime 不支持某项原生能力时仍可从目录打开面板并获得准确说明。 - [ ] Runtime 不支持某项原生能力时仍可从目录打开面板并获得准确说明。
@@ -731,7 +742,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
### 17.4 生命周期与恢复 ### 17.4 生命周期与恢复
- [ ] Runtime、终端、Job 和进程具有权威 Main 快照和有序增量事件。 - [ ] Runtime、终端、Task 级执行和进程具有权威 Main 快照和有序增量事件。
- [ ] 取消、终止、失败、断线和应用退出都有确定终态。 - [ ] 取消、终止、失败、断线和应用退出都有确定终态。
- [ ] 切换跟随目标后不显示上一对象的过期状态。 - [ ] 切换跟随目标后不显示上一对象的过期状态。
- [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。 - [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。
+231 -102
View File
@@ -4,74 +4,177 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 当前基础能力已存在,扩展调度设计中 | | 状态 | 首期稳定 Task 生命周期与创建体验已实现;高级触发和执行治理待实施 |
| 版本 | 0.4 | | 版本 | 0.6 |
| 日期 | 2026-08-19 | | 日期 | 2026-08-19 |
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
| 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) | | 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) |
## 1. 产品定义 ## 1. 产品定义
Scheduled Task 是带时间或事件触发器的 Task。它不是 Schedule 定义与临时 Task 的松散组合, Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task 关联一条 Conversation
也不创建第二条 Conversation。 条 Conversation 可以同时承载多个 Task
创建 Scheduled Task 时: 创建 Scheduled Task 时:
1. 创建一个 Task 1. 用户选择关联当前 Conversation 或创建新 Conversation
2. 为该 Task 创建唯一 Conversation 2. 系统创建一个 Task,并保存稳定 `conversationId` 和 Schedule/Trigger Binding
3. 保存 Schedule/Trigger Binding 3. 每次触发在同一 Task 内创建新的 Job 和 Run
4. 每次触发在同一 Task 内创建新的 Job 和 Run 4. 面向用户的文本进展和结果写回关联 Conversation,并标明 Task 来源
5. 将面向用户的进展和结果持续写回同一 Task Conversation 5. 独立交付物保存为 Artifact,并由结果消息引用
因此,一个每日任务在 Task Center 中始终是一条 Task,而不是每天新增一条 Task 因此,一个每日任务在 Task Center 中始终是一条 Task,而不是每天新增一条 Task;左侧会话
列表通过行首展开按钮和带任务图标的子项呈现其关联。
## 2. 当前能力 ## 2. 当前能力与差距
GoodBuddy 当前支持单次、每日和每周触发固定 Ask 提示,并持久化计划、Task、运行状态和成果。 GoodBuddy 当前已实现首期统一生命周期:
近期改进不得破坏现有数据、错过执行结算、暂停、立即运行和应用退出行为。
- 创建 Modal 可以关联当前 Conversation 或原子创建新 Conversation,不修改当前
Conversation 的标题和既有消息。
- 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再
为每次触发创建新的顶层 Task。
- 默认选择 Execute,并允许用户主动切换 Ask;不支持工具执行时明确禁用 Execute。
- 单次、每日和每周计划支持暂停、恢复、立即运行、应用重启恢复和最多 4 个独立计划并发。
- 文本结果和失败写回关联 Conversation 并带 Task 来源;独立文件和图片继续保存为 Artifact。
- 左侧 Conversation 列表、Conversation Task 区和 Task Center 使用同一产品 Task;普通
模型请求、Subagent、委派和 Smart Heartbeat 内部 Task 不进入产品索引。
- v22 迁移保留 Schedule 配置和历史运行,并为旧计划补齐稳定 Task 与 Conversation。
尚未实现的高级能力包括 IANA 时区与 DST 墙上时间、每月/工作日/受限 Cron、事件触发、
可配置错过执行策略、租约、重试与结果未知治理、完整预算和权限快照,以及面向内部
Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间隔递推。
## 3. 目标 ## 3. 目标
- 支持单次、每日、每周、每月、工作日和受限 Cron。 - 支持单次、每日、每周、每月、工作日和受限 Cron。
- 支持 Task 完成、失败、Conversation 完成等内部事件触发。 - 支持 Task 完成、失败、Conversation 完成等内部事件触发。
- 允许自然语言生成结构化草稿,但必须由用户检查后启用 - 创建时明确选择当前或新 Conversation
- 默认使用 Execute,并允许用户主动切换到 Ask。
- 冻结 Project、Runtime、工作目录、工具、知识、记忆和审批范围。
- 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。 - 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。
- 让所有重复触发复用同一 Task Conversation。 - 让所有重复触发复用同一 Task Conversation 关联
- 为一次触发建立清晰 Job/Run,而不是创建新的顶层 Task。 - 为一次触发建立清晰 Job/Run,而不是创建新的顶层 Task。
## 4. 非目标 ## 4. 非目标
- 不提供任意脚本和循环的通用 DAG 编辑器。 - 不提供任意脚本和循环的通用 DAG 编辑器。
- 不允许模型生成并直接执行任意 Shell、SQL 或无限频率 Cron。 - 不允许模型生成并直接启用任意 Shell、SQL 或无限频率 Cron。
- 不承诺应用退出后继续运行。 - 不承诺应用退出后继续运行。
- 不允许计划静默扩大权限、目录、知识记忆范围。 - 不允许后台计划静默扩大权限、目录、知识记忆或网络范围。
- 不把 Smart Heartbeat 变成 Scheduled Task。 - 不把 Smart Heartbeat 变成 Scheduled Task。
- 不把每次触发重试显示为新的 Task。 - 不把每次触发重试、Job 或 Run 显示为新的顶层 Task。
- 不在左侧会话列表继续展开 Job、Subjob 或 Run。
## 5. 创建与配置 ## 5. 创建入口与 Modal
用户可以输入自然语言意图: Task Center 和 Conversation 操作都可以提供“新建定制任务”,但共用同一个 Modal,不在
窄侧栏长期展开完整表单。
```text ```text
每周五下午 5 点总结本项目本周完成和失败的工作, 新建定制任务
列出下周三个优先事项,不要修改文件。 创建一个可以按计划自动运行,并持续记录在会话中的任务
任务名称 *
[ 每周项目总结 ]
任务要求 *
[ 总结本周完成和失败的工作,并列出下周优先事项。 ]
关联会话
◉ 当前会话
产品发布讨论 · GoodBuddy Desktop · 已有 2 个任务
○ 新建会话
为任务创建一条新会话,默认标题为任务名称
执行模式
[ Execute ] [ Ask ]
运行频率
[ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ]
首次运行 [ 2026-08-21 ] [ 17:00 ]
时区 [ Asia/Shanghai ▾ ]
执行范围
GoodBuddy Desktop · OpenCode · 项目工作目录
8 个工具可用 · 高风险操作需要审批 [编辑]
[取消] [创建任务]
``` ```
模型只生成草稿: ### 5.1 Conversation 选择
- 名称和说明 - 从当前聊天发起时默认选择当前 Conversation
- 时间或事件触发器 - 从 Task Center 发起时默认选择新 Conversation
- 工作模式和 Runtime 建议 - 当前选择必须持续可见,不能根据入口静默决定后隐藏
- Project、知识、记忆、目录和工具范围 - 关联当前 Conversation 不修改其标题、既有消息和普通聊天能力
- 输入、输出和通知 - 当前 Conversation 已有关联 Task 时,显示 Task 数量和共享上下文说明
- 预算、并发和错过执行策略 - 新 Conversation 默认使用 Task 名称作为标题,用户可以单独修改
- 远程通道、归档、正在删除或 Project 不匹配的 Conversation 不可选择,并显示原因。
用户确认后,系统一次性创建 Task、Conversation 和 Schedule Binding。编辑计划只影响后续 ### 5.2 创建摘要
Job;已启动 Run 使用冻结快照。
## 6. 触发器 提交前显示确定性摘要:
### 6.1 时间触发 ```text
✓ 为当前 Conversation 新增一个 Task
✓ 在左侧会话列表显示“任务 3”
✓ 默认以 Execute 模式运行
✓ 每周五 17:00 自动执行此 Task
✓ 文本结果写入当前 Conversation
✓ 独立交付物保存到成果
```
创建 Task、可选新 Conversation、关联关系和 Schedule Binding 必须在 Main 中原子提交。
失败时保持 Modal 和用户输入,不只显示短暂通知。提交期间锁定重复操作。
### 5.3 Modal 行为与无障碍
- 使用 `role="dialog"``aria-modal="true"`、稳定标题和说明关联。
- 打开后聚焦首个必填字段,Tab 焦点限制在 Modal 内。
- Escape 在未提交时关闭并恢复触发按钮焦点。
- 窄窗口使用接近全宽布局,保留 `16px` 外边距。
- 字段错误靠近字段;非字段异步错误保留在 Modal 内并提供重试。
## 6. 工作模式、Runtime 与工具
### 6.1 默认 Execute
创建 Modal 默认选择 Execute
- Execute 可以调用当前 Runtime 与 Project 已启用、且被 Task 快照允许的工具。
- Ask 保持 Runtime 边界只读,只能调用允许的只读能力。
- 所选 Runtime 不支持工具执行时,不能静默降级为 Ask;用户必须更换 Runtime 或主动选择
Ask。
- Modal 持续显示实际 Runtime、Project、工作目录和权限摘要。
### 6.2 权限快照
Task 创建时冻结:
- Project 和工作目录。
- Runtime 与模型选择。
- 工作模式。
- Skills、MCP、知识库、记忆和上下文范围。
- 可用工具与审批策略。
- 预算、并发和输出限制。
后续设置变化不修改已启动 Run。编辑 Task 配置只影响后续 Job。
### 6.3 审批
- Execute 继续遵守当前 Runtime、GoodBuddy 原生能力和工具审批控制。
- 已启用且按现有策略允许自动执行的工具可以在后台运行。
- 需要额外确认的动作进入 `waiting_approval`,暂停所属 Job 并发送应用内及桌面通知。
- 用户批准后继续同一个 Job/Run;拒绝后按协议失败、跳过或请求调整。
- 定时触发不能把高风险、越界或未授权动作转换成自动批准。
- 结果未知的外部副作用进入 `outcome_unknown`,不得自动重试。
## 7. 触发器
### 7.1 时间触发
```ts ```ts
type TimeTrigger = type TimeTrigger =
@@ -96,10 +199,12 @@ type TimeTrigger =
} }
``` ```
“工作日”是 `weekly` 的周一至周五预设,不增加新的持久化触发类型。
受限 Cron 使用五字段,不支持秒、年份、宏、`L``W``#` 或供应商扩展。Main 负责解析, 受限 Cron 使用五字段,不支持秒、年份、宏、`L``W``#` 或供应商扩展。Main 负责解析,
默认最小间隔为 15 分钟,并展示未来五次触发时间。 默认最小间隔为 15 分钟,并展示未来五次触发时间。
### 6.2 事件触发 ### 7.2 事件触发
后续支持: 后续支持:
@@ -112,27 +217,80 @@ type TimeTrigger =
事件触发配置来源范围、确定性过滤、去重窗口、冷却时间和并发上限。基础匹配不调用模型。 事件触发配置来源范围、确定性过滤、去重窗口、冷却时间和并发上限。基础匹配不调用模型。
### 6.3 手动触发 ### 7.3 手动触发
“立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。 “立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。
重复点击使用调用级幂等键去重。 重复点击使用调用级幂等键去重。
## 7. 一次触发的对象关系 ## 8. 一次触发的对象关系
```text ```text
Scheduled Task Conversation
Conversation(持续复用) Scheduled Task
├─ Schedule Binding ├─ Schedule Binding
└─ Job: scheduled_occurrence └─ Job: scheduled_occurrence
└─ Run └─ Run
``` ```
- `scheduledFor` 和计划版本形成幂等键。 - `scheduledFor` 和计划版本形成幂等键。
- 同一 Scheduled Task 默认最多一个活动 occurrence Job。 - 同一 Scheduled Task 默认最多一个活动 occurrence Job。
- 若允许并行 occurrence,它们仍属于同一 Task Conversation,并由协调器有序汇总 - 若允许并行 occurrence,它们仍属于同一 Task,并由协调器有序写回关联 Conversation。
- 重试产生新 Run,不产生新 Task 或新 Job。 - 重试产生新 Run,不产生新 Task 或新 occurrence Job。
## 8. 错过执行 ## 9. 左侧会话列表
普通 Conversation 保持单行。包含 Task 的 Conversation 显示行首展开按钮:
```text
▾ 产品发布讨论 10:24
▣ 每周进度总结
每周五 17:00 · Execute · 下次 8 月 21 日
▣ 发布前检查
单次 · Execute · 等待确认
```
- 父会话行不重复显示任务标签或数量;Task 身份只在展开后的子项中使用稳定任务图标表达。
- 点击 Conversation 标题打开聊天;点击 Task 子项打开同一 Conversation 并定位到该 Task。
- 新建 Task 成功后首次自动展开。用户手动折叠后持久化其选择,后台运行不强制展开。
- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。
- Task 子项显示本地化的模式、计划和状态文字;状态不能只靠任务图标颜色表达。
- 左侧只展开 Task;当前产品 UI 的其他区域也不提供 Job/Run 树或独立导航。
## 10. Conversation 内呈现
打开包含 Task 的 Conversation 后,顶部提供可折叠 Task 条:
```text
本会话有 2 个任务
[每周进度总结] [发布前检查] [管理任务]
```
选中 Task 后显示:
- 名称、状态和模式。
- 计划、下次执行和未来预览。
- 最近一次执行结果。
- “立即运行”“暂停”“编辑计划”等操作。
- 需要审批时的明确恢复入口。
每条自动结果消息显示 Task 名称、触发来源和时间。普通文本作为消息保存;文件、图片、PDF 和
其他独立交付物保存为 Artifact,并从消息引用。多个 Task 并发时,最终文本以完整消息写入,
不能把流式 Token 无序混入同一消息时间线。
## 11. Task Center
Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模式、最近进展、需要
关注和下次触发时间:
- 点击条目打开关联 Conversation,并定位到该 Task。
- “立即运行”在内部创建 Job/Run,但 UI 仍只呈现 Task,不改变计划时间。
- 暂停只阻止新 Job,不取消已经完成的外部副作用。
- Task Center 是完整索引;左侧展开列表只是最近 Conversation 下的轻量入口。
- 不新增平行 Automation Center。
## 12. 错过执行
| 策略 | 行为 | | 策略 | 行为 |
| --- | --- | | --- | --- |
@@ -142,85 +300,56 @@ Scheduled Task
默认补跑最多 3 次、最多回溯 7 天。补跑同样受 Task 的并发、权限和预算控制。 默认补跑最多 3 次、最多回溯 7 天。补跑同样受 Task 的并发、权限和预算控制。
## 9. 时区和夏令时 ## 13. 时区和夏令时
- 保存 IANA 时区,不保存固定 UTC 偏移。 - 保存 IANA 时区,不保存固定 UTC 偏移。
- 春季不存在的本地时间在当日第一个有效分钟触发。 - 春季不存在的本地时间在当日第一个有效分钟触发。
- 秋季重复时间只触发一次。 - 秋季重复时间只触发一次。
- 系统时区变化不自动修改计划时区。 - 系统时区变化不自动修改计划时区。
- UI 显示计划时区、本机时区差异和未来触发时间。 - UI 显示计划时区、本机时区差异和未来五次触发时间。
## 10. Ask、Execute 与审批 ## 14. 预算、恢复和删除
第一阶段保持 Ask 每个 Scheduled Task 配置最大 Job 耗时、模型/Token/工具调用、成果大小、活动 Job 数和后台
优先级。前台请求优先,后台达到上限时记录 `deferred`
- Runtime 边界只读 - 瞬时且没有未知副作用的失败可以有界重试
- 不写文件、不执行命令、不发送消息、不修改远程数据。
- 输出写回 Task Conversation;独立交付物才进入成果。
Execute 按顺序开放:
1. 有人值守,沿用逐工具审批。
2. 预批准低风险工具和参数范围。
3. 经过专项验证的内置无人值守模板。
高风险、越界或未预授权动作进入 `waiting_approval`,不能因定时触发而绕过策略。
## 11. 预算与背压
每个 Scheduled Task 配置:
- 最大 Job 耗时。
- 最大模型、Token 和工具调用。
- 最大成果大小。
- 最大活动 Job 数。
- 后台优先级。
前台请求优先。后台达到上限时延后并记录 `deferred`,不能挤占用户正在等待的请求,也不能
在恢复空闲时一次释放全部积压。
## 12. 重试、恢复和取消
- 瞬时、无副作用失败可以有界重试。
- 配置、权限和范围错误不重试。 - 配置、权限和范围错误不重试。
- 外部副作用结果未知时进入 `outcome_unknown`,不自动重试。
- 应用退出将活动 Job/Run 标记为 `interrupted` - 应用退出将活动 Job/Run 标记为 `interrupted`
- 暂停计划只阻止新 Job,不假装取消已发生的外部操作。
- 取消 Task 必须传播到活动 Job、Subjob 和 Runtime。 - 取消 Task 必须传播到活动 Job、Subjob 和 Runtime。
- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。
- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。
- 删除 Conversation 前显示关联 Task 数量,并先处理活动 Job。
## 13. 界面 ## 15. 兼容迁移
Task Center 显示 Scheduled Task 的范围、状态、最近进展、需要关注和下次触发时间。点击条目
打开同一 Task Conversation。
Task 内可查看:
- 计划和触发器。
- 下次执行和未来预览。
- 每次 occurrence Job。
- Run、审批、活动和成果。
不新增平行 Automation Center。
## 14. 兼容迁移
现有 Schedule、Schedule Run、Task 和 Conversation 数据渐进关联: 现有 Schedule、Schedule Run、Task 和 Conversation 数据渐进关联:
- 保留现有计划 ID、启停状态、下次时间和历史。 - 保留现有计划 ID、启停状态、下次时间和历史。
- 为每个现有计划建立或绑定一个持续 Task Conversation - 为每个现有 Schedule 创建一个稳定产品级 Task
- 旧 Schedule 不猜测绑定已有用户 Conversation;为其创建新的关联 Conversation。
- 历史每次执行映射为该 Task 下的 occurrence Job/Run。 - 历史每次执行映射为该 Task 下的 occurrence Job/Run。
- 迁移不得复制消息、成果或顶层 Task。 - 旧执行产生的 Task 行在映射成功后不再作为产品级 Task 索引,但其状态、活动和成果继续
通过迁移后的 Job/Run 归属保留。
- 旧文本 Artifact 可以保留,但迁移不得把它们重复写成新消息。
- 迁移不得复制用户消息、独立成果或顶层 Task。
## 15. 验收标准 ## 16. 验收标准
- [ ] 创建 Scheduled Task 只创建一个 Task 和一个 Conversation。 - [x] 创建 Scheduled Task 可以选择当前或新 Conversation。
- [ ] 重复触发始终复用该 Task Conversation - [x] 关联当前 Conversation 不修改其标题、类型或既有消息
- [ ] 每次触发创建 Job/Run,不创建新的顶层 Task。 - [x] 一条 Conversation 可以在左侧展开一个或多个 Task。
- [x] 默认工作模式为 Execute,且用户可以主动选择 Ask。
- [ ] Execute 能调用快照允许的工具,但不能绕过 Runtime 和审批控制。
- [x] 不支持工具的 Runtime 不会让 Execute 静默降级。
- [x] 重复触发始终复用同一 Task 和 Conversation 关联。
- [x] 每次触发创建内部运行记录,不创建新的顶层 Task。
- [x] 内部运行记录只用于执行和审计,不在 UI 中显示为独立层级。
- [ ] 支持单次、每日、每周、每月、工作日和受限 Cron。 - [ ] 支持单次、每日、每周、每月、工作日和受限 Cron。
- [ ] UI 显示计划时区和未来五次触发时间。 - [ ] UI 显示计划时区和未来五次触发时间。
- [ ] 夏令时不会造成漂移或双跑。 - [ ] 夏令时不会造成漂移或双跑。
- [ ] 错过执行按配置跳过、补一次或有界补跑。 - [ ] 错过执行按配置跳过、补一次或有界补跑。
- [ ] 手动运行不改变下次计划时间。 - [x] 手动运行不改变下次计划时间。
- [ ] Ask 在 Runtime 边界拒绝写操作和外部副作用 - [x] 文本结果只写入 Conversation,独立交付物才进入成果
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。 - [ ] 应用重启不自动重放结果未知的副作用。
- [ ] Task Center 不因重复触发新增条目。
+150 -59
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 产品边界已确认,部分能力待实施 | | 状态 | 产品边界与 Scheduled Task 首期已实现;通用 Job/Run 能力待实施 |
| 版本 | 0.1 | | 版本 | 0.3 |
| 日期 | 2026-08-19 | | 日期 | 2026-08-19 |
| 适用产品 | GoodBuddy 桌面端 | | 适用产品 | GoodBuddy 桌面端 |
| 文档角色 | Task、Conversation、Job、Run 与 Subagent 的权威定义 | | 文档角色 | Task、Conversation、Job、Run 与 Subagent 的权威定义 |
@@ -14,25 +14,34 @@
### 1.1 Task ### 1.1 Task
Task 是用户明确创建或由已启用计划创建的工作单位,也是 Task Center 的顶层对象。 Task 是用户明确创建或确认的工作单位,也是 Task Center 的顶层对象。
- 创建 Task 就创建一条新的 Conversation。 - 每个 Task 必须关联且只关联一条 Conversation。
- Task 的内容载体是这条 Conversation不再维护第二份任务正文或消息时间线 - 创建 Task 时,用户可以关联当前 Conversation也可以同时创建一条新 Conversation
- 打开 Task 就打开其 Conversation - 关联当前 Conversation 不改变其对象类型、标题、既有消息或普通聊天能力,只增加 Task
- Task 的目标、状态、范围、计划、Job、审批、活动和成果都围绕同一 Conversation 组织 关联及其可见入口
- 一个 Task 在生命周期内保持同一个 `taskId``conversationId` 绑定 - Task 的目标、状态、范围、计划、Job、审批、活动和成果使用独立 Task 身份保存
- 打开 Task 会打开关联的 Conversation,并定位或展开对应 Task。
- 一个 Task 在生命周期内保持稳定的 `taskId``conversationId` 关联。
- 删除 Task 默认停止其计划并移除关联,不删除 Conversation 或既有消息。
普通 Conversation 不自动成为 Task。用户只是聊天时,不应因为存在模型调用或工具步骤就 普通模型请求、工具调用或 Runtime Run 不自动成为产品级 Task。只有用户明确创建、确认或
产生顶层 Task。 由已启用产品流程创建的工作,才进入 Task Center 和 Conversation 的 Task 列表
### 1.2 Conversation ### 1.2 Conversation
Conversation 是 Task 的交互和内容载体: Conversation 是用户消息、助手消息和面向用户结果的内容容器,不因为关联 Task 而变成另一
种 Conversation
- 保存用户消息、助手消息和面向用户的进展 - 一条 Conversation 可以不关联 Task,也可以关联一个或多个 Task
- 承载同一 Task 内多个 Job 的可理解汇总。 - 多个 Task 可以共享同一 Conversation 的可见上下文,但各自拥有独立配置、计划、权限
- 不让并行 Job 直接无序写入同一消息流;由 Task 协调器合并进展和结果 快照、状态、Job、Run 和成果引用
- 删除、归档和切换范围时遵循 Task 的生命周期规则 - Conversation 标题与 Task 名称相互独立。创建或重命名 Task 不静默修改现有会话标题
- 左侧会话列表根据显式 Task 关联显示行首展开按钮;父会话行不重复任务标签,展开后的
Task 子项使用任务图标,并只展开到 Task 层。
- 并行 Job 不直接无序写入消息流;进度留在各自 Task/Job 状态中,最终文本以带来源元数据
的完整消息写入 Conversation。
- 删除 Conversation 前必须说明关联 Task 数量,并先停止或结算仍活动的 Job。
### 1.3 Job ### 1.3 Job
@@ -40,9 +49,10 @@ Job 是 Task 内部的执行单位,不是新的顶层 Task:
- 一次计划触发、一个执行步骤、一项专家委派或一组并行工作都可以是 Job。 - 一次计划触发、一个执行步骤、一项专家委派或一组并行工作都可以是 Job。
- 一个 Task 可以串行或并行运行多个 Job。 - 一个 Task 可以串行或并行运行多个 Job。
- 所有 Job 仍属于同一个 Task 和同一条 Conversation。 - 所有 Job 仍属于同一个 Task,并通过该 Task 关联的 Conversation 呈现用户可见结果
- Job 可以有自己的状态、预算、Runtime、执行者、输入快照和成果引用。 - Job 可以有自己的状态、预算、Runtime、执行者、输入快照和成果引用。
- Job 不进入 Task Center;它显示在 Task 的时间线、活动或 Runtime 视图中。 - Job 不进入 Task Center、左侧会话列表或独立详情页。当前产品 UI 的对象层级止于 Task;
活动和 Runtime 只按 Task 展示有界执行事件、工具、审批与错误,不呈现 Job 树。
### 1.4 Subjob ### 1.4 Subjob
@@ -50,16 +60,17 @@ Subjob 是 Job 的子执行单元。它用于分解和并发,不创建新的 T
- 父 Job 负责合并 Subjob 结果。 - 父 Job 负责合并 Subjob 结果。
- 取消父 Job 必须传播到仍活动的 Subjob。 - 取消父 Job 必须传播到仍活动的 Subjob。
- Subjob 不能扩大父 Job 的项目、目录、工具、知识、记忆或审批范围。 - Subjob 不能扩大父 Job 的 Project、目录、工具、知识、记忆或审批范围。
- 深度、数量、并发、时间、Token 和输出大小必须有界。 - 深度、数量、并发、时间、Token 和输出大小必须有界。
### 1.5 Run ### 1.5 Run
Run 是 Task 或 Job 的一次执行尝试和审计记录,不是用户工作对象: Run 是 Job 或 Subjob 的一次执行尝试和审计记录,不是用户工作对象:
- 重试、恢复或手动重新运行可以产生新的 Run。 - 重试、恢复或手动重新运行可以产生新的 Run。
- Run 冻结当次配置、范围、预算Runtime。 - Run 冻结当次配置、范围、预算Runtime 和权限策略
- Run 进入活动记录和审计,不进入 Task Center。 - Run 进入内部审计;当前 UI 可以显示某次 Task 执行的时间、状态和活动,但不把 Run 呈现为
可导航的产品对象。
- `completed` 只表示该次执行按协议结束,不必然表示 Task 目标达成。 - `completed` 只表示该次执行按协议结束,不必然表示 Task 目标达成。
### 1.6 Subagent ### 1.6 Subagent
@@ -68,84 +79,164 @@ Subagent 是执行 Job 或 Subjob 的受限执行者,不是对象层级:
- 专家、Agent Runtime 或其他执行器可以承担 Job。 - 专家、Agent Runtime 或其他执行器可以承担 Job。
- Subagent 不自动拥有独立 Task 或 Conversation。 - Subagent 不自动拥有独立 Task 或 Conversation。
- Subagent 输出先回到所属 Job,再由 Task 协调器写入同一 Conversation。 - Subagent 输出先回到所属 Job,再由 Task 协调器写入关联 Conversation。
## 2. 对象关系 ## 2. 对象关系
```text ```text
Task 1 ── 1 Conversation Conversation 1 ── 0..N Task
├─ Schedule / Trigger Binding(可选) ├─ Schedule / Trigger Binding(可选)
├─ Job 1 ├─ Job 1
│ ├─ Run 1..N │ ├─ Run 0..N
│ └─ Subjob 0..N │ └─ Subjob 0..N
├─ Job 2(可与 Job 1 并行) │ └─ Run 0..N
└─ Artifact / Approval / Activity / Notification ├─ Job 2(可与 Job 1 并行)
└─ Artifact / Approval / Activity / Notification
```
从 Task 方向看:
```text
Task N ── 1 Conversation
``` ```
不允许: 不允许:
```text ```text
Task → 第二条 Conversation Task → 没有关联 Conversation
Task → 同时关联多条 Conversation
Job → 新建顶层 Task Job → 新建顶层 Task
Subagent → 自动新建 Conversation Subagent → 自动新建 Conversation
Run → 出现在 Task Center Job / Run → 成为可独立导航的 UI 对象
``` ```
## 3. Scheduled Task ## 3. 创建 Task
Scheduled Task 仍然是 Task,而不是独立的自动化对象 创建定制 Task 时必须明确选择 Conversation
1. 用户创建 Scheduled Task。 ```text
2. 系统创建一个 Task 和一个 Conversation,并保存 Schedule/Trigger Binding。 关联当前 Conversation
创建新 Conversation
```
- 从当前聊天发起时,默认选择当前 Conversation。
- 从 Task Center 发起时,默认选择新 Conversation。
- 选择当前 Conversation 时持续显示会话标题、Project 和已有 Task 数量。
- 选择新 Conversation 时,默认使用 Task 名称作为会话标题,但允许用户修改。
- Task、Conversation 关联和可选 Schedule Binding 必须在 Main 中原子创建或回滚。
## 4. Scheduled Task
Scheduled Task 仍然是 Task,而不是 Schedule 定义和临时 Task 的松散组合:
1. 用户选择当前或新 Conversation。
2. 系统创建一个 Task,建立稳定 `conversationId` 关联,并保存 Schedule/Trigger Binding。
3. 到期时在该 Task 内创建新的 Job 和 Run。 3. 到期时在该 Task 内创建新的 Job 和 Run。
4. 每次触发的进展和结果入同一个 Task Conversation。 4. 每次触发的进展和文本结果入同一关联 Conversation。
5. 编辑计划影响后续 Job,不修改已经启动的 Run 5. 独立文件、图片和其他交付物保存为 Artifact,并从结果消息引用
6. 编辑计划影响后续 Job,不修改已经启动的 Run。
同一 Scheduled Task 默认串行触发。需要并行时,应显式允许多个 Job 并发,并继续使用同一 同一 Scheduled Task 默认串行触发。需要并行时,应显式允许多个 Job 并发,并继续使用同一
Conversation,而不是复制 Task。 Task 和 Conversation 关联,而不是复制顶层 Task。
## 4. 状态分层 ## 5. 消息归属
Task 产生的用户可见消息至少记录:
```ts
type TaskMessageMetadata = {
taskId: string
jobId: string
runId: string
trigger: 'manual' | 'scheduled' | 'event' | 'goal'
}
```
同一 Conversation 关联多个 Task 时:
- 消息持续显示来源 Task 名称。
- 点击左侧展开项或 Task Center 条目可以定位对应 Task 和近期消息。
- 任务筛选只改变定位和高亮,不隐藏用户未主动筛选的普通消息。
- 多个活动 Job 的流式细节进入各自活动记录,最终文本有界持久化后再写入 Conversation。
## 6. 状态分层
| 层级 | 典型状态 | 用户在哪里看到 | | 层级 | 典型状态 | 用户在哪里看到 |
| --- | --- | --- | | --- | --- | --- |
| Task | queued、running、waiting_approval、paused、completed、failed、cancelled、interrupted | Task Center、Task Conversation | | Task | idle、queued、running、waiting_approval、paused、completed、failed、cancelled、interrupted | Task Center、左侧会话展开项、Conversation |
| Job | queued、running、waiting、completed、failed、cancelled | Task 时间线、活动、Runtime | | Job | queued、running、waiting_approval、completed、failed、cancelled | 内部协调与审计,不作为 UI 对象 |
| Run | claimed、running、completed、failed、cancelled、interrupted、budget_exceeded | 活动与审计 | | Run | claimed、running、completed、failed、cancelled、interrupted、budget_exceeded、outcome_unknown | 内部执行与审计,不作为 UI 对象 |
Task 状态由当前目标和所属 Job 聚合得出,但不能用“任一 Job 完成”直接推断 Task 完成。 Task 状态由当前目标和所属 Job 聚合得出,但不能用“任一 Job 完成”直接推断 Task 完成。
Conversation 折叠行只显示其关联 Task 中最高优先级的关注状态:
## 5. 兼容映射 ```text
waiting_approval > failed > running > paused > idle
```
## 7. UI 展示边界
当前产品 UI 的对象层级统一止于 Task:
- 左侧会话列表展开到 Task。
- Task Center 只索引 Task。
- Conversation 顶部任务区只选择和管理 Task。
- 活动与 Runtime 可以展示 Task 的执行时间、工具、审批、错误、成果和状态事件,但不显示
Job/Subjob 树,不提供 Job/Run 路由、列表或独立操作菜单。
- “立即运行”“重试”和“恢复”在 UI 上都是 Task 操作;Job/Run 只在内部创建和审计。
## 8. 左侧 Conversation Task 列表
左侧最近会话列表是轻量发现入口,不替代 Task Center
- 无 Task 的 Conversation 保持现有单行样式。
- 有 Task 的 Conversation 显示行首展开按钮,父会话行不重复任务标签或数量。
- 展开后只显示带任务图标和本地化摘要的 Task,不继续显示 Job、Subjob 或 Run。
- 新建 Task 成功后首次自动展开;用户手动折叠后保持选择,后台状态变化不强制展开。
- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。
- Task 子项的任务图标表示身份;运行、审批、失败和暂停同时使用本地化状态文字。
- 删除最后一个关联 Task 后,Conversation 的展开按钮自动消失。
## 9. 兼容映射
当前代码和旧文档中的对象按以下方式收敛: 当前代码和旧文档中的对象按以下方式收敛:
| 旧概念 | 目标概念 | | 旧概念 | 目标概念 |
| --- | --- | | --- | --- |
| 自动任务 | Scheduled Task、Event Task 或 Goal Task | | 自动任务 | Scheduled Task、Event Task 或 Goal Task |
| 自动会话 | 删除该独立概念,使用 Task Conversation | | 自动会话 | 删除该独立概念,使用关联 Conversation |
| 子任务、Child Task | Job 或 Subjob | | 子任务、Child Task | Job 或 Subjob |
| 专家子任务 | 由专家 Subagent 执行的 Job/Subjob | | 专家子任务 | 由专家 Subagent 执行的 Job/Subjob |
| 多任务并行 | 一个 Task 内多个并行 Job确实独立的用户目标才创建多个 Task | | 多任务并行 | 一个或多个 Task 下的并行 Job根据用户目标和 Conversation 归属明确建模 |
| Schedule Run | Scheduled Task 内的 Job Run | | Schedule Run | Scheduled Task 内的 Job Run |
| Automation Run | Task 或 Job 的 Run | | Automation Run | Task 所属 Job 或 Subjob 的 Run |
| 普通请求 Task 行 | 内部执行/审计记录,不自动成为产品级 Task |
数据库字段可以在兼容期保留旧名称,但新产品文案、PRD 和新增契约必须使用本模型。 数据库字段可以在兼容期保留旧名称,但新产品文案、PRD 和新增契约必须使用本模型。
## 6. 安全和数据要求 ## 10. 安全和数据要求
- Main 验证 Task、Conversation、Job、Run 和 Project 的归属链。 - Main 验证 Conversation、Task、Job、Run 和 Project 的完整归属链。
- Renderer 不能把任意 Job 绑定到其他 Task 或 Conversation。 - Task 只能关联同一 Project 范围内允许使用的 Conversation。
- Job/Subjob 继承父级能力上限,只能缩小,不能扩大 - Renderer 不能把任意 Task 或 Job 绑定到其他 Project 的 Conversation
- Job/Subjob 继承 Task 的能力上限,只能缩小,不能扩大。
- Execute Task 冻结 Runtime、工作目录、工具和审批策略;后台触发不能扩大权限。
- 并行输出先有界持久化,再按确定顺序汇总到 Conversation。 - 并行输出先有界持久化,再按确定顺序汇总到 Conversation。
- 取消、超时、审批和应用退出必须沿 Task → Job Subjob → Runtime 传播。 - 取消、超时、审批和应用退出必须沿 Task → Job / Subjob → Run → Runtime 传播。
- 用户删除 Task 时,先处理活动 Job,再按数据保留规则清理关联对象 - 删除 Task 默认保留 Conversation 和消息;删除 Conversation 必须处理其全部关联 Task
## 7. 验收原则 ## 11. 验收原则
- [ ] 创建 Task 时只创建一条对应 Conversation。 - [ ] 每个 Task 只关联一条 Conversation。
- [ ] Scheduled Task 的重复触发复用同一 Task Conversation - [ ] 一条 Conversation 可以关联零个、一个或多个 Task
- [ ] 一个 Task 可以在同一 Conversation 下运行多个并行 Job - [ ] 创建 Task 可以选择当前 Conversation 或新 Conversation,且不会改变当前会话类型
- [ ] Job、Subjob、Run 和 Subagent 不进入 Task Center - [ ] 左侧会话列表通过行首按钮展开带任务图标和本地化摘要的 Task,但不展开 Job/Run
- [ ] 当前 UI 不提供 Job、Subjob 或 Run 的独立列表、树、路由或操作菜单。
- [ ] Scheduled Task 的重复触发复用同一 Task 和 Conversation 关联。
- [ ] 一个 Task 可以运行多个串行或并行 Job。
- [ ] Job、Subjob、Run 和 Subagent 不进入 Task Center,也不成为其他可导航 UI 对象。
- [ ] Task 消息可以通过 `taskId``jobId``runId` 追溯来源。
- [ ] 并行 Job 不直接无序写入 Conversation。 - [ ] 并行 Job 不直接无序写入 Conversation。
- [ ] 取消和权限范围能够沿层级正确传播。 - [ ] 取消和权限范围能够沿层级正确传播。
- [ ] 新文档不再把 Job/Subjob 定义为新的顶层 Task。
+77 -16
View File
@@ -4,56 +4,117 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 设计中 | | 状态 | Scheduled Task 首期已实现;Goal/Event Task 与完整操作待实施 |
| 版本 | 0.1 | | 版本 | 0.3 |
| 日期 | 2026-08-19 | | 日期 | 2026-08-19 |
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
| 界面归属 | [通用助手工作栏与执行空间](../assistant-experience/assistant-workbar-and-execution-spaces-prd.md) | | 界面归属 | [通用助手工作栏与执行空间](../assistant-experience/assistant-workbar-and-execution-spaces-prd.md) |
## 1. 产品定义 ## 1. 产品定义
Task Center 是所有 Task 的应用级单例索引。它不是第二份任务数据,也不是 Automation Task Center 是所有产品级 Task 的应用级单例索引不是 Automation Center,也不复制
Center。点击条目直接打开 Task 自身的 Conversation Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
Conversation 不因为关联 Task 而改变对象类型。
## 2. 收录边界 ## 2. 收录边界
收录: 收录:
- 用户明确创建的 Task。 - 用户明确创建或确认的 Task。
- Scheduled Task、Event Task 和 Goal Task。 - Scheduled Task、Event Task 和 Goal Task。
- 未来由用户确认创建的其他顶层 Task。 - 未来由用户确认创建的其他顶层 Task。
不收录: 不收录:
- 普通 Conversation。 - 没有显式 Task 关联的普通 Conversation。
- 普通模型请求或工具调用产生的内部执行记录。
- Job、Subjob、Run、工具步骤或 Subagent。 - Job、Subjob、Run、工具步骤或 Subagent。
- Smart Heartbeat 配置、报告和建议。 - Smart Heartbeat 配置、报告和建议。
- 仅用于审计的活动记录。 - 仅用于审计的活动记录。
当前产品 UI 的对象层级止于 Task。Task Center、左侧会话列表和 Conversation 任务区都不显示
Job/Subjob/Run 树、独立详情或路由。
## 3. 列表信息 ## 3. 列表信息
每条 Task 至少显示: 每条 Task 至少显示:
- 名称和 Global / Project 范围。 - 名称和 Global / Project 范围。
- 关联 Conversation 标题。
- Task 类型和触发来源。 - Task 类型和触发来源。
- Ask / Execute 模式。
- 当前聚合状态。 - 当前聚合状态。
- 最近一次面向用户的进展。 - 最近一次面向用户的进展。
- 最近活动时间。 - 最近活动时间。
- 等待审批、失败或需要关注数量 - 等待审批、失败或需要关注状态
- 下次计划时间(如适用)。 - 下次计划时间(如适用)。
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
## 4. 交互 ## 4. 交互
- 点击条目打开 Task Conversation。 - 点击条目打开关联 Conversation,并定位到该 Task
- 支持按需要关注、进行中、已暂停、已结束筛选。 - 支持按需要关注、进行中、已暂停、已结束筛选。
- 支持暂停、恢复、取消和打开详情,但不在窄栏复制完整 Job 时间线 - 支持立即运行、暂停、恢复、取消、编辑和删除
- 后台变化更新状态和徽标,不自动抢占当前页面。 - 后台变化更新状态和徽标,不自动抢占当前页面。
- Task 的计划、JobRun、审批和成果在 Task 自身或对应活动视图管理 - 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
独立交付物在成果中查看。
## 5. 验收标准 ## 5. 左侧 Conversation Task 列表
- [ ] Task Center 只展示 Task。 左侧最近会话列表承担轻量 Task 发现,不替代 Task Center
- [ ] 点击 Task 不会跳转到另一条内容相同的附属 Conversation。
- [ ] Job/Subjob/Run 不会重复成为顶层条目。 ```text
- [ ] Scheduled Task 显示下次时间,但每次触发不新增 Task 条目。 ▾ 产品发布讨论 10:24
- [ ] Smart Heartbeat 不进入 Task Center。
▣ 每周进度总结
每周五 17:00 · Execute · 下次 8 月 21 日
▣ 发布前检查
单次 · Execute · 等待确认
```
- 无 Task 的 Conversation 保持现有单行样式。
- 有 Task 时在行最左侧显示独立展开按钮;父会话行不重复显示任务标签或数量。
- 会话标题溢出时保持时间和操作区固定;悬停会话行后,标题在自身裁切区域内横向滑动展示
完整名称。未溢出标题不滑动,减少动态效果偏好下使用完整标题提示而不产生位移。
- 展开后每个 Task 子项使用任务图标,并显示本地化的模式、计划和状态;不显示 Job、
Subjob、Run 或工具步骤。
- 点击 Conversation 标题打开聊天;点击 Task 打开同一 Conversation 并定位该 Task。
- 新建 Task 后首次自动展开;用户手动折叠后保持选择。
- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。
- 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。
## 6. Conversation 任务区
包含 Task 的 Conversation 顶部显示可折叠任务区:
```text
本会话有 2 个任务
[每周进度总结] [发布前检查] [管理任务]
```
选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、
错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。
## 7. 删除关系
- 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。
- 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。
- 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。
- 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。
## 8. 验收标准
- [x] Task Center 只展示产品级 Task。
- [x] 一条 Conversation 可以关联并展开多个 Task。
- [x] 点击 Task 打开正确 Conversation 并定位到对应 Task。
- [x] 左侧会话列表通过独立展开按钮显示带任务图标和本地化摘要的 Task 子项。
- [x] 当前 UI 不显示 Job/Subjob/Run 树或独立页面。
- [x] Scheduled Task 显示下次时间,但每次触发不新增 Task 条目。
- [x] 普通模型请求和工具调用不会误显示为 Task。
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
- [x] Smart Heartbeat 不进入 Task Center。
+14 -6
View File
@@ -60,14 +60,18 @@ GoodBuddy 应能够:
#### 任务中心 #### 任务中心
- 保留 Task Center 作为工作栏中的稳定入口,不先建设平行的独立任务或自动化平台。 - 保留 Task Center 作为工作栏中的稳定入口,不先建设平行的独立任务或自动化平台。
- 每个 Task 与唯一 Conversation 一对一绑定;打开 Task 就打开该 Conversation。 - 每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;打开 Task
就打开关联 Conversation 并定位该 Task。
- Task Center 只索引 Task,显示范围、状态、最近进展和需要关注信息。 - Task Center 只索引 Task,显示范围、状态、最近进展和需要关注信息。
- 普通 Conversation、Job、Run、工具步骤、Subagent 和智能心跳事项不作为顶层 Task。 - 普通 Conversation、Job、Run、工具步骤、Subagent 和智能心跳事项不作为顶层 Task。
- 左侧最近会话对关联 Task 显示行首展开按钮和带任务图标的子项,父行不重复任务标签,
当前 UI 只展开到 Task。
#### 监督、Runtime 与进程 #### 监督、Runtime 与进程
- 监督展示所选会话、任务、自动化或实验的带证据评论和介入请求。 - 监督展示所选 Conversation、Task 或实验的带证据评论和介入请求。
- Runtime 展示所选会话或 Run 的工具、Subagent、后台 Job、Workflow/Hook 和生命周期 - Runtime 按所选 Conversation 或 Task 聚合工具、委派、后台执行、Workflow/Hook 和生命周期
不提供 Job/Run 树或独立操作对象。
- 进程只展示并控制 GoodBuddy 创建、托管或明确接管的进程。 - 进程只展示并控制 GoodBuddy 创建、托管或明确接管的进程。
- 待审批和高风险状态在所有栏目中持续可见,但不无条件抢占当前栏目。 - 待审批和高风险状态在所有栏目中持续可见,但不无条件抢占当前栏目。
@@ -132,7 +136,8 @@ GoodBuddy 应能够:
### 4.3 后台 Task 与 Job ### 4.3 后台 Task 与 Job
- Task 拥有唯一 Conversation;内部步骤、委派、并行分支和重复触发使用 Job/Subjob。 - Task 只关联一条 ConversationConversation 可以承载多个 Task;内部步骤、委派、并行
分支和重复触发使用 Job/Subjob,但当前 UI 不展示这些内部层级。
- Task 状态:排队、运行、等待审批、暂停、完成、失败、取消、中断。 - Task 状态:排队、运行、等待审批、暂停、完成、失败、取消、中断。
- 应用隐藏后 Job 可以继续运行,应用退出后不承诺继续执行。 - 应用隐藏后 Job 可以继续运行,应用退出后不承诺继续执行。
- 重启时将未完成 Run 标记为中断,并允许用户恢复。 - 重启时将未完成 Run 标记为中断,并允许用户恢复。
@@ -280,7 +285,8 @@ GoodBuddy 应能够:
### 阶段 2Task 与 Job ### 阶段 2Task 与 Job
- 持久化 Task、Job、Run 和事件。 - 持久化 Task、Job、Run 和事件。
- 在现有 Task Center 补齐范围、状态、最近进展、需要关注和直接打开 Task Conversation。 - 在现有 Task Center 补齐范围、状态、最近进展、需要关注和直接打开关联 Conversation。
- 在左侧最近会话增加行首展开按钮和 Task 子项图标,展开层级止于 Task。
- 取消、重试、恢复和审批收件箱。 - 取消、重试、恢复和审批收件箱。
- 托盘状态和桌面通知。 - 托盘状态和桌面通知。
@@ -325,9 +331,11 @@ GoodBuddy 应能够:
### 8.3 Task ### 8.3 Task
- 每个 Task 与唯一 Conversation 一对一绑定,用户无需理解第二层内容载体。 - 每个 Task 只关联一条 Conversation,一条 Conversation 可以承载多个 Task,关联不改变
Conversation 类型或复制内容。
- Task Center 入口保留,普通 Conversation、Job、Run 和心跳事项不会混入顶层列表。 - Task Center 入口保留,普通 Conversation、Job、Run 和心跳事项不会混入顶层列表。
- 每个 Task 显示范围、状态、最近进展和需要关注信息,并可直接打开其 Conversation。 - 每个 Task 显示范围、状态、最近进展和需要关注信息,并可直接打开其 Conversation。
- 左侧会话列表显示行首展开按钮和 Task 子项图标;当前 UI 不展示 Job/Subjob/Run 层级。
- 事件持久化后再展示。 - 事件持久化后再展示。
- 取消、失败、重试和应用重启均有确定状态。 - 取消、失败、重试和应用重启均有确定状态。
- 审批在全局右侧栏可见。 - 审批在全局右侧栏可见。
+301 -6
View File
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('migrates existing databases to schema version 20', async () => { it('migrates existing databases to schema version 22', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-') join(tmpdir(), 'goodbuddy-assistant-migration-')
) )
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(21) ).toBe(22)
expect( expect(
current current
.prepare( .prepare(
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(21) ).toBe(22)
expect( expect(
current current
.prepare( .prepare(
@@ -269,6 +269,131 @@ describe('AssistantDatabase', () => {
current.close() current.close()
}) })
it('backfills one stable Task and Conversation for each v21 schedule', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-stable-schedule-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const project = initial.listProjects()[0]!
const legacySchedule = initial.createSchedule({
projectId: project.id,
title: '旧版每日报告',
prompt: '生成每日报告',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2026-08-20T00:00:00.000Z'
})
initial.close()
const legacyTaskId =
'00000000-0000-4000-8000-000000000221'
const legacyRunId =
'00000000-0000-4000-8000-000000000222'
const raw = new DatabaseSync(databasePath)
raw.exec('BEGIN IMMEDIATE')
raw
.prepare('DELETE FROM tasks WHERE id = ?')
.run(legacySchedule.taskId)
raw
.prepare('DELETE FROM conversations WHERE id = ?')
.run(legacySchedule.conversationId)
raw
.prepare(
`INSERT INTO tasks
(id, project_id, conversation_id, schedule_id, parent_task_id,
expert_id, routing_mode, title, instructions, origin, status,
priority, work_mode, progress, created_at, started_at,
completed_at, error, visible)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, 'schedule',
'completed', 0, 'ask', NULL, ?, ?, ?, NULL, 1)`
)
.run(
legacyTaskId,
project.id,
`schedule:${legacySchedule.id}`,
legacySchedule.title,
legacySchedule.prompt,
'2026-08-18T00:00:00.000Z',
'2026-08-18T00:00:00.000Z',
'2026-08-18T00:01:00.000Z'
)
raw
.prepare(
`INSERT INTO schedule_runs
(id, schedule_id, scheduled_for, task_id, status)
VALUES (?, ?, ?, ?, 'completed')`
)
.run(
legacyRunId,
legacySchedule.id,
'2026-08-18T00:00:00.000Z',
legacyTaskId
)
raw.exec(`
DROP INDEX IF EXISTS idx_tasks_schedule;
PRAGMA user_version = 21;
COMMIT;
`)
raw.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const [schedule] = migrated.listSchedules(project.id)
expect(schedule).toMatchObject({
id: legacySchedule.id,
title: legacySchedule.title,
taskId: expect.any(String),
conversationId: expect.any(String)
})
expect(schedule!.taskId).not.toBe(legacyTaskId)
expect(
migrated.getConversation(schedule!.conversationId)
).toMatchObject({
projectId: project.id,
title: legacySchedule.title,
messages: []
})
expect(migrated.listTasks()).toEqual([
expect.objectContaining({
id: schedule!.taskId,
conversationId: schedule!.conversationId,
scheduleId: schedule!.id,
status: 'idle'
})
])
migrated.close()
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 22 })
expect(
inspected
.prepare(
`SELECT parent_task_id, visible
FROM tasks
WHERE id = ?`
)
.get(legacyTaskId)
).toEqual({
parent_task_id: schedule!.taskId,
visible: 0
})
expect(
inspected
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'index' AND name = 'idx_tasks_schedule'`
)
.get()
).toEqual({ name: 'idx_tasks_schedule' })
inspected.close()
})
it('backfills checklist todos when migrating existing magic notes', async () => { it('backfills checklist todos when migrating existing magic notes', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-magic-todo-migration-') join(tmpdir(), 'goodbuddy-magic-todo-migration-')
@@ -997,6 +1122,26 @@ describe('AssistantDatabase', () => {
recurrence: 'daily', recurrence: 'daily',
nextRunAt: '2026-07-31T00:00:00.000Z' nextRunAt: '2026-07-31T00:00:00.000Z'
}) })
expect(schedule).toMatchObject({
projectId: project.id,
workMode: 'ask',
taskId: expect.any(String),
conversationId: expect.any(String)
})
expect(
database.listTasks().find((task) => task.id === schedule.taskId)
).toMatchObject({
conversationId: schedule.conversationId,
scheduleId: schedule.id,
origin: 'schedule',
status: 'idle'
})
expect(database.getConversation(schedule.conversationId)).toMatchObject({
id: schedule.conversationId,
projectId: project.id,
title: '每日摘要',
messages: []
})
const [claim] = database.claimDueSchedules( const [claim] = database.claimDueSchedules(
new Date('2026-07-31T00:01:00.000Z') new Date('2026-07-31T00:01:00.000Z')
) )
@@ -1011,7 +1156,6 @@ describe('AssistantDatabase', () => {
database.completeScheduleRun( database.completeScheduleRun(
claim!.runId, claim!.runId,
'completed', 'completed',
undefined,
new Date('2026-07-31T00:01:00.000Z') new Date('2026-07-31T00:01:00.000Z')
) )
expect(database.listSchedules(project.id)[0]).toMatchObject({ expect(database.listSchedules(project.id)[0]).toMatchObject({
@@ -1019,6 +1163,89 @@ describe('AssistantDatabase', () => {
nextRunAt: '2026-08-01T00:00:00.000Z', nextRunAt: '2026-08-01T00:00:00.000Z',
lastRunAt: '2026-07-31T00:01:00.000Z' lastRunAt: '2026-07-31T00:01:00.000Z'
}) })
expect(
database.listTasks().find((task) => task.id === schedule.taskId)
).toMatchObject({
status: 'idle',
completedAt: undefined
})
const manualClaim = database.claimScheduleNow(schedule.id)
expect(manualClaim.schedule).toMatchObject({
taskId: schedule.taskId,
conversationId: schedule.conversationId
})
expect(() => database.claimScheduleNow(schedule.id)).toThrow(
'已有一次运行正在进行'
)
database.completeScheduleRun(
manualClaim.runId,
'completed',
new Date('2026-07-31T00:02:00.000Z')
)
database.appendConversationMessage({
conversationId: schedule.conversationId,
role: 'assistant',
content: '今日任务状态正常',
status: '定时任务',
task: {
id: schedule.taskId,
title: schedule.title
}
})
expect(
database.getConversation(schedule.conversationId).messages
).toEqual([
expect.objectContaining({
content: '今日任务状态正常',
task: {
id: schedule.taskId,
title: schedule.title
}
})
])
const sharedConversationSchedule = database.createSchedule({
projectId: project.id,
conversationId: schedule.conversationId,
title: '每周复盘',
prompt: '复盘本周任务',
workMode: 'execute',
recurrence: 'weekly',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
expect(sharedConversationSchedule.conversationId).toBe(
schedule.conversationId
)
expect(
database
.listTasks()
.filter(
(task) =>
task.conversationId === schedule.conversationId &&
task.origin === 'schedule'
)
).toHaveLength(2)
const countsBeforeFailedCreate = {
schedules: database.listSchedules().length,
tasks: database.listTasks().length,
conversations: database.listConversations().length
}
expect(() =>
database.createSchedule({
projectId: project.id,
conversationId:
'00000000-0000-4000-8000-000000000299',
title: '不应创建',
prompt: '无效对话',
workMode: 'execute',
recurrence: 'once',
nextRunAt: '2027-01-02T00:00:00.000Z'
})
).toThrow('所选对话不存在或不可用于任务')
expect({
schedules: database.listSchedules().length,
tasks: database.listTasks().length,
conversations: database.listConversations().length
}).toEqual(countsBeforeFailedCreate)
const overdue = database.createSchedule({ const overdue = database.createSchedule({
projectId: project.id, projectId: project.id,
title: '过期摘要', title: '过期摘要',
@@ -1033,7 +1260,6 @@ describe('AssistantDatabase', () => {
database.completeScheduleRun( database.completeScheduleRun(
overdueClaim!.runId, overdueClaim!.runId,
'completed', 'completed',
undefined,
new Date('2026-07-31T00:01:00.000Z') new Date('2026-07-31T00:01:00.000Z')
) )
expect( expect(
@@ -1043,6 +1269,22 @@ describe('AssistantDatabase', () => {
).toMatchObject({ ).toMatchObject({
nextRunAt: '2026-08-01T00:00:00.000Z' nextRunAt: '2026-08-01T00:00:00.000Z'
}) })
database.removeSchedule(sharedConversationSchedule.id)
expect(
database
.listSchedules(project.id)
.some((item) => item.id === sharedConversationSchedule.id)
).toBe(false)
expect(
database
.listTasks()
.find((task) => task.id === sharedConversationSchedule.taskId)
).toMatchObject({
conversationId: schedule.conversationId,
scheduleId: undefined,
status: 'completed',
workMode: 'execute'
})
database.close() database.close()
}) })
@@ -1083,7 +1325,6 @@ describe('AssistantDatabase', () => {
recovered.completeScheduleRun( recovered.completeScheduleRun(
reclaimed!.runId, reclaimed!.runId,
'completed', 'completed',
undefined,
new Date('2026-08-13T00:02:00.000Z') new Date('2026-08-13T00:02:00.000Z')
) )
expect(recovered.listSchedules()[0]).toMatchObject({ expect(recovered.listSchedules()[0]).toMatchObject({
@@ -1091,9 +1332,45 @@ describe('AssistantDatabase', () => {
enabled: false, enabled: false,
lastRunAt: '2026-08-13T00:02:00.000Z' lastRunAt: '2026-08-13T00:02:00.000Z'
}) })
expect(() =>
recovered.setScheduleEnabled(schedule.id, true)
).toThrow('已执行的一次性计划不能恢复自动运行')
recovered.close() recovered.close()
}) })
it('claims a bounded batch of independent due schedules', async () => {
const database = await createDatabase()
const scheduleIds = Array.from({ length: 3 }, (_, index) =>
database.createSchedule({
title: `批量任务 ${index + 1}`,
prompt: `执行批量任务 ${index + 1}`,
workMode: 'execute',
recurrence: 'once',
nextRunAt: '2026-08-13T00:00:00.000Z'
}).id
)
const firstBatch = database.claimDueSchedules(
new Date('2026-08-13T00:01:00.000Z'),
2
)
expect(firstBatch).toHaveLength(2)
expect(
new Set(firstBatch.map((claim) => claim.schedule.id)).size
).toBe(2)
const secondBatch = database.claimDueSchedules(
new Date('2026-08-13T00:01:00.000Z'),
2
)
expect(secondBatch).toHaveLength(1)
expect(scheduleIds).toContain(secondBatch[0]?.schedule.id)
for (const claim of [...firstBatch, ...secondBatch]) {
database.completeScheduleRun(claim.runId, 'completed')
}
database.close()
})
it('durably interrupts active tasks with completion times and audit events on startup', async () => { it('durably interrupts active tasks with completion times and audit events on startup', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-recovery-') join(tmpdir(), 'goodbuddy-assistant-recovery-')
@@ -1847,9 +2124,27 @@ describe('AssistantDatabase', () => {
role: 'user', role: 'user',
content: '远程消息' content: '远程消息'
}) })
const linkedSchedule = database.createSchedule({
conversationId: localId,
title: '随会话删除的任务',
prompt: '整理会话',
workMode: 'execute',
recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
expect(database.deleteLocalConversation(localId)).toBe(true) expect(database.deleteLocalConversation(localId)).toBe(true)
expect(database.deleteLocalConversation(localId)).toBe(false) expect(database.deleteLocalConversation(localId)).toBe(false)
expect(
database
.listSchedules()
.some((schedule) => schedule.id === linkedSchedule.id)
).toBe(false)
expect(
database
.listTasks()
.some((task) => task.id === linkedSchedule.taskId)
).toBe(false)
expect(() => expect(() =>
database.getConversation(localId) database.getConversation(localId)
).toThrow('对话不存在') ).toThrow('对话不存在')
File diff suppressed because it is too large Load Diff
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count ).count
check.close() check.close()
migrated.close() migrated.close()
expect(version).toBe(21) expect(version).toBe(22)
expect(heartbeatTableCount).toBe(4) expect(heartbeatTableCount).toBe(4)
}) })
+2 -1
View File
@@ -165,7 +165,8 @@ export class SubagentService {
instructions: input.parentRequest.prompt, instructions: input.parentRequest.prompt,
workMode: 'ask', workMode: 'ask',
origin: 'subagent', origin: 'subagent',
status: 'queued' status: 'queued',
visible: false
}) })
this.emit(input, { this.emit(input, {
childTaskId, childTaskId,
+141
View File
@@ -2668,7 +2668,14 @@ describe('registerIpcHandlers agent terminal state', () => {
updatedAt: Date.now(), updatedAt: Date.now(),
messages: [] messages: []
})), })),
appendConversationMessage: vi.fn(),
appendRemoteConversationMessage: vi.fn(), appendRemoteConversationMessage: vi.fn(),
completeScheduleRun: vi.fn(),
claimScheduleNow: vi.fn(),
listSchedules: vi.fn(() => []),
createSchedule: vi.fn(),
setScheduleEnabled: vi.fn(),
removeSchedule: vi.fn(),
upsertModelUsageCall: vi.fn(), upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn(), clearAssistantData: vi.fn(),
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []), listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
@@ -2799,6 +2806,140 @@ describe('registerIpcHandlers agent terminal state', () => {
senderFrame: webContents.mainFrame senderFrame: webContents.mainFrame
}) })
it('defaults custom scheduled Tasks to Execute', async () => {
const harness = createHarness({
runtimeId: 'continue',
capability: 'chat',
supportsToolExecution: true,
async *run() {
yield { type: 'done', requestId: 'unused' } as const
}
})
const conversationId =
'00000000-0000-4000-8000-000000000705'
const createInput = {
projectId: '00000000-0000-4000-8000-000000000401',
conversationId,
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
}
await electronMocks.handlers.get(
ipcChannels.schedulesCreate
)?.(trustedEvent(harness.webContents), createInput)
expect(
harness.assistantDatabase.createSchedule
).toHaveBeenCalledWith({
...createInput,
workMode: 'execute'
})
await harness.dispose()
})
it('reuses a scheduled Task and writes text results to its Conversation', async () => {
const taskId = '00000000-0000-4000-8000-000000000701'
const conversationId =
'00000000-0000-4000-8000-000000000702'
const scheduleId =
'00000000-0000-4000-8000-000000000703'
const runId = '00000000-0000-4000-8000-000000000704'
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
input: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
expect(request.requestId).toBe(runId)
await authorize({
scopeKey: 'workspace.write',
title: '写入工作区',
description: '更新状态文件'
})
yield {
requestId: request.requestId,
type: 'text',
delta: '每日状态正常'
} as const
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const schedule = {
id: scheduleId,
projectId: '00000000-0000-4000-8000-000000000401',
taskId,
conversationId,
title: '每日状态',
prompt: '汇总状态',
workMode: 'execute' as const,
recurrence: 'daily' as const,
nextRunAt: '2026-08-20T00:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
harness.assistantDatabase.claimScheduleNow.mockReturnValue({
schedule,
runId
})
harness.approvalBroker.request.mockResolvedValue('once')
await electronMocks.handlers.get(
ipcChannels.schedulesRunNow
)?.(trustedEvent(harness.webContents), scheduleId)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeScheduleRun
).toHaveBeenCalledWith(runId, 'completed')
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(taskId, 'running')
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
expect.objectContaining({
requestId: taskId,
conversationId
}),
expect.any(AbortSignal),
expect.any(Function)
)
expect(
harness.assistantDatabase.appendConversationMessage
).toHaveBeenCalledWith({
conversationId,
role: 'assistant',
content: '每日状态正常',
status: '定时任务',
task: {
id: taskId,
title: '每日状态'
}
})
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationsChanged
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => { it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = { const runtime = {
runtimeId: 'continue', runtimeId: 'continue',
+232 -92
View File
@@ -156,8 +156,7 @@ import {
expertCreateSchema, expertCreateSchema,
type AssistantSchedule, type AssistantSchedule,
type AssistantArtifact, type AssistantArtifact,
type ConversationAttachment, type ConversationAttachment
type WorkMode
} from '../shared/assistant-contracts' } from '../shared/assistant-contracts'
import { import {
CHANNEL_LIMITS, CHANNEL_LIMITS,
@@ -1158,7 +1157,8 @@ export function registerIpcHandlers(
title: '智能心跳回顾', title: '智能心跳回顾',
instructions: '根据有界本地输入生成智能心跳报告', instructions: '根据有界本地输入生成智能心跳报告',
workMode: 'ask', workMode: 'ask',
origin: 'assistant' origin: 'assistant',
visible: false
}) })
let output = '' let output = ''
let completed = false let completed = false
@@ -1242,28 +1242,52 @@ export function registerIpcHandlers(
) )
} }
} }
const publishConversationChange = (): void => {
const executeSchedule = async ( if (!window.isDestroyed()) {
schedule: Omit<AssistantSchedule, 'workMode'> & { window.webContents.send(ipcChannels.conversationsChanged)
workMode: WorkMode
},
origin: 'schedule' | 'delegation' | 'channel' = 'schedule',
externalSignal?: AbortSignal,
remoteContext?: {
channel: keyof typeof projectChannelLabels
channelLabel: string
senderDisplay: string
projectId: string
projectName: string
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId?: string
contextIds?: string[]
resultFileRequested?: boolean
} }
}
type ExecutionTemplate = Omit<
AssistantSchedule,
'taskId' | 'conversationId'
>
type ChannelExecutionContext = {
channel: keyof typeof projectChannelLabels
channelLabel: string
senderDisplay: string
projectId: string
projectName: string
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId: string
contextIds?: string[]
resultFileRequested?: boolean
}
type TaskWorkExecution =
| {
origin: 'schedule'
schedule: AssistantSchedule
scheduleRunId: string
externalSignal?: AbortSignal
}
| {
origin: 'delegation'
schedule: ExecutionTemplate
externalSignal?: AbortSignal
}
| {
origin: 'channel'
schedule: ExecutionTemplate
externalSignal: AbortSignal
remoteContext: ChannelExecutionContext
}
const executeTaskWork = async (
input: TaskWorkExecution
): Promise<{ ): Promise<{
status: 'completed' | 'failed' status: 'completed' | 'failed'
output?: string output?: string
@@ -1271,13 +1295,24 @@ export function registerIpcHandlers(
attachments?: ChannelMediaAttachment[] attachments?: ChannelMediaAttachment[]
artifactIds?: string[] artifactIds?: string[]
}> => { }> => {
const { origin, schedule } = input
const externalSignal = input.externalSignal
const remoteContext =
input.origin === 'channel' ? input.remoteContext : undefined
if (shuttingDown || executionPaused) { if (shuttingDown || executionPaused) {
return { status: 'failed', error: '应用正在退出' } return { status: 'failed', error: '应用正在退出' }
} }
if (externalSignal?.aborted) { if (externalSignal?.aborted) {
return { status: 'failed', error: '请求已取消' } return { status: 'failed', error: '请求已取消' }
} }
const requestId = remoteContext?.taskId ?? randomUUID() const taskId =
input.origin === 'schedule'
? input.schedule.taskId
: input.origin === 'channel'
? input.remoteContext.taskId
: randomUUID()
const requestId =
input.origin === 'schedule' ? input.scheduleRunId : taskId
const controller = new AbortController() const controller = new AbortController()
const abortFromExternal = (): void => { const abortFromExternal = (): void => {
controller.abort(externalSignal?.reason) controller.abort(externalSignal?.reason)
@@ -1287,22 +1322,27 @@ export function registerIpcHandlers(
}) })
activeRequests.set(requestId, controller) activeRequests.set(requestId, controller)
const runtimeConversationId = const runtimeConversationId =
remoteContext?.conversationId ?? `${origin}:${schedule.id}` remoteContext?.conversationId ??
if (remoteContext?.taskId) { (input.origin === 'schedule'
assistantDatabase.updateTaskStatus(requestId, 'running') ? input.schedule.conversationId
: undefined) ??
`${origin}:${schedule.id}`
if (input.origin !== 'delegation') {
assistantDatabase.updateTaskStatus(taskId, 'running')
} else { } else {
assistantDatabase.createTask({ assistantDatabase.createTask({
id: requestId, id: taskId,
projectId: schedule.projectId, projectId: schedule.projectId,
conversationId: runtimeConversationId, conversationId: runtimeConversationId,
title: schedule.title, title: schedule.title,
instructions: schedule.prompt, instructions: schedule.prompt,
workMode: schedule.workMode, workMode: schedule.workMode,
origin: origin === 'channel' ? 'delegation' : origin origin: 'delegation',
visible: false
}) })
} }
if (origin === 'schedule') { if (origin === 'schedule') {
assistantDatabase.bindScheduleRunTask(schedule.id, requestId) publishConversationChange()
} }
let output = '' let output = ''
let completed = false let completed = false
@@ -1313,7 +1353,7 @@ export function registerIpcHandlers(
onError: (error) => controller.abort(error), onError: (error) => controller.abort(error),
onEvent: (event) => { onEvent: (event) => {
assistantDatabase.appendTaskEvent( assistantDatabase.appendTaskEvent(
requestId, taskId,
event.type, event.type,
event event
) )
@@ -1324,7 +1364,9 @@ export function registerIpcHandlers(
remoteContext?.runtime ?? remoteContext?.runtime ??
(await resolveRequestRuntime({ (await resolveRequestRuntime({
projectId: schedule.projectId, projectId: schedule.projectId,
runtimeSelection: remoteContext?.runtimeSelection, runtimeSelection:
remoteContext?.runtimeSelection ??
schedule.runtimeSelection,
workspaceOverride: remoteContext?.rootPath, workspaceOverride: remoteContext?.rootPath,
followConfiguredAgentRuntime: followConfiguredAgentRuntime:
remoteContext?.followConfiguredAgentRuntime remoteContext?.followConfiguredAgentRuntime
@@ -1389,9 +1431,12 @@ export function registerIpcHandlers(
return channelToolPolicy === 'policy' ? 'deny' : 'once' return channelToolPolicy === 'policy' ? 'deny' : 'once'
} }
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
requestId, taskId,
'waiting_approval' 'waiting_approval'
) )
if (origin === 'schedule') {
publishConversationChange()
}
const settings = await settingsStore.getPolicySettings() const settings = await settingsStore.getPolicySettings()
try { try {
return await approvalBroker.request( return await approvalBroker.request(
@@ -1401,7 +1446,8 @@ export function registerIpcHandlers(
settings.toolApproval === 'policy' settings.toolApproval === 'policy'
? 'policy' ? 'policy'
: undefined, : undefined,
requestId, requestId:
origin === 'schedule' ? taskId : requestId,
conversationId: runtimeConversationId conversationId: runtimeConversationId
}, },
controller.signal, controller.signal,
@@ -1417,7 +1463,10 @@ export function registerIpcHandlers(
) )
} finally { } finally {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
assistantDatabase.updateTaskStatus(requestId, 'running') assistantDatabase.updateTaskStatus(taskId, 'running')
if (origin === 'schedule') {
publishConversationChange()
}
} }
} }
} }
@@ -1445,14 +1494,21 @@ export function registerIpcHandlers(
agentRuntimeSelected ? undefined : authorize agentRuntimeSelected ? undefined : authorize
)) { )) {
if (agentEvent.type === 'model-usage') { if (agentEvent.type === 'model-usage') {
persistModelUsage(agentEvent) persistModelUsage({
...agentEvent,
requestId: taskId,
callId:
origin === 'schedule'
? `${requestId}:${agentEvent.callId}`
: agentEvent.callId
})
continue continue
} }
const taskEvent = const taskEvent =
agentEvent.type === 'generated-image' agentEvent.type === 'generated-image'
? persistGeneratedImage(agentEvent, { ? persistGeneratedImage(agentEvent, {
projectId: schedule.projectId, projectId: schedule.projectId,
taskId: requestId, taskId,
title: schedule.title title: schedule.title
}) })
: agentEvent : agentEvent
@@ -1553,15 +1609,35 @@ export function registerIpcHandlers(
}) })
} }
} }
if (origin !== 'channel' && output.trim()) { if (
input.origin === 'schedule' &&
(output.trim() || artifactIds.length > 0)
) {
assistantDatabase.appendConversationMessage({
conversationId: input.schedule.conversationId,
role: 'assistant',
content:
output.trim() ||
'任务已完成,独立成果已保存到成果工作栏。',
status: '定时任务',
...(artifactIds.length > 0 ? { artifactIds } : {}),
task: {
id: taskId,
title: schedule.title
}
})
publishConversationChange()
} else if (origin === 'delegation' && output.trim()) {
assistantDatabase.createTextArtifact({ assistantDatabase.createTextArtifact({
projectId: schedule.projectId, projectId: schedule.projectId,
taskId: requestId, taskId,
title: schedule.title, title: schedule.title,
content: output content: output
}) })
} }
assistantDatabase.updateTaskStatus(requestId, 'completed') if (origin !== 'schedule') {
assistantDatabase.updateTaskStatus(taskId, 'completed')
}
showDesktopNotificationWhenUnfocused(window, { showDesktopNotificationWhenUnfocused(window, {
title: title:
origin === 'channel' origin === 'channel'
@@ -1571,7 +1647,7 @@ export function registerIpcHandlers(
origin === 'channel' origin === 'channel'
? '结果已回复,并保存到远程通道会话。' ? '结果已回复,并保存到远程通道会话。'
: origin === 'schedule' : origin === 'schedule'
? '结果已保存到 GoodBuddy 成果工作栏。' ? '结果已写入关联对话。'
: '结果已保存到成果工作栏和委派记录。' : '结果已保存到成果工作栏和委派记录。'
}) })
return { return {
@@ -1586,10 +1662,24 @@ export function registerIpcHandlers(
eventBuffer.flush() eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败') const message = safeRuntimeError(error, '定时任务执行失败')
assistantDatabase.updateTaskStatus( assistantDatabase.updateTaskStatus(
requestId, taskId,
controller.signal.aborted ? 'cancelled' : 'failed', controller.signal.aborted ? 'cancelled' : 'failed',
message message
) )
if (input.origin === 'schedule') {
assistantDatabase.appendConversationMessage({
conversationId: input.schedule.conversationId,
role: 'assistant',
content: message,
state: 'error',
status: '定时任务失败',
task: {
id: taskId,
title: schedule.title
}
})
publishConversationChange()
}
showDesktopNotificationWhenUnfocused(window, { showDesktopNotificationWhenUnfocused(window, {
title: title:
origin === 'channel' origin === 'channel'
@@ -1720,34 +1810,71 @@ export function registerIpcHandlers(
yield { requestId: request.requestId, type: 'done' } yield { requestId: request.requestId, type: 'done' }
} }
let scheduleTickRunning = false const maximumConcurrentScheduleRuns = 4
const runDueSchedules = async (): Promise<void> => { let scheduleClaimRunning = false
if (scheduleTickRunning || shuttingDown || executionPaused) { let activeScheduleRuns = 0
const launchDueSchedules = (): void => {
if (
scheduleClaimRunning ||
shuttingDown ||
executionPaused ||
activeScheduleRuns >= maximumConcurrentScheduleRuns
) {
return return
} }
scheduleTickRunning = true scheduleClaimRunning = true
try { try {
for (const claim of assistantDatabase.claimDueSchedules()) { const claims = assistantDatabase.claimDueSchedules(
const result = await trackExecution( new Date(),
executeSchedule(claim.schedule) maximumConcurrentScheduleRuns - activeScheduleRuns
) )
assistantDatabase.completeScheduleRun( activeScheduleRuns += claims.length
claim.runId, for (const claim of claims) {
result.status, const execution = (async () => {
assistantDatabase.getScheduleRunTaskId(claim.runId) const result = await executeTaskWork({
) origin: 'schedule',
} schedule: claim.schedule,
if (!shuttingDown && !executionPaused) { scheduleRunId: claim.runId
await trackExecution(heartbeatService.processDue()) })
assistantDatabase.completeScheduleRun(
claim.runId,
result.status
)
publishConversationChange()
})()
void trackExecution(execution)
.catch(() => undefined)
.finally(() => {
activeScheduleRuns -= 1
launchDueSchedules()
})
} }
} finally { } finally {
scheduleTickRunning = false scheduleClaimRunning = false
} }
} }
const scheduleInterval = setInterval(() => { let heartbeatTickRunning = false
void trackExecution(runDueSchedules()).catch(() => undefined) const runDueHeartbeats = async (): Promise<void> => {
}, 30_000) if (
void trackExecution(runDueSchedules()).catch(() => undefined) heartbeatTickRunning ||
shuttingDown ||
executionPaused
) {
return
}
heartbeatTickRunning = true
try {
await heartbeatService.processDue()
} finally {
heartbeatTickRunning = false
}
}
const runDueWork = (): void => {
launchDueSchedules()
void trackExecution(runDueHeartbeats()).catch(() => undefined)
}
const scheduleInterval = setInterval(runDueWork, 30_000)
runDueWork()
const delegationEndpoint = const delegationEndpoint =
process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim() process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim()
const delegationToken = const delegationToken =
@@ -1768,18 +1895,23 @@ export function registerIpcHandlers(
assistantDatabase.markDelegationDelivered(taskId) assistantDatabase.markDelegationDelivered(taskId)
}, },
onTask: (task) => onTask: (task) =>
trackExecution(executeSchedule({ trackExecution(
id: task.id, executeTaskWork({
projectId: task.projectId, origin: 'delegation',
title: task.title, schedule: {
prompt: task.prompt, id: task.id,
workMode: task.workMode, projectId: task.projectId,
recurrence: 'once', title: task.title,
nextRunAt: new Date().toISOString(), prompt: task.prompt,
enabled: true, workMode: task.workMode,
createdAt: new Date().toISOString(), recurrence: 'once',
updatedAt: new Date().toISOString() nextRunAt: new Date().toISOString(),
}, 'delegation')) enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
})
)
}) })
: undefined : undefined
remoteDelegation?.start() remoteDelegation?.start()
@@ -1927,7 +2059,8 @@ export function registerIpcHandlers(
title: `${channelLabel}远程请求`, title: `${channelLabel}远程请求`,
instructions: executionPrompt, instructions: executionPrompt,
workMode: parsed.workMode, workMode: parsed.workMode,
origin: 'delegation' origin: 'delegation',
visible: false
}) })
publishRemoteActivity({ publishRemoteActivity({
requestId: remoteTaskId, requestId: remoteTaskId,
@@ -2021,8 +2154,9 @@ export function registerIpcHandlers(
const now = new Date().toISOString() const now = new Date().toISOString()
const result = await trackExecution( const result = await trackExecution(
executeSchedule( executeTaskWork({
{ origin: 'channel',
schedule: {
id: randomUUID(), id: randomUUID(),
projectId: project.id, projectId: project.id,
title: `${channelLabel}远程请求`, title: `${channelLabel}远程请求`,
@@ -2034,9 +2168,8 @@ export function registerIpcHandlers(
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}, },
'channel', externalSignal: signal,
signal, remoteContext: {
{
channel, channel,
channelLabel, channelLabel,
senderDisplay, senderDisplay,
@@ -2053,7 +2186,7 @@ export function registerIpcHandlers(
message.text message.text
) )
} }
) })
) )
const responseText = const responseText =
result.output?.trim() || result.output?.trim() ||
@@ -2408,7 +2541,8 @@ export function registerIpcHandlers(
conversationId: request.conversationId, conversationId: request.conversationId,
title: parsedRequest.prompt.slice(0, 120), title: parsedRequest.prompt.slice(0, 120),
instructions: parsedRequest.prompt, instructions: parsedRequest.prompt,
workMode: request.workMode ?? 'ask' workMode: request.workMode ?? 'ask',
visible: false
}) })
} catch (error) { } catch (error) {
knowledgeGateway?.revoke(knowledgeCapabilityToken) knowledgeGateway?.revoke(knowledgeCapabilityToken)
@@ -4255,12 +4389,14 @@ export function registerIpcHandlers(
value.scheduleId, value.scheduleId,
value.enabled value.enabled
) )
publishConversationChange()
} }
) )
registerHandler(ipcChannels.schedulesRemove, (event, input: unknown) => { registerHandler(ipcChannels.schedulesRemove, (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
assistantDatabase.removeSchedule(assistantIdSchema.parse(input)) assistantDatabase.removeSchedule(assistantIdSchema.parse(input))
publishConversationChange()
}) })
registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => { registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => {
@@ -4271,15 +4407,19 @@ export function registerIpcHandlers(
const claim = assistantDatabase.claimScheduleNow( const claim = assistantDatabase.claimScheduleNow(
assistantIdSchema.parse(input) assistantIdSchema.parse(input)
) )
void trackExecution(executeSchedule(claim.schedule)) const execution = (async () => {
.then((result) => { const result = await executeTaskWork({
assistantDatabase.completeScheduleRun( origin: 'schedule',
claim.runId, schedule: claim.schedule,
result.status, scheduleRunId: claim.runId
assistantDatabase.getScheduleRunTaskId(claim.runId)
)
}) })
.catch(() => undefined) assistantDatabase.completeScheduleRun(
claim.runId,
result.status
)
publishConversationChange()
})()
void trackExecution(execution).catch(() => undefined)
}) })
registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => { registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => {
+138 -3
View File
@@ -15,7 +15,12 @@ import type {
DesktopApi DesktopApi
} from '../../shared/contracts' } from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { AssistantProject } from '../../shared/assistant-contracts' import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ConversationSnapshot
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({ const speechRecognitionMocks = vi.hoisted(() => ({
@@ -397,6 +402,8 @@ const api: DesktopApi = {
create: vi.fn(async (input) => ({ create: vi.fn(async (input) => ({
...input, ...input,
id: crypto.randomUUID(), id: crypto.randomUUID(),
taskId: crypto.randomUUID(),
conversationId: input.conversationId ?? crypto.randomUUID(),
enabled: true, enabled: true,
createdAt: '2026-07-31T00:00:00.000Z', createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z' updatedAt: '2026-07-31T00:00:00.000Z'
@@ -778,6 +785,8 @@ describe('App', () => {
vi.mocked(api.conversations.onChanged) vi.mocked(api.conversations.onChanged)
.mockReset() .mockReset()
.mockReturnValue(() => undefined) .mockReturnValue(() => undefined)
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined api.channels = undefined
newConversationListener = undefined newConversationListener = undefined
beforeQuitListener = undefined beforeQuitListener = undefined
@@ -899,6 +908,130 @@ describe('App', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('discovers product Tasks through their Conversation without exposing Runs', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000821'
const taskId = '00000000-0000-4000-8000-000000000822'
const scheduleId =
'00000000-0000-4000-8000-000000000823'
const conversation: ConversationSnapshot = {
id: conversationId,
projectId,
title: '产品发布讨论',
updatedAt: Date.now(),
messages: []
}
const task: AssistantTask = {
id: taskId,
projectId,
conversationId,
scheduleId,
title: '每周发布总结',
instructions: '总结本周发布进度',
origin: 'schedule',
status: 'idle',
createdAt: '2026-08-19T00:00:00.000Z'
}
const schedule: AssistantSchedule = {
id: scheduleId,
projectId,
taskId,
conversationId,
title: task.title,
prompt: task.instructions,
workMode: 'execute',
recurrence: 'weekly',
nextRunAt: '2026-08-21T09:00:00.000Z',
enabled: true,
createdAt: task.createdAt,
updatedAt: task.createdAt
}
vi.mocked(api.conversations.list).mockResolvedValue([conversation])
vi.mocked(api.tasks.list).mockResolvedValue([task])
vi.mocked(api.schedules.list).mockResolvedValue([schedule])
render(<App />)
const toggle = await screen.findByLabelText(
'展开或折叠“产品发布讨论”中的 1 个任务'
)
const conversationButton =
toggle.parentElement?.querySelector('.conversation-item')
expect(toggle.nextElementSibling).toBe(conversationButton)
expect(
conversationButton?.querySelector('.conversation-item__title')
).toHaveAttribute('title', '产品发布讨论')
expect(screen.queryByText('任务 1')).not.toBeInTheDocument()
fireEvent.click(toggle)
const taskTitle = await screen.findByText('每周发布总结', {
selector: '.conversation-task-child__title'
})
const taskChild = taskTitle.closest('button')
expect(taskChild).not.toBeNull()
expect(
taskChild?.querySelector('.conversation-task-child__icon')
).toHaveClass('conversation-task-child__icon--idle')
expect(
taskChild?.querySelector('.conversation-task-child__meta')
).toHaveTextContent('Execute · 每周 · 空闲')
expect(screen.queryByText('Run')).not.toBeInTheDocument()
fireEvent.click(taskChild!)
const taskRegion = await screen.findByRole('region', {
name: '当前会话的任务'
})
expect(within(taskRegion).getByText('Execute')).toBeInTheDocument()
})
it('routes scheduled Task approvals to the associated Conversation', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000831'
const taskId = '00000000-0000-4000-8000-000000000832'
vi.mocked(api.conversations.list).mockResolvedValue([
{
id: conversationId,
projectId,
title: '发布审批会话',
updatedAt: Date.now(),
messages: []
}
])
vi.mocked(api.tasks.list).mockResolvedValue([
{
id: taskId,
projectId,
conversationId,
scheduleId: '00000000-0000-4000-8000-000000000833',
title: '发布任务',
instructions: '更新发布文件',
origin: 'schedule',
status: 'running',
createdAt: '2026-08-19T00:00:00.000Z'
}
])
render(<App />)
await screen.findAllByText('发布审批会话')
await waitFor(() => expect(api.tasks.list).toHaveBeenCalled())
act(() => {
agentListener?.({
requestId: taskId,
type: 'approval',
approvalId: '00000000-0000-4000-8000-000000000834',
title: '请求写入工作区',
description: '更新发布文件',
toolName: 'write_file',
argumentSummary: 'release.md',
allowPermanent: false
})
})
expect(await screen.findAllByText('请求写入工作区'))
.not.toHaveLength(0)
expect(screen.getByText('仅此次')).toBeInTheDocument()
expect(screen.getByLabelText('任务结果:发布任务'))
.toBeInTheDocument()
})
it('schedules lazy workspace routes for idle preloading', () => { it('schedules lazy workspace routes for idle preloading', () => {
render(<App />) render(<App />)
@@ -928,7 +1061,7 @@ describe('App', () => {
expect(api.memory.list).toHaveBeenCalledWith(projectId) expect(api.memory.list).toHaveBeenCalledWith(projectId)
expect(api.memory.list).toHaveBeenCalledWith() expect(api.memory.list).toHaveBeenCalledWith()
expect(api.schedules.list).toHaveBeenCalledOnce() expect(api.schedules.list).toHaveBeenCalledOnce()
expect(api.schedules.list).toHaveBeenCalledWith(projectId) expect(api.schedules.list).toHaveBeenCalledWith()
expect(api.heartbeats.list).toHaveBeenCalledOnce() expect(api.heartbeats.list).toHaveBeenCalledOnce()
}) })
}) })
@@ -6737,7 +6870,9 @@ describe('App', () => {
expect( expect(
screen.getByRole('tab', { name: '任务中心' }) screen.getByRole('tab', { name: '任务中心' })
).toHaveAttribute('aria-selected', 'true') ).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('自动化')).toBeInTheDocument() expect(
screen.getByRole('heading', { name: '任务索引' })
).toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument() expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '上下文' })) fireEvent.click(screen.getByRole('tab', { name: '上下文' }))
expect( expect(
+638 -68
View File
@@ -4,6 +4,7 @@ import {
Check, Check,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronRight,
CircleAlert, CircleAlert,
CircleHelp, CircleHelp,
Copy, Copy,
@@ -13,6 +14,7 @@ import {
HeartPulse, HeartPulse,
Info, Info,
Library, Library,
ListTodo,
LoaderCircle, LoaderCircle,
Maximize2, Maximize2,
MessageSquarePlus, MessageSquarePlus,
@@ -158,6 +160,13 @@ import {
type PendingSidebarApproval, type PendingSidebarApproval,
type SidebarArtifact type SidebarArtifact
} from './RightAssistantSidebar' } from './RightAssistantSidebar'
import {
CustomTaskDialog,
type CustomTaskDestination
} from './CustomTaskDialog'
import { ConversationTaskStrip } from './ConversationTaskStrip'
import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories' import type { SettingsCategoryId } from './settings-categories'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png'
@@ -665,6 +674,7 @@ function ChatHistoryPane({
onVisibleMessageCountChange, onVisibleMessageCountChange,
quickActions, quickActions,
scrollSnapshot, scrollSnapshot,
taskStrip,
visibleMessageCount visibleMessageCount
}: { }: {
active: boolean active: boolean
@@ -703,6 +713,7 @@ function ChatHistoryPane({
) => void ) => void
quickActions: ChatQuickAction[] quickActions: ChatQuickAction[]
scrollSnapshot?: ChatScrollSnapshot scrollSnapshot?: ChatScrollSnapshot
taskStrip?: ReactNode
visibleMessageCount: number visibleMessageCount: number
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('app') const { t } = useTranslation('app')
@@ -911,6 +922,7 @@ function ChatHistoryPane({
hidden={!active} hidden={!active}
inert={!active} inert={!active}
> >
{taskStrip}
<section <section
className="chat" className="chat"
id={active ? 'chat-message-list' : undefined} id={active ? 'chat-message-list' : undefined}
@@ -1132,6 +1144,13 @@ function isConversation(value: unknown): value is Conversation {
entry.artifactIds.every( entry.artifactIds.every(
(artifactId) => typeof artifactId === 'string' (artifactId) => typeof artifactId === 'string'
))) && ))) &&
(entry.task === undefined ||
(typeof entry.task === 'object' &&
entry.task !== null &&
typeof (entry.task as Record<string, unknown>).id ===
'string' &&
typeof (entry.task as Record<string, unknown>).title ===
'string')) &&
(entry.attachments === undefined || (entry.attachments === undefined ||
(Array.isArray(entry.attachments) && (Array.isArray(entry.attachments) &&
entry.attachments.length <= 8 && entry.attachments.length <= 8 &&
@@ -1180,6 +1199,7 @@ function toConversationMessage(message: Message): ConversationMessage {
sourceReferences: message.sourceReferences, sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval, knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds, artifactIds: message.artifactIds,
task: message.task,
attachments: message.attachments attachments: message.attachments
} }
} }
@@ -1256,6 +1276,55 @@ function mergeArtifacts(
) )
} }
function mergePersistedConversations(
current: readonly Conversation[],
incoming: readonly ConversationSnapshot[],
persistedLocal: Map<string, Conversation>
): Conversation[] {
const incomingById = new Map(
incoming.map((conversation) => [conversation.id, conversation])
)
const currentById = new Map(
current.map((conversation) => [conversation.id, conversation])
)
const merged = incoming.map((conversation): Conversation => {
if (conversation.remote) {
return conversation
}
const local = currentById.get(conversation.id)
if (!local || local.remote) {
persistedLocal.set(conversation.id, conversation)
return conversation
}
const localMessageById = new Map(
local.messages.map((message) => [message.id, message])
)
const serverMessageIds = new Set(
conversation.messages.map((message) => message.id)
)
const messages = [
...conversation.messages.map(
(message) => localMessageById.get(message.id) ?? message
),
...local.messages.filter(
(message) => !serverMessageIds.has(message.id)
)
].slice(-500)
const next =
local.updatedAt > conversation.updatedAt
? { ...local, messages }
: { ...conversation, messages }
persistedLocal.set(conversation.id, conversation)
return next
})
for (const conversation of current) {
if (!incomingById.has(conversation.id)) {
merged.push(conversation)
}
}
return merged.sort((left, right) => right.updatedAt - left.updatedAt)
}
function getProjectDefaultRuntimeSelection( function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined, project: AssistantProject | undefined,
settings: RuntimeSettings settings: RuntimeSettings
@@ -1722,6 +1791,7 @@ function ComposerMenuSelect<T extends string>({
function App(): React.JSX.Element { function App(): React.JSX.Element {
const { i18n, t } = useTranslation('app') const { i18n, t } = useTranslation('app')
const { t: tWorkspace } = useTranslation('workspace')
const tRef = useRef(t) const tRef = useRef(t)
useEffect(() => { useEffect(() => {
tRef.current = t tRef.current = t
@@ -1758,6 +1828,7 @@ function App(): React.JSX.Element {
const [projects, setProjects] = useState<AssistantProject[]>([]) const [projects, setProjects] = useState<AssistantProject[]>([])
const projectsRef = useRef(projects) const projectsRef = useRef(projects)
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([]) const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
const assistantTasksRef = useRef(assistantTasks)
const [tokenUsage, setTokenUsage] = const [tokenUsage, setTokenUsage] =
useState<TokenUsageSummary>(emptyTokenUsage) useState<TokenUsageSummary>(emptyTokenUsage)
const [workspaceChanges, setWorkspaceChanges] = const [workspaceChanges, setWorkspaceChanges] =
@@ -1778,6 +1849,13 @@ function App(): React.JSX.Element {
const [assistantSchedules, setAssistantSchedules] = useState< const [assistantSchedules, setAssistantSchedules] = useState<
AssistantSchedule[] AssistantSchedule[]
>([]) >([])
const [selectedAssistantTaskId, setSelectedAssistantTaskId] =
useState<string>()
const [expandedTaskConversationIds, setExpandedTaskConversationIds] =
useState<Set<string>>(() => new Set())
const [customTaskDialog, setCustomTaskDialog] = useState<{
defaultDestination: CustomTaskDestination
}>()
const [assistantHeartbeats, setAssistantHeartbeats] = useState< const [assistantHeartbeats, setAssistantHeartbeats] = useState<
AssistantHeartbeatConfig[] AssistantHeartbeatConfig[]
>([]) >([])
@@ -2344,6 +2422,10 @@ function App(): React.JSX.Element {
projectsRef.current = projects projectsRef.current = projects
}, [projects]) }, [projects])
useEffect(() => {
assistantTasksRef.current = assistantTasks
}, [assistantTasks])
useEffect(() => { useEffect(() => {
resizeComposerTextarea(inputRef.current) resizeComposerTextarea(inputRef.current)
}, [input]) }, [input])
@@ -2977,6 +3059,48 @@ function App(): React.JSX.Element {
deferredSearchQuery, deferredSearchQuery,
searchConversationSnapshot searchConversationSnapshot
]) ])
const productAssistantTasks = useMemo(
() =>
assistantTasks.filter(
(task) => !task.parentTaskId && task.origin === 'schedule'
),
[assistantTasks]
)
const tasksByConversation = useMemo(() => {
const grouped = new Map<string, AssistantTask[]>()
for (const task of productAssistantTasks) {
if (!task.conversationId) {
continue
}
const existing = grouped.get(task.conversationId) ?? []
existing.push(task)
grouped.set(task.conversationId, existing)
}
for (const tasks of grouped.values()) {
tasks.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt)
)
}
return grouped
}, [productAssistantTasks])
const conversationTitles = useMemo(
() =>
new Map(
conversations.map((conversation) => [
conversation.id,
getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
])
),
[conversations, t]
)
const projectNames = useMemo(
() =>
new Map(projects.map((project) => [project.id, project.name])),
[projects]
)
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>( const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() => () =>
conversations.flatMap((conversation) => conversations.flatMap((conversation) =>
@@ -3310,6 +3434,94 @@ function App(): React.JSX.Element {
(event: AgentEvent): void => { (event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId) const run = activeRuns.current.get(event.requestId)
if (!run) { if (!run) {
if (event.type !== 'approval') {
return
}
const attachScheduledApproval = (
task: AssistantTask | undefined
): void => {
if (
!task?.conversationId ||
task.origin !== 'schedule'
) {
return
}
setAssistantTasks((current) =>
current.map((candidate) =>
candidate.id === task.id
? { ...candidate, status: 'waiting_approval' }
: candidate
)
)
if (
activeConversationIdRef.current !== task.conversationId
) {
setUnreadConversationIds((current) => {
const next = new Set(current)
next.add(task.conversationId!)
return next
})
}
setConversations((current) =>
current.map((conversation) => {
if (conversation.id !== task.conversationId) {
return conversation
}
const existing = conversation.messages.find(
(message) =>
message.approval?.id === event.approvalId
)
if (existing) {
return conversation
}
return {
...conversation,
updatedAt: Date.now(),
messages: [
...conversation.messages,
{
id: crypto.randomUUID(),
role: 'assistant',
content: event.title,
createdAt: Date.now(),
state: 'complete',
task: {
id: task.id,
title: task.title
},
approval: {
id: event.approvalId,
title: event.title,
description: event.description,
toolName: event.toolName,
argumentSummary: event.argumentSummary,
allowPermanent: event.allowPermanent
}
}
]
}
})
)
}
const task = assistantTasksRef.current.find(
(candidate) => candidate.id === event.requestId
)
if (task) {
attachScheduledApproval(task)
} else {
void window.goodbuddy.tasks
.list()
.then((tasks) => {
setAssistantTasks(tasks)
assistantTasksRef.current = tasks
attachScheduledApproval(
tasks.find(
(candidate) => candidate.id === event.requestId
)
)
})
.catch(() => undefined)
}
return return
} }
@@ -3998,9 +4210,69 @@ function App(): React.JSX.Element {
} }
let active = true let active = true
let refreshSequence = 0 let refreshSequence = 0
const remove = window.goodbuddy.conversations.onChanged(() => { let refreshTimer: number | undefined
const sequence = ++refreshSequence let refreshInFlight = false
void window.goodbuddy.conversations let refreshQueued = false
const queueRefresh = (): void => {
refreshSequence += 1
refreshQueued = true
if (refreshInFlight || refreshTimer !== undefined) {
return
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
refresh()
}, 50)
}
const refresh = (): void => {
if (refreshInFlight) {
refreshQueued = true
return
}
refreshInFlight = true
refreshQueued = false
const sequence = refreshSequence
const tasksRefresh = window.goodbuddy.tasks
.list()
.then((tasks) => {
if (!active || sequence !== refreshSequence) {
return
}
setAssistantTasks(tasks)
setActivityRecords((current) =>
reconcileActivityRecords(
current,
tasks,
new Set(activeRuns.current.keys())
)
)
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: 'error',
message: tRef.current('notices.taskHistoryReadFailed'),
dedupeKey: 'task-lifecycle-refresh'
})
}
})
const schedulesRefresh = window.goodbuddy.schedules
.list()
.then((schedules) => {
if (active && sequence === refreshSequence) {
setAssistantSchedules(schedules)
}
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: 'error',
message: tRef.current('notices.schedulesReadFailed'),
dedupeKey: 'schedule-lifecycle-refresh'
})
}
})
const conversationsRefresh = window.goodbuddy.conversations
.list() .list()
.then((persisted) => { .then((persisted) => {
if (!active || sequence !== refreshSequence) { if (!active || sequence !== refreshSequence) {
@@ -4045,17 +4317,16 @@ function App(): React.JSX.Element {
dedupeKey: 'remote-channel-message' dedupeKey: 'remote-channel-message'
}) })
} }
const local = conversationsRef.current.filter( setConversations((current) =>
(conversation) => !conversation.remote mergePersistedConversations(
) current,
setConversations( persisted,
[...remote, ...local].sort( persistedLocalConversationsRef.current
(left, right) => right.updatedAt - left.updatedAt
) )
) )
}) })
.catch(() => { .catch(() => {
if (active) { if (active && sequence === refreshSequence) {
notify({ notify({
tone: 'error', tone: 'error',
message: tRef.current( message: tRef.current(
@@ -4065,9 +4336,23 @@ function App(): React.JSX.Element {
}) })
} }
}) })
}) void Promise.allSettled([
tasksRefresh,
schedulesRefresh,
conversationsRefresh
]).finally(() => {
refreshInFlight = false
if (active && refreshQueued) {
queueRefresh()
}
})
}
const remove = window.goodbuddy.conversations.onChanged(queueRefresh)
return () => { return () => {
active = false active = false
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer)
}
remove() remove()
} }
}, [conversationStoreReady]) }, [conversationStoreReady])
@@ -4327,7 +4612,7 @@ function App(): React.JSX.Element {
return return
} }
void window.goodbuddy.schedules void window.goodbuddy.schedules
.list(activeProjectId) .list()
.then(setAssistantSchedules) .then(setAssistantSchedules)
.catch(() => .catch(() =>
notify({ notify({
@@ -5726,11 +6011,14 @@ function App(): React.JSX.Element {
updateMessage(conversationId, messageId, (message) => ({ updateMessage(conversationId, messageId, (message) => ({
...message, ...message,
approval: undefined, approval: undefined,
status: approved status:
? tRef.current('chat.approval.executing', { approved && message.task
decision: decisionLabel ? undefined
}) : approved
: tRef.current('chat.approval.denied') ? tRef.current('chat.approval.executing', {
decision: decisionLabel
})
: tRef.current('chat.approval.denied')
})) }))
} catch { } catch {
updateMessage(conversationId, messageId, (message) => ({ updateMessage(conversationId, messageId, (message) => ({
@@ -6109,6 +6397,163 @@ function App(): React.JSX.Element {
setView('chat') setView('chat')
} }
const openAssistantTask = (task: AssistantTask): void => {
if (!task.conversationId) {
notify({
tone: 'info',
message: t('notices.conversationDeleted')
})
return
}
const conversation = conversations.find(
(candidate) => candidate.id === task.conversationId
)
if (!conversation) {
notify({
tone: 'info',
message: t('notices.conversationDeleted')
})
return
}
if (task.projectId) {
const project = projects.find(
(candidate) => candidate.id === task.projectId
)
setActiveProjectId(task.projectId)
if (project) {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
}
setSelectedAssistantTaskId(task.id)
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
next.add(conversation.id)
return next
})
setActiveId(conversation.id)
setView('chat')
}
const openCustomTaskDialog = (
defaultDestination: CustomTaskDestination
): void => {
if (!activeProject || activeProject.kind !== 'user') {
notify({
tone: 'info',
message: t('customTask.errors.projectUnavailable')
})
return
}
setCustomTaskDialog({ defaultDestination })
}
const createCustomTask = async (
input: Parameters<typeof window.goodbuddy.schedules.create>[0]
): Promise<AssistantSchedule> => {
if (input.conversationId) {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}
const schedule = await window.goodbuddy.schedules.create(input)
setAssistantSchedules((current) => [
schedule,
...current.filter((item) => item.id !== schedule.id)
])
setSelectedAssistantTaskId(schedule.taskId)
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
next.add(schedule.conversationId)
return next
})
const [conversationResult, taskResult, scheduleResult] =
await Promise.allSettled([
window.goodbuddy.conversations.list(),
window.goodbuddy.tasks.list(),
window.goodbuddy.schedules.list()
])
if (conversationResult.status === 'fulfilled') {
setConversations((current) =>
mergePersistedConversations(
current,
conversationResult.value,
persistedLocalConversationsRef.current
)
)
} else {
notify({
tone: 'error',
message: t('notices.remoteConversationRefreshFailed'),
dedupeKey: 'custom-task-conversation-refresh'
})
}
if (taskResult.status === 'fulfilled') {
setAssistantTasks(taskResult.value)
}
if (scheduleResult.status === 'fulfilled') {
setAssistantSchedules(scheduleResult.value)
}
if (
taskResult.status === 'rejected' ||
scheduleResult.status === 'rejected'
) {
notify({
tone: 'error',
message: t('notices.taskHistoryReadFailed'),
dedupeKey: 'custom-task-discovery-refresh'
})
}
if (schedule.projectId) {
const project = projects.find(
(candidate) => candidate.id === schedule.projectId
)
setActiveProjectId(schedule.projectId)
if (project) {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
}
setActiveId(schedule.conversationId)
setView('chat')
return schedule
}
const runAssistantSchedule = async (
scheduleId: string
): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId)
notify({
tone: 'success',
message: t('notices.scheduleStarted')
})
}
const setAssistantScheduleEnabled = async (
scheduleId: string,
enabled: boolean
): Promise<void> => {
await window.goodbuddy.schedules.setEnabled(scheduleId, enabled)
setAssistantSchedules((current) =>
current.map((schedule) =>
schedule.id === scheduleId
? { ...schedule, enabled }
: schedule
)
)
}
const removeAssistantSchedule = async (
scheduleId: string
): Promise<void> => {
await window.goodbuddy.schedules.remove(scheduleId)
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId)
)
}
const clearLocalData = async (): Promise<void> => { const clearLocalData = async (): Promise<void> => {
conversationPersistencePausedRef.current = true conversationPersistencePausedRef.current = true
try { try {
@@ -6432,7 +6877,16 @@ function App(): React.JSX.Element {
<div className="conversation-list"> <div className="conversation-list">
<p className="section-label">{t('sidebar.recent')}</p> <p className="section-label">{t('sidebar.recent')}</p>
{filteredConversations.map((conversation) => ( {filteredConversations.map((conversation) => {
const conversationTasks =
tasksByConversation.get(conversation.id) ?? []
const tasksExpanded =
expandedTaskConversationIds.has(conversation.id)
const conversationTitle = getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
return (
<div className="conversation-entry" key={conversation.id}> <div className="conversation-entry" key={conversation.id}>
<div <div
className={ className={
@@ -6441,6 +6895,34 @@ function App(): React.JSX.Element {
: 'conversation-row' : 'conversation-row'
} }
> >
{conversationTasks.length > 0 && (
<button
aria-expanded={tasksExpanded}
aria-label={t('conversation.tasks.toggle', {
title: conversationTitle,
count: conversationTasks.length
})}
className="conversation-task-toggle"
onClick={() =>
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
if (next.has(conversation.id)) {
next.delete(conversation.id)
} else {
next.add(conversation.id)
}
return next
})
}
type="button"
>
{tasksExpanded ? (
<ChevronDown aria-hidden="true" size={13} />
) : (
<ChevronRight aria-hidden="true" size={13} />
)}
</button>
)}
<button <button
className={ className={
conversation.id === activeId conversation.id === activeId
@@ -6450,6 +6932,7 @@ function App(): React.JSX.Element {
type="button" type="button"
onClick={() => { onClick={() => {
setConversationActionsId('') setConversationActionsId('')
setSelectedAssistantTaskId(undefined)
setActiveId(conversation.id) setActiveId(conversation.id)
setUnreadConversationIds((current) => { setUnreadConversationIds((current) => {
if (!current.has(conversation.id)) { if (!current.has(conversation.id)) {
@@ -6462,7 +6945,7 @@ function App(): React.JSX.Element {
setView('chat') setView('chat')
}} }}
> >
<span> <span className="conversation-item__primary">
{conversation.remote && ( {conversation.remote && (
<b className="conversation-source-badge"> <b className="conversation-source-badge">
{ {
@@ -6472,10 +6955,10 @@ function App(): React.JSX.Element {
} }
</b> </b>
)} )}
{getConversationDisplayTitle( <OverflowMarquee
conversation, className="conversation-item__title"
t('conversation.defaultTitle') text={conversationTitle}
)} />
{unreadConversationIds.has(conversation.id) && ( {unreadConversationIds.has(conversation.id) && (
<i <i
aria-label={t('conversation.unread')} aria-label={t('conversation.unread')}
@@ -6515,10 +6998,7 @@ function App(): React.JSX.Element {
conversationActionsId === conversation.id conversationActionsId === conversation.id
} }
aria-label={t('conversation.actions.more', { aria-label={t('conversation.actions.more', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
className="conversation-more" className="conversation-more"
onClick={() => { onClick={() => {
@@ -6548,10 +7028,7 @@ function App(): React.JSX.Element {
{conversationActionsId === conversation.id && ( {conversationActionsId === conversation.id && (
<div <div
aria-label={t('conversation.actions.region', { aria-label={t('conversation.actions.region', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
className="conversation-actions" className="conversation-actions"
id={`conversation-actions-${conversation.id}`} id={`conversation-actions-${conversation.id}`}
@@ -6594,16 +7071,10 @@ function App(): React.JSX.Element {
{!conversation.remote && ( {!conversation.remote && (
<DestructiveConfirmActions <DestructiveConfirmActions
cancelAriaLabel={t('conversation.delete.cancelAria', { cancelAriaLabel={t('conversation.delete.cancelAria', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
confirmAriaLabel={t('conversation.delete.confirmAria', { confirmAriaLabel={t('conversation.delete.confirmAria', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
confirmLabel={t('conversation.delete.confirm')} confirmLabel={t('conversation.delete.confirm')}
confirming={ confirming={
@@ -6622,10 +7093,7 @@ function App(): React.JSX.Element {
setConfirmingConversationId(conversation.id) setConfirmingConversationId(conversation.id)
} }
triggerAriaLabel={t('conversation.delete.triggerAria', { triggerAriaLabel={t('conversation.delete.triggerAria', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
triggerLabel={t('conversation.delete.trigger')} triggerLabel={t('conversation.delete.trigger')}
/> />
@@ -6647,10 +7115,7 @@ function App(): React.JSX.Element {
> >
<input <input
aria-label={t('conversation.renameAria', { aria-label={t('conversation.renameAria', {
title: getConversationDisplayTitle( title: conversationTitle
conversation,
t('conversation.defaultTitle')
)
})} })}
autoFocus autoFocus
defaultValue={conversation.title} defaultValue={conversation.title}
@@ -6683,8 +7148,73 @@ function App(): React.JSX.Element {
</button> </button>
</form> </form>
)} )}
{tasksExpanded && conversationTasks.length > 0 && (
<ul
aria-label={t('conversation.tasks.list', {
title: conversationTitle
})}
className="conversation-task-children"
>
{conversationTasks.slice(0, 3).map((task) => {
const schedule = findTaskSchedule(
task,
assistantSchedules
)
return (
<li key={task.id}>
<button
className={
selectedAssistantTaskId === task.id
? 'conversation-task-child conversation-task-child--active'
: 'conversation-task-child'
}
onClick={() => openAssistantTask(task)}
type="button"
>
<ListTodo
aria-hidden="true"
className={`conversation-task-child__icon conversation-task-child__icon--${task.status}`}
size={13}
/>
<span className="conversation-task-child__title">
{task.title}
</span>
<small className="conversation-task-child__meta">
{schedule
? `${tWorkspace(`task.mode.${schedule.workMode}`)} · ${tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
)} · `
: ''}
{tWorkspace(`task.status.${task.status}`)}
</small>
</button>
</li>
)
})}
{conversationTasks.length > 3 && (
<li>
<button
className="conversation-task-view-all"
onClick={() => {
setSelectedAssistantTaskId(
conversationTasks[0]?.id
)
setActiveId(conversation.id)
setView('chat')
}}
type="button"
>
{t('conversation.tasks.viewAll', {
count: conversationTasks.length
})}
</button>
</li>
)}
</ul>
)}
</div> </div>
))} )
})}
{filteredConversations.length === 0 && ( {filteredConversations.length === 0 && (
<p className="conversation-empty"> <p className="conversation-empty">
{activeProject?.kind === 'channel' && {activeProject?.kind === 'channel' &&
@@ -6883,6 +7413,35 @@ function App(): React.JSX.Element {
scrollSnapshot={ scrollSnapshot={
chatScrollSnapshots[conversation.id] chatScrollSnapshots[conversation.id]
} }
taskStrip={
!conversation.remote ? (
<ConversationTaskStrip
locale={locale}
onCreate={() =>
openCustomTaskDialog('current')
}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={
setAssistantScheduleEnabled
}
schedules={assistantSchedules}
selectedTaskId={
(tasksByConversation.get(conversation.id) ?? [])
.some(
(task) =>
task.id === selectedAssistantTaskId
)
? selectedAssistantTaskId
: undefined
}
tasks={
tasksByConversation.get(conversation.id) ?? []
}
/>
) : undefined
}
visibleMessageCount={ visibleMessageCount={
visibleMessageCounts[conversation.id] ?? visibleMessageCounts[conversation.id] ??
messageRenderBatchSize messageRenderBatchSize
@@ -8348,14 +8907,41 @@ function App(): React.JSX.Element {
</section> </section>
</div> </div>
)} )}
{customTaskDialog && activeProject?.kind === 'user' && (
<CustomTaskDialog
currentConversationAvailable={Boolean(
activeConversation &&
!activeConversation.remote &&
activeConversation.projectId === activeProject.id
)}
currentConversationId={activeConversation?.id}
defaultDestination={customTaskDialog.defaultDestination}
onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProject.name}
runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean(
runtime?.supportsToolExecution
)}
workspaceLabel={
activeProject.rootPath || t('customTask.scope.noWorkspace')
}
/>
)}
<RightAssistantSidebar <RightAssistantSidebar
approvals={pendingSidebarApprovals} approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts} artifacts={sidebarArtifacts}
attachments={attachments} attachments={attachments}
browserState={browserStates[activeId]} browserState={browserStates[activeId]}
conversationTitles={conversationTitles}
enabledLibraries={enabledSidebarLibraries} enabledLibraries={enabledSidebarLibraries}
memories={assistantMemories} memories={assistantMemories}
onCreateCustomTask={() => openCustomTaskDialog('new')}
schedules={assistantSchedules} schedules={assistantSchedules}
selectedTaskId={selectedAssistantTaskId}
tasks={productAssistantTasks}
projectNames={projectNames}
onClose={() => setAssistantSidebarOpen(false)} onClose={() => setAssistantSidebarOpen(false)}
onInteractBrowser={async () => { onInteractBrowser={async () => {
if (!activeId) { if (!activeId) {
@@ -8392,13 +8978,6 @@ function App(): React.JSX.Element {
}) })
} }
}} }}
onCreateSchedule={async (input) => {
const schedule = await window.goodbuddy.schedules.create({
...input,
projectId: activeProjectId || undefined
})
setAssistantSchedules((current) => [schedule, ...current])
}}
onImportArtifacts={async () => { onImportArtifacts={async () => {
const imported = await window.goodbuddy.artifacts.importFiles( const imported = await window.goodbuddy.artifacts.importFiles(
activeProjectId || undefined activeProjectId || undefined
@@ -8423,12 +9002,8 @@ function App(): React.JSX.Element {
) )
}} }}
onRemoveAttachment={removeAttachment} onRemoveAttachment={removeAttachment}
onRemoveSchedule={async (scheduleId) => { onOpenTask={openAssistantTask}
await window.goodbuddy.schedules.remove(scheduleId) onRemoveSchedule={removeAssistantSchedule}
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId)
)
}}
onRespondApproval={(approval, decision) => { onRespondApproval={(approval, decision) => {
void respondToApproval( void respondToApproval(
approval.conversationId, approval.conversationId,
@@ -8437,13 +9012,8 @@ function App(): React.JSX.Element {
decision decision
) )
}} }}
onRunSchedule={async (scheduleId) => { onRunSchedule={runAssistantSchedule}
await window.goodbuddy.schedules.runNow(scheduleId) onSetScheduleEnabled={setAssistantScheduleEnabled}
notify({
tone: 'success',
message: t('notices.scheduleStarted')
})
}}
onListWorkspaceDirectory={listWorkspaceDirectory} onListWorkspaceDirectory={listWorkspaceDirectory}
onLoadWorkspaceFile={loadWorkspaceFile} onLoadWorkspaceFile={loadWorkspaceFile}
onOpenWorkspaceEntry={openWorkspaceEntry} onOpenWorkspaceEntry={openWorkspaceEntry}
+35
View File
@@ -160,6 +160,41 @@ describe('ChatTimeline', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('labels scheduled result messages with Task provenance', () => {
const messages: Message[] = [
{
id: 'scheduled-result',
role: 'assistant',
content: '今日状态正常',
task: {
id: '00000000-0000-4000-8000-000000000831',
title: '每日状态'
},
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(
screen.getByLabelText('任务结果:每日状态')
).toHaveTextContent('每日状态')
})
it('shows every parallel expert output in its own expandable card', () => { it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [ const messages: Message[] = [
{ {
+13
View File
@@ -3,6 +3,7 @@ import {
Download, Download,
FileText, FileText,
Library, Library,
ListTodo,
ShieldCheck, ShieldCheck,
TerminalSquare, TerminalSquare,
UserRound UserRound
@@ -64,6 +65,7 @@ export type Message = {
sourceReferences?: KnowledgeSearchReference[] sourceReferences?: KnowledgeSearchReference[]
knowledgeRetrieval?: KnowledgeRetrievalStatus knowledgeRetrieval?: KnowledgeRetrievalStatus
artifactIds?: string[] artifactIds?: string[]
task?: ConversationMessage['task']
attachments?: ConversationAttachment[] attachments?: ConversationAttachment[]
} }
@@ -374,6 +376,17 @@ function ChatMessageRowView({
<strong> <strong>
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')} {message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
</strong> </strong>
{message.task && (
<span
aria-label={t('chat.taskResult', {
title: message.task.title
})}
className="message__task"
>
<ListTodo aria-hidden="true" size={12} />
{message.task.title}
</span>
)}
<span>{formatTime(message.createdAt, locale)}</span> <span>{formatTime(message.createdAt, locale)}</span>
</div> </div>
{message.attachments && message.attachments.length > 0 && ( {message.attachments && message.attachments.length > 0 && (
@@ -0,0 +1,90 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ConversationTaskStrip } from './ConversationTaskStrip'
afterEach(cleanup)
describe('ConversationTaskStrip', () => {
it('shows Task details and controls the stable schedule', async () => {
const taskId = '00000000-0000-4000-8000-000000000811'
const scheduleId =
'00000000-0000-4000-8000-000000000812'
const conversationId =
'00000000-0000-4000-8000-000000000813'
const onRunSchedule = vi.fn(async () => undefined)
const onSetScheduleEnabled = vi.fn(async () => undefined)
const onRemoveSchedule = vi.fn(async () => undefined)
render(
<ConversationTaskStrip
locale="zh-CN"
onCreate={vi.fn()}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSelectTask={vi.fn()}
onSetScheduleEnabled={onSetScheduleEnabled}
schedules={[
{
id: scheduleId,
taskId,
conversationId,
title: '每日状态',
prompt: '汇总状态',
workMode: 'execute',
recurrence: 'daily',
nextRunAt: '2026-08-20T09:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
]}
selectedTaskId={taskId}
tasks={[
{
id: taskId,
conversationId,
scheduleId,
title: '每日状态',
instructions: '汇总状态',
origin: 'schedule',
status: 'idle',
createdAt: '2026-08-19T00:00:00.000Z'
}
]}
/>
)
expect(screen.getByText('每日状态', { selector: 'strong' }))
.toBeInTheDocument()
expect(screen.getByText('Execute')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
await waitFor(() =>
expect(onRunSchedule).toHaveBeenCalledWith(scheduleId)
)
fireEvent.click(screen.getByRole('button', { name: '暂停' }))
await waitFor(() =>
expect(onSetScheduleEnabled).toHaveBeenCalledWith(
scheduleId,
false
)
)
fireEvent.click(
screen.getByRole('button', { name: '删除计划' })
)
expect(onRemoveSchedule).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', {
name: '确认删除“每日状态”的计划'
})
)
await waitFor(() =>
expect(onRemoveSchedule).toHaveBeenCalledWith(scheduleId)
)
})
})
+218
View File
@@ -0,0 +1,218 @@
import {
ChevronDown,
ChevronUp,
CircleAlert,
ListTodo,
Plus
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask
} from '../../shared/assistant-contracts'
import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
type ConversationTaskStripProps = {
locale: string
onCreate: () => void
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSelectTask: (taskId: string) => void
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
schedules: AssistantSchedule[]
selectedTaskId?: string
tasks: AssistantTask[]
}
export function ConversationTaskStrip({
locale,
onCreate,
onRemoveSchedule,
onRunSchedule,
onSelectTask,
onSetScheduleEnabled,
schedules,
selectedTaskId,
tasks
}: ConversationTaskStripProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const [manuallyExpanded, setManuallyExpanded] = useState(false)
const [collapsedTaskId, setCollapsedTaskId] = useState('')
const [actionError, setActionError] = useState('')
const selectedTask = useMemo(
() =>
tasks.find((task) => task.id === selectedTaskId) ?? tasks[0],
[selectedTaskId, tasks]
)
const selectedSchedule = selectedTask
? findTaskSchedule(selectedTask, schedules)
: undefined
const expanded =
manuallyExpanded ||
(Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId)
return (
<section
aria-label={t('taskStrip.ariaLabel')}
className="conversation-task-strip"
>
<header className="conversation-task-strip__header">
<button
aria-expanded={expanded}
className="conversation-task-strip__toggle"
onClick={() => {
if (expanded) {
setManuallyExpanded(false)
setCollapsedTaskId(selectedTaskId ?? '')
} else {
setManuallyExpanded(true)
setCollapsedTaskId('')
}
}}
type="button"
>
<ListTodo aria-hidden="true" size={15} />
<strong>{t('taskStrip.title')}</strong>
<span>
{t('taskStrip.count', {
count: tasks.length
})}
</span>
{expanded ? (
<ChevronUp aria-hidden="true" size={14} />
) : (
<ChevronDown aria-hidden="true" size={14} />
)}
</button>
<button
className="secondary-button conversation-task-strip__create"
onClick={onCreate}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('taskStrip.create')}
</button>
</header>
{expanded && (
<div className="conversation-task-strip__content">
{tasks.length === 0 ? (
<p className="conversation-task-strip__empty">
{t('taskStrip.empty')}
</p>
) : (
<>
<div
aria-label={t('taskStrip.taskList')}
className="conversation-task-strip__list"
>
{tasks.map((task) => (
<button
aria-pressed={selectedTask?.id === task.id}
className={
selectedTask?.id === task.id
? 'conversation-task-strip__task conversation-task-strip__task--active'
: 'conversation-task-strip__task'
}
key={task.id}
onClick={() => onSelectTask(task.id)}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span>{task.title}</span>
</button>
))}
</div>
{selectedTask && (
<article className="conversation-task-details">
<header>
<span>
<strong>{selectedTask.title}</strong>
<small>
{t(`task.status.${selectedTask.status}`)}
</small>
</span>
{selectedTask.status === 'failed' && (
<CircleAlert aria-hidden="true" size={15} />
)}
</header>
<dl>
<div>
<dt>{t('task.fields.mode')}</dt>
<dd>
{selectedSchedule
? t(`task.mode.${selectedSchedule.workMode}`)
: selectedTask.workMode
? t(`task.mode.${selectedTask.workMode}`)
: t('task.mode.unavailable')}
</dd>
</div>
<div>
<dt>{t('task.fields.schedule')}</dt>
<dd>
{selectedSchedule
? t(
`sidebar.tasks.schedule.recurrence.${selectedSchedule.recurrence}`
)
: t('task.schedule.none')}
</dd>
</div>
<div>
<dt>{t('task.fields.nextRun')}</dt>
<dd>
{selectedSchedule
? new Date(
selectedSchedule.nextRunAt
).toLocaleString(locale)
: t('task.notAvailable')}
</dd>
</div>
<div>
<dt>{t('task.fields.outcome')}</dt>
<dd>
{selectedTask.error ??
(selectedTask.completedAt
? t('task.completedAt', {
time: new Date(
selectedTask.completedAt
).toLocaleString(locale)
})
: t('task.noOutcome'))}
</dd>
</div>
</dl>
{selectedSchedule && (
<div className="conversation-task-details__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={selectedSchedule}
taskTitle={selectedTask.title}
/>
</div>
)}
</article>
)}
</>
)}
{actionError && (
<p className="conversation-task-strip__error" role="alert">
{actionError}
</p>
)}
</div>
)}
</section>
)
}
+127
View File
@@ -0,0 +1,127 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CustomTaskDialog } from './CustomTaskDialog'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
afterEach(cleanup)
function futureLocalDateTime(): string {
const date = new Date(Date.now() + 24 * 60 * 60 * 1_000)
const local = new Date(
date.getTime() - date.getTimezoneOffset() * 60_000
)
return local.toISOString().slice(0, 16)
}
describe('CustomTaskDialog', () => {
it('keeps dialog spacing and form controls inside their layout bounds', () => {
expect(stylesheet).toMatch(/--space-5:\s*20px;/u)
expect(stylesheet).toMatch(
/\.custom-task-dialog,\s*\.custom-task-dialog \*,\s*\.custom-task-dialog \*::before,\s*\.custom-task-dialog \*::after\s*\{[^}]*box-sizing:\s*border-box;/u
)
expect(stylesheet).toMatch(
/\.custom-task-dialog__content\s*\{[^}]*padding:\s*var\(--space-5\);[^}]*overflow-y:\s*auto;[^}]*scrollbar-gutter:\s*stable;/u
)
expect(stylesheet).toMatch(
/@media \(max-height:\s*720px\)\s*\{[\s\S]*?\.custom-task-dialog__actions\s*\{[^}]*padding:\s*var\(--space-3\) var\(--space-5\);/u
)
})
it('defaults to Execute and keeps the current Conversation association', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000801'
const onCreate = vi.fn(async (input) => ({
...input,
id: '00000000-0000-4000-8000-000000000802',
taskId: '00000000-0000-4000-8000-000000000803',
conversationId,
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}))
render(
<CustomTaskDialog
currentConversationAvailable
currentConversationId={conversationId}
defaultDestination="current"
onClose={vi.fn()}
onCreate={onCreate}
projectId="00000000-0000-4000-8000-000000000804"
projectName="GoodBuddy Desktop"
runtimeLabel="OpenCode"
supportsToolExecution
workspaceLabel="C:\\Workspace"
/>
)
expect(
screen.getByRole('dialog', { name: '新建定制任务' })
).toHaveAttribute('aria-modal', 'true')
expect(screen.getByRole('button', { name: 'Execute' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByLabelText('任务名称')).toHaveFocus()
fireEvent.change(screen.getByLabelText('任务名称'), {
target: { value: '每周项目总结' }
})
fireEvent.change(screen.getByLabelText('任务要求'), {
target: { value: '总结完成和失败的工作' }
})
fireEvent.change(screen.getByLabelText('首次运行'), {
target: { value: futureLocalDateTime() }
})
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
title: '每周项目总结',
prompt: '总结完成和失败的工作',
workMode: 'execute',
recurrence: 'once'
})
)
})
it('uses Ask when the selected Runtime cannot execute tools', () => {
render(
<CustomTaskDialog
currentConversationAvailable={false}
defaultDestination="new"
onClose={vi.fn()}
onCreate={vi.fn()}
projectName="GoodBuddy Desktop"
runtimeLabel="Direct model"
supportsToolExecution={false}
workspaceLabel="C:\\Workspace"
/>
)
expect(screen.getByRole('button', { name: 'Execute' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Ask' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
})
})
+379
View File
@@ -0,0 +1,379 @@
import { CalendarClock, FolderKanban, ShieldCheck, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
ScheduleCreateInput
} from '../../shared/assistant-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
export type CustomTaskDestination = 'current' | 'new'
type CustomTaskDialogProps = {
currentConversationAvailable: boolean
currentConversationId?: string
defaultDestination: CustomTaskDestination
projectId?: string
projectName: string
runtimeLabel: string
workspaceLabel: string
supportsToolExecution: boolean
onClose: () => void
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
}
function toLocalDateTimeValue(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
export function CustomTaskDialog({
currentConversationAvailable,
currentConversationId,
defaultDestination,
projectId,
projectName,
runtimeLabel,
workspaceLabel,
supportsToolExecution,
onClose,
onCreate
}: CustomTaskDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const titleRef = useRef<HTMLInputElement>(null)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
const [destination, setDestination] = useState<CustomTaskDestination>(
defaultDestination === 'current' && currentConversationAvailable
? 'current'
: 'new'
)
const [workMode, setWorkMode] =
useState<ScheduleCreateInput['workMode']>(
supportsToolExecution ? 'execute' : 'ask'
)
const [recurrence, setRecurrence] =
useState<ScheduleCreateInput['recurrence']>('once')
const [nextRunAt, setNextRunAt] = useState(() =>
toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000))
)
const [errors, setErrors] = useState<
Partial<Record<'title' | 'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
>({})
const [submitting, setSubmitting] = useState(false)
useEffect(
() => activateModalFocus(() => titleRef.current),
[]
)
const destinationOptions = useMemo(
() => [
{
value: 'current' as const,
label: t('customTask.destination.current'),
disabled: !currentConversationAvailable
},
{
value: 'new' as const,
label: t('customTask.destination.new')
}
],
[currentConversationAvailable, t]
)
const submit = async (): Promise<void> => {
const nextErrors: typeof errors = {}
if (!title.trim()) {
nextErrors.title = t('customTask.errors.title')
}
if (!prompt.trim()) {
nextErrors.prompt = t('customTask.errors.instructions')
}
if (
destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)
) {
nextErrors.destination = t('customTask.errors.destination')
}
const runAt = new Date(nextRunAt)
if (!nextRunAt || Number.isNaN(runAt.getTime())) {
nextErrors.nextRunAt = t('customTask.errors.time')
} else if (runAt.getTime() <= Date.now()) {
nextErrors.nextRunAt = t('customTask.errors.futureTime')
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
return
}
setSubmitting(true)
try {
await onCreate({
...(projectId ? { projectId } : {}),
...(destination === 'current' && currentConversationId
? { conversationId: currentConversationId }
: {}),
title: title.trim(),
prompt: prompt.trim(),
workMode,
recurrence,
nextRunAt: runAt.toISOString()
})
onClose()
} catch (reason) {
setErrors((current) => ({
...current,
form:
reason instanceof Error
? reason.message
: t('customTask.errors.create')
}))
} finally {
setSubmitting(false)
}
}
return createPortal(
<div
className="custom-task-dialog"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !submitting) {
onClose()
}
}}
>
<section
aria-describedby="custom-task-description"
aria-labelledby="custom-task-title"
aria-modal="true"
className="custom-task-dialog__surface"
onKeyDown={(event) => {
if (event.key === 'Escape' && !submitting) {
event.preventDefault()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
}
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<header className="custom-task-dialog__header">
<div>
<span className="custom-task-dialog__eyebrow">
<CalendarClock aria-hidden="true" size={14} />
{t('customTask.eyebrow')}
</span>
<h2 id="custom-task-title">{t('customTask.title')}</h2>
<p id="custom-task-description">
{t('customTask.description')}
</p>
</div>
<button
aria-label={t('customTask.close')}
className="icon-button"
disabled={submitting}
onClick={onClose}
type="button"
>
<X aria-hidden="true" size={17} />
</button>
</header>
<div className="custom-task-dialog__content">
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.name')}</span>
<input
aria-describedby={errors.title ? 'custom-task-title-error' : undefined}
aria-invalid={Boolean(errors.title)}
maxLength={120}
onChange={(event) => {
setTitle(event.target.value)
setErrors((current) => ({ ...current, title: undefined }))
}}
ref={titleRef}
value={title}
/>
{errors.title && (
<small id="custom-task-title-error" role="alert">
{errors.title}
</small>
)}
</label>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.instructions')}</span>
<textarea
aria-describedby={errors.prompt ? 'custom-task-prompt-error' : undefined}
aria-invalid={Boolean(errors.prompt)}
maxLength={100_000}
onChange={(event) => {
setPrompt(event.target.value)
setErrors((current) => ({ ...current, prompt: undefined }))
}}
rows={5}
value={prompt}
/>
{errors.prompt && (
<small id="custom-task-prompt-error" role="alert">
{errors.prompt}
</small>
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.destination')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.destination')}
onChange={(value) => {
setDestination(value)
setErrors((current) => ({
...current,
destination: undefined
}))
}}
options={destinationOptions}
value={destination}
/>
<small>
{destination === 'current'
? t('customTask.destination.currentHelp')
: t('customTask.destination.newHelp')}
</small>
{!currentConversationAvailable && (
<small>{t('customTask.destination.currentUnavailable')}</small>
)}
{errors.destination && (
<small role="alert">{errors.destination}</small>
)}
</div>
<div className="custom-task-dialog__two-columns">
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.mode')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.mode')}
onChange={setWorkMode}
options={[
{
value: 'execute',
label: t('customTask.mode.execute'),
disabled: !supportsToolExecution
},
{ value: 'ask', label: t('customTask.mode.ask') }
]}
value={workMode}
/>
{!supportsToolExecution && (
<small>{t('customTask.mode.executeUnavailable')}</small>
)}
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.time')}</span>
<input
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
)}
</label>
<section
aria-label={t('customTask.scope.title')}
className="custom-task-dialog__scope"
>
<header>
<FolderKanban aria-hidden="true" size={16} />
<strong>{t('customTask.scope.title')}</strong>
</header>
<dl>
<div>
<dt>{t('customTask.scope.project')}</dt>
<dd>{projectName}</dd>
</div>
<div>
<dt>{t('customTask.scope.runtime')}</dt>
<dd>{runtimeLabel}</dd>
</div>
<div>
<dt>{t('customTask.scope.workspace')}</dt>
<dd>{workspaceLabel}</dd>
</div>
<div>
<dt>{t('customTask.scope.tools')}</dt>
<dd>
<ShieldCheck aria-hidden="true" size={13} />
{workMode === 'execute'
? t('customTask.scope.executeApproval')
: t('customTask.scope.askReadOnly')}
</dd>
</div>
</dl>
</section>
{errors.form && (
<p className="custom-task-dialog__form-error" role="alert">
{errors.form}
</p>
)}
</div>
<footer className="custom-task-dialog__actions">
<button
className="secondary-button"
disabled={submitting}
onClick={onClose}
type="button"
>
{t('customTask.cancel')}
</button>
<button
className="primary-button"
disabled={submitting}
onClick={() => void submit()}
type="button"
>
{submitting
? t('customTask.creating')
: t('customTask.create')}
</button>
</footer>
</section>
</div>,
document.body
)
}
+1
View File
@@ -155,6 +155,7 @@ export function HeartbeatCenter({
} }
const taskStatusLabels: Record<AssistantTask['status'], string> = { const taskStatusLabels: Record<AssistantTask['status'], string> = {
queued: t('statuses.task.queued'), queued: t('statuses.task.queued'),
idle: t('statuses.task.idle'),
running: t('statuses.task.running'), running: t('statuses.task.running'),
waiting_approval: t('statuses.task.waitingApproval'), waiting_approval: t('statuses.task.waitingApproval'),
paused: t('statuses.task.paused'), paused: t('statuses.task.paused'),
+93
View File
@@ -0,0 +1,93 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { OverflowMarquee } from './OverflowMarquee'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
).replaceAll('\r\n', '\n')
function setMeasuredWidth(
element: HTMLElement,
property: 'clientWidth' | 'scrollWidth',
value: number
): void {
Object.defineProperty(element, property, {
configurable: true,
value
})
}
describe('OverflowMarquee', () => {
afterEach(() => cleanup())
it('enables sliding only when the text exceeds its container', () => {
const text = '这是一个明显超过会话列表宽度的完整会话名称'
render(<OverflowMarquee text={text} />)
const container = screen.getByTitle(text)
const track = container.querySelector<HTMLElement>(
'.overflow-marquee__track'
)
expect(track).not.toBeNull()
setMeasuredWidth(container, 'clientWidth', 120)
setMeasuredWidth(track!, 'scrollWidth', 280)
fireEvent.mouseEnter(container)
expect(container).toHaveAttribute('data-overflowing', 'true')
expect(
container.style.getPropertyValue('--overflow-marquee-distance')
).toBe('160px')
expect(
container.style.getPropertyValue('--overflow-marquee-duration')
).toBe('2500ms')
setMeasuredWidth(track!, 'scrollWidth', 100)
fireEvent.mouseEnter(container)
expect(container).not.toHaveAttribute('data-overflowing')
expect(
container.style.getPropertyValue('--overflow-marquee-distance')
).toBe('')
})
it('keeps one readable text copy while its visual track moves', () => {
render(<OverflowMarquee text="完整会话名称" />)
const container = screen.getByTitle('完整会话名称')
expect(container).toHaveTextContent('完整会话名称')
expect(
container.querySelector('.overflow-marquee__track')
).toHaveTextContent('完整会话名称')
expect(container.querySelector('.sr-only')).not.toBeInTheDocument()
})
it('slides on row hover and removes displacement for reduced motion', () => {
const rowHoverIndex = stylesheet.indexOf('.conversation-row:hover')
const hoverTransformIndex = stylesheet.indexOf(
'calc(-1 * var(--overflow-marquee-distance))',
rowHoverIndex
)
expect(rowHoverIndex).toBeGreaterThan(-1)
expect(hoverTransformIndex).toBeGreaterThan(rowHoverIndex)
const reducedMotionIndex = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)'
)
const reducedMotionHoverIndex = stylesheet.indexOf(
'.conversation-row:hover',
reducedMotionIndex
)
const reducedMotionResetIndex = stylesheet.indexOf(
'transform: none;',
reducedMotionHoverIndex
)
expect(reducedMotionHoverIndex).toBeGreaterThan(reducedMotionIndex)
expect(reducedMotionResetIndex).toBeGreaterThan(
reducedMotionHoverIndex
)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
interface OverflowMarqueeProps {
className?: string
text: string
}
const marqueePixelsPerSecond = 64
const minimumMarqueeDurationMs = 1_200
const maximumMarqueeDurationMs = 8_000
export function OverflowMarquee({
className,
text
}: OverflowMarqueeProps): React.JSX.Element {
const containerRef = useRef<HTMLSpanElement>(null)
const trackRef = useRef<HTMLSpanElement>(null)
const measureOverflow = useCallback((): void => {
const container = containerRef.current
const track = trackRef.current
if (!container || !track) {
return
}
const overflowDistance = Math.ceil(
track.scrollWidth - container.clientWidth
)
if (overflowDistance <= 1) {
delete container.dataset.overflowing
container.style.removeProperty('--overflow-marquee-distance')
container.style.removeProperty('--overflow-marquee-duration')
return
}
const duration = Math.min(
maximumMarqueeDurationMs,
Math.max(
minimumMarqueeDurationMs,
Math.round(
(overflowDistance / marqueePixelsPerSecond) * 1_000
)
)
)
container.dataset.overflowing = 'true'
container.style.setProperty(
'--overflow-marquee-distance',
`${overflowDistance}px`
)
container.style.setProperty(
'--overflow-marquee-duration',
`${duration}ms`
)
}, [])
useLayoutEffect(() => {
measureOverflow()
const container = containerRef.current
const track = trackRef.current
if (
!container ||
!track ||
typeof ResizeObserver !== 'function'
) {
window.addEventListener('resize', measureOverflow)
return () =>
window.removeEventListener('resize', measureOverflow)
}
const observer = new ResizeObserver(measureOverflow)
observer.observe(container)
observer.observe(track)
return () => observer.disconnect()
}, [measureOverflow, text])
return (
<span
className={
className
? `overflow-marquee ${className}`
: 'overflow-marquee'
}
onMouseEnter={measureOverflow}
ref={containerRef}
title={text}
>
<span
className="overflow-marquee__track"
ref={trackRef}
>
{text}
</span>
</span>
)
}
@@ -32,8 +32,11 @@ function renderSidebar({
enabledLibraries={[]} enabledLibraries={[]}
memories={[]} memories={[]}
schedules={[]} schedules={[]}
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onClose={vi.fn()} onClose={vi.fn()}
onCreateSchedule={vi.fn(async () => undefined)} onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)} onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({ onListWorkspaceDirectory={vi.fn(async (path: string) => ({
path, path,
@@ -49,6 +52,8 @@ function renderSidebar({
onRemoveSchedule={vi.fn(async () => undefined)} onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()} onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)} onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onOpenTask={vi.fn()}
onStopBrowser={vi.fn(async () => undefined)} onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()} onTabChange={vi.fn()}
open open
@@ -161,14 +166,17 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('keeps automation in the task center without recent tasks', () => { it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' }) renderSidebar({ tab: 'tasks' })
expect(screen.getByText('等待审批')).toBeInTheDocument() expect(screen.getByText('等待审批')).toBeInTheDocument()
expect(screen.getByText('自动化')).toBeInTheDocument()
expect( expect(
screen.getByLabelText('定时任务标题') screen.getByRole('heading', { name: '任务索引' })
).toBeInTheDocument() ).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建定制任务' })
).toBeInTheDocument()
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument() expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
}) })
+195 -135
View File
@@ -2,12 +2,14 @@ import {
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CircleAlert,
ExternalLink, ExternalLink,
FileText, FileText,
FolderTree, FolderTree,
Hourglass, ListTodo,
Monitor, Monitor,
PanelRightClose, PanelRightClose,
Plus,
RefreshCw, RefreshCw,
ShieldAlert, ShieldAlert,
Upload, Upload,
@@ -18,7 +20,7 @@ import { useTranslation } from 'react-i18next'
import type { import type {
AssistantMemory, AssistantMemory,
AssistantSchedule, AssistantSchedule,
ScheduleCreateInput, AssistantTask,
WorkspaceChanges, WorkspaceChanges,
WorkspaceDirectoryListing, WorkspaceDirectoryListing,
WorkspaceFilePreview WorkspaceFilePreview
@@ -31,6 +33,11 @@ import type {
KnowledgeLibrary KnowledgeLibrary
} from '../../shared/contracts' } from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
export type AssistantSidebarTab = export type AssistantSidebarTab =
| 'tasks' | 'tasks'
@@ -65,13 +72,17 @@ type RightAssistantSidebarProps = {
enabledLibraries: KnowledgeLibrary[] enabledLibraries: KnowledgeLibrary[]
memories: AssistantMemory[] memories: AssistantMemory[]
schedules: AssistantSchedule[] schedules: AssistantSchedule[]
tasks: AssistantTask[]
conversationTitles: ReadonlyMap<string, string>
projectNames: ReadonlyMap<string, string>
selectedTaskId?: string
workspaceChanges?: WorkspaceChanges workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string workspaceProjectId?: string
browserState?: BrowserLiveState browserState?: BrowserLiveState
onClose: () => void onClose: () => void
onInteractBrowser: () => Promise<void> onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void> onStopBrowser: () => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void> onCreateCustomTask: () => void
onImportArtifacts: () => Promise<void> onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void> onLoadArtifact: (artifactId: string) => Promise<void>
onRemoveAttachment: (attachmentId: string) => void onRemoveAttachment: (attachmentId: string) => void
@@ -90,6 +101,11 @@ type RightAssistantSidebarProps = {
decision: ApprovalDecision decision: ApprovalDecision
) => void ) => void
onRunSchedule: (scheduleId: string) => Promise<void> onRunSchedule: (scheduleId: string) => Promise<void>
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
onOpenTask: (task: AssistantTask) => void
onTabChange: (tab: AssistantSidebarTab) => void onTabChange: (tab: AssistantSidebarTab) => void
} }
@@ -133,13 +149,17 @@ export function RightAssistantSidebar({
enabledLibraries, enabledLibraries,
memories, memories,
schedules, schedules,
tasks,
conversationTitles,
projectNames,
selectedTaskId,
workspaceChanges, workspaceChanges,
workspaceProjectId, workspaceProjectId,
browserState, browserState,
onClose, onClose,
onInteractBrowser, onInteractBrowser,
onStopBrowser, onStopBrowser,
onCreateSchedule, onCreateCustomTask,
onImportArtifacts, onImportArtifacts,
onLoadArtifact, onLoadArtifact,
onRemoveAttachment, onRemoveAttachment,
@@ -150,6 +170,8 @@ export function RightAssistantSidebar({
onRemoveSchedule, onRemoveSchedule,
onRespondApproval, onRespondApproval,
onRunSchedule, onRunSchedule,
onSetScheduleEnabled,
onOpenTask,
onTabChange onTabChange
}: RightAssistantSidebarProps): React.JSX.Element { }: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace') const { i18n, t } = useTranslation('workspace')
@@ -203,12 +225,9 @@ export function RightAssistantSidebar({
>() >()
const workspacePreviewRequest = useRef(0) const workspacePreviewRequest = useRef(0)
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0) const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
const [scheduleTitle, setScheduleTitle] = useState('') const [taskFilter, setTaskFilter] = useState<
const [schedulePrompt, setSchedulePrompt] = useState('') 'attention' | 'active' | 'paused' | 'finished'
const [scheduleTime, setScheduleTime] = useState('') >('active')
const [scheduleRecurrence, setScheduleRecurrence] = useState<
ScheduleCreateInput['recurrence']
>('once')
const [actionError, setActionError] = useState('') const [actionError, setActionError] = useState('')
const activeMemories = memories.filter( const activeMemories = memories.filter(
(memory) => memory.status === 'confirmed' (memory) => memory.status === 'confirmed'
@@ -222,6 +241,34 @@ export function RightAssistantSidebar({
const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth) const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth)
const canResize = const canResize =
open && viewportWidth >= compactSidebarBreakpoint open && viewportWidth >= compactSidebarBreakpoint
const topLevelTasks = useMemo(
() => tasks.filter((task) => !task.parentTaskId),
[tasks]
)
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'attention') {
return (
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'active') {
return (
task.status === 'idle' ||
task.status === 'queued' ||
task.status === 'running'
)
}
if (taskFilter === 'paused') {
return task.status === 'paused'
}
return task.status === 'completed' || task.status === 'cancelled'
}),
[taskFilter, topLevelTasks]
)
useEffect(() => { useEffect(() => {
const handleViewportResize = (): void => { const handleViewportResize = (): void => {
@@ -502,9 +549,19 @@ export function RightAssistantSidebar({
) : null} ) : null}
{tab === 'tasks' && ( {tab === 'tasks' && (
<section className="assistant-sidebar__section"> <section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description"> <div className="task-center__heading">
{t('sidebar.tasks.description')} <p className="assistant-sidebar__section-description">
</p> {t('sidebar.tasks.description')}
</p>
<button
className="primary-button"
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('sidebar.tasks.createCustom')}
</button>
</div>
<h3> <h3>
<ShieldAlert size={15} /> <ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')} {t('sidebar.tasks.approvalsTitle')}
@@ -547,132 +604,135 @@ export function RightAssistantSidebar({
)} )}
<h3> <h3>
<Hourglass size={15} /> <ListTodo size={15} />
{t('sidebar.tasks.automationTitle')} {t('sidebar.tasks.taskIndexTitle')}
</h3> </h3>
<div className="assistant-sidebar__schedule-form"> <div className="task-center__filters">
<input <SegmentedControl
aria-label={t( ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
'sidebar.tasks.schedule.titleAriaLabel' onChange={setTaskFilter}
)} options={[
maxLength={120} {
onChange={(event) => setScheduleTitle(event.target.value)} value: 'attention',
placeholder={t( label: t('sidebar.tasks.filters.attention')
'sidebar.tasks.schedule.titlePlaceholder' },
)} {
value={scheduleTitle} value: 'active',
label: t('sidebar.tasks.filters.active')
},
{
value: 'paused',
label: t('sidebar.tasks.filters.paused')
},
{
value: 'finished',
label: t('sidebar.tasks.filters.finished')
}
]}
value={taskFilter}
/> />
<textarea
aria-label={t(
'sidebar.tasks.schedule.promptAriaLabel'
)}
maxLength={100_000}
onChange={(event) => setSchedulePrompt(event.target.value)}
placeholder={t(
'sidebar.tasks.schedule.promptPlaceholder'
)}
rows={3}
value={schedulePrompt}
/>
<input
aria-label={t(
'sidebar.tasks.schedule.timeAriaLabel'
)}
onChange={(event) => setScheduleTime(event.target.value)}
type="datetime-local"
value={scheduleTime}
/>
<select
aria-label={t(
'sidebar.tasks.schedule.recurrenceAriaLabel'
)}
onChange={(event) =>
setScheduleRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={scheduleRecurrence}
>
<option value="once">
{t('sidebar.tasks.schedule.recurrence.once')}
</option>
<option value="daily">
{t('sidebar.tasks.schedule.recurrence.daily')}
</option>
<option value="weekly">
{t('sidebar.tasks.schedule.recurrence.weekly')}
</option>
</select>
<button
className="primary-button"
disabled={
!scheduleTitle.trim() ||
!schedulePrompt.trim() ||
!scheduleTime
}
onClick={() => {
runAction(
() =>
onCreateSchedule({
title: scheduleTitle.trim(),
prompt: schedulePrompt.trim(),
workMode: 'ask',
recurrence: scheduleRecurrence,
nextRunAt: new Date(scheduleTime).toISOString()
}),
t('sidebar.errors.addSchedule'),
() => {
setScheduleTitle('')
setSchedulePrompt('')
setScheduleTime('')
}
)
}}
type="button"
>
{t('sidebar.tasks.schedule.add')}
</button>
</div> </div>
{schedules.map((schedule) => ( {filteredTasks.length === 0 ? (
<article <p className="assistant-sidebar__empty">
className="assistant-sidebar__schedule" {topLevelTasks.length === 0
key={schedule.id} ? t('sidebar.tasks.empty')
> : t('sidebar.tasks.noFilterResults')}
<span> </p>
<strong>{schedule.title}</strong> ) : (
<small> filteredTasks.map((task) => {
{new Date(schedule.nextRunAt).toLocaleString(locale)} ·{' '} const schedule = findTaskSchedule(task, schedules)
{t( const conversationTitle = task.conversationId
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}` ? conversationTitles.get(task.conversationId)
: undefined
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
return (
<article
className={
selectedTaskId === task.id
? 'task-center__item task-center__item--selected'
: 'task-center__item'
}
key={task.id}
>
<button
className="task-center__item-main"
onClick={() => onOpenTask(task)}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span>
<strong>{task.title}</strong>
<small>
{conversationTitle ??
t('sidebar.tasks.conversationUnavailable')}
</small>
</span>
{task.status === 'failed' ? (
<CircleAlert aria-hidden="true" size={14} />
) : (
<ChevronRight aria-hidden="true" size={14} />
)}
</button>
<div className="task-center__metadata">
<span>
{projectName
? t('sidebar.tasks.projectScope', {
project: projectName
})
: t('sidebar.tasks.globalScope')}
</span>
<span>
{schedule
? t(`task.mode.${schedule.workMode}`)
: task.workMode
? t(`task.mode.${task.workMode}`)
: t('task.mode.unavailable')}
</span>
<span>{t(`task.status.${task.status}`)}</span>
</div>
<p>
{task.error ??
(task.completedAt
? t('task.completedAt', {
time: new Date(
task.completedAt
).toLocaleString(locale)
})
: task.startedAt
? t('sidebar.tasks.startedAt', {
time: new Date(
task.startedAt
).toLocaleString(locale)
})
: schedule
? t('sidebar.tasks.nextRunAt', {
time: new Date(
schedule.nextRunAt
).toLocaleString(locale)
})
: t('sidebar.tasks.notStarted'))}
</p>
{schedule && (
<div className="task-center__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule}
taskTitle={task.title}
/>
</div>
)} )}
</small> </article>
</span> )
<div> })
<button )}
onClick={() =>
runAction(
() => onRunSchedule(schedule.id),
t('sidebar.errors.runSchedule')
)
}
type="button"
>
{t('sidebar.tasks.schedule.runNow')}
</button>
<button
onClick={() =>
runAction(
() => onRemoveSchedule(schedule.id),
t('sidebar.errors.deleteSchedule')
)
}
type="button"
>
{t('sidebar.tasks.schedule.delete')}
</button>
</div>
</article>
))}
</section> </section>
)} )}
+127
View File
@@ -0,0 +1,127 @@
import {
CalendarClock,
Pause,
Play,
Trash2
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
export function findTaskSchedule(
task: AssistantTask,
schedules: readonly AssistantSchedule[]
): AssistantSchedule | undefined {
return schedules.find(
(schedule) =>
schedule.id === task.scheduleId || schedule.taskId === task.id
)
}
type TaskScheduleActionsProps = {
onError: (message: string) => void
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
schedule: AssistantSchedule
taskTitle: string
}
export function TaskScheduleActions({
onError,
onRemoveSchedule,
onRunSchedule,
onSetScheduleEnabled,
schedule,
taskTitle
}: TaskScheduleActionsProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const [busy, setBusy] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const runAction = async (
action: () => Promise<void>,
fallback: string
): Promise<void> => {
setBusy(true)
onError('')
try {
await action()
} catch (reason) {
onError(reason instanceof Error ? reason.message : fallback)
} finally {
setBusy(false)
}
}
return (
<>
<button
disabled={busy}
onClick={() =>
void runAction(
() => onRunSchedule(schedule.id),
t('sidebar.errors.runSchedule')
)
}
type="button"
>
<Play aria-hidden="true" size={12} />
{t('sidebar.tasks.schedule.runNow')}
</button>
{(schedule.recurrence !== 'once' || !schedule.lastRunAt) && (
<button
disabled={busy}
onClick={() =>
void runAction(
() =>
onSetScheduleEnabled(schedule.id, !schedule.enabled),
t('sidebar.errors.updateSchedule')
)
}
type="button"
>
{schedule.enabled ? (
<Pause aria-hidden="true" size={12} />
) : (
<CalendarClock aria-hidden="true" size={12} />
)}
{schedule.enabled
? t('task.actions.pause')
: t('task.actions.resume')}
</button>
)}
<DestructiveConfirmActions
cancelAriaLabel={t('sidebar.tasks.schedule.cancelDelete')}
confirmAriaLabel={t(
'sidebar.tasks.schedule.confirmDelete',
{ title: taskTitle }
)}
confirmLabel={t(
'sidebar.tasks.schedule.confirmDeleteAction'
)}
confirming={confirmingDelete}
disabled={busy}
icon={<Trash2 aria-hidden="true" size={12} />}
message={t('sidebar.tasks.schedule.deleteMessage')}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => {
setConfirmingDelete(false)
void runAction(
() => onRemoveSchedule(schedule.id),
t('sidebar.errors.deleteSchedule')
)
}}
onRequestConfirm={() => setConfirmingDelete(true)}
triggerLabel={t('sidebar.tasks.schedule.delete')}
/>
</>
)
}
+17 -1
View File
@@ -106,7 +106,7 @@ describe('WorkspacePrimitives', () => {
/\.section-label\s*\{[^}]*font-size:\s*var\(--font-caption\);[^}]*font-weight:\s*600;/u /\.section-label\s*\{[^}]*font-size:\s*var\(--font-caption\);[^}]*font-weight:\s*600;/u
) )
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.conversation-item span\s*\{[^}]*font-size:\s*var\(--font-body\);/u /\.conversation-item__primary\s*\{[^}]*font-size:\s*var\(--font-body\);/u
) )
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.conversation-item small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u /\.conversation-item small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
@@ -134,6 +134,22 @@ describe('WorkspacePrimitives', () => {
) )
}) })
it('keeps the conversation action close to the sidebar edge', () => {
expect(stylesheet).toMatch(
/\.conversation-row \.conversation-item\s*\{[^}]*padding-right:\s*36px;/u
)
expect(stylesheet).toMatch(
/\.conversation-more\s*\{[^}]*right:\s*5px;/u
)
expect(stylesheet).toMatch(
/\.conversation-row:has\(\.conversation-activity-indicator\)\s+\.conversation-item\s*\{[^}]*padding-right:\s*52px;/u
)
expect(stylesheet).toMatch(
/\.conversation-activity-indicator\s*\{[^}]*right:\s*38px;/u
)
expect(stylesheet).not.toContain('.conversation-delete')
})
it('separates Runtime-specific controls from the main composer toolbar', () => { it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u /\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -93,6 +93,11 @@ export const app = {
copy: 'Copy full conversation', copy: 'Copy full conversation',
export: 'Export Markdown' export: 'Export Markdown'
}, },
tasks: {
toggle: 'Expand or collapse {{count}} tasks in “{{title}}”',
list: 'Tasks in “{{title}}”',
viewAll: 'View all {{count}} tasks'
},
delete: { delete: {
cancelAria: 'Cancel deleting conversation {{title}}', cancelAria: 'Cancel deleting conversation {{title}}',
confirmAria: 'Confirm permanently deleting conversation {{title}}', confirmAria: 'Confirm permanently deleting conversation {{title}}',
@@ -103,6 +108,65 @@ export const app = {
trigger: 'Delete conversation' trigger: 'Delete conversation'
} }
}, },
customTask: {
eyebrow: 'CUSTOM TASK',
title: 'New custom task',
description:
'Create a task that runs on schedule and keeps recording results in a conversation.',
close: 'Close new custom task',
fields: {
name: 'Task name',
instructions: 'Task instructions',
destination: 'Conversation',
mode: 'Work mode',
recurrence: 'Frequency',
time: 'First run'
},
destination: {
current: 'Current conversation',
new: 'New conversation',
currentHelp:
'Associate the task with this conversation without renaming it or changing normal chat.',
newHelp:
'Create a conversation for this task, using the task name as its initial title.',
currentUnavailable:
'The current conversation cannot host this task. Choose a new conversation.'
},
mode: {
execute: 'Execute',
ask: 'Ask',
executeUnavailable:
'The current Runtime cannot use tools, so read-only Ask is selected.'
},
recurrence: {
once: 'Once',
daily: 'Daily',
weekly: 'Weekly'
},
scope: {
title: 'Execution scope',
project: 'Project',
runtime: 'Runtime',
workspace: 'Workspace',
tools: 'Tools and approval',
executeApproval:
'Use authorized tools; high-risk actions still require approval',
askReadOnly: 'Read-only run with no changes allowed',
noWorkspace: 'No workspace configured'
},
errors: {
title: 'Enter a task name.',
instructions: 'Enter task instructions.',
destination: 'Choose an available conversation.',
time: 'Choose a valid first run time.',
futureTime: 'The first run must be in the future.',
create: 'Could not create the task. Try again.',
projectUnavailable: 'Select an available regular project first.'
},
cancel: 'Cancel',
create: 'Create task',
creating: 'Creating…'
},
runtime: { runtime: {
unavailable: 'Runtime unavailable', unavailable: 'Runtime unavailable',
detecting: 'Detecting runtime', detecting: 'Detecting runtime',
@@ -135,6 +199,7 @@ export const app = {
}, },
chat: { chat: {
user: 'You', user: 'You',
taskResult: 'Task result: {{title}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY WORKSPACE', eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?', title: 'What would you like to accomplish today?',
@@ -152,6 +152,7 @@ export const heartbeat = {
}, },
task: { task: {
queued: 'Queued', queued: 'Queued',
idle: 'Idle',
running: 'Running', running: 'Running',
waitingApproval: 'Waiting for approval', waitingApproval: 'Waiting for approval',
paused: 'Pending', paused: 'Pending',
@@ -93,26 +93,40 @@ export const workspace = {
tasks: { tasks: {
description: description:
'Review pending approvals and create or manage automations.', 'Review pending approvals and create or manage automations.',
createCustom: 'New custom task',
approvalsTitle: 'Awaiting approval', approvalsTitle: 'Awaiting approval',
noApprovals: 'There are no operations awaiting approval.', noApprovals: 'There are no operations awaiting approval.',
deny: 'Deny', deny: 'Deny',
allowOnce: 'Allow once', allowOnce: 'Allow once',
automationTitle: 'Automation', taskIndexTitle: 'Task index',
empty: 'Explicitly created tasks will appear here.',
noFilterResults: 'No tasks match this filter.',
conversationUnavailable: 'Conversation unavailable',
projectScope: 'Project: {{project}}',
globalScope: 'Global',
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
active: 'Active',
paused: 'Paused',
finished: 'Finished'
},
schedule: { schedule: {
titleAriaLabel: 'Scheduled task title',
titlePlaceholder: 'Task title',
promptAriaLabel: 'Scheduled task instructions',
promptPlaceholder: 'Read-only task to complete on schedule',
timeAriaLabel: 'Scheduled task time',
recurrenceAriaLabel: 'Scheduled task recurrence',
recurrence: { recurrence: {
once: 'Once', once: 'Once',
daily: 'Daily', daily: 'Daily',
weekly: 'Weekly' weekly: 'Weekly'
}, },
add: 'Add scheduled task',
runNow: 'Run now', runNow: 'Run now',
delete: 'Delete' delete: 'Delete schedule',
cancelDelete: 'Cancel deleting the schedule',
confirmDelete: 'Confirm deleting the schedule for “{{title}}”',
confirmDeleteAction: 'Stop future runs',
deleteMessage:
'This stops future automatic runs but keeps the task, conversation, and existing results.'
} }
}, },
context: { context: {
@@ -179,9 +193,9 @@ export const workspace = {
}, },
errors: { errors: {
workspacePreview: 'Could not preview the workspace file', workspacePreview: 'Could not preview the workspace file',
addSchedule: 'Could not add the scheduled task',
runSchedule: 'Could not run the scheduled task', runSchedule: 'Could not run the scheduled task',
deleteSchedule: 'Could not delete the scheduled task', deleteSchedule: 'Could not delete the scheduled task',
updateSchedule: 'Could not update the scheduled task',
refreshWorkspace: 'Could not refresh workspace files', refreshWorkspace: 'Could not refresh workspace files',
importResult: 'Could not import results', importResult: 'Could not import results',
loadResult: 'Could not load the result', loadResult: 'Could not load the result',
@@ -189,6 +203,48 @@ export const workspace = {
stopBrowser: 'Could not stop the browser' stopBrowser: 'Could not stop the browser'
} }
}, },
task: {
status: {
queued: 'Idle',
idle: 'Idle',
running: 'Running',
waiting_approval: 'Awaiting approval',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
interrupted: 'Interrupted'
},
mode: {
ask: 'Ask',
execute: 'Execute',
unavailable: 'Mode unavailable'
},
fields: {
mode: 'Mode',
schedule: 'Schedule',
nextRun: 'Next run',
outcome: 'Latest result'
},
schedule: {
none: 'No schedule'
},
actions: {
pause: 'Pause',
resume: 'Resume'
},
notAvailable: 'Unavailable',
completedAt: 'Completed {{time}}',
noOutcome: 'No run result yet'
},
taskStrip: {
ariaLabel: 'Tasks in this conversation',
title: 'Conversation tasks',
count: '{{count}}',
create: 'New task',
empty: 'This conversation has no tasks yet.',
taskList: 'Task list'
},
files: { files: {
statuses: { statuses: {
added: 'Added', added: 'Added',
@@ -90,6 +90,11 @@ export const app = {
copy: '复制完整会话', copy: '复制完整会话',
export: '导出 Markdown' export: '导出 Markdown'
}, },
tasks: {
toggle: '展开或折叠“{{title}}”中的 {{count}} 个任务',
list: '“{{title}}”中的任务',
viewAll: '查看全部 {{count}} 个任务'
},
delete: { delete: {
cancelAria: '取消删除对话 {{title}}', cancelAria: '取消删除对话 {{title}}',
confirmAria: '确认永久删除对话 {{title}}', confirmAria: '确认永久删除对话 {{title}}',
@@ -100,6 +105,59 @@ export const app = {
trigger: '删除对话' trigger: '删除对话'
} }
}, },
customTask: {
eyebrow: '定制任务',
title: '新建定制任务',
description: '创建一个按计划自动运行,并持续记录在会话中的任务。',
close: '关闭新建定制任务',
fields: {
name: '任务名称',
instructions: '任务要求',
destination: '关联会话',
mode: '执行模式',
recurrence: '运行频率',
time: '首次运行'
},
destination: {
current: '当前会话',
new: '新建会话',
currentHelp: '把任务关联到当前会话,不更改会话名称或普通聊天能力。',
newHelp: '为任务创建一条新会话,默认使用任务名称作为标题。',
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
},
mode: {
execute: 'Execute',
ask: 'Ask',
executeUnavailable: '当前 Runtime 不支持工具执行,已使用只读 Ask。'
},
recurrence: {
once: '单次',
daily: '每日',
weekly: '每周'
},
scope: {
title: '执行范围',
project: '项目',
runtime: 'Runtime',
workspace: '工作目录',
tools: '工具与审批',
executeApproval: '使用已授权工具;高风险操作仍需审批',
askReadOnly: '只读运行,不允许执行变更',
noWorkspace: '未设置工作目录'
},
errors: {
title: '请输入任务名称。',
instructions: '请输入任务要求。',
destination: '请选择可用的关联会话。',
time: '请选择有效的首次运行时间。',
futureTime: '首次运行时间必须晚于当前时间。',
create: '创建任务失败,请重试。',
projectUnavailable: '请先选择一个可用的普通项目。'
},
cancel: '取消',
create: '创建任务',
creating: '创建中…'
},
runtime: { runtime: {
unavailable: 'Runtime 不可用', unavailable: 'Runtime 不可用',
detecting: '正在检测运行时', detecting: '正在检测运行时',
@@ -132,6 +190,7 @@ export const app = {
}, },
chat: { chat: {
user: '用户', user: '用户',
taskResult: '任务结果:{{title}}',
welcome: { welcome: {
eyebrow: 'GOODBUDDY 工作台', eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?', title: '今天想一起完成什么?',
@@ -148,6 +148,7 @@ export const heartbeat = {
}, },
task: { task: {
queued: '等待中', queued: '等待中',
idle: '空闲',
running: '运行中', running: '运行中',
waitingApproval: '等待审批', waitingApproval: '等待审批',
paused: '待处理', paused: '待处理',
@@ -88,26 +88,39 @@ export const workspace = {
}, },
tasks: { tasks: {
description: '处理当前待审批操作,并创建和管理自动化任务。', description: '处理当前待审批操作,并创建和管理自动化任务。',
createCustom: '新建定制任务',
approvalsTitle: '等待审批', approvalsTitle: '等待审批',
noApprovals: '当前没有等待审批的操作。', noApprovals: '当前没有等待审批的操作。',
deny: '拒绝', deny: '拒绝',
allowOnce: '仅此次允许', allowOnce: '仅此次允许',
automationTitle: '自动化', taskIndexTitle: '任务索引',
empty: '明确创建的任务会显示在这里。',
noFilterResults: '当前筛选条件下没有任务。',
conversationUnavailable: '关联会话不可用',
projectScope: '项目:{{project}}',
globalScope: '全局',
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
active: '进行中',
paused: '已暂停',
finished: '已完成'
},
schedule: { schedule: {
titleAriaLabel: '定时任务标题',
titlePlaceholder: '任务标题',
promptAriaLabel: '定时任务内容',
promptPlaceholder: '要定时完成的只读任务',
timeAriaLabel: '定时任务时间',
recurrenceAriaLabel: '定时任务重复规则',
recurrence: { recurrence: {
once: '仅一次', once: '仅一次',
daily: '每天', daily: '每天',
weekly: '每周' weekly: '每周'
}, },
add: '添加定时任务',
runNow: '立即运行', runNow: '立即运行',
delete: '删除' delete: '删除计划',
cancelDelete: '取消删除计划',
confirmDelete: '确认删除“{{title}}”的计划',
confirmDeleteAction: '停止后续运行',
deleteMessage: '这会停止后续自动运行,但保留任务、会话和既有结果。'
} }
}, },
context: { context: {
@@ -168,9 +181,9 @@ export const workspace = {
}, },
errors: { errors: {
workspacePreview: '工作区文件预览失败', workspacePreview: '工作区文件预览失败',
addSchedule: '添加定时任务失败',
runSchedule: '运行定时任务失败', runSchedule: '运行定时任务失败',
deleteSchedule: '删除定时任务失败', deleteSchedule: '删除定时任务失败',
updateSchedule: '更新定时任务失败',
refreshWorkspace: '刷新工作区文件失败', refreshWorkspace: '刷新工作区文件失败',
importResult: '导入成果失败', importResult: '导入成果失败',
loadResult: '加载成果失败', loadResult: '加载成果失败',
@@ -178,6 +191,48 @@ export const workspace = {
stopBrowser: '停止浏览器失败' stopBrowser: '停止浏览器失败'
} }
}, },
task: {
status: {
queued: '空闲',
idle: '空闲',
running: '运行中',
waiting_approval: '等待审批',
paused: '已暂停',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
interrupted: '已中断'
},
mode: {
ask: 'Ask',
execute: 'Execute',
unavailable: '模式不可用'
},
fields: {
mode: '模式',
schedule: '计划',
nextRun: '下次运行',
outcome: '最近结果'
},
schedule: {
none: '无计划'
},
actions: {
pause: '暂停',
resume: '恢复'
},
notAvailable: '不可用',
completedAt: '{{time}} 完成',
noOutcome: '尚无运行结果'
},
taskStrip: {
ariaLabel: '当前会话的任务',
title: '会话任务',
count: '{{count}} 个',
create: '新建任务',
empty: '当前会话还没有任务。',
taskList: '任务列表'
},
files: { files: {
statuses: { statuses: {
added: '新增', added: '新增',
+818 -21
View File
@@ -53,6 +53,7 @@
--space-2: 8px; --space-2: 8px;
--space-3: 12px; --space-3: 12px;
--space-4: 16px; --space-4: 16px;
--space-5: 20px;
--space-6: 24px; --space-6: 24px;
--page-gutter: 32px; --page-gutter: 32px;
--content-reading: 820px; --content-reading: 820px;
@@ -80,6 +81,749 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
/* Product Tasks */
.chat-history-pane {
display: flex;
flex-direction: column;
}
.chat-history-pane > .chat {
height: auto;
flex: 1 1 auto;
}
.conversation-row:has(.conversation-task-toggle) .conversation-item {
padding-left: 42px;
}
.conversation-task-toggle {
position: absolute;
z-index: 1;
left: 7px;
display: grid;
width: 25px;
height: 25px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
place-items: center;
}
.conversation-task-toggle:hover,
.conversation-task-toggle[aria-expanded='true'] {
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-children {
display: grid;
margin: 2px 5px var(--space-2) 18px;
padding: 0;
gap: 2px;
list-style: none;
}
.conversation-task-children > li {
display: flex;
min-width: 0;
}
.conversation-task-children > li > button {
flex: 1;
}
.conversation-task-child,
.conversation-task-view-all {
display: flex;
min-width: 0;
min-height: 30px;
align-items: center;
padding: 5px var(--space-2);
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
text-align: left;
}
.conversation-task-child:hover,
.conversation-task-child--active {
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-child__icon {
flex: 0 0 auto;
color: var(--text-muted);
}
.conversation-task-child__icon--running {
animation: pulse 1.1s ease-in-out infinite;
color: var(--accent);
}
.conversation-task-child__icon--waiting_approval {
color: var(--warning);
}
.conversation-task-child__icon--failed,
.conversation-task-child__icon--interrupted {
color: var(--danger);
}
.conversation-task-child__icon--completed {
color: var(--success);
}
.conversation-task-child__icon--paused,
.conversation-task-child__icon--cancelled {
color: var(--text-disabled);
}
.conversation-task-child__title {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: var(--font-caption);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-child__meta {
flex: 0 0 auto;
max-width: 55%;
overflow: hidden;
color: var(--text-muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-view-all {
justify-content: center;
color: var(--accent);
font-size: var(--font-caption);
}
.conversation-task-strip {
flex: 0 0 auto;
padding:
var(--space-2)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2));
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-raised);
}
.conversation-task-strip__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.conversation-task-strip__toggle {
display: flex;
min-width: 0;
align-items: center;
padding: 5px var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
}
.conversation-task-strip__toggle:hover {
background: var(--surface-subtle);
color: var(--text-primary);
}
.conversation-task-strip__toggle strong {
color: var(--text-primary);
font-size: var(--font-caption);
}
.conversation-task-strip__toggle span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-strip__create {
min-height: 30px;
padding: 0 var(--space-2);
font-size: var(--font-caption);
}
.conversation-task-strip__content {
display: grid;
padding: var(--space-2) 0 var(--space-1);
gap: var(--space-2);
}
.conversation-task-strip__list {
display: flex;
min-width: 0;
overflow-x: auto;
gap: var(--space-1);
scrollbar-width: thin;
}
.conversation-task-strip__task {
display: inline-flex;
min-width: max-content;
align-items: center;
padding: 5px var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: var(--font-caption);
gap: var(--space-2);
}
.conversation-task-strip__task:hover,
.conversation-task-strip__task--active {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-details {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-3);
}
.conversation-task-details > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.conversation-task-details > header > span {
display: grid;
min-width: 0;
gap: 2px;
}
.conversation-task-details > header strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-details > header small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-details > header > svg {
flex: 0 0 auto;
color: var(--danger);
}
.conversation-task-details dl {
display: grid;
margin: 0;
gap: var(--space-2);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.conversation-task-details dl > div {
min-width: 0;
}
.conversation-task-details dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-details dd {
margin: 2px 0 0;
overflow: hidden;
color: var(--text-secondary);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-details__actions,
.task-center__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.conversation-task-details__actions button,
.task-center__actions button {
display: inline-flex;
min-height: 28px;
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--border-control);
border-radius: 6px;
background: var(--surface-raised);
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: var(--font-caption);
gap: var(--space-1);
}
.conversation-task-details__actions button:hover,
.task-center__actions button:hover {
border-color: var(--accent-selected);
color: var(--accent);
}
.conversation-task-details__actions button:disabled,
.task-center__actions button:disabled {
cursor: wait;
opacity: 0.6;
}
.conversation-task-strip__empty,
.conversation-task-strip__error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-strip__error {
color: var(--danger);
}
.task-center__heading {
display: grid;
align-items: start;
gap: var(--space-2);
}
.task-center__heading .primary-button {
justify-self: start;
}
.task-center__filters {
min-width: 0;
overflow-x: auto;
}
.task-center__item {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-2);
}
.task-center__item--selected {
border-color: var(--accent-selected);
box-shadow: inset 3px 0 0 var(--accent);
}
.task-center__item-main {
display: grid;
min-width: 0;
align-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
text-align: left;
}
.task-center__item-main > span:nth-child(2) {
display: grid;
min-width: 0;
gap: 2px;
}
.task-center__item-main strong,
.task-center__item-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-center__item-main strong {
font-size: var(--font-body);
}
.task-center__item-main small,
.task-center__item > p {
color: var(--text-muted);
font-size: var(--font-caption);
}
.task-center__item > p {
margin: 0;
}
.task-center__item-main > svg {
color: var(--text-muted);
}
.task-center__metadata {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.task-center__metadata span {
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 9px;
}
.task-status-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-muted);
}
.task-status-dot--running {
animation: pulse 1.1s ease-in-out infinite;
background: var(--accent);
}
.task-status-dot--waiting_approval {
background: var(--warning);
}
.task-status-dot--failed,
.task-status-dot--interrupted {
background: var(--danger);
}
.task-status-dot--completed {
background: var(--success);
}
.task-status-dot--paused,
.task-status-dot--cancelled {
background: var(--text-disabled);
}
.message__meta .message__task {
display: inline-flex;
min-width: 0;
max-width: min(280px, 50vw);
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent);
font-size: 10px;
font-weight: 600;
gap: 4px;
}
.message__task svg {
flex: 0 0 auto;
}
.custom-task-dialog {
position: fixed;
z-index: 130;
inset: 0;
display: grid;
padding: var(--space-6);
background: color-mix(in srgb, var(--surface-canvas) 62%, transparent);
backdrop-filter: blur(6px);
place-items: center;
}
.custom-task-dialog,
.custom-task-dialog *,
.custom-task-dialog *::before,
.custom-task-dialog *::after {
box-sizing: border-box;
}
.custom-task-dialog__surface {
display: grid;
width: min(680px, 100%);
max-height: min(820px, calc(100vh - 64px));
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.custom-task-dialog__header,
.custom-task-dialog__actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-5);
gap: var(--space-4);
}
.custom-task-dialog__header {
border-bottom: 1px solid var(--border-subtle);
}
.custom-task-dialog__header > div {
min-width: 0;
}
.custom-task-dialog__header h2,
.custom-task-dialog__header p {
margin: 0;
}
.custom-task-dialog__header h2 {
color: var(--text-primary);
font-size: var(--font-page-title);
}
.custom-task-dialog__header p {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-body);
line-height: 1.5;
}
.custom-task-dialog__eyebrow {
display: inline-flex;
align-items: center;
margin-bottom: var(--space-2);
color: var(--accent);
font-size: var(--font-caption);
font-weight: 700;
gap: var(--space-1);
}
.custom-task-dialog__content {
display: grid;
min-height: 0;
padding: var(--space-5);
overflow-y: auto;
gap: var(--space-4);
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.custom-task-dialog__field,
.custom-task-dialog__choice {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.custom-task-dialog__field > span,
.custom-task-dialog__choice > span {
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
.custom-task-dialog__field input,
.custom-task-dialog__field textarea,
.custom-task-dialog__field select {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
outline: none;
background: var(--surface-subtle);
color: var(--text-primary);
font: inherit;
}
.custom-task-dialog__field textarea {
min-height: 104px;
resize: vertical;
}
.custom-task-dialog__field
:is(input, textarea, select):focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.custom-task-dialog__field
:is(input, textarea)[aria-invalid='true'] {
border-color: var(--danger);
}
.custom-task-dialog__field small,
.custom-task-dialog__choice small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.custom-task-dialog__field small[role='alert'],
.custom-task-dialog__choice small[role='alert'] {
color: var(--danger);
}
.custom-task-dialog__two-columns {
display: grid;
min-width: 0;
align-items: start;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.custom-task-dialog__scope {
display: grid;
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-3);
}
.custom-task-dialog__scope > header {
display: flex;
align-items: center;
color: var(--text-primary);
gap: var(--space-2);
}
.custom-task-dialog__scope > header svg {
color: var(--accent);
}
.custom-task-dialog__scope dl {
display: grid;
margin: 0;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.custom-task-dialog__scope dl > div {
min-width: 0;
}
.custom-task-dialog__scope dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.custom-task-dialog__scope dd {
display: flex;
min-width: 0;
align-items: center;
margin: 3px 0 0;
overflow-wrap: anywhere;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.custom-task-dialog__form-error {
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
border-radius: var(--radius-control);
margin: 0;
background: var(--danger-subtle);
color: var(--danger);
font-size: var(--font-caption);
}
.custom-task-dialog__actions {
align-items: center;
justify-content: flex-end;
padding-top: var(--space-4);
padding-bottom: var(--space-4);
border-top: 1px solid var(--border-subtle);
}
@media (max-height: 720px) {
.custom-task-dialog {
padding: var(--space-3);
}
.custom-task-dialog__surface {
max-height: calc(100vh - 24px);
}
.custom-task-dialog__header,
.custom-task-dialog__content {
padding: var(--space-3) var(--space-5);
}
.custom-task-dialog__content {
gap: var(--space-3);
}
.custom-task-dialog__field textarea {
min-height: 88px;
}
.custom-task-dialog__actions {
padding: var(--space-3) var(--space-5);
}
}
@media (max-width: 720px) {
.custom-task-dialog {
padding: var(--space-3);
}
.custom-task-dialog__surface {
max-height: calc(100vh - 24px);
}
.custom-task-dialog__header,
.custom-task-dialog__content,
.custom-task-dialog__actions {
padding: var(--space-4);
}
.custom-task-dialog__two-columns,
.custom-task-dialog__scope dl,
.conversation-task-details dl {
grid-template-columns: minmax(0, 1fr);
}
.conversation-task-strip {
padding-right: var(--space-3);
padding-left: var(--space-3);
}
.conversation-task-strip__header {
align-items: stretch;
flex-direction: column;
}
.conversation-task-strip__create {
width: 100%;
justify-content: center;
}
}
.knowledge-scope__retrieval-mode { .knowledge-scope__retrieval-mode {
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-2);
@@ -1923,17 +2667,68 @@ textarea:focus-visible {
font-weight: 650; font-weight: 650;
} }
.conversation-item span { .conversation-item__primary {
display: flex;
min-width: 0;
flex: 1; flex: 1;
overflow: hidden; align-items: center;
font-size: var(--font-body); font-size: var(--font-body);
}
.conversation-item__title {
min-width: 0;
flex: 1;
}
.conversation-item__primary
> :where(
.conversation-source-badge,
.conversation-unread
) {
flex: 0 0 auto;
}
.conversation-item small {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.overflow-marquee {
--overflow-marquee-distance: 0px;
--overflow-marquee-duration: var(--motion-normal);
display: block;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.conversation-item small { .overflow-marquee__track {
color: var(--text-muted); display: inline-block;
font-size: var(--font-caption); min-width: max-content;
transform: translateX(0);
transition: transform var(--motion-normal) ease-out;
}
.conversation-row:hover
.overflow-marquee[data-overflowing='true'],
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true'] {
text-overflow: clip;
}
.conversation-row:hover
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track,
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track {
transform: translateX(
calc(-1 * var(--overflow-marquee-distance))
);
transition-delay: 300ms;
transition-duration: var(--overflow-marquee-duration);
transition-timing-function: linear;
} }
.conversation-source-badge { .conversation-source-badge {
@@ -7799,12 +8594,17 @@ details.settings-section > :not(summary) + :not(summary) {
} }
.conversation-row .conversation-item { .conversation-row .conversation-item {
padding-right: 58px; padding-right: 36px;
}
.conversation-row:has(.conversation-activity-indicator)
.conversation-item {
padding-right: 52px;
} }
.conversation-activity-indicator { .conversation-activity-indicator {
position: absolute; position: absolute;
right: 10px; right: 38px;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
@@ -7815,8 +8615,7 @@ details.settings-section > :not(summary) + :not(summary) {
pointer-events: none; pointer-events: none;
} }
.conversation-more, .conversation-more {
.conversation-delete {
position: absolute; position: absolute;
display: grid; display: grid;
width: 25px; width: 25px;
@@ -7830,18 +8629,12 @@ details.settings-section > :not(summary) + :not(summary) {
} }
.conversation-more { .conversation-more {
right: 31px;
}
.conversation-delete {
right: 5px; right: 5px;
} }
.conversation-row:hover .conversation-more, .conversation-row:hover .conversation-more,
.conversation-row:hover .conversation-delete,
.conversation-row--active .conversation-more, .conversation-row--active .conversation-more,
.conversation-more:focus-visible, .conversation-more:focus-visible {
.conversation-delete:focus-visible {
opacity: 1; opacity: 1;
} }
@@ -7856,11 +8649,6 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--accent); color: var(--accent);
} }
.conversation-delete:hover {
background: var(--danger-subtle);
color: var(--danger);
}
.conversation-actions { .conversation-actions {
display: grid; display: grid;
padding: var(--space-1); padding: var(--space-1);
@@ -11542,6 +12330,15 @@ details.settings-section > :not(summary) + :not(summary) {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.conversation-row:hover
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track,
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track {
transform: none;
}
*, *,
*::before, *::before,
*::after { *::after {
+15 -1
View File
@@ -250,6 +250,13 @@ export const conversationMessageSchema = z
.strict() .strict()
.optional(), .optional(),
artifactIds: z.array(assistantIdSchema).max(8).optional(), artifactIds: z.array(assistantIdSchema).max(8).optional(),
task: z
.object({
id: assistantIdSchema,
title: z.string().trim().min(1).max(120)
})
.strict()
.optional(),
attachments: z attachments: z
.array(conversationAttachmentSchema) .array(conversationAttachmentSchema)
.max(8) .max(8)
@@ -433,6 +440,7 @@ export type AssistantTaskStatus =
| 'running' | 'running'
| 'waiting_approval' | 'waiting_approval'
| 'paused' | 'paused'
| 'idle'
| 'completed' | 'completed'
| 'failed' | 'failed'
| 'cancelled' | 'cancelled'
@@ -442,6 +450,7 @@ export type AssistantTask = {
id: string id: string
projectId?: string projectId?: string
conversationId?: string conversationId?: string
scheduleId?: string
parentTaskId?: string parentTaskId?: string
expertId?: string expertId?: string
routingMode?: 'manual' | 'smart' routingMode?: 'manual' | 'smart'
@@ -449,6 +458,7 @@ export type AssistantTask = {
instructions: string instructions: string
origin: 'user' | 'assistant' | 'schedule' | 'delegation' | 'subagent' origin: 'user' | 'assistant' | 'schedule' | 'delegation' | 'subagent'
status: AssistantTaskStatus status: AssistantTaskStatus
workMode?: WorkMode
progress?: number progress?: number
createdAt: string createdAt: string
startedAt?: string startedAt?: string
@@ -535,9 +545,10 @@ export type AssistantMemory = MemoryCreateInput & {
export const scheduleCreateSchema = z export const scheduleCreateSchema = z
.object({ .object({
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120), title: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(100_000), prompt: z.string().trim().min(1).max(100_000),
workMode: z.literal('ask'), workMode: workModeSchema.default('execute'),
recurrence: z.enum(['once', 'daily', 'weekly']), recurrence: z.enum(['once', 'daily', 'weekly']),
nextRunAt: z.string().datetime({ offset: true }) nextRunAt: z.string().datetime({ offset: true })
}) })
@@ -547,6 +558,9 @@ export type ScheduleCreateInput = z.infer<typeof scheduleCreateSchema>
export type AssistantSchedule = ScheduleCreateInput & { export type AssistantSchedule = ScheduleCreateInput & {
id: string id: string
taskId: string
conversationId: string
runtimeSelection?: ProjectCreateInput['runtimeSelection']
enabled: boolean enabled: boolean
lastRunAt?: string lastRunAt?: string
createdAt: string createdAt: string