fix: clarify supervisor review and activity layouts

Work Review could place its empty state beside the controls, while redundant copy and secondary activity statistics obscured progress. Use a full-width vertical review flow with a separate run status, connected review stages, prominent saved-batch coverage, and expandable secondary details.

Validation: 33 component tests, 40 Electron fixture layout cases, typecheck, targeted ESLint, and diff checks passed. No model calls.

Release note: 修复工作回顾空态布局,精简顶部控件与重复说明。活动记录通过相连阶段和已保存批次展示进度,次要统计可在详情中展开。
This commit is contained in:
mesalogo
2026-09-26 12:53:10 +08:00
parent 4c7193e1ea
commit 325b702934
10 changed files with 61 additions and 27 deletions
+1 -1
View File
@@ -716,7 +716,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。 - 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。
- 顶层页签为“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。 - 顶层页签为“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。自动监督集中展示当前计划、运行概览、指标、趋势、自动监督报告、记忆与行动建议及心跳运行审计;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。 - 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。自动监督集中展示当前计划、运行概览、指标、趋势、自动监督报告、记忆与行动建议及心跳运行审计;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
- 监督者保留统一页面壳层;阅读与活动子面板分别复用 `--content-reading` 和 `--content-dashboard`,在壳层内左对齐。各面板的工具栏、历史导航、状态和结果共享宽度,不在全宽卡片内部再次居中窄正文。活动筛选按内容紧邻排列,阶段以文字状态行呈现。 - 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工具栏、历史导航、状态和结果共享宽度,不再增加子面板宽度上限。工作回顾按纵向排列,空态独占工具栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用 - 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
Checkbox,不能依靠进入页面时的当前项目推断。 Checkbox,不能依靠进入页面时的当前项目推断。
- 状态卡片使用统一状态令牌,不只依赖颜色。 - 状态卡片使用统一状态令牌,不只依赖颜色。
@@ -8,13 +8,13 @@
当前应用页只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”(英文活动标签为 Activity records)。自动监督独立管理计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。 当前应用页只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”(英文活动标签为 Activity records)。自动监督独立管理计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。
工作回顾只读取统一监督结果。顶部为紧凑的“新回顾”范围、时间、发起与刷新控件,不新增 Modal;简短帮助说明选择仅作用于新请求。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。 工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
新回顾运行中保留已显示结果及其日期、范围,禁用重复提交,并显示简短状态和活动记录入口;该入口清除计划筛选并聚焦活动页签。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时显示手动回顾入口;自动报告是否为空不影响此判断。 新回顾运行中保留已显示结果及其日期、范围,禁用重复提交,并显示简短状态和活动记录入口;该入口清除计划筛选并聚焦活动页签。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
## 活动 ## 活动
文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动说明只描述手动回顾、自动监督执行及自动刷新;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。 文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
对应 FR-S10、US-S26。活动按开始时间倒序,每页 50 条,提供较新/更早记录和刷新。列表保留触发方式、执行范围、开始/结束时间、实际状态、阶段状态、错误及已保存摘要。没有结果时不显示结果按钮;有监督结果时可分别进入该结果的工作回顾和故事线图谱,焦点移到目标页签。超过最近 20 条的结果仍按 ID 读取,不能回退最新结果。 对应 FR-S10、US-S26。活动按开始时间倒序,每页 50 条,提供较新/更早记录和刷新。列表保留触发方式、执行范围、开始/结束时间、实际状态、阶段状态、错误及已保存摘要。没有结果时不显示结果按钮;有监督结果时可分别进入该结果的工作回顾和故事线图谱,焦点移到目标页签。超过最近 20 条的结果仍按 ID 读取,不能回退最新结果。
@@ -24,9 +24,9 @@
加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,存在运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新;每次最多一个请求。切换顶层页签、离开监督者或关闭应用后停止定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。 加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,存在运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新;每次最多一个请求。切换顶层页签、离开监督者或关闭应用后停止定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。
活动列表与工具栏铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设仪表盘宽度上限。范围、计划筛选、清除筛选和刷新紧邻排列,空间不足时换行。列表使用共享表面、边框、状态颜色和按钮。每条运行首先显示类型、状态、开始时间和范围;“收集来源 / 整理批次 / 合并摘要 / 发布回顾”紧随其后,宽窗口使用按内容排布的紧凑状态行,窄于 720px 时两列,不为每个阶段铺设独立色块卡片。阶段读取已保存位置和真实运行状态,剩余来源为零不表示发布成功。旧运行没有阶段字段时显示“未记录”,不猜测失败位置;已提交来源清单可确认收集结束。无变化运行不显示新结果跳转,后续无需执行的阶段单独标注。 活动列表与工具栏铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设仪表盘宽度上限。范围、计划筛选、清除筛选和刷新紧邻排列,空间不足时换行。列表使用共享表面、边框、状态颜色和按钮。每条运行首先显示类型、状态、开始时间和范围;恢复与事实操作紧随其后,再展示“收集来源 / 整理批次 / 合并摘要 / 发布回顾”。阶段使用编号圆点和连接线,宽窗口四项等宽,窄于 720px 时两列;当前及已完成阶段突出显示,不为每个阶段铺设独立色块卡片。阶段读取已保存位置和真实运行状态,剩余来源为零不表示发布成功。旧运行没有阶段字段时显示“未记录”,不猜测失败位置;已提交来源清单可确认收集结束。无变化运行不显示新结果跳转,后续无需执行的阶段单独标注。
阶段下显示已保存批数、已读字符数、剩余来源和已保存导航合并数,运行中另显示排队或在途数。字符进度按码点统计,界面不展示 Unicode 术语。恢复操作先于详情:运行中可暂停,失败或暂停后沿用同一运行继续;已记录合并、发布阶段时分别显示“继续合并摘要”“重试发布”。成功叶子保留。来源失效时显示新建回顾提示,不提供无效继续。原始错误默认折叠,展开后的完整文本最多占 240px 高度,可滚动、选择复制和键盘聚焦;错误与恢复操作位于批次树之前。结束时间、覆盖区间、报告状态和已有摘要收进“运行详情与配置”,已保存配置在其中再次折叠。 阶段下突出显示已保存批数和剩余来源,运行中另显示合计的排队或在途数,不拆分未经接口提供的数量。已读字符数和已保存导航合并数放入默认折叠的“运行详情与配置”。字符进度按码点统计,界面不展示 Unicode 术语。恢复操作先于详情:运行中可暂停,失败或暂停后沿用同一运行继续;已记录合并、发布阶段时分别显示“继续合并摘要”“重试发布”。成功叶子保留。来源失效时显示新建回顾提示,不提供无效继续。原始错误默认折叠,展开后的完整文本最多占 240px 高度,可滚动、选择复制和键盘聚焦;错误与恢复操作位于批次树之前。结束时间、覆盖区间、报告状态和已有摘要收进“运行详情与配置”,已保存配置在其中再次折叠。
“已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。 “已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。
@@ -99,6 +99,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
隔离视觉验证使用 `tests/support/supervisor-layout-regression.tsx` 渲染生产 React 组件;所有输入标为模拟 fixture,不读取用户数据库或调用模型。`tests/supervisor-layout.electron.test.ts` 启动 Vite 与 Electron,通过 `executeJavaScript` 测量布局、`capturePage` 截图,覆盖 1440、1024、390px、浅深主题、长名称、密集事件和异常状态。可通过 `GOODBUDDY_SUPERVISOR_ARTIFACTS` 保留截图;交互预览入口为 `node tests/support/supervisor-preview.mjs`。 隔离视觉验证使用 `tests/support/supervisor-layout-regression.tsx` 渲染生产 React 组件;所有输入标为模拟 fixture,不读取用户数据库或调用模型。`tests/supervisor-layout.electron.test.ts` 启动 Vite 与 Electron,通过 `executeJavaScript` 测量布局、`capturePage` 截图,覆盖 1440、1024、390px、浅深主题、长名称、密集事件和异常状态。可通过 `GOODBUDDY_SUPERVISOR_ARTIFACTS` 保留截图;交互预览入口为 `node tests/support/supervisor-preview.mjs`。
设置 `GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT=1` 验证工作回顾、空态、活动、待处理及失败场景,在 2000、1440、1024、390px 和浅深主题下共 40 个组合,检查全宽、空态居中及位于工具栏下方、阶段连接线和长内容溢出。
设置 `GOODBUDDY_SUPERVISOR_PLAN_MODAL=1` 可只验证五页签 fixture 中的计划 Modal:创建和编辑各覆盖浅深主题、1440×1100、390×1100、390×480,检查关闭按钮、底部按钮对齐、正文滚动、鼠标命中和原生键盘操作。截图及测量写入同一产物目录。 设置 `GOODBUDDY_SUPERVISOR_PLAN_MODAL=1` 可只验证五页签 fixture 中的计划 Modal:创建和编辑各覆盖浅深主题、1440×1100、390×1100、390×480,检查关闭按钮、底部按钮对齐、正文滚动、鼠标命中和原生键盘操作。截图及测量写入同一产物目录。
设置 `GOODBUDDY_SUPERVISOR_RECAP=1` 可验证本次工作回顾分区:1440×1100 和 390×1100 下覆盖监督结果与自动报告均有内容、仅有监督结果、两者均空,另检查历史结果运行中仍可阅读以及活动入口。该分支检查每页一处刷新、自动报告和建议的归属、正文段落与宽度,并截取自动页建议、展开报告和审计区块。 设置 `GOODBUDDY_SUPERVISOR_RECAP=1` 可验证本次工作回顾分区:1440×1100 和 390×1100 下覆盖监督结果与自动报告均有内容、仅有监督结果、两者均空,另检查历史结果运行中仍可阅读以及活动入口。该分支检查每页一处刷新、自动报告和建议的归属、正文段落与宽度,并截取自动页建议、展开报告和审计区块。
+1 -2
View File
@@ -22,8 +22,7 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
</li> </li>
})} })}
</ol> </ol>
<p className="supervisor-activity__coverage">{t('reviewSettings.progress', { batches: progress.batches, characters: progress.characters, remaining: progress.remainingSources })}</p> <p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{progress.navigationNodes !== undefined && <p className="supervisor-activity__note">{t('activity.navigationSaved', { count: progress.navigationNodes })}</p>}
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>} {!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>} {row.supervisionStatus === 'running' && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div> </div>
+7 -2
View File
@@ -28,7 +28,7 @@ it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and re
expect(details.closest('details')).toHaveAttribute('open') expect(details.closest('details')).toHaveAttribute('open')
expect(details).toHaveStyle({ userSelect: 'text' }) expect(details).toHaveStyle({ userSelect: 'text' })
expect(screen.getByText(copy.activity.status.failed)).toBeVisible() expect(screen.getByText(copy.activity.status.failed)).toBeVisible()
expect(screen.getByText(copy.activity.description)).toBeVisible() expect(screen.queryByText(copy.activity.description)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: copy.reviewSettings.resume })).toBeEnabled() expect(screen.getByRole('button', { name: copy.reviewSettings.resume })).toBeEnabled()
}) })
@@ -134,12 +134,17 @@ it.each(['summarizing', 'saving', undefined] as const)('renders saved phase %s w
settings: { pageSize: 50, batchCharacters: 8000, batchMessages: 20, executionSeconds: 300, timeoutSeconds: 240, concurrency: 1 } } settings: { pageSize: 50, batchCharacters: 8000, batchMessages: 20, executionSeconds: 300, timeoutSeconds: 240, concurrency: 1 } }
}]), resume } }) }]), resume } })
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />) const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
await screen.findByText(/已保存 6 批/) expect(await screen.findByText('已保存 6 批 · 剩余 0 个来源')).toBeVisible()
const detailedProgress = screen.getByText('已保存 6 批 · 已读 4515 个字符 · 剩余 0 个来源')
expect(detailedProgress).not.toBeVisible()
expect(container.querySelector('[data-phase="saving"]')).toHaveAttribute('data-state', phase === 'saving' ? 'failed' : phase ? 'pending' : 'unknown') expect(container.querySelector('[data-phase="saving"]')).toHaveAttribute('data-state', phase === 'saving' ? 'failed' : phase ? 'pending' : 'unknown')
expect(container.querySelector('[data-phase="summarizing"]')).toHaveAttribute('data-state', phase === 'saving' ? 'completed' : phase ? 'failed' : 'unknown') expect(container.querySelector('[data-phase="summarizing"]')).toHaveAttribute('data-state', phase === 'saving' ? 'completed' : phase ? 'failed' : 'unknown')
expect(container.querySelector('.supervisor-activity__diagnostics')).not.toHaveAttribute('open') expect(container.querySelector('.supervisor-activity__diagnostics')).not.toHaveAttribute('open')
expect(container.querySelector('.supervisor-activity__advanced')).not.toHaveAttribute('open') expect(container.querySelector('.supervisor-activity__advanced')).not.toHaveAttribute('open')
expect(screen.getByText('本次回顾的已保存配置')).not.toBeVisible() expect(screen.getByText('本次回顾的已保存配置')).not.toBeVisible()
fireEvent.click(container.querySelector('.supervisor-activity__advanced > summary')!)
expect(detailedProgress).toBeVisible()
expect(screen.getByText('本次回顾的已保存配置')).toBeVisible()
const action = screen.getByRole('button', { name: phase === 'summarizing' ? '继续合并摘要' : phase === 'saving' ? '重试发布' : '继续已保存回顾' }) const action = screen.getByRole('button', { name: phase === 'summarizing' ? '继续合并摘要' : phase === 'saving' ? '重试发布' : '继续已保存回顾' })
expect(action.compareDocumentPosition(container.querySelector('.supervisor-activity__diagnostics')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(action.compareDocumentPosition(container.querySelector('.supervisor-activity__diagnostics')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(action) fireEvent.click(action)
+3 -2
View File
@@ -89,7 +89,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
<RefreshCw size={14} aria-hidden="true" />{t('center.actions.refresh')} <RefreshCw size={14} aria-hidden="true" />{t('center.actions.refresh')}
</button> </button>
</div> </div>
<p className="supervisor-activity__note">{t('activity.description')}</p>
{error && <div role="alert" className="heartbeat-center__error"><p>{error}</p> {error && <div role="alert" className="heartbeat-center__error"><p>{error}</p>
<button className="secondary-button" type="button" onClick={() => reload()}>{t('center.actions.retry')}</button> <button className="secondary-button" type="button" onClick={() => reload()}>{t('center.actions.retry')}</button>
</div>} </div>}
@@ -102,7 +101,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time> <time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
</header> </header>
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p> <p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
{row.reviewProgress && <SupervisionReviewStages row={row} />}
{!row.reviewProgress && <> {!row.reviewProgress && <>
<p className="supervisor-activity__stages"> <p className="supervisor-activity__stages">
{row.heartbeatStatus && <span>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</span>} {row.heartbeatStatus && <span>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</span>}
@@ -123,6 +121,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button> <button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
</div> </div>
</>} </>}
{row.reviewProgress && <SupervisionReviewStages row={row} />}
{row.error && <div className="supervisor-activity__failure"> {row.error && <div className="supervisor-activity__failure">
<p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error) <p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error)
? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p> ? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p>
@@ -141,6 +140,8 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
<div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? date(row.completedAt) : t('common.unavailable')}</dd></div> <div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? date(row.completedAt) : t('common.unavailable')}</dd></div>
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>} {row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
</dl> </dl>
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
{row.reviewProgress && row.heartbeatStatus && <p>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</p>} {row.reviewProgress && row.heartbeatStatus && <p>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</p>}
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>} {row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary> {row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
+1 -5
View File
@@ -449,7 +449,7 @@ export function SupervisorWorkspace({
</button> </button>
</div> </div>
)} )}
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__actions" role="status"> {(pending === 'run' || pausedReview) && <div className="supervisor-workspace__run-status" role="status">
<span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span> <span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>} {onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
</div>} </div>}
@@ -480,7 +480,6 @@ export function SupervisorWorkspace({
{tab === 'overview' && ( {tab === 'overview' && (
<> <>
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}> <div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<strong>{t('supervisor.newReview')}</strong>
<label> <label>
{t('supervisor.scope')} {t('supervisor.scope')}
<select <select
@@ -529,9 +528,6 @@ export function SupervisorWorkspace({
{t('center.actions.refresh')} {t('center.actions.refresh')}
</button> </button>
</div> </div>
<p className="supervisor-workspace__muted">
{t('supervisor.sourcesHint')}
</p>
{results.length > 0 && <div className="supervisor-workspace__result-navigation"> {results.length > 0 && <div className="supervisor-workspace__result-navigation">
<label> <label>
{t('supervisor.history')} {t('supervisor.history')}
@@ -41,6 +41,7 @@ export const heartbeat = {
phases: { collecting: 'Collect sources', extracting: 'Review batches', summarizing: 'Merge summaries', saving: 'Publish review' }, phases: { collecting: 'Collect sources', extracting: 'Review batches', summarizing: 'Merge summaries', saving: 'Publish review' },
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' }, stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.', stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
navigationSaved: '{{count}} navigation merges saved', navigationSaved: '{{count}} navigation merges saved',
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration', resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.', treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
@@ -38,6 +38,7 @@ export const heartbeat = {
phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' }, phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' },
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' }, stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。', stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
navigationSaved: '已保存 {{count}} 次导航合并', navigationSaved: '已保存 {{count}} 次导航合并',
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置', resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。', treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
+28 -6
View File
@@ -1,4 +1,6 @@
.supervisor-workspace { .supervisor-workspace {
display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
container: supervisor / inline-size; container: supervisor / inline-size;
@@ -7,6 +9,23 @@
min-width: 0; min-width: 0;
flex: 0 0 auto; flex: 0 0 auto;
} }
.supervisor-workspace > .empty-state {
width: 100%;
box-sizing: border-box;
align-self: stretch;
}
.supervisor-workspace__run-status {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--accent-subtle);
color: var(--text-secondary);
}
.supervisor-workspace p, .supervisor-workspace p,
.supervisor-workspace time { .supervisor-workspace time {
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -607,9 +626,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3); gap: var(--space-3);
} }
.supervisor-workspace__toolbar > strong {
grid-column: 1 / -1;
}
.supervisor-workspace__recap, .supervisor-workspace__recap,
.supervisor-workspace__detail, .supervisor-workspace__detail,
.supervisor-workspace__source, .supervisor-workspace__source,
@@ -673,11 +689,16 @@
.supervisor-activity__status--failed { color: var(--danger); } .supervisor-activity__status--failed { color: var(--danger); }
.supervisor-activity__item > header time { margin-left: auto; color: var(--text-secondary); font-size: var(--font-caption); } .supervisor-activity__item > header time { margin-left: auto; color: var(--text-secondary); font-size: var(--font-caption); }
.supervisor-activity__scope { color: var(--text-secondary); margin-block: var(--space-2) var(--space-4); } .supervisor-activity__scope { color: var(--text-secondary); margin-block: var(--space-2) var(--space-4); }
.supervisor-activity__steps { display: flex; flex-wrap: wrap; gap: var(--space-4); padding: 0; margin: 0; list-style: none; } .supervisor-activity__workflow { margin-top: var(--space-4); }
.supervisor-activity__steps li { display: flex; align-items: baseline; gap: var(--space-2); padding-block: var(--space-1); } .supervisor-activity__steps { display: flex; gap: var(--space-3); padding: 0; margin: 0; list-style: none; }
.supervisor-activity__steps li { display: flex; flex: 1; min-width: 0; align-items: center; gap: var(--space-2); padding-block: var(--space-1); }
.supervisor-activity__steps li:not(:last-child)::after { content: ''; flex: 1; min-width: var(--space-3); border-top: 1px solid var(--border-control); }
.supervisor-activity__steps li[aria-current='step'] strong { color: var(--accent); font-weight: 600; }
.supervisor-activity__steps strong { font-size: var(--font-body); font-weight: 500; } .supervisor-activity__steps strong { font-size: var(--font-body); font-weight: 500; }
.supervisor-activity__steps small { margin-left: var(--space-2); color: var(--text-secondary); font-size: var(--font-caption); } .supervisor-activity__steps small { margin-left: var(--space-2); color: var(--text-secondary); font-size: var(--font-caption); }
.supervisor-activity__step-number { color: var(--text-muted); } .supervisor-activity__step-number { display: grid; place-items: center; flex: 0 0 24px; height: 24px; border: 1px solid var(--border-control); border-radius: 50%; color: var(--text-secondary); font-size: var(--font-caption); }
.supervisor-activity__steps [aria-current='step'] .supervisor-activity__step-number { border-color: var(--accent); background: var(--accent-subtle); color: var(--accent); }
.supervisor-activity__steps [data-state='completed'] .supervisor-activity__step-number { border-color: var(--success); color: var(--success); }
.supervisor-activity__steps [data-state="failed"] small { color: var(--danger); } .supervisor-activity__steps [data-state="failed"] small { color: var(--danger); }
.supervisor-activity__steps [data-state="running"] small { color: var(--accent); } .supervisor-activity__steps [data-state="running"] small { color: var(--accent); }
.supervisor-activity__steps [data-state="completed"] small { color: var(--success); } .supervisor-activity__steps [data-state="completed"] small { color: var(--success); }
@@ -699,6 +720,7 @@
.supervisor-activity__batch-label { display: inline-block; vertical-align: top; max-width: calc(100% - var(--space-4)); max-height: 3em; line-height: 1.5; overflow: hidden; } .supervisor-activity__batch-label { display: inline-block; vertical-align: top; max-width: calc(100% - var(--space-4)); max-height: 3em; line-height: 1.5; overflow: hidden; }
@media (max-width: 719px) { @media (max-width: 719px) {
.supervisor-activity__steps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); } .supervisor-activity__steps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
.supervisor-activity__steps li:nth-child(2)::after { display: none; }
.supervisor-activity__steps small { display: block; margin-left: 0; margin-top: var(--space-1); } .supervisor-activity__steps small { display: block; margin-left: 0; margin-top: var(--space-1); }
.supervisor-activity__item > header time { margin-left: 0; flex-basis: 100%; } .supervisor-activity__item > header time { margin-left: 0; flex-basis: 100%; }
.supervisor-activity__conversation, .supervisor-activity__batch { margin-left: var(--space-1); padding-left: var(--space-2); } .supervisor-activity__conversation, .supervisor-activity__batch { margin-left: var(--space-1); padding-left: var(--space-2); }
+11 -4
View File
@@ -63,8 +63,8 @@ app
const reports = [] const reports = []
if (process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT) { if (process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT) {
win.show() win.show()
for (const view of ['recap', 'activity', 'pending', 'failure']) { for (const view of ['recap', 'empty', 'activity', 'pending', 'failure']) {
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (view === 'activity' ? '?activity=1' : '?recap=1&long-summary=1' + (view === 'failure' ? '&fail-run=1' : ''))) await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (view === 'activity' ? '?activity=1' : '?recap=1&long-summary=1' + (view === 'failure' ? '&fail-run=1' : view === 'empty' ? '&state=empty' : '')))
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"') await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
if (view === 'activity') { if (view === 'activity') {
await js('document.querySelector("#supervisor-tab-activity").click()') await js('document.querySelector("#supervisor-tab-activity").click()')
@@ -81,17 +81,23 @@ app
await js('document.querySelector(".page-shell").scrollTop = 0') await js('document.querySelector(".page-shell").scrollTop = 0')
await settle() await settle()
const report = await js(`(() => { const report = await js(`(() => {
const box = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r && { left:r.left, right:r.right, width:r.width, height:r.height }; }; const box = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r && { left:r.left, right:r.right, width:r.width, height:r.height, top:r.top, bottom:r.bottom }; };
return { width:innerWidth, pageWidth:document.documentElement.scrollWidth, return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
panel:box('.heartbeat-center > [role=tabpanel]:not([hidden])'), toolbar:box('.supervisor-workspace__toolbar'), history:box('.supervisor-workspace__result-navigation'), recap:box('.supervisor-workspace__recap'), panel:box('.heartbeat-center > [role=tabpanel]:not([hidden])'), toolbar:box('.supervisor-workspace__toolbar'), history:box('.supervisor-workspace__result-navigation'), recap:box('.supervisor-workspace__recap'),
prose:box('.supervisor-workspace__prose'), activity:box('.supervisor-activity'), prose:box('.supervisor-workspace__prose'), activity:box('.supervisor-activity'),
steps:box('.supervisor-activity__steps'), steps:box('.supervisor-activity__steps'),
empty:box('.supervisor-workspace > .empty-state'), emptyTitle:box('.supervisor-workspace > .empty-state strong'),
stageConnectors:[...document.querySelectorAll('.supervisor-activity__steps li:not(:last-child)')].map(e=>getComputedStyle(e,'::after').borderTopWidth),
summaryLength:[...document.querySelectorAll('.supervisor-workspace__summary')].map(e=>e.textContent).join('').length, summaryLength:[...document.querySelectorAll('.supervisor-workspace__summary')].map(e=>e.textContent).join('').length,
tail:document.querySelector('.supervisor-workspace__prose')?.textContent.includes('长摘要末尾'), tail:document.querySelector('.supervisor-workspace__prose')?.textContent.includes('长摘要末尾'),
controls:[...document.querySelectorAll('.supervisor-activity > .supervisor-workspace__action-bar > *')].map(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top};}) }; controls:[...document.querySelectorAll('.supervisor-activity > .supervisor-workspace__action-bar > *')].map(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top};}) };
})()`) })()`)
assert(report.pageWidth <= width, 'No page overflow') assert(report.pageWidth <= width, 'No page overflow')
if (view !== 'activity') { if (view === 'empty') {
assert(Math.abs(report.empty.width - report.panel.width) < 1, 'Empty state fills panel');
assert(Math.abs((report.emptyTitle.left + report.emptyTitle.right) / 2 - (report.panel.left + report.panel.right) / 2) < 1, 'Empty state title centered');
assert(report.empty.top >= report.toolbar.bottom, 'Empty state below toolbar');
} else if (view !== 'activity') {
assert(report.summaryLength > 2000 && report.tail, 'Long summary retained') assert(report.summaryLength > 2000 && report.tail, 'Long summary retained')
assert(Math.abs(report.recap.width - report.panel.width) < 1, 'Recap fills the page panel') assert(Math.abs(report.recap.width - report.panel.width) < 1, 'Recap fills the page panel')
for (const control of [report.toolbar, report.history]) { for (const control of [report.toolbar, report.history]) {
@@ -102,6 +108,7 @@ app
assert(Math.abs(report.activity.width - report.panel.width) < 1, 'Activity fills the page panel') assert(Math.abs(report.activity.width - report.panel.width) < 1, 'Activity fills the page panel')
if (width >= 1024) { if (width >= 1024) {
assert(report.steps.height < 40, 'Compact stage row') assert(report.steps.height < 40, 'Compact stage row')
assert(report.stageConnectors.every(value => value === '1px'), 'Connected stages')
assert(report.controls[2].left - report.controls[1].right <= 24, 'Refresh grouped with filter') assert(report.controls[2].left - report.controls[1].right <= 24, 'Refresh grouped with filter')
} }
} }