fix: unify supervisor review layouts and story spiral colors

Review history and context were split across recap and graph views, while the story spiral rendered saved event intervals in a single neutral color. Share the history picker and context bar, collapse new-review inputs, improve recap and responsive detail layouts, and align graph toolbars and accessible legends.

Color actual event intervals and markers by primary story, preserve neutral gaps and unassigned events, and refresh colors across themes. Include related regression coverage, layout fixtures, overlay timing and workspace-import test adjustments, and updated design and validation documentation.

Release note: Work review and story graph now share the selected review and a compact history picker. Recaps and graph details fit smaller windows, and the time spiral uses story colors for recorded event intervals without filling gaps.
This commit is contained in:
mesalogo
2026-10-04 22:50:31 +08:00
parent b9186a616c
commit ea3d607fdd
22 changed files with 819 additions and 394 deletions
+3 -2
View File
@@ -503,6 +503,7 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
### 6.14 InlineHelp 同行帮助
页面、区块和字段的补充用途、格式及操作说明统一收进标题或标签旁的 `InlineHelp` 问号按钮。
图谱图例使用同一组件的 `info` 图标变体,只显示 i 图标,保留可访问名称与简短 title;浮层和键盘行为相同。
`PageHeader` 与 `SettingsSectionHeader` 通过 `help` 接收这类内容;`description` 保留需要常显的上下文,两处不重复说明。描述缺省或为空时不渲染段落,不保留空行。
当前范围、状态、字段错误、操作进度、风险、费用和影响用户决策的限制仍就地可见。混合文案先拆分,再将补充说明移入帮助。
@@ -739,9 +740,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.4 智能心跳
- 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。
- 顶层页签为“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。
- 顶层页签为“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;历史结果不得标为最新。
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。智能心跳集中展示心跳计划、监督建议和只读的历史心跳报告;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
- 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工具栏、历史导航、状态和结果共享宽度,不再增加子面板宽度上限。工作回顾按纵向排列,空态独占工具栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
- 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工作回顾与图谱共用稳定的回顾上下文栏,新请求字段按需展开。工作回顾正文及辅助内容按实际容器宽度排列,空态独占上下文栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
Checkbox,不能依靠进入页面时的当前项目推断。
- 状态卡片使用统一状态令牌,不只依赖颜色。
@@ -4,8 +4,42 @@
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
## 2026-10-04 螺旋工具栏与事件颜色修正
接续未提交的回顾布局调整。平铺与螺旋共用标题工具栏样式,项目层级菜单放在面包屑旁,画布内的视角入口和左下角圈数说明已移除。默认斜俯视,拖动及键盘操作保留;两种图谱均以共享 InlineHelp 的 i 图标打开图例。共用历史回顾栏、下钻返回、详情选择与中窄窗口详情面板保留。当前规则见 [UI 设计](./ui-design.md#故事线图谱)。
颜色缺失的直接原因是 `StoryGraph3D` 将整条螺旋创建为一个 muted 材质的管状网格;`buildStoryTree` 只保留事件起点,丢弃结束时间,也未保留事件的主故事 ID。现在模型保留实际区间与精确成员归属,渲染在同一网格上按区间切换材质,事件点和短横按主故事配色。Three.js 默认的弧长参数化会移动时间边界,因此该曲线明确按时间采样位置与切线;区间规则及重叠处理见 [3D 概念](../story-graph/3d-concept.md#时间事件与故事)。主题变化重新读取令牌,不重置相机。
验证结果:
- `npx vitest run src/renderer/src/story-graph-3d-model.test.ts src/renderer/src/SupervisorWorkspace.test.tsx src/renderer/src/InlineHelp.test.tsx src/renderer/src/AnchoredMenu.test.tsx src/renderer/src/HeartbeatCenter.test.tsx`:98 项通过。覆盖历史 ID、空成员、真实起止、点事件、空档、重叠与裁剪、半径、选择和浮层键盘行为。
- `npx vitest run src/renderer/src/App.test.tsx -t "routes Supervisor review feedback|shows retryable page-local Supervisor errors|graph navigation opens the pinned"`:3 项通过,其余 345 项按名称未运行。
- `tests/supervisor-layout.electron.test.ts` 的默认平铺分支和 `GOODBUDDY_SUPERVISOR_SPIRAL=1` 分支通过。螺旋使用两个故事的确定性区间 fixture,测量实际 TubeGeometry 顶点环和材质组,断言彩色面没有覆盖空档、主故事与木片颜色一致、主题切换更新 WebGL 材质;另检查两层布局、平铺与螺旋字体一致、原生拖动和 Space/Escape、层级菜单键盘、根返回、历史切换及详情选择。
- 只读复用已有临时 `spiral-portable.sqlite`,通过生产 Store 与图谱查询读取历史结果。1440px 浅深主题、1024px 浅色、390px 浅深主题均保留 21 个事件,其中 17 个当前有主故事、4 个未归属。该结果全部为时间点,没有可着色的非零事件区间,因此真实数据验证的是彩色点及中性未归属点,不宣称真实历史有彩色持续时段。测试 API 返回空成员后仍显示 21 个事件。单螺旋实际为 2,801 个顶点环,半径约 78~152,根场景全部顶点在视口内,无页面横向溢出或 Renderer 错误。数值报告位于系统临时 `opencode/supervisor-refinements/portable-measurements.json`。
- `npm run typecheck`、`npm run lint`、`git diff --check` 通过。中文文档扫描及人工复核只处理本轮段落,保留数据口径和未实现边界说明。
一次初测的控件边界断言失败,未改变产品代码的复跑通过;后续尺寸切换先等待实际视口达到目标宽高,再测布局。最终断言保留。所有 Electron 运行关闭截图,模型调用为 0;没有修改原用户库或备份、打包、全量测试、提交或推送。此次变更仅影响桌面展示和测试,Agent 与远程 Runtime 路径不受影响。
## 2026-10-04 回顾与图谱布局统一
本节保留此前验证记录;视角入口、图例及事件着色以本页上一节和当前 UI 设计为准。
工作回顾与图谱共用页签下方的回顾栏,新回顾表单按需展开。回顾正文使用主次两栏,辅助内容为空时恢复全宽;图谱保留独立层级面包屑、共享模式切换、画布角落的视角菜单及图例。中窄工作区的详情改为图谱内按需打开的非模态面板。尺寸、键盘和范围显示规则见 [UI 设计](./ui-design.md#应用入口)与[图谱布局](./ui-design.md#故事线图谱)。本轮接续此前未提交的历史选择移动,将“历史选择替代全部面包屑”改为上述共用栏,并保留原有下钻与历史切换回归。
验证结果:
- `npx vitest run src/renderer/src/AnchoredMenu.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx src/renderer/src/HeartbeatCenter.test.tsx src/renderer/src/story-graph-3d-model.test.ts src/renderer/src/supervision-story-digest.test.ts`:96 项通过,覆盖默认最新、显式导航优先、迟到响应、新回顾草稿、摘要展开、故事与经验选择及视角菜单键盘操作。
- `npx vitest run src/renderer/src/App.test.tsx -t "routes Supervisor review feedback|shows retryable page-local Supervisor errors|graph navigation opens the pinned"`:3 项通过,其他 345 项未运行。通知、后台执行和固定结果导航保留。
- `tests/supervisor-layout.electron.test.ts` 的默认布局、`GOODBUDDY_SUPERVISOR_RECAP=1`、`GOODBUDDY_SUPERVISOR_SPIRAL=1`、`GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT=1` 分别通过。内容分支检查 40 个场景/宽度/主题组合,增加实际 2:1 列宽与窄屏顺序断言;螺旋分支检查共用栏跨页签位置、单一历史选择、层级返回、视角键盘、中宽详情开关及短窗口高度。默认分支还约束实际工作区宽度至 800~1191px,验证容器响应。测试窗口加入仓库既有的 `CalculateNativeWinOcclusion` 禁用设置,修正窗口被遮挡时的动画帧等待超时。
- 只读复用临时 `spiral-portable.sqlite`,没有重新读取或修改在线用户库。真实历史结果仍有 21 个事件,1440px 浅深主题、1024px 浅色、390px 浅深主题全部通过;所有根场景顶点在视口内,页面无横向溢出。额外将测试 API 成员响应置空后,21 个事件仍保留。几何测量保存于系统临时 `opencode/supervisor-redesign-final/portable-measurements.json`,连续螺旋采样点仍为 1,401 个,半径范围仍为 78~152。
- `npm run typecheck`、修改文件的定向 ESLint、`git diff --check` 通过。中文 UI 文档扫描无阻断项;复核提示中涉及范围与未实现边界的原有说明保留。
本轮只修改桌面 UI、组件测试、Electron 布局检查及设计文档。保留工作区其他并行修改,包括布局 fixture 的通知消息记录。所有 Electron 运行使用 `GOODBUDDY_SUPERVISOR_NO_SCREENSHOT=1`,不输出截图。没有真实模型调用、数据库或设置写入、全量测试、打包、提交或推送。平铺图仍保留 660px 最小高度,短窗口可纵向滚动;螺旋单独使用 320px 最小高度。390px 平铺标签随画布缩小,完整名称和选择入口保留在列表中。
## 2026-10-04 历史螺旋缺失事件与工具栏
以下保留本轮布局调整前的验证记录;历史入口和视角的当前规则以上节及其 UI 设计链接为准。
读取仓库便携版 `data/assistant.sqlite`,用只读连接及 SQLite online backup 保存到系统临时 `opencode` 目录,后续查询只读备份。报告对应结果 `b1ec5e99-5a72-444b-8c2b-1c4eb4c50960`:本地时间 9 月 28 日 09:48:58 生成,区间 9 月 21 日 09:43:13 至 9 月 28 日 09:43:13,21 个事件、16 个实体。21 个事件均未被 supersede;36 条来源都有 source key、revision 和项目 locator。按同 source key、revision 及片段范围相交检查,没有其他当前事件替代这批来源,排除本例的 ID 过期猜测。
备份时已有 3 个功能故事,均于 10 月 4 日 20:46:32 创建,其中包含该结果的 17 个事件,另 4 个没有故事成员。结果早于故事生成;数据库未保存截图时刻的成员快照,不能声称重现了截图当时的空成员响应。代码中的缺失原因可确定:3D 仅接收当前故事成员,没有所选结果的完整事件输入;无成员时过滤后只剩灰色螺旋,部分有成员时其余事件仍丢失。现在完整事件输入独立于故事成员,显示规则见[故事线模型](./storyline-model-design.md#5-展示与使用)。历史选择移入中间画布标题区,视角改用共享分段控件,并修正默认相机底部裁切。
@@ -8,9 +8,11 @@
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满剩余可见高度,标题、说明与“工作回顾”按钮居中,刷新保留在共用回顾栏。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
工作回顾与故事线图谱在主页签下方共用回顾上下文栏。历史选择器最多占 280px,收起时只显示生成日期时间,随后是“刷新”和“新回顾”,不在右侧重复范围和覆盖区间。展开复用共享锚定单选菜单,每项显示生成时间及该结果自身的真实范围、覆盖起止时间,长说明换行;当前项带选中标记,触发按钮的无障碍描述保留所选结果的完整上下文。即使只有一条结果也显示选择器。切换页签和图谱模式时,这一栏的位置、内容和选择保持一致,空态及读取失败时仍可访问。
点击“新回顾”才展开范围、时间、发起按钮和重新整理菜单。表单位于上下文栏下方,以次级表面区分新请求与已选结果;再次点击收起并保留输入。工作回顾正文铺满页面壳层,不另设阅读宽度上限。容器宽于 860px 且有辅助内容时,左侧约占三分之二,显示工作总结和本次变化;右侧显示未解决事项、故事进展和经验,以弱分隔线分区。没有辅助内容时只保留全宽正文。窄屏按正文、辅助内容顺序排列为单列。摘要保留原有段落和换行,故事摘要保留“显示更多”及进入图谱的选择。
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用。手动发起、完成、暂停、取消、无变化和失败统一使用应用 Toast;重复点击发起提示已有运行,不新建或排队。同一回顾操作的通知互相替换,关闭通知不暂停或取消执行。成功和信息沿用共享自动消失时间,错误由用户关闭。页面不保留操作状态条或错误横幅,加载历史结果、切换页签和重新进入页面不触发回顾通知。
@@ -86,7 +88,7 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
当前页面提供全局或单项目范围,以及 1、7、30 天时间区间选择。没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。
选择历史结果后,图谱读取该结果的成员和内容,标题显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。
@@ -111,11 +113,11 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
## 故事线图谱
中间画布列的标题区左上角提供与“工作回顾”共用的“历史结果”选择框,标签与选择框同行,不在整个三栏区域上方另占一行。首次进入默认最新结果;两页之间切换保留所选结果,从侧栏或建议显式打开的结果、故事、经验优先于默认值。选择框显示生成时间和范围;切换时清除旧来源详情,迟到请求不得覆盖新选择。没有画布的空态及读取失败状态仍保留历史入口。
历史选择统一由上述回顾上下文栏承载,不占用画布面包屑。首次进入默认最新结果;两页之间切换保留所选结果,从侧栏或建议显式打开的结果、故事、经验优先于默认值。切换结果时清除旧来源详情,迟到请求不得覆盖新选择。平铺与时间螺旋均为上方标题工具栏、下方画布两层。标题工具栏使用相同的正文大小、内边距和淡分隔线;螺旋的“全部 / 项目 / 功能”面包屑旁放层级下拉菜单、图例 i 图标,右侧保留共享 SegmentedControl 的“平铺 / 时间螺旋”切换。窄窗口允许标题区控件换行,画布内不另设工具栏。
时间螺旋使用所选回顾的时间区间与完整事件集合,半径含义见 [3D 密度规则](../story-graph/3d-concept.md)。当前故事成员按精确事件 ID 与结果求交;其余事件按来源确定的项目进入“当前无故事归属”分组,项目未知时显示“项目范围不可用”。画布常显“本次回顾事件按当前故事归属分组;未保存当时的故事归属”。故事与经验列表仍提供该范围的当前完整记录,供查看和调整。
时间螺旋使用所选回顾的时间区间与完整事件集合,半径和区间着色规则见 [3D 密度规则](../story-graph/3d-concept.md)。当前故事成员按精确事件 ID 与结果求交;其余事件按来源确定的项目进入“当前无故事归属”分组,项目未知时显示“项目范围不可用”。图例说明“本次回顾事件按当前故事归属分组;未保存当时的故事归属”。故事与经验列表仍提供该范围的当前完整记录,供查看和调整。
根视图直接显示事件点,邻近事件按画布密度聚合并显示数量;“事件”选择菜单保留当前层级的逐条入口,选择后同步左侧列表与详情。视角位于画布工具栏右上角,复用共享 SegmentedControl;工具栏四周使用 16px 内边距,视角与图例间隔 8px,窄屏按需换行。
根视图直接显示事件点,同一主故事的邻近事件按画布密度聚合并显示数量,未归属点使用中性色。事件和经验从左侧列表选择,画布不重复提供这两种选择器。保留一个层级菜单:左侧列表选择记录并打开详情,标题区层级菜单改变画布中的项目、功能或子线索层级。菜单与面包屑均可用键盘操作。默认斜俯视,不显示视角按钮、菜单或页签;自由拖动、平移、滚轮、方向键、数字键和 Escape 返回上层操作保留。
关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,使用上述可编辑正文预览,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。
@@ -123,13 +125,13 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 660px(再矮平铺图的文字会小于 11px,这时改为页面滚动)。三栏时整页不再出现纵向滚动;左侧列表和右侧详情在这一行内独立滚动,滚动只影响自身,不带动页面。中间画布列不出现滚动条:标题栏和底部单行“事件浏览”栏之外的高度全部给图,平铺与时间螺旋一致。画布标题为“事件与知识”,下方一行小字依次显示图谱范围、时间区间、生成时间和事件/实体数,刷新为标题栏右侧图标按钮;图谱上方不再单独占一行显示范围与时间。没有图谱数据时,刷新按钮保留在页签顶部。图例默认收起,从标题栏“图例”按钮打开,浮在画布右上角,可用 X 关闭;可见事件与实体数量常驻画布左下角。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。
高度按窗口剩余可见空间计算,底部保留约 24px 边距(不小于页面底部内边距)。平铺图最低 660px,时间螺旋最低 320px;低于最小高度时页面允许纵向滚动。中间画布不内部滚动,工具栏与事件浏览栏之外的空间给图;左侧列表和右侧详情各自滚动。两种视图的图例均使用共享 InlineHelp 的纯 i 图标,保留可访问名称和 title;点击或 Enter/Space 固定展开,再次点击、Escape、点击或聚焦外部关闭。平铺可见数量仍在画布角落;螺旋左下角不显示圈长及圈数,这些信息收进图例。窗口尺寸及上方表单展开、收起后重新计算可用高度。
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。
实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例及读图说明按需展开。
画布每批最多显示 8 个事件、6 个实体;同时间事件分开排布。列表保留全部返回记录和完整名称,选择实体会定位到真实关联事件所在批次,选择关系会优先显示两端实体。滑块明确命名“事件浏览”,只切换当前事件,不还原实体历史状态,也不宣称完整历史回放。当前接口返回单条故事线,不复制 Demo 中的三条模拟故事。后续视图规划见[图谱设计](../story-graph/prd.md)。
按实际内容容器宽度响应:不超过 `1190px` 时变为 `184px` 列表加画布,详情移到下方;不超过 `860px` 时变为单列,画布优先,列表限制高度并独立滚动。SVG 最小宽度为 `660px`,窄屏只在画布内部横向滚动,页面本身不横向溢出。范围、时间和操作可换行。节点、关系与事件均可从列表选择,画布节点支持 Enter、Space 和可见焦点。加载、无数据、读取失败和服务不可用各自呈现,错误提供可行的恢复说明或重试入口。
按实际工作区容器宽度响应:宽于 `1190px` 保留三栏和左侧分类页签;`861~1190px` 为 `184px` 列表加画布,分类改用紧凑选择框。详情通过工具栏按钮或选择记录打开,作为图谱右侧的非模态面板,不在画布下方增加长区块;关闭按钮和 Escape 返回详情触发按钮。`860px` 以下画布优先,列表在下方、最高 320px,详情面板不超过画布高度。SVG 随画布缩放,390px 下页面和画布均不横向滚动;缩小后的完整名称可从列表读取。范围、时间和操作按需换行。节点、关系与事件均可从列表选择,画布节点支持 Enter、Space 和可见焦点。加载、无数据、读取失败和服务不可用各自呈现,错误提供重试入口。
隔离视觉验证使用 `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`。
+5 -1
View File
@@ -8,7 +8,11 @@
每圈时长从 3、6、12、24、168、720、2160、8760 小时中选择,在不超过 14 圈的候选中取最接近 8 圈的一档。生产界面自动选择粒度;手动指定仍是 Demo 能力。不足一圈时保留实际圈数,时间区间不因显示需要而扩大。
生产视图将同一分组中屏幕上过于接近的事件合并为一段,显示事件数量;缩放后重新计算聚合。根视图也绘制事件点,点击聚合点进入对应分组,“事件”菜单可逐条选择当前层级的记录并打开详情。没有当前故事归属的事件使用显式未归属分组,数据口径见[故事线模型](../conversation-supervision/storyline-model-design.md#5-展示与使用)。Demo 另提供点击聚合段缩小时间范围的交互,生产视图尚未实现该自动缩放。
生产视图将同一分组、同一主故事中屏幕上过于接近的事件合并为一段,显示事件数量;缩放后重新计算聚合。根视图也绘制事件点,点击聚合点进入对应分组,左侧“事件”列表可逐条选择记录并打开详情。没有当前故事归属的事件使用显式未归属分组,数据口径见[故事线模型](../conversation-supervision/storyline-model-design.md#5-展示与使用)。Demo 另提供点击聚合段缩小时间范围的交互,生产视图尚未实现该自动缩放。
生产时间线按所选结果的 `started_at`(缺省取 `occurred_at`)至 `ended_at` 着色,裁到回顾窗口。缺少结束时间、结束早于开始或起止相同的事件只画点,不延长到下一事件,也不用故事的首尾跨度填满空档。颜色由当前主故事 ID 确定,同一故事的木片、事件短横、点和时间区间共用主题调色板;项目木片表示聚合层,跨项目关联不覆盖主归属。未归属事件使用中性色点和短横,时间空档保留细灰线。
多事件区间重叠时,同一条时间线上显示开始较晚的事件;开始相同时按事件 ID 排序取首个。其他事件仍保留各自标记和详情入口。实现使用一个连续管状网格,在真实区间边界切换材质,保持高度与时间成正比,不增加平行螺旋。主题变化后重读颜色令牌,相机位置保持不变。
[3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。
+1 -1
View File
@@ -27,7 +27,7 @@ it('imports multichunk binary and empty files into the selected directory, retai
const listing = await access.listDirectory({ path: 'selected' })
const metadata = await stat(join(root, 'selected/image.png'))
expect(listing.entries.find(entry => entry.name === 'image.png')).toMatchObject({ modifiedAt: metadata.mtime.toISOString(), ...(metadata.birthtimeMs > 0 ? { createdAt: metadata.birthtime.toISOString() } : {}) })
})
}, 15_000)
it('removes a partial import after a write failure and continues the batch', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-import-failure-')); roots.push(root)
+34
View File
@@ -0,0 +1,34 @@
import { useRef, useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { AnchoredMenu } from './AnchoredMenu'
afterEach(cleanup)
it('focuses the selected radio item, navigates enabled items and restores the anchor on Escape', () => {
function Menu() {
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <>
<button ref={anchorRef} onClick={() => setOpen(true)}>View</button>
{open && <AnchoredMenu anchorRef={anchorRef} id="views" label="View" onClose={() => setOpen(false)}>
<button role="menuitemradio" aria-checked={false}>Oblique</button>
<button role="menuitemradio" aria-checked={false} disabled>Side</button>
<button role="menuitemradio" aria-checked={true}>Top</button>
<button role="menuitem">Reset</button>
</AnchoredMenu>}
</>
}
render(<Menu />)
fireEvent.click(screen.getByRole('button', { name: 'View' }))
expect(screen.getByRole('menuitemradio', { name: 'Top' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(screen.getByRole('menuitemradio', { name: 'Oblique' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(screen.getByRole('menuitem', { name: 'Reset' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(screen.getByRole('menuitemradio', { name: 'Oblique' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'View' })).toHaveFocus()
})
+3 -2
View File
@@ -41,7 +41,8 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
const anchor = anchorRef.current
const focusFirst = (): void => {
if (document.activeElement === document.body || document.activeElement === menu) {
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
(menu.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not(:disabled), [role="menuitem"][aria-current="true"]:not(:disabled)')
?? menu.querySelector<HTMLElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)') ?? menu).focus()
}
}
menu.focus()
@@ -65,7 +66,7 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
}
if (role !== 'menu' || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const items = Array.from(menu.querySelectorAll<HTMLElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
+2 -1
View File
@@ -14216,7 +14216,7 @@ describe("App", () => {
await waitFor(() =>
expect(screen.queryByText("监督者加载失败")).not.toBeInTheDocument(),
);
expect(screen.getByRole("button", { name: "回顾" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "新回顾" })).toBeInTheDocument();
});
it('routes Supervisor review feedback through dismissible application notifications across tabs', async () => {
@@ -14225,6 +14225,7 @@ describe("App", () => {
vi.mocked(api.supervision.run).mockImplementation(() => new Promise((_, fail) => { reject = fail; }));
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: '监督者' }));
fireEvent.click(await screen.findByRole('button', { name: '新回顾' }));
fireEvent.click(await screen.findByRole('button', { name: '回顾' }));
const started = await screen.findByText('回顾已开始,可到活动记录查看进度。');
expect(started.closest('.app-notification')).toHaveAttribute('role', 'status');
+5 -4
View File
@@ -202,9 +202,9 @@ describe('HeartbeatCenter', () => {
renderComponent(<PageShell variant="supervisor"><HeartbeatCenter {...props} /></PageShell>)
await screen.findByText('Review summary')
expect(screen.getByRole('heading', { level: 1, name: t('center.title') }).closest('.page-shell')).toHaveClass('page-shell--supervisor')
expect(screen.getByRole('heading', { name: t('supervisor.latest') })).toBeVisible()
expect(screen.getByRole('button', { name: t('supervisor.run') })).toBeEnabled()
expect(screen.getByRole('combobox', { name: t('supervisor.scope') })).toBeVisible()
expect(screen.getByRole('heading', { name: t('supervisor.summary') })).toBeVisible()
expect(screen.getByRole('button', { name: t('supervisor.newReview') })).toBeEnabled()
expect(screen.queryByRole('combobox', { name: t('supervisor.scope') })).not.toBeInTheDocument()
expect(screen.queryByText(t('supervisor.sourcesHint'))).not.toBeInTheDocument()
const pageTabKeys = ['supervisor.recap', 'supervisor.graph', 'supervisor.automatic', 'activity.title', 'supervisor.settings']
for (const key of pageTabKeys) {
@@ -262,7 +262,7 @@ describe('HeartbeatCenter', () => {
}
expect(within(panel).getByRole('region', { name: t('supervisor.canvas') })).toBeVisible()
expect(within(panel).getByRole('heading', { name: t('supervisor.inspector') })).toBeVisible()
expect(within(panel).getByText(new RegExp(`^${t('supervisor.graphScope')}: ${t('center.scope.global')} · `))).toBeVisible()
expect(within(panel).getByRole('group', { name: t('supervisor.recap') })).toHaveTextContent(t('center.scope.global'))
}
if (key === 'activity.title') {
await screen.findByText(t('activity.empty'))
@@ -709,6 +709,7 @@ describe('HeartbeatCenter', () => {
const props = createProps({ configs: [] })
renderComponent(<HeartbeatCenter {...props} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: props.projects[0]!.id } })
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
+5 -4
View File
@@ -1,12 +1,13 @@
import { CircleHelp } from 'lucide-react'
import { CircleHelp, Info } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { FloatingPortal } from './FloatingPortal'
import './inline-help.css'
export function InlineHelp({ label, children, id }: {
export function InlineHelp({ label, children, id, icon = 'help' }: {
label: string
children: ReactNode
id?: string
icon?: 'help' | 'info'
}): React.JSX.Element {
const generatedId = useId()
const contentId = id ?? generatedId
@@ -92,7 +93,7 @@ export function InlineHelp({ label, children, id }: {
}
}, [open])
return <>
<button type="button" className="inline-help" ref={anchorRef} aria-label={label}
<button type="button" className="inline-help" ref={anchorRef} aria-label={label} title={icon === 'info' ? label : undefined}
aria-expanded={open} aria-controls={open ? contentId : undefined} aria-describedby={open || id ? contentId : undefined}
onMouseEnter={enter} onMouseLeave={leave} onFocus={() => { cancelClose(); setOpen(true) }} onBlur={leave}
onClick={event => {
@@ -100,7 +101,7 @@ export function InlineHelp({ label, children, id }: {
if (pinned.current) close()
else { cancelClose(); pinned.current = true; setOpen(true) }
}}>
<CircleHelp size={14} aria-hidden="true" />
{icon === 'info' ? <Info size={14} aria-hidden="true" /> : <CircleHelp size={14} aria-hidden="true" />}
</button>
{/* Referenced hidden text remains an accessible description without a second visible copy. */}
{!open && id && <span id={contentId} hidden>{children}</span>}
+72 -58
View File
@@ -1,12 +1,12 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Info, X } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, timelineSegments, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
/*
* 3D story view: one helix of time inside, story staves on the outer cylinder.
@@ -14,6 +14,7 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels,
* Loaded lazily so Three.js is not part of the workspace bundle.
*/
type Props = {
toolbar?: ReactNode
stories: SupervisionStory[]
events: SupervisionGraphView['events']
projectNames: Map<string, string>
@@ -31,8 +32,7 @@ const TAU = Math.PI * 2, HEIGHT = 430, STAVE_R = 188, DIST = 900, CLUSTER_PX = 9
const noExperiences: SupervisionExperience[] = []
type EngineState = { focus: StoryNode; hovered?: string; selected?: string; experiences: SupervisionExperience[]; experience?: string }
const VIEWS = { oblique: { yaw: -0.4, tilt: 0.38 }, side: { yaw: -0.4, tilt: 0 }, top: { yaw: -0.4, tilt: Math.PI / 2 - 1e-3 } } as const
type ViewName = keyof typeof VIEWS | 'free'
// Existing graph tokens, so light and dark themes keep the same story colours as the flat view.
// Reuse the graph theme palette; story identity determines its tone across levels.
const toneVariables = ['--graph-node-1-border', '--graph-node-2-border', '--graph-node-3-border', '--graph-node-4-border', '--accent']
function cssColor(element: Element, variable: string, fallback: string): string {
@@ -63,7 +63,7 @@ function webglAvailable(): boolean {
catch { return false }
}
export default function StoryGraph3D({ stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ toolbar, stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -74,23 +74,25 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
// Context creation failed or the GPU dropped the context; `attempt` recreates the scene on retry.
const [failure, setFailure] = useState<'create' | 'lost'>()
const [attempt, setAttempt] = useState(0)
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme)
useEffect(() => {
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
const tree = useMemo(() => buildStoryTree(stories, projectNames, { events,
unassigned: t('supervisor.graph3d.unassigned'), unknownProject: t('supervisor.graph3d.unknownProject') }), [stories, events, projectNames, t])
const [focusId, setFocusId] = useState('root')
const focus = findNode(tree, focusId) ?? tree
const [view, setView] = useState<ViewName>('oblique')
const [hovered, setHovered] = useState<string>()
const [legendOpen, setLegendOpen] = useState(false)
const legendId = useId()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 })
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
const range = useMemo(() => {
if (timeRange) return storyWindow(Date.parse(timeRange.from), Date.parse(timeRange.to))
const times = tree.events.map(event => event.t)
const times = tree.events.flatMap(event => [event.t, event.end ?? event.t])
return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined
}, [tree, timeRange])
const level = visibleLevel(focus)
const shownLinks = useMemo(() => experienceLinks(experiences, level.children), [experiences, level])
const trail: StoryNode[] = []
for (let node: StoryNode | undefined = focus; node; node = node.parent) trail.unshift(node)
const dateText = (value: number) => new Date(value).toLocaleString(i18n.resolvedLanguage, range && range.turnHours >= 24
@@ -123,7 +125,8 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
const ctx = overlay.getContext('2d')!
const muted = cssColor(host, '--text-muted', '#7d8a84')
const tones = toneVariables.map((variable, index) => cssColor(host, variable, ['#2563eb', '#0f766e', '#7c3aed', '#b45309', '#086fc7'][index]!))
const colorOf = (node: StoryNode) => tones[Math.abs([...node.id].reduce((hash, char) => hash * 31 + char.charCodeAt(0) | 0, 7)) % tones.length]!
const storyColor = (id?: string) => id ? tones[Math.abs([...id].reduce((hash, char) => hash * 31 + char.charCodeAt(0) | 0, 7)) % tones.length]! : muted
const colorOf = (node: StoryNode) => storyColor(node.level === 'unassigned' ? undefined : node.id)
const levelAt = radiusLevels(range, attention)
const u = (time: number) => (time - range.from) / (range.to - range.from)
const y = (time: number) => (u(time) - 0.5) * HEIGHT
@@ -137,9 +140,36 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
const flat = (color: string, opacity: number) => own(new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false }))
const tube = (points: THREE.Vector3[], r: number, material: THREE.Material, segments = Math.max(8, points.length * 2)) =>
new THREE.Mesh(own(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points), segments, r, 8, false)), material)
// Static base: the grey time helix and turn ticks.
// One continuous tube, with material boundaries at actual event interval endpoints.
const base = new THREE.Group()
base.add(tube(Array.from({ length: 1401 }, (_, i) => helix(range.from + (range.to - range.from) * i / 1400)), 1.1, solid(muted), 2800))
const intervals = timelineSegments(tree.events, range)
const times = [...new Set([...Array.from({ length: 2801 }, (_, i) => range.from + (range.to - range.from) * i / 2800),
...intervals.flatMap(interval => [interval.from, interval.to])])].sort((a, b) => a - b)
class TimeCurve extends THREE.Curve<THREE.Vector3> {
constructor() { super() }
getPoint(u: number, target = new THREE.Vector3()) {
const index = Math.min(times.length - 2, Math.floor(u * (times.length - 1)))
return target.copy(helix(times[index]! + (times[index + 1]! - times[index]!) * (u * (times.length - 1) - index)))
}
// TubeGeometry normally reparameterizes by arc length, which would move time/color boundaries.
getPointAt(u: number, target?: THREE.Vector3) { return this.getPoint(u, target) }
getTangentAt(u: number, target?: THREE.Vector3) { return this.getTangent(u, target) }
}
const geometry = own(new THREE.TubeGeometry(new TimeCurve(), times.length - 1, 1.1, 8, false))
const materials = [solid(muted), ...tones.map(color => solid(color))]
geometry.clearGroups()
let intervalIndex = 0
for (let i = 0; i < times.length - 1; i++) {
while (intervalIndex < intervals.length - 1 && times[i]! >= intervals[intervalIndex]!.to) intervalIndex++
const color = storyColor(intervals[intervalIndex]?.event?.storyId)
const materialIndex = color === muted ? 0 : tones.indexOf(color) + 1
const last = geometry.groups.at(-1)
if (last?.materialIndex === materialIndex) last.count += 48
else geometry.addGroup(i * 48, 48, materialIndex)
}
const timeline = new THREE.Mesh(geometry, materials)
timeline.userData.timeline = intervals
base.add(timeline)
const ticks = Array.from({ length: Math.floor(range.turns + 1e-6) + 1 }, (_, d) => range.from + d * range.turnHours * 3_600_000).filter(time => time < range.to)
scene.add(base)
// What is shown; the renderer and scene persist while focus, hover and selection change.
@@ -170,15 +200,18 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
const mesh = new THREE.Mesh(geometry, flat(color, emphasis === 'focus' ? 0.3 : emphasis === 'dim' ? 0.05 : 0.15))
mesh.userData.node = stave
world.add(mesh); meshes.push(mesh)
const clusters = clusterEvents(stave.events, span)
const owners = [...new Set(stave.events.map(event => event.storyId))]
const clusters = owners.flatMap(id => clusterEvents(stave.events.filter(event => event.storyId === id), span))
groups.set(stave.id, clusters)
for (const cluster of clusters) {
const color = storyColor(cluster.events[0]?.storyId)
const arc = Array.from({ length: 11 }, (_, i) => on(cluster.t, a0 + (a1 - a0) * i / 10, STAVE_R + 0.6))
const active = cluster.events.some(event => event.id === state.selected)
world.add(tube(arc, (Math.min(6, 1.3 + Math.log2(cluster.events.length) * 1.1) + (active ? 2 : 0)) * 0.45, solid(color, emphasis === 'dim' ? 0.3 : 0.9), 20))
}
// Every event is represented at the root; connectors emphasize focus and selection only.
for (const cluster of clusters) {
const color = storyColor(cluster.events[0]?.storyId)
const p = helix(cluster.t), active = cluster.events.some(event => event.id === state.selected)
if (emphasis === 'focus' || active) world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color, emphasis === 'dim' ? 0.3 : 1))
@@ -265,7 +298,6 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
resize.observe(canvas)
const zoom = (event: WheelEvent) => {
event.preventDefault()
setView('free')
camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012)))
draw()
}
@@ -284,10 +316,10 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss(); glCanvas.remove() }
}
return () => { engine.current?.dispose(); engine.current = undefined }
// Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene.
}, [supported, range, attention, i18n.resolvedLanguage, attempt]) // eslint-disable-line react-hooks/exhaustive-deps
// Focus, hover and selection use update(); data, locale, theme and retries recreate the scene.
}, [supported, range, attention, i18n.resolvedLanguage, attempt, theme]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId])
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId, theme, attempt])
const animate = (goal: Partial<typeof camera.current>) => {
const from = { ...camera.current }
@@ -302,7 +334,7 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
}
requestAnimationFrame(step)
}
const choose = (name: ViewName) => { setView(name); if (name !== 'free') animate({ ...VIEWS[name], zoom: 1, pan: 0 }) }
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0 })
const open = (node: StoryNode) => {
if (node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setFocusId(node.id)
@@ -313,43 +345,41 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
const rect = event.currentTarget.getBoundingClientRect()
return [event.clientX - rect.left, event.clientY - rect.top] as const
}
if (!supported) return <p className="supervisor-workspace__muted" role="status">{t('supervisor.graph3d.unsupported')}</p>
if (failure) return <div className="supervisor-workspace__inline-error" role="alert">
if (!supported) return <>{toolbar}<p className="supervisor-workspace__muted" role="status">{t('supervisor.graph3d.unsupported')}</p></>
if (failure) return <>{toolbar}<div className="supervisor-workspace__inline-error" role="alert">
<strong>{t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={() => { setFailure(undefined); setAttempt(value => value + 1) }}>{t('supervisor.graph3d.retry')}</button>
</div>
if (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
</div></>
if (!range) return <>{toolbar}<p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p></>
const childKinds = new Set(level.children.map(node => node.level))
const childLevelName = t(`supervisor.graph3d.childLevels.${childKinds.size === 1 ? [...childKinds][0] : 'mixed'}`)
const scale = t('supervisor.graph3d.scale', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })
return <div className="story-graph-3d">
<div className="story-graph-3d__toolbar">
<nav aria-label={t('supervisor.graph3d.levels')}>
<div className="supervisor-workspace__canvas-heading story-graph-3d__toolbar">
<nav className="story-graph-3d__breadcrumb" aria-label={t('supervisor.graph3d.levels')}>
{trail.map((node, index) => <span key={node.id}>
{index > 0 && ' › '}
{index === trail.length - 1 ? <strong>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</strong>
: <button type="button" className="link-button" onClick={() => setFocusId(node.id)}>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</button>}
</span>)}
</nav>
<div className="story-graph-3d__views">
<SegmentedControl ariaLabel={t('supervisor.graph3d.views')} value={view} onChange={choose}
options={(['oblique', 'side', 'top', 'free'] as const).map(value => ({ value, label: t(`supervisor.graph3d.view.${value}`) }))} />
<button type="button" className="secondary-button" aria-expanded={legendOpen} aria-controls={legendId}
onClick={() => setLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>
{/* Keyboard and screen-reader access to the same staves and experiences shown on the canvas. */}
{/* The record list selects details; this menu drills the actual project/feature hierarchy. */}
<div className="story-graph-3d__pickers">
<PickerMenu label={t('supervisor.graph3d.staves')} button={t('supervisor.graph3d.pickChildren', { level: childLevelName, count: level.children.length })}
items={level.children.map(node => ({ id: node.id, title: node.name, detail: `${t('supervisor.stories.count', { count: node.events.length })} · ${dateText(node.start)} – ${dateText(node.end)}`,
current: node.id === focus.id, onPick: () => open(node) }))} />
<PickerMenu label={t('supervisor.events')} button={t('supervisor.graph3d.pickEvents', { count: focus.events.length })}
items={focus.events.map(event => ({ id: event.id, title: event.title, detail: dateText(event.t),
current: event.id === selectedEventId, onPick: () => onSelectEvent(event.id) }))} />
{shownLinks.length > 0 && <PickerMenu label={t('supervisor.graph3d.experiences')} button={t('supervisor.graph3d.pickExperience', { count: shownLinks.length })}
items={shownLinks.map(link => ({ id: link.id, title: link.statement, detail: `${link.from.stave.name} → ${link.to.stave.name}`,
current: link.id === selectedExperienceId, onPick: () => onSelectExperience?.(link.id) }))} />}
</div>
<span className="supervisor-workspace__muted">{t('supervisor.graph3d.membershipNote')}</span>
<InlineHelp icon="info" label={t('supervisor.graph3d.legendTitle')}>
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<ul>
<li>{t('supervisor.graph3d.legendItems.time', { scale })}</li>
<li>{t('supervisor.graph3d.legendItems.radius')}</li>
<li>{t('supervisor.graph3d.legendItems.stave')}</li>
<li>{t('supervisor.graph3d.legendItems.experience')}</li>
<li>{t('supervisor.graph3d.membershipNote')}</li>
</ul>
</InlineHelp>
{toolbar}
</div>
<div className="story-graph-3d__viewport">
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
@@ -364,7 +394,6 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
return
}
const dx = x - drag.current.x, dy = y - drag.current.y
if (Math.hypot(x - drag.current.sx, y - drag.current.sy) >= 5 && view !== 'free') setView('free')
if (drag.current.pan) camera.current.pan = Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, camera.current.pan + dy))
else { camera.current.yaw += dx * 0.008; camera.current.tilt = Math.max(-Math.PI / 2 + 1e-3, Math.min(Math.PI / 2 - 1e-3, camera.current.tilt + dy * 0.006)) }
drag.current.x = x; drag.current.y = y
@@ -388,24 +417,9 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
const move: Record<string, () => void> = { ArrowLeft: () => { camera.current.yaw -= 0.12 }, ArrowRight: () => { camera.current.yaw += 0.12 },
ArrowUp: () => { camera.current.tilt = Math.min(Math.PI / 2 - 1e-3, camera.current.tilt + 0.06) }, ArrowDown: () => { camera.current.tilt = Math.max(-Math.PI / 2 + 1e-3, camera.current.tilt - 0.06) } }
if (!move[event.key]) return
event.preventDefault(); setView('free'); move[event.key]!(); engine.current?.draw()
event.preventDefault(); move[event.key]!(); engine.current?.draw()
}} />
<canvas ref={overlayRef} className="story-graph-3d__overlay" aria-hidden="true" />
{/* Scale stays visible on the canvas; the full explanation opens on demand. */}
<span className="story-graph-3d__scale" aria-hidden="true">{scale}</span>
{legendOpen && <div className="story-graph-3d__legend" id={legendId} role="note">
<div className="story-graph-3d__legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')} title={t('supervisor.graph3d.legendClose')}
onClick={() => setLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul>
<li>{t('supervisor.graph3d.legendItems.time', { scale })}</li>
<li>{t('supervisor.graph3d.legendItems.radius')}</li>
<li>{t('supervisor.graph3d.legendItems.stave')}</li>
<li>{t('supervisor.graph3d.legendItems.experience')}</li>
</ul>
</div>}
</div>
</div>
}
+83 -19
View File
@@ -9,8 +9,54 @@ const render = (ui: React.ReactNode) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
const selectHistory = (id: string) => {
fireEvent.click(screen.getByLabelText('历史结果'))
fireEvent.click(screen.getAllByRole('menuitemradio').find(item => (item as HTMLButtonElement).value === id)!)
}
describe('SupervisorWorkspace', () => {
it.each(['overview', 'graph'] as const)('shows each history result scope and range only inside the %s picker', async tab => {
const old = { ...result, id: 'old', createdAt: '2026-08-02T00:00:00.000Z',
scope: { kind: 'projects', projectIds: ['11111111-1111-4111-8111-111111111111'] },
timeRange: { from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z' } }
window.goodbuddy = { supervision: { overview: async () => [result, old],
graph: async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
render(<SupervisorWorkspace tab={tab} projects={[{ id: old.scope.projectIds[0], name: 'Project One' } as never]} />)
const trigger = await screen.findByLabelText('历史结果')
await waitFor(() => expect(trigger).toBeEnabled())
const date = (value: string) => new Date(value).toLocaleString('zh-CN')
expect(trigger).toHaveTextContent(date(result.createdAt))
expect(trigger).not.toHaveTextContent('全局')
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(date(result.timeRange.from)))
const context = trigger.closest('.supervisor-workspace__review-context')!
expect(context.querySelector('.supervisor-workspace__review-meta')).toBeNull()
expect(Array.from(context.children).map(item => item.tagName)).toEqual(['DIV', 'BUTTON', 'BUTTON'])
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
const options = screen.getAllByRole('menuitemradio')
for (const [index, item] of [result, old].entries()) {
expect(options[index]).toHaveTextContent(date(item.createdAt))
expect(options[index]).toHaveTextContent(date(item.timeRange.from))
expect(options[index]).toHaveTextContent(date(item.timeRange.to))
}
expect(options[0]).toHaveTextContent('全局')
expect(options[1]).toHaveTextContent('Project One')
expect(options[1]).not.toHaveTextContent(date(result.timeRange.from))
expect(options[0]).toHaveFocus()
fireEvent.keyDown(options[0]!, { key: 'End' })
expect(options[1]).toHaveFocus()
fireEvent.click(options[1]!)
await waitFor(() => expect(trigger).toBeEnabled())
expect(trigger).toHaveValue(old.id)
expect(trigger).toHaveTextContent(date(old.createdAt))
expect(trigger).toHaveAccessibleDescription(expect.stringContaining('Project One'))
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(date(old.timeRange.from)))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
expect(screen.getAllByRole('menuitemradio')[1]).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
})
it('defaults the graph history to latest and synchronizes selection in both directions, including empty graphs', async () => {
const old = { ...result, id: 'old', createdAt: '2026-08-01T00:00:00.000Z', summary: 'Old review' }
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
@@ -18,13 +64,18 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
const context = document.querySelector('.supervisor-workspace__review-context')
expect(screen.queryByLabelText('关注范围')).not.toBeInTheDocument()
expect(screen.queryByLabelText('时间范围')).not.toBeInTheDocument()
expect(graph).toHaveBeenLastCalledWith({ resultId: result.id, storyLineId: result.storyLineId })
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: old.id } })
selectHistory(old.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="overview" />)
expect(document.querySelector('.supervisor-workspace__review-context')).toBe(context)
expect(document.querySelector('.supervisor-workspace__recap')).toHaveAttribute('data-sidebar', 'false')
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByText('Old review')).toBeVisible()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
selectHistory(result.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" />)
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
@@ -44,8 +95,8 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={navigation} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__graph-canvas')).not.toBeNull()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__review-context')).not.toBeNull()
selectHistory(result.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ ...navigation }} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
@@ -103,7 +154,7 @@ describe('SupervisorWorkspace', () => {
render(<SupervisorWorkspace projects={[project]} onTabChange={onTabChange} onNotify={onNotify} />)
const history = await screen.findByLabelText('历史结果')
await waitFor(() => expect(history).toBeEnabled())
fireEvent.change(history, { target: { value: 'old' } })
selectHistory('old')
await waitFor(() => expect(history).toBeEnabled())
expect(history).toHaveValue('old')
expect(screen.queryByText('最近一次成功回顾')).not.toBeInTheDocument()
@@ -112,11 +163,17 @@ describe('SupervisorWorkspace', () => {
expect(screen.getByText('Pending decision')).toBeVisible()
const recap = screen.getByRole('article')
const frozenText = recap.textContent
expect(recap).toHaveAttribute('data-sidebar', 'true')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: project.id } })
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
expect(screen.queryByLabelText('关注范围')).not.toBeInTheDocument()
expect(history).toHaveValue('old')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
expect(screen.getByLabelText('时间范围')).toHaveValue('30')
expect(screen.getByLabelText('关注范围')).toHaveValue(project.id)
expect(recap.textContent).toBe(frozenText)
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
expect(onTabChange).toHaveBeenCalledWith('graph')
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
@@ -154,6 +211,7 @@ describe('SupervisorWorkspace', () => {
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(cancel).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ message: i18nResources['zh-CN'].heartbeat.reviewSettings.cancelling }))
expect(run).not.toHaveBeenCalled()
@@ -174,6 +232,7 @@ describe('SupervisorWorkspace', () => {
const onNotify = vi.fn()
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith({ tone: 'info', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewBusy, dedupeKey: 'supervisor-review' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -225,7 +284,7 @@ describe('SupervisorWorkspace', () => {
expect(screen.queryByText('Stale A')).not.toBeInTheDocument()
view.rerender(<SupervisorWorkspace graphNavigation={next} />)
expect(screen.getByLabelText('历史结果')).toHaveValue('B')
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: 'A' } })
selectHistory('A')
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story' }))
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story' }))
@@ -254,16 +313,13 @@ describe('SupervisorWorkspace', () => {
const onTabChange = vi.fn()
const view = render(<SupervisorWorkspace onTabChange={onTabChange} />)
await screen.findByText('B recap')
// The history select is identified by its selected result ID, not scope labels.
const select = [...document.querySelectorAll('select')].find((element) => element.value === 'B')!
fireEvent.change(select, { target: { value: 'A' } })
selectHistory('A')
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story-A' }))
fireEvent.change(select, { target: { value: 'B' } })
const navigation = { resultId: 'B' }
view.rerender(<SupervisorWorkspace graphNavigation={navigation} onTabChange={onTabChange} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story-B' }))
await act(async () => resolveA(graphFor('Wrong A event')))
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
expect(onTabChange).toHaveBeenCalledWith('graph')
view.rerender(<SupervisorWorkspace tab="graph" onTabChange={onTabChange} />)
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
expect(within(await screen.findByRole('group', { name: '故事线图谱' })).getByRole('button', { name: 'B selected' })).toBeInTheDocument()
expect(screen.queryByText('Wrong A event')).not.toBeInTheDocument()
})
@@ -305,6 +361,7 @@ describe('SupervisorWorkspace', () => {
} } as never
render(<SupervisorWorkspace />)
await waitFor(() => expect(screen.getByText('还没有成功回顾')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(window.goodbuddy.supervision.run).toHaveBeenCalledOnce())
expect(window.goodbuddy.supervision.run).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'manual', scope: { kind: 'global' } }))
@@ -320,6 +377,7 @@ describe('SupervisorWorkspace', () => {
} } as never
render(<SupervisorWorkspace />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '更多回顾操作' }))
fireEvent.click(await screen.findByRole('menuitem', { name: '重新整理…' }))
const dialog = screen.getByRole('alertdialog', { name: '重新整理这段时间?' })
@@ -343,6 +401,7 @@ describe('SupervisorWorkspace', () => {
const onNotify = vi.fn()
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewFailed })))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -360,6 +419,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText(copy.supervisor.empty)
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.newReview }))
fireEvent.change(screen.getByLabelText(copy.supervisor.period), { target: { value: '30' } })
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
@@ -377,6 +437,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview: async () => [], run: vi.fn().mockResolvedValue({ status }) } } as never
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText(copy.supervisor.empty)
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.newReview }))
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
const message = { completed: copy.supervisor.reviewCompleted, paused: copy.reviewSettings.pausedHint,
cancelled: copy.supervisor.reviewCancelled, no_change: copy.supervisor.reviewNoChange, failed: copy.supervisor.reviewFailed }[status]
@@ -396,6 +457,7 @@ describe('SupervisorWorkspace', () => {
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
expect(onNotify).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewStartFailed })))
expect(run).not.toHaveBeenCalled()
@@ -412,6 +474,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview, execution, run, cancel } } as never
const view = render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(phase === 'preflight' ? execution : run).toHaveBeenCalledOnce())
view.unmount()
@@ -439,12 +502,13 @@ describe('SupervisorWorkspace', () => {
expect(within(canvas).queryByRole('heading', { name: '时间事件' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('group', { name: '故事线图谱' })).toHaveAttribute('width', '100%')
expect(canvas.querySelector('.supervisor-workspace__timeline-ring')).toHaveAttribute('marker-end')
// The legend is closed by default and opens on the canvas, so the graph keeps the column.
// The icon opens shared help without taking space from the canvas.
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '图例' }))
expect(within(canvas).getByRole('list', { name: '图谱图例' })).toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '关闭图例' }))
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
expect(screen.getByRole('list', { name: '图谱图例' })).toBeInTheDocument()
expect(within(canvas).getByRole('button', { name: '图例' })).toHaveTextContent('')
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('slider', { name: '事件浏览' })).toBeDisabled()
expect(graph.querySelector('.supervisor-workspace__entity text')).toHaveTextContent('方案')
expect(graph.querySelector('.supervisor-workspace__node text')).toHaveTextContent('决定')
+99 -96
View File
@@ -1,10 +1,12 @@
import { Component, lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
import { BookOpen, ChevronLeft, ChevronRight, Ellipsis, Info, Network, RefreshCw, X } from 'lucide-react'
import { BookOpen, Check, ChevronDown, ChevronLeft, ChevronRight, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
import { InlineHelp } from './InlineHelp'
import { AnchoredMenu } from './AnchoredMenu'
import type { AppNotificationInput } from './notifications'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import { EmptyState, PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { storyDigest } from './supervision-story-digest'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
import { useFillHeight } from './use-fill-height'
@@ -85,7 +87,6 @@ export function SupervisorWorkspace({
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
const [flatLegendOpen, setFlatLegendOpen] = useState(false)
const [source, setSource] = useState<{
id: string
title: string
@@ -95,6 +96,10 @@ export function SupervisorWorkspace({
}>()
const [projectId, setProjectId] = useState('global')
const [days, setDays] = useState(7)
const [newReviewOpen, setNewReviewOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(Boolean(graphNavigation?.focus))
const detailToggleRef = useRef<HTMLButtonElement>(null)
const detailRef = useRef<HTMLElement>(null)
const runPending = useRef(false)
const mounted = useRef(false)
useEffect(() => {
@@ -102,6 +107,9 @@ export function SupervisorWorkspace({
return () => { mounted.current = false }
}, [])
const [moreOpen, setMoreOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const historyRef = useRef<HTMLButtonElement>(null)
const historyId = useId()
const [confirmReanalyze, setConfirmReanalyze] = useState(false)
const moreRef = useRef<HTMLButtonElement>(null)
const moreId = useId()
@@ -114,6 +122,7 @@ export function SupervisorWorkspace({
setLoading(true)
setSource(undefined)
setSelection(graphNavigation?.focus)
setDetailOpen(Boolean(graphNavigation?.focus))
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
setPending(undefined)
setLoadError(undefined)
@@ -406,6 +415,7 @@ export function SupervisorWorkspace({
const stageEvent = layout.events[stage]
const select = (next: Selection) => {
setSelection(next)
setDetailOpen(true)
setListTab(next.kind)
setSource(undefined)
setConfirmRemoval(false)
@@ -476,32 +486,51 @@ export function SupervisorWorkspace({
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
const graphLayoutRef = useRef<HTMLDivElement>(null)
// The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat
// graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT)
// Flat labels need a taller minimum; the spiral can use short windows without that constraint.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, graphMode === 'spiral' ? 320 : GRAPH_MIN_HEIGHT)
const graphEmptyRef = useRef<HTMLDivElement>(null)
// Without a graph, the empty state takes the visible height so its message sits in the middle of the page.
useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320)
// The work review reads the selected result's own scope, so it matches the period shown.
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
const digest = latest && recapStories.available ? storyDigest(recapStories.view, latest.timeRange) : undefined
const hasDigest = digest && [digest.advanced, digest.started, digest.concluded, digest.experiences, digest.quiet].some(items => items.length > 0)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
useEffect(() => {
if (!detailOpen || !detailRef.current || !detailToggleRef.current || getComputedStyle(detailToggleRef.current).display === 'none') return
detailRef.current.focus({ preventScroll: true })
detailRef.current.scrollIntoView?.({ block: 'nearest' })
}, [detailOpen, selection?.id, tab])
const busy = !!api && (loading || pending !== undefined)
const historyDetails = (item: SupervisionResultView) =>
`${scopeText(item.scope)} · ${t('supervisor.period')}: ${date(item.timeRange.from)} – ${date(item.timeRange.to)}`
const historySelector = results.length > 0 && <div className="supervisor-workspace__result-navigation">
<label>
{t('supervisor.history')}
<select value={resultId ?? ''} disabled={busy}
onChange={(event) => { setSelection(undefined); setSource(undefined); void refresh(event.target.value) }}>
{!results.some((item) => item.id === resultId) && <option value={resultId ?? ''}>{t('supervisor.loading')}</option>}
{results.map((item) => <option key={item.id} value={item.id}>
{date(item.createdAt)} · {scopeText(item.scope)}
</option>)}
</select>
</label>
{tab === 'overview' && latest && <button className="secondary-button" onClick={() => onTabChange?.('graph')}>
{t('supervisor.graph')}
</button>}
<span id={`${historyId}-label`}>{t('supervisor.history')}</span>
<button ref={historyRef} type="button" className="model-button" value={resultId ?? ''} disabled={busy}
aria-labelledby={`${historyId}-label`} aria-describedby={`${historyId}-context`}
aria-haspopup="menu" aria-expanded={historyOpen && !busy} aria-controls={historyOpen && !busy ? historyId : undefined}
onClick={() => setHistoryOpen(value => !value)}
onKeyDown={event => { if (event.key === 'ArrowDown') { event.preventDefault(); setHistoryOpen(true) } }}>
<span className="model-button__label">{latest ? date(latest.createdAt) : t('supervisor.loading')}</span>
<ChevronDown size={14} aria-hidden="true" />
</button>
<span id={`${historyId}-context`} className="sr-only">{latest && `${t('supervisor.generatedAt', { time: date(latest.createdAt) })} · ${historyDetails(latest)}`}</span>
{historyOpen && !busy && <AnchoredMenu anchorRef={historyRef} id={historyId} label={t('supervisor.history')}
width={480} className="supervisor-workspace__history-menu" onClose={() => setHistoryOpen(false)}>
{results.map(item => <button key={item.id} type="button" role="menuitemradio" value={item.id} aria-checked={item.id === resultId}
onClick={() => { setHistoryOpen(false); historyRef.current?.focus(); setSelection(undefined); setSource(undefined); setDetailOpen(false); void refresh(item.id) }}>
<span><span>{t('supervisor.generatedAt', { time: date(item.createdAt) })}</span><small>{historyDetails(item)}</small></span>
{item.id === resultId && <Check size={16} aria-hidden="true" />}
</button>)}
</AnchoredMenu>}
</div>
const graphTools = <>
{storyState.available && <div className="supervisor-workspace__graph-mode"><SegmentedControl ariaLabel={t('supervisor.graph3d.mode')}
value={graphMode} onChange={setGraphMode} options={(['flat', 'spiral'] as const).map(value => ({ value, label: t(`supervisor.graph3d.modes.${value}`) }))} /></div>}
<button ref={detailToggleRef} type="button" className="secondary-button supervisor-workspace__detail-toggle"
aria-expanded={detailOpen} aria-controls={`${graphId}-detail`} onClick={() => setDetailOpen(value => !value)}>{t('supervisor.inspector')}</button>
</>
return (
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
@@ -515,6 +544,12 @@ export function SupervisorWorkspace({
</div>
) : (
<>
{(tab === 'overview' || tab === 'graph') && <div className="supervisor-workspace__review-context" role="group" aria-label={t('supervisor.recap')}>
{historySelector}
<button type="button" className="secondary-button" disabled={busy} onClick={() => void refresh()}>{t('center.actions.refresh')}</button>
<button type="button" className={newReviewOpen ? 'secondary-button' : 'primary-button'} aria-expanded={newReviewOpen} aria-controls={`${graphId}-new-review`}
onClick={() => { setNewReviewOpen(value => !value); setConfirmReanalyze(false) }}>{t('supervisor.newReview')}</button>
</div>}
{loading && (
<EmptyState
variant="loading"
@@ -536,9 +571,9 @@ export function SupervisorWorkspace({
</button>
</div>
)}
{tab === 'overview' && (
{newReviewOpen && (tab === 'overview' || tab === 'graph') && (
<>
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<div id={`${graphId}-new-review`} className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<label>
<select
aria-label={t('supervisor.scope')}
@@ -574,13 +609,6 @@ export function SupervisorWorkspace({
>
{t('supervisor.run')}
</button>
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
<button ref={moreRef} type="button" className="icon-button" aria-label={t('supervisor.more')} title={t('supervisor.more')}
aria-haspopup="menu" aria-expanded={moreOpen} aria-controls={moreOpen ? moreId : undefined}
onClick={() => setMoreOpen(!moreOpen)}><Ellipsis size={16} aria-hidden="true" /></button>
@@ -602,19 +630,12 @@ export function SupervisorWorkspace({
<button type="button" className="primary-button" onClick={() => void run(true)}>{t('supervisor.reanalyzeConfirm')}</button>
</div>
</div>}
{historySelector}
{latest && recapStories.available && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
</>
)}
{tab === 'overview' && (
<>
{latest ? (
<article className="supervisor-workspace__recap">
<div className="supervisor-workspace__section-heading">
<h2>{t('supervisor.latest')}</h2>
<time dateTime={latest.createdAt}>{date(latest.createdAt)}</time>
</div>
<p>
{scopeText(latest.scope)} · {date(latest.timeRange.from)} –{' '}
{date(latest.timeRange.to)}
</p>
<article className="supervisor-workspace__recap" data-sidebar={Boolean(latest.openItems.length || hasDigest)}>
<div className="supervisor-workspace__prose">
<h3>{t('supervisor.summary')}</h3>
{latest.summary.split(/\r?\n\s*\r?\n/u).map((paragraph, index) => (
@@ -624,6 +645,8 @@ export function SupervisorWorkspace({
<h3>{t('supervisor.changes')}</h3>
<p className="supervisor-workspace__summary">{latest.changeDigest}</p>
</>}
</div>
{(latest.openItems.length > 0 || hasDigest) && <aside className="supervisor-workspace__recap-sidebar">
{latest.openItems.length > 0 && (
<>
<h3>{t('supervisor.openItems')}</h3>
@@ -634,7 +657,9 @@ export function SupervisorWorkspace({
</ul>
</>
)}
</div>
{hasDigest && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
</aside>}
</article>
) : (
!loading &&
@@ -650,8 +675,6 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
{!graph.events.length && historySelector}
{!loading && !loadError && !graph.events.length && (
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
<EmptyState
@@ -666,24 +689,22 @@ export function SupervisorWorkspace({
>
{t('supervisor.recap')}
</button>
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
</div>
}
/>
</div>
)}
{graph.events.length > 0 && (
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef}>
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef} data-detail-open={detailOpen} data-mode={graphMode}>
<aside
className="supervisor-workspace__graph-list"
aria-label={t('supervisor.selection')}
>
<select className="field-control supervisor-workspace__category" aria-label={t('supervisor.selection')}
value={listTab} onChange={event => setListTab(event.target.value as Selection['kind'])}>
{(['event', 'entity', 'relation', ...(storyState.available ? ['story', 'experience'] as const : [])] as const).map(kind =>
<option key={kind} value={kind}>{t(`supervisor.listTabs.${kind}`)} {kind === 'event' ? layout.events.length : kind === 'entity' ? layout.entities.length : kind === 'relation' ? graph.relations.length : kind === 'story' ? storyState.view.stories.length : storyState.view.experiences.length}</option>)}
</select>
<PageTabs
ariaLabel={t('supervisor.selection')}
idPrefix={`${graphId}-list`}
@@ -792,41 +813,33 @@ export function SupervisorWorkspace({
className="supervisor-workspace__graph-canvas"
aria-label={t('supervisor.canvas')}
>
<div className="supervisor-workspace__canvas-heading">
{/* History stays inside the canvas column with its title and review metadata. */}
<div className="supervisor-workspace__canvas-title">
{historySelector}
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{[
graphScope && `${t('supervisor.graphScope')}: ${scopeText(graphScope)}`,
latest && `${date(latest.timeRange.from)} – ${date(latest.timeRange.to)}`,
latest && t('supervisor.generatedAt', { time: date(latest.createdAt) }),
t('supervisor.counts', { events: layout.events.length, entities: layout.entities.length })
].filter(Boolean).join(' · ')}
</span>
</div>
<button type="button" className="icon-button" aria-label={t('center.actions.refresh')} title={t('center.actions.refresh')}
disabled={busy} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
{graphMode === 'flat' && <div className="supervisor-workspace__canvas-tools">
{graphMode === 'flat' && <div className="supervisor-workspace__canvas-heading">
<strong>{t('supervisor.graph3d.all')}</strong>
<div className="supervisor-workspace__canvas-tools">
{selection && <button type="button" className="link-button" onClick={() => { setSelection(undefined); setSource(undefined) }}>
{t('supervisor.showAll')}</button>}
<button type="button" className="secondary-button" aria-expanded={flatLegendOpen} aria-controls={`${graphId}-legend`}
onClick={() => setFlatLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>}
{storyState.available && <div role="group" aria-label={t('supervisor.graph3d.mode')} className="supervisor-workspace__graph-mode">
{(['flat', 'spiral'] as const).map((mode) => <button key={mode} type="button" className="secondary-button"
aria-pressed={graphMode === mode} onClick={() => setGraphMode(mode)}>{t(`supervisor.graph3d.modes.${mode}`)}</button>)}
</div>}
</div>
<InlineHelp icon="info" label={t('supervisor.graph3d.legendTitle')}>
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<ul className="supervisor-workspace__legend" aria-label={t('supervisor.legendLabel')}>
<li><i className="supervisor-workspace__legend-event" aria-hidden="true" />{t('supervisor.events')}</li>
<li><i className="supervisor-workspace__legend-entity" aria-hidden="true" />{t('supervisor.entities')}</li>
<li><i className="supervisor-workspace__legend-link" aria-hidden="true" />{t('supervisor.eventImpact')}</li>
<li><i className="supervisor-workspace__legend-relation" aria-hidden="true" />{t('supervisor.relations')}</li>
</ul>
<p>{t('supervisor.canvasNote')}</p>
<p>{t('supervisor.canvasCaption')}</p>
</InlineHelp>
</div>
{graphTools}
</div>}
{graphMode === 'spiral' ? (
<SpiralBoundary fallback={(retry) => <div className="supervisor-workspace__inline-error" role="alert">
<SpiralBoundary fallback={(retry) => <><div className="supervisor-workspace__canvas-heading">{graphTools}</div><div className="supervisor-workspace__inline-error" role="alert">
<strong>{t('supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={retry}>{t('supervisor.graph3d.retry')}</button>
<button type="button" className="link-button" onClick={() => setGraphMode('flat')}>{t('supervisor.graph3d.modes.flat')}</button>
</div>}>
<Suspense fallback={<p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p>}>
</div></>}>
<Suspense fallback={<><div className="supervisor-workspace__canvas-heading">{graphTools}</div><p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p></>}>
<StoryGraph3D stories={storyState.view.stories} events={graph.events} projectNames={projectNames} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
toolbar={graphTools}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
@@ -1023,21 +1036,6 @@ export function SupervisorWorkspace({
<span className="supervisor-workspace__map-hint" aria-hidden="true">
{t('supervisor.visibleCounts', { events: layout.visibleEvents.length, entities: layout.visibleEntities.length })}
</span>
{flatLegendOpen && <div className="supervisor-workspace__map-legend" id={`${graphId}-legend`} role="note">
<div className="supervisor-workspace__map-legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')}
title={t('supervisor.graph3d.legendClose')} onClick={() => setFlatLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul className="supervisor-workspace__legend" aria-label={t('supervisor.legendLabel')}>
<li><i className="supervisor-workspace__legend-event" aria-hidden="true" />{t('supervisor.events')}</li>
<li><i className="supervisor-workspace__legend-entity" aria-hidden="true" />{t('supervisor.entities')}</li>
<li><i className="supervisor-workspace__legend-link" aria-hidden="true" />{t('supervisor.eventImpact')}</li>
<li><i className="supervisor-workspace__legend-relation" aria-hidden="true" />{t('supervisor.relations')}</li>
</ul>
<p>{t('supervisor.canvasNote')}</p>
<p>{t('supervisor.canvasCaption')}</p>
</div>}
</figure>
{stageEvent && (
<div className="supervisor-workspace__playback">
@@ -1093,9 +1091,15 @@ export function SupervisorWorkspace({
</>}
</section>
<aside
ref={detailRef}
tabIndex={-1}
id={`${graphId}-detail`}
className="supervisor-workspace__detail"
aria-label={t('supervisor.inspector')}
onKeyDown={(event) => { if (event.key === 'Escape' && detailOpen) { event.stopPropagation(); setDetailOpen(false); detailToggleRef.current?.focus() } }}
>
<button type="button" className="icon-button supervisor-workspace__detail-toggle" aria-label={t('supervisor.closeDetails')}
onClick={() => { setDetailOpen(false); detailToggleRef.current?.focus() }}><X size={16} aria-hidden="true" /></button>
<h2>{t('supervisor.inspector')}</h2>
{selection?.kind === 'experience' ? (
selectedExperience ? <>
@@ -1119,7 +1123,6 @@ export function SupervisorWorkspace({
: selectedEntity
? t('supervisor.entities')
: t('supervisor.relations')}
{graphScope && ` · ${scopeText(graphScope)}`}
</div>
<h3>
{selectedEvent?.title ??
+5 -4
View File
@@ -12,7 +12,7 @@
-webkit-app-region: no-drag;
}
.anchored-menu [role="menuitem"] {
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]) {
display: flex;
align-items: center;
gap: var(--space-2);
@@ -29,8 +29,9 @@
cursor: pointer;
}
.anchored-menu [role="menuitem"] svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu [role="menuitem"]:hover { background: var(--surface-subtle); }
.anchored-menu [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]) svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]):hover { background: var(--surface-subtle); }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]):focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu [role="menuitemradio"][aria-checked="true"] { background: var(--accent-subtle); font-weight: 600; }
.anchored-menu [role="separator"] { border-top: 1px solid var(--border-subtle); margin: var(--space-2) 0; }
.anchored-menu p { margin: var(--space-2); color: var(--text-secondary); overflow-wrap: anywhere; }
@@ -110,7 +110,7 @@ export const heartbeat = {
canvasTitle: 'Events and knowledge', generatedAt: 'Generated {{time}}', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
visibleCounts: '{{events}} events / {{entities}} entities on canvas', showAll: 'Show all connections', connections: 'Graph connections',
loadingHint: 'Reading saved reviews, events, and sources.', unavailableHint: 'Reopen the application to try again. Saved reviews remain on this device.',
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources',
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources', closeDetails: 'Close details',
counts: '{{events}} events · {{entities}} entities', canvasCaption: 'Events are evenly spaced in time order, not by elapsed time. Each batch shows up to 8 events and 6 entities; select from the list to switch batches and read full names. Scroll the canvas horizontally on narrow screens. Entity states reflect the selected review result.',
legendLabel: 'Graph legend', eventImpact: 'Event impact', playback: 'Browse events', previousStage: 'Previous event', nextStage: 'Next event',
navigation: 'Supervisor views', recap: 'Work review', graph: 'Story graph', settings: 'Settings',
@@ -133,14 +133,14 @@ export const heartbeat = {
},
graph3d: {
mode: 'Graph view', modes: { flat: 'Flat', spiral: 'Time spiral' },
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the toolbar lists offer the same items.',
levels: 'Stave levels', all: 'All', views: 'View', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level. Use the level menu to drill down and the sidebar to select records.',
view: { oblique: 'Oblique', side: 'Side', top: 'Top', free: 'Free' },
staves: 'Stories at this level',
experiences: 'Experiences across stories',
scale: 'One turn {{turn}} · {{count}} turns',
legendTitle: 'Legend', legendClose: 'Close legend',
legendItems: {
time: 'Height is time; {{scale}}.',
time: 'Height is time; {{scale}}. Colored segments cover actual event intervals and match the primary story. Gaps stay gray; timestamp-only events are dots. Overlaps show the later-starting event; unassigned events use neutral marks.',
radius: 'Radius compares user and assistant messages per hour within this review. Equal rates have equal radii; no recorded messages gives the minimum radius. It does not measure time spent or productivity.',
stave: 'A stave spans its story from first to last event.',
experience: 'Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.'
@@ -107,7 +107,7 @@ export const heartbeat = {
canvasTitle: '事件与知识', generatedAt: '生成于 {{time}}', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
visibleCounts: '画布中 {{events}} 个事件 / {{entities}} 个实体', showAll: '显示全部关联', connections: '图谱关联',
loadingHint: '正在读取已保存的回顾、事件与来源。', unavailableHint: '请重新打开应用后重试;已保存的回顾仍保留在本机。',
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源',
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源', closeDetails: '关闭详情',
counts: '{{events}} 个事件 · {{entities}} 个实体', canvasCaption: '事件按时间顺序等距排列,不表示时间间隔。画布每批最多显示 8 个事件和 6 个实体;从列表选择可切换批次并查看完整名称,窄屏可横向滚动画布。实体状态以所选回顾结果为准。',
legendLabel: '图谱图例', eventImpact: '事件影响', playback: '事件浏览', previousStage: '上一事件', nextStage: '下一事件',
navigation: '监督者视图', recap: '工作回顾', graph: '故事线图谱', settings: '设置',
@@ -130,14 +130,14 @@ export const heartbeat = {
},
graph3d: {
mode: '图谱视图', modes: { flat: '平铺', spiral: '时间螺旋' },
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;工具栏的列表提供同样的内容。',
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;层级菜单用于下钻,左侧列表用于选择记录。',
view: { oblique: '斜俯视', side: '侧视', top: '俯视', free: '自由' },
staves: '当前层级的故事',
experiences: '跨故事的经验',
scale: '一圈 {{turn}} · 共 {{count}} 圈',
legendTitle: '图例', legendClose: '关闭图例',
legendItems: {
time: '高度为时间,{{scale}}。',
time: '高度为时间,{{scale}}。彩色线段只表示事件的实际起止区间,颜色对应主故事;空档为灰线,仅有时间点的事件显示为圆点。重叠区间显示较晚开始的事件,未归属事件用中性色标记。',
radius: '半径比较本次回顾内每小时的用户与助手消息数。密度相同则半径相同,无消息记录时取最小半径,不代表投入时长或工作效率。',
stave: '木片上下跨度为故事的起止时间。',
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
+32 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, visibleLevel } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, timelineSegments, visibleLevel } from './story-graph-3d-model'
const day = 86_400_000
const at = (d: number) => new Date(Date.parse('2026-09-01T00:00:00Z') + d * day).toISOString()
@@ -25,9 +25,10 @@ describe('story graph 3d model', () => {
['feature', ['exact-member']], ['unassigned', ['old-extraction', 'never-assigned']]
])
expect(tree.children[1]!.name).toBe('Unknown')
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)) })
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)), end: Date.parse(at(3.5)), storyId: 'feature' })
const beforeStories = buildStoryTree([], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(beforeStories.events).toEqual(tree.events)
expect(beforeStories.events.map(event => event.id)).toEqual(tree.events.map(event => event.id))
expect(beforeStories.events.every(event => !event.storyId)).toBe(true)
expect(beforeStories.children.flatMap(project => project.children).every(node => node.level === 'unassigned')).toBe(true)
})
@@ -57,6 +58,34 @@ describe('story graph 3d model', () => {
expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6)
})
it('colors only saved intervals with primary membership, keeping points, gaps and overlaps truthful', () => {
const feature = story('feature', 'feature', [1])
const cross = story('cross', 'cross', [1], { events: feature.events.map(event => ({ ...event, primary: false })) })
const tree = buildStoryTree([feature, cross], new Map(), { unassigned: 'Unassigned', unknownProject: 'Unknown', events: [
{ id: 'feature-0', title: 'Saved interval', description: '', occurred_at: at(1), started_at: at(1), ended_at: at(3) },
{ id: 'point', title: 'Point', description: '', occurred_at: at(5) }
] })
expect(tree.events[0]).toMatchObject({ t: Date.parse(at(1)), end: Date.parse(at(3)), storyId: 'feature' })
expect(tree.children[0]!.end).toBe(Date.parse(at(3)))
expect(tree.children.find(node => node.level === 'cross')!.events[0]!.storyId).toBe('feature')
const segments = timelineSegments(tree.events, storyWindow(Date.parse(at(0)), Date.parse(at(6))))
expect(segments.map(segment => [(segment.from - Date.parse(at(0))) / day, (segment.to - Date.parse(at(0))) / day, segment.event?.storyId])).toEqual([
[0, 1, undefined], [1, 3, 'feature'], [3, 6, undefined]
])
const overlapping = [
{ id: 'a', title: '', t: -1, end: 6, storyId: 'one' },
{ id: 'b', title: '', t: 2, end: 4, storyId: 'two' },
{ id: 'c', title: '', t: 2, end: 3, storyId: 'three' },
{ id: 'point', title: '', t: 7, end: 7 },
{ id: 'unassigned', title: '', t: 8, end: 12 }
]
const mapped = timelineSegments(overlapping, storyWindow(0, 10))
expect(mapped.map(segment => [segment.from, segment.to, segment.event?.id])).toEqual([
[0, 2, 'a'], [2, 3, 'b'], [3, 4, 'b'], [4, 6, 'a'], [6, 8, undefined], [8, 10, 'unassigned']
])
expect(timelineSegments([...overlapping].reverse(), storyWindow(0, 10))).toEqual(mapped)
})
it('keeps each turn round with continuous transitions and actual density differences', () => {
const window = storyWindow(Date.parse(at(0)), Date.parse(at(8)))
const attention = [{ start: at(0.5), turns: 100, characters: 1 }, { start: at(4.5), turns: 1, characters: 1 }]
+16 -4
View File
@@ -21,7 +21,7 @@ export type StoryNode = {
a1: number
concluded: boolean
}
export type StoryEventPoint = { id: string; title: string; t: number }
export type StoryEventPoint = { id: string; title: string; t: number; end?: number; storyId?: string }
export type StoryWindow = { from: number; to: number; turnHours: number; turns: number }
const TAU = Math.PI * 2
@@ -34,11 +34,15 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
}): StoryNode {
const events = review && new Map(review.events.map(event => [event.id, event]))
if (events) stories = stories.map(story => ({ ...story, events: story.events.filter(event => events.has(event.id)) }))
const owners = new Map(stories.filter(story => story.level !== 'cross').flatMap(story =>
story.events.filter(event => event.primary).map(event => [event.id, story.id] as const)))
const root: StoryNode = { id: 'root', name: '', level: 'root', children: [], events: [], start: 0, end: 0, a0: 0, a1: TAU, concluded: false }
const node = (story: SupervisionStory, parent: StoryNode): StoryNode => ({ id: story.id, name: story.name, level: story.level, parent, children: [],
events: story.events.filter(event => story.level === 'cross' || event.primary).map(event => {
const saved = events?.get(event.id)
return { id: event.id, title: saved?.title ?? event.title, t: Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt) }
const t = Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt)
const end = Date.parse(saved ? saved.ended_at ?? saved.started_at ?? saved.occurred_at : event.endedAt)
return { id: event.id, title: saved?.title ?? event.title, t, end: Number.isFinite(end) ? Math.max(t, end) : t, storyId: owners.get(event.id) }
}),
start: 0, end: 0, a0: 0, a1: 0, concluded: story.state === 'concluded' })
const projects = new Map<string, StoryNode>()
@@ -73,7 +77,8 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
project.children.push(unassigned)
}
unassigned.events.push({ id: event.id, title: event.title, t: Date.parse(event.started_at ?? event.occurred_at) })
const t = Date.parse(event.started_at ?? event.occurred_at), end = Date.parse(event.ended_at ?? event.started_at ?? event.occurred_at)
unassigned.events.push({ id: event.id, title: event.title, t, end: Number.isFinite(end) ? Math.max(t, end) : t })
}
}
finish(root)
@@ -90,7 +95,7 @@ function finish(node: StoryNode): void {
}
node.events.sort((a, b) => a.t - b.t || a.id.localeCompare(b.id))
node.start = node.events[0]?.t ?? 0
node.end = node.events.at(-1)?.t ?? 0
node.end = node.events.reduce((end, event) => Math.max(end, event.end ?? event.t), node.start)
node.children = node.children.filter(child => child.events.length > 0)
}
@@ -149,6 +154,13 @@ export function radiusLevels(window: StoryWindow, attention: SupervisionAttentio
}
export type StoryCluster = { t0: number; t1: number; t: number; events: StoryEventPoint[] }
/** Exact interval boundaries; overlapping events use the latest start, then stable ID. Points do not fill gaps. */
export function timelineSegments(events: StoryEventPoint[], window: StoryWindow): Array<{ from: number; to: number; event?: StoryEventPoint }> {
const intervals = events.filter(event => (event.end ?? event.t) > event.t && event.t < window.to && event.end! > window.from)
.sort((a, b) => b.t - a.t || a.id.localeCompare(b.id))
const boundaries = [...new Set([window.from, window.to, ...intervals.flatMap(event => [Math.max(window.from, event.t), Math.min(window.to, event.end!)])])].sort((a, b) => a - b)
return boundaries.slice(0, -1).map((from, index) => ({ from, to: boundaries[index + 1]!, event: intervals.find(event => event.t <= from && event.end! > from) }))
}
/** Events closer than `span` (ms) along time merge into one band; zooming in re-splits them. */
export function clusterEvents(events: StoryEventPoint[], span: number): StoryCluster[] {
const out: StoryCluster[] = []
+121 -119
View File
@@ -194,7 +194,6 @@
min-width: 0;
}
.supervisor-workspace__toolbar label,
.supervisor-workspace__result-navigation label,
.supervisor-workspace__recap label,
.supervisor-workspace__detail label {
display: grid;
@@ -203,7 +202,6 @@
font-size: var(--font-caption);
}
.supervisor-workspace__toolbar select,
.supervisor-workspace__result-navigation select,
.supervisor-workspace__recap select,
.supervisor-workspace__detail input {
min-height: 32px;
@@ -353,31 +351,27 @@
margin-top: var(--space-2);
}
.supervisor-workspace__result-navigation {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-3);
margin-top: var(--space-6);
}
.supervisor-workspace__result-navigation label {
flex: 1 1 240px;
min-width: 0;
}
.supervisor-workspace__result-navigation select {
width: 100%;
min-width: 0;
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation {
margin-top: 0;
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation label {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
color: var(--text-secondary);
font-size: var(--font-caption);
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation select {
.supervisor-workspace__result-navigation .model-button {
flex: 1;
min-width: 0;
}
.supervisor-workspace__history-menu [role="menuitemradio"] > span {
flex: 1;
min-width: 0;
}
.supervisor-workspace__history-menu small {
display: block;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: normal;
}
.supervisor-workspace__recap li {
margin: var(--space-2) 0;
@@ -400,7 +394,7 @@
/*
* Wide layouts: the graph row is as tall as the window allows (`--fill-height`, set by useFillHeight with a
* steady bottom margin), and each column in it scrolls on its own, so long lists never grow the page.
* With three columns that is the whole layout; with two, the detail wraps below at its natural height.
* At medium widths the detail opens inside the graph row; it never grows a second grid row.
* Single-column layouts keep page scrolling.
*/
@container supervisor (min-width: 861px) {
@@ -455,12 +449,6 @@
scrollbar-gutter: stable;
}
}
@container supervisor (min-width: 861px) and (max-width: 1190px) {
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
max-height: none;
overflow-y: visible;
}
}
.supervisor-workspace__canvas-heading {
display: flex;
justify-content: space-between;
@@ -471,17 +459,9 @@
}
.supervisor-workspace__canvas-heading {
align-items: center;
}
.supervisor-workspace__canvas-title {
display: grid;
gap: 2px;
flex: 1 1 240px;
min-width: 0;
}
.supervisor-workspace__canvas-title span {
color: var(--text-muted);
font-size: var(--font-caption);
overflow-wrap: anywhere;
flex: 0 0 auto;
font-size: var(--font-body);
background: var(--surface-raised);
}
.supervisor-workspace__figure {
position: relative;
@@ -512,39 +492,11 @@
font-size: var(--font-caption);
pointer-events: none;
}
.supervisor-workspace__map-legend {
position: absolute;
inset-inline-end: var(--space-3);
inset-block-start: var(--space-3);
z-index: 1;
width: min(300px, calc(100% - 2 * var(--space-3)));
max-height: calc(100% - 2 * var(--space-3));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.supervisor-workspace__map-legend-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-primary);
}
.supervisor-workspace__map-legend .supervisor-workspace__legend {
.inline-help__content .supervisor-workspace__legend {
padding: var(--space-2) 0;
border-top: 0;
gap: var(--space-2) var(--space-3);
}
.supervisor-workspace__map-legend p {
margin: var(--space-1) 0 0;
}
.supervisor-workspace__figure figcaption {
padding: 0 var(--space-4) var(--space-4);
color: var(--text-secondary);
@@ -947,10 +899,6 @@
.supervisor-workspace__graph-layout {
grid-template-columns: 184px minmax(0, 1fr);
}
.supervisor-workspace__detail {
grid-column: 1 / -1;
border-top: 1px solid var(--border-default);
}
.supervisor-workspace__graph-canvas {
border-right: 0;
}
@@ -1212,17 +1160,10 @@
.story-graph-3d {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-4);
}
.story-graph-3d__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
font-size: var(--font-caption);
justify-content: flex-start;
}
.story-graph-3d__toolbar > div {
display: flex;
@@ -1230,18 +1171,20 @@
gap: var(--space-2);
min-width: 0;
}
.story-graph-3d__views {
margin-inline-start: auto;
.story-graph-3d__breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.story-graph-3d__viewport {
position: relative;
width: 100%;
aspect-ratio: 760 / 620;
min-height: 320px;
border-radius: var(--radius-control);
background: var(--surface-raised);
overflow: hidden;
}
@@ -1269,7 +1212,7 @@
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
flex-basis: 100%;
flex: 0 1 auto;
}
.story-graph-3d__picker {
display: inline-flex;
@@ -1277,6 +1220,12 @@
gap: var(--space-1);
min-width: 0;
max-width: 100%;
min-height: 28px;
padding: var(--space-1) var(--space-2);
font-size: var(--font-body);
font-weight: 400;
border-color: var(--border-subtle);
background: var(--surface-subtle);
}
.story-graph-3d__picker > span {
overflow: hidden;
@@ -1318,42 +1267,95 @@
.anchored-menu.story-graph-3d__menu [role="menuitem"] > svg {
color: var(--accent);
}
.story-graph-3d__scale {
position: absolute;
inset-inline-start: var(--space-2);
inset-block-end: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-control);
background: color-mix(in srgb, var(--surface-raised) 85%, transparent);
color: var(--text-muted);
font-size: var(--font-caption);
pointer-events: none;
}
.story-graph-3d__legend {
position: absolute;
inset-inline-end: var(--space-2);
inset-block-start: var(--space-2);
width: min(280px, calc(100% - 2 * var(--space-2)));
max-height: calc(100% - 2 * var(--space-2));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
font-size: var(--font-caption);
}
.story-graph-3d__legend-head {
/* Review context is shared by both result views, independently of new-review inputs. */
.supervisor-workspace__review-context {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding-block: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.story-graph-3d__legend ul {
.supervisor-workspace__review-context .supervisor-workspace__result-navigation {
margin: 0;
flex: 0 1 280px;
min-width: 0;
}
.supervisor-workspace__review-context > button { flex-shrink: 0; }
.supervisor-workspace__toolbar {
padding: var(--space-3);
background: var(--surface-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.supervisor-workspace__recap {
display: grid;
gap: var(--space-1);
margin: var(--space-1) 0 0;
padding-inline-start: 1.1em;
color: var(--text-muted);
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
border: 0;
border-radius: 0;
}
.supervisor-workspace__recap > * { min-width: 0; }
.supervisor-workspace__recap h3:first-child { margin-top: 0; }
.supervisor-workspace__recap-sidebar {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
}
.supervisor-workspace__recap-sidebar .supervisor-digest {
margin: var(--space-4) 0 0;
padding: var(--space-4) 0 0;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0;
}
.supervisor-workspace__recap-sidebar .supervisor-digest:first-child { margin: 0; padding: 0; border: 0; }
.supervisor-workspace__recap-sidebar .supervisor-digest h2 { font-size: var(--font-section-title); }
.supervisor-workspace__recap-sidebar .supervisor-digest__grid { grid-template-columns: minmax(0, 1fr); }
@container supervisor (min-width: 861px) {
.supervisor-workspace__recap[data-sidebar='true'] { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.supervisor-workspace__recap-sidebar {
border-top: 0;
border-left: 1px solid var(--border-subtle);
padding: 0 0 0 var(--space-6);
}
}
.supervisor-workspace__graph-layout { position: relative; }
.supervisor-workspace__detail-toggle { display: none; }
@container supervisor (max-width: 1190px) {
.supervisor-workspace__detail-toggle { display: inline-flex; }
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
display: none;
position: absolute;
inset: 0 0 0 auto;
z-index: 2;
width: min(360px, 100%);
max-height: 100%;
box-sizing: border-box;
overflow-y: auto;
overscroll-behavior: contain;
border-left: 1px solid var(--border-default);
box-shadow: var(--shadow-card);
}
.supervisor-workspace__graph-layout[data-detail-open='true'] > .supervisor-workspace__detail { display: block; }
}
.supervisor-workspace__category { display: none; }
@container supervisor (max-width: 1190px) {
.supervisor-workspace__category { display: block; width: 100%; flex-shrink: 0; }
.supervisor-workspace__graph-list > .page-tabs { display: none; }
}
@container supervisor (max-width: 860px) {
.supervisor-workspace__graph-canvas {
display: flex;
flex-direction: column;
height: var(--fill-height, 660px);
min-height: 0;
overflow: hidden;
}
.supervisor-workspace__graph-canvas > .supervisor-workspace__figure,
.supervisor-workspace__graph-canvas > .story-graph-3d { flex: 1 1 0; min-height: 0; }
.supervisor-workspace__graph-canvas .supervisor-workspace__map-scroll { position: absolute; inset: 0; overflow: hidden; }
.supervisor-workspace__graph-canvas .supervisor-workspace__map { min-width: 0; height: 100%; max-height: none; }
.supervisor-workspace__graph-canvas .story-graph-3d__viewport { flex: 1 1 0; min-height: 0; aspect-ratio: auto; }
.supervisor-workspace__graph-layout > .supervisor-workspace__detail { max-height: var(--fill-height, 660px); }
}
+1
View File
@@ -257,6 +257,7 @@ app.whenReady().then(async () => {
evidence.workspaceLayouts = workspaceLayouts
evidence.workspaceTriggerHeight = idleTrigger
await key('Escape')
await wait('document.querySelector(".sidebar").inert')
win.setContentSize(1280, 800)
await wait('innerWidth === 1280')
await click('.sidebar-toggle')
+247 -46
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, writeFile as writeArtifact } from 'node:fs/promises'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
@@ -8,8 +8,10 @@ import { setTimeout } from 'node:timers/promises'
const directory = process.env.GOODBUDDY_SUPERVISOR_DIRECTORY
const artifacts = process.env.GOODBUDDY_SUPERVISOR_ARTIFACTS || directory
const writeFile = (path, data) => process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT && path.endsWith('.png') ? Promise.resolve() : writeArtifact(path, data)
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app
.whenReady()
.then(async () => {
@@ -61,72 +63,166 @@ app
await settle()
}
const reports = []
const selectHistory = async (id) => {
await js('document.querySelector(".supervisor-workspace__result-navigation button").click()')
await wait('!!document.querySelector(".supervisor-workspace__history-menu")')
await js(`[...document.querySelectorAll('.supervisor-workspace__history-menu button')].find(item => item.value === ${JSON.stringify(id)}).click()`)
}
if (process.env.GOODBUDDY_SUPERVISOR_HISTORY) {
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?recap=1')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
for (const width of [1440, 390]) for (const theme of ['light', 'dark']) {
win.setContentSize(width, 800)
await wait(`innerWidth === ${width}`)
await js(`document.documentElement.dataset.theme = '${theme}'`)
await js('document.querySelector(".supervisor-workspace__result-navigation button").click()')
await wait('!!document.querySelector(".supervisor-workspace__history-menu")')
await settle()
const report = await js(`(() => {
const menu = document.querySelector('.supervisor-workspace__history-menu');
const context = document.querySelector('.supervisor-workspace__review-context');
const r = menu.getBoundingClientRect();
return { left:r.left, right:r.right, bottom:r.bottom, top:r.top,
overflow:menu.scrollWidth > menu.clientWidth + 1 || context.scrollWidth > context.clientWidth + 1,
options:[...menu.querySelectorAll('[role=menuitemradio]')].map(e => e.textContent),
duplicate:!!context.querySelector('.supervisor-workspace__review-meta') };
})()`)
assert(report.left >= 16 && report.right <= width - 16 && report.top >= 16 && report.bottom <= 784)
assert(!report.overflow && !report.duplicate)
assert.equal(report.options.length, 2)
assert(report.options.every(text => text.includes('生成于') && text.includes('全局') && text.includes('时间范围')))
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
reports.push({ width, theme, ...report })
}
await selectHistory('older-result')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector("#supervisor-tab-graph").click()')
assert.equal(await js('document.querySelector(".supervisor-workspace__result-navigation button").value'), 'older-result')
assert.deepEqual(errors, [])
console.log(JSON.stringify({ history: 'passed', reports }))
win.destroy(); app.quit(); return
}
const checkSpiral = async () => {
const report = await js(`(() => {
const heading = document.querySelector('.story-graph-3d__toolbar');
const viewport = document.querySelector('.story-graph-3d__viewport');
const picker = document.querySelector('.story-graph-3d__picker');
const info = heading.querySelector('button[title="图例"]');
const h = heading.getBoundingClientRect(), v = viewport.getBoundingClientRect(), p = picker.getBoundingClientRect();
return {scene:JSON.parse(document.documentElement.dataset.helix),
levels:heading.parentElement.children.length, headingBottom:h.bottom, canvasTop:v.top,
pickerInHeading:heading.contains(picker), pickerTop:p.top, headingTop:h.top,
rootFont:getComputedStyle(heading.querySelector('strong')).fontSize,
iconOnly:info.textContent === '', infoLabel:info.getAttribute('aria-label'),
presets:document.querySelectorAll('.story-graph-3d__views,[aria-label="视角"]').length,
caption:document.querySelectorAll('.story-graph-3d__scale').length};
})()`)
assert.equal(report.levels, 2, 'Only heading controls and canvas')
assert.equal(report.canvasTop, report.headingBottom, 'No inner toolbar row between title and canvas')
assert(report.pickerInHeading && report.pickerTop >= report.headingTop)
assert.equal(report.rootFont, '13px')
assert(report.iconOnly && report.infoLabel === '图例')
assert.equal(report.presets, 0); assert.equal(report.caption, 0)
const { timeline, marks, staves } = report.scene
for (const mark of marks) assert(mark.storyId ? mark.color !== timeline.colors[0] : mark.color === timeline.colors[0], 'Assigned and unassigned marks keep their color semantics')
assert.equal(timeline.groups.reduce((total, group) => total + group.count, 0), timeline.indices, 'Every face belongs to the single helix')
assert(timeline.groups.some(group => group.materialIndex === 0), 'Sparse events leave neutral gaps')
const assignedIntervals = timeline.intervals.filter(interval => interval.event?.storyId)
if (assignedIntervals.length) assert(timeline.groups.some(group => group.materialIndex > 0), 'Saved intervals produce colored tube faces')
for (const interval of assignedIntervals) {
const mark = marks.find(mark => mark.ids.includes(interval.event.id))
assert(mark && mark.storyId === interval.event.storyId)
assert(timeline.colors.includes(mark.color), 'Event mark and interval share story palette')
assert(interval.from >= interval.event.t && interval.to <= interval.event.end)
}
for (const group of timeline.groups.filter(group => group.materialIndex > 0)) {
const covered = assignedIntervals.filter(interval => interval.from < group.to && interval.to > group.from)
const duration = covered.reduce((sum, interval) => sum + Math.max(0, Math.min(interval.to, group.to) - Math.max(interval.from, group.from)), 0)
assert(Math.abs(duration - (group.to - group.from)) < 200, 'Actual colored vertices never cover a gap (Float32 geometry tolerance)')
for (const interval of covered.filter(interval => Math.min(interval.to, group.to) - Math.max(interval.from, group.from) > 200)) {
assert.equal(timeline.colors[group.materialIndex], marks.find(mark => mark.ids.includes(interval.event.id))?.color)
}
}
for (const stave of staves) for (const mark of marks.filter(mark => mark.storyId === stave.id)) assert.equal(stave.color, mark.color)
await js(`document.querySelector('.story-graph-3d__toolbar button[title="图例"]').focus(); document.activeElement.click()`)
await wait('!!document.querySelector(".inline-help__content")')
assert(await js('document.activeElement.getAttribute("aria-expanded") === "true"'))
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
await wait('!document.querySelector(".inline-help__content")')
assert(await js('document.activeElement.getAttribute("title") === "图例"'), 'Info Escape retains trigger focus')
return { coloredIntervals: assignedIntervals.length, coloredGroups: timeline.groups.filter(group => group.materialIndex > 0).length,
marks: marks.length, assignedEvents: marks.filter(mark => mark.storyId).flatMap(mark => mark.ids).length,
unassignedEvents: marks.filter(mark => !mark.storyId).flatMap(mark => mark.ids).length }
}
const capturePage = async () => process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT
? { toPNG: () => new Uint8Array() }
: win.webContents.capturePage()
if (process.env.GOODBUDDY_SUPERVISOR_BACKUP) {
const resultId = process.env.GOODBUDDY_SUPERVISOR_RESULT
assert(resultId, 'Specify the historical result to measure')
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&portable=1')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector("#supervisor-tab-graph").click()')
await js(`(() => { const e = document.querySelector('.supervisor-workspace__result-navigation select'); e.value = ${JSON.stringify(resultId)}; e.dispatchEvent(new Event('change', {bubbles:true})); })()`)
await selectHistory(resultId)
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && document.querySelectorAll(".supervisor-workspace__list-panel > button").length === 21')
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21')
await js('document.fonts.ready')
await settle()
const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
const initialCamera = await js('JSON.parse(document.documentElement.dataset.camera)')
assert(Math.abs(initialCamera[1] / 900 - Math.sin(0.38)) < 1e-6, 'Default oblique camera')
const expected = await js(`fetch('/portable-review.json').then(r=>r.json()).then(data=>data.graphs[${JSON.stringify(resultId)}].events.map(e=>e.id))`)
assert.deepEqual([...geometry.events].sort(), [...expected].sort())
assert.deepEqual([...geometry.dots].sort(), [...expected].sort(), 'Real event clusters on the helix, not just empty staves')
assert.equal(geometry.points.length, 1401)
assert.equal(geometry.points.length, 2801, 'Actual rings of one continuous tube for point-only history')
assert(geometry.clusters > 1 && geometry.clusters <= 21, 'Events occupy multiple real time positions after layout')
const radii = geometry.points.map(point => point[0])
assert(Math.max(...radii) - Math.min(...radii) > 40)
for (const [width, theme] of [[1440, 'light'], [390, 'dark']]) {
for (const [width, theme] of [[1440, 'light'], [1440, 'dark'], [1024, 'light'], [390, 'light'], [390, 'dark']]) {
win.setContentSize(width, 1000)
await wait(`innerWidth === ${width} && innerHeight === 1000`)
await js(`document.documentElement.dataset.theme = '${theme}'`)
await settle()
const boxes = await js(`(() => {
const box = e => { const r=e.getBoundingClientRect(); return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height}; };
const column=document.querySelector('.supervisor-workspace__graph-canvas');
const selector=column.querySelector('.supervisor-workspace__result-navigation select');
const selector=document.querySelector('.supervisor-workspace__result-navigation button');
const toolbar=document.querySelector('.story-graph-3d__toolbar');
const views=toolbar.querySelector('.story-graph-3d__views');
const segment=views.querySelector('.segmented-control');
const views=toolbar.querySelector('button[title="图例"]');
const segment=toolbar.querySelector('.segmented-control');
const buttons=[...segment.querySelectorAll('button')].map(box);
return {width:innerWidth,pageWidth:document.documentElement.scrollWidth,column:box(column),selector:box(selector),
heading:box(column.querySelector('.supervisor-workspace__canvas-heading')),toolbar:box(toolbar),views:box(views),
context:box(document.querySelector('.supervisor-workspace__review-context')),toolbar:box(toolbar),views:box(views),
canvas:box(document.querySelector('.story-graph-3d__gl')),gaps:buttons.slice(1).map((b,i)=>b.left-buttons[i].right),
segmentPadding:getComputedStyle(segment).paddingTop,controlGap:getComputedStyle(views).gap,
segmentPadding:getComputedStyle(segment).paddingTop,
events:document.querySelectorAll('.supervisor-workspace__list-panel > button').length,
bounds:JSON.parse(document.documentElement.dataset.sceneBounds),
scene:JSON.parse(document.documentElement.dataset.helix).dots.length,
clusters:JSON.parse(document.documentElement.dataset.helix).clusters};
})()`)
assert(boxes.selector.left >= boxes.column.left + 16 && boxes.selector.right <= boxes.column.right - 16, 'Selector inside central-column padding')
assert(boxes.selector.top >= boxes.column.top && boxes.selector.bottom <= boxes.heading.bottom, 'History inside heading, not above three columns')
assert.equal(Math.round(boxes.toolbar.top - boxes.heading.bottom), 16, 'Toolbar top inset')
assert(boxes.selector.top >= boxes.context.top && boxes.selector.bottom <= boxes.context.bottom, 'History inside shared context')
assert(boxes.context.bottom <= boxes.column.top, 'Review context above graph columns')
assert(boxes.gaps.every(gap => gap >= 2), 'Shared camera segment gaps')
assert.equal(boxes.segmentPadding, '3px')
assert.equal(boxes.controlGap, '8px')
assert(boxes.views.right <= boxes.column.right - 16 && boxes.pageWidth <= width, 'Controls fit without overflow')
assert(boxes.views.right <= boxes.column.right - 16 && boxes.pageWidth <= width, `Controls fit without overflow: ${JSON.stringify(boxes)}`)
assert(boxes.canvas.width > 0 && boxes.canvas.height > 0)
assert.equal(boxes.events, 21); assert.equal(boxes.scene, 21)
assert(Object.values(boxes.bounds).every(value => Math.abs(value) < 1), 'All root scene vertices fit within the camera viewport')
reports.push({ theme, ...boxes })
reports.push({ theme, ...boxes, colors: await checkSpiral() })
if (width === 1440 && !process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT) await writeFile(join(artifacts, 'portable-spiral.png'), (await win.webContents.capturePage()).toPNG())
}
await js('[...document.querySelectorAll(".story-graph-3d__picker")].find(b=>b.textContent.includes("事件")).click()')
await wait('document.querySelectorAll(".story-graph-3d__menu [role=menuitem]").length === 21')
await js('document.querySelector(".story-graph-3d__menu [role=menuitem]").click()')
await js('document.querySelector(".supervisor-workspace__list-panel > button").click()')
await wait('document.querySelector(".supervisor-workspace__list-panel > button")?.getAttribute("aria-pressed") === "true"')
// The pre-assignment case uses the same real events with an explicitly empty membership response.
await js(`window.goodbuddy.supervision.stories = async () => ({stories:[],experiences:[],unassigned:21,canUndo:false}); document.querySelector('.supervisor-workspace__canvas-heading > .icon-button').click()`)
await js(`window.goodbuddy.supervision.stories = async () => ({stories:[],experiences:[],unassigned:21,canUndo:false}); document.querySelector('.supervisor-workspace__review-context > .secondary-button').click()`)
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21 && document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector(".story-graph-3d__picker").click()')
await wait('document.querySelectorAll(".story-graph-3d__menu [role=menuitem]").length === 1')
await js('document.querySelector(".story-graph-3d__menu [role=menuitem]").click()')
await wait('document.querySelector(".story-graph-3d__picker")?.textContent.includes("未归属事件")')
assert.equal(await js('JSON.parse(document.documentElement.dataset.helix).dots.length'), 21)
assert(await js('JSON.parse(document.documentElement.dataset.helix).marks.every(mark => !mark.storyId)'), 'Empty membership keeps neutral event marks')
assert.deepEqual(errors, [])
await writeFile(join(artifacts, 'portable-measurements.json'), JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], errors }, null, 2))
console.log(JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], emptyMembershipEvents: 21, errors }))
@@ -140,29 +236,123 @@ app
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 8')
const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
assert.equal(geometry.points.length, 1401, 'One continuous, full-span helix')
const palettes = new Map()
assert(new Set(geometry.timeline.groups.filter(group => group.materialIndex > 0).map(group => group.materialIndex)).size >= 2, 'Different primary stories paint distinct interval materials in this fixture')
assert(geometry.points.length >= 2801, 'One continuous helix with exact interval boundaries')
const radii = geometry.points.map(p => p[0])
assert(Math.max(...radii) - Math.min(...radii) > 40, 'Actual rendered geometry reflects uneven attention')
geometry.points.forEach((point, i) => assert(Math.abs(point[1] - (-215 + 430 * i / 1400)) < 1e-8, 'Height advances uniformly'))
geometry.points.forEach((point, i) => { if (i) assert(point[1] > geometry.points[i - 1][1], 'Actual tube advances monotonically in time') })
assert(!geometry.events.includes('outside-review'), 'Aggregate story events outside this review are not plotted')
for (const [width, theme] of [[1440, 'light'], [390, 'dark']]) {
for (const [width, theme] of [[1440, 'light'], [1440, 'dark'], [1024, 'light'], [390, 'light'], [390, 'dark']]) {
win.setContentSize(width, 1000)
await wait(`innerWidth === ${width} && innerHeight === 1000`)
await js(`document.documentElement.dataset.theme = '${theme}'`)
await settle()
const boxes = await js(`(() => {
const selector = document.querySelector('.supervisor-workspace__result-navigation select');
const selector = document.querySelector('.supervisor-workspace__result-navigation button');
const r = selector.getBoundingClientRect(), gl = document.querySelector('.story-graph-3d__gl').getBoundingClientRect();
return { value: selector.value, left: r.left, right: r.right, width: innerWidth, canvasWidth: gl.width, canvasHeight: gl.height, pageWidth: document.documentElement.scrollWidth };
const toolbar = document.querySelector('.story-graph-3d__toolbar').getBoundingClientRect();
const breadcrumb = document.querySelector('.story-graph-3d__breadcrumb');
const context = document.querySelector('.supervisor-workspace__review-context').getBoundingClientRect();
return { value: selector.value, left: r.left, right: r.right, top: r.top, bottom: r.bottom,
toolbar: { left: toolbar.left, top: toolbar.top, bottom: toolbar.bottom },
atRoot: breadcrumb.textContent === '全部', context: {top:context.top,bottom:context.bottom},
pickers: document.querySelectorAll('.story-graph-3d__picker').length,
headingSelectors: document.querySelectorAll('.supervisor-workspace__canvas-heading select').length,
selectors: document.querySelectorAll('.supervisor-workspace__result-navigation button').length,
width: innerWidth, canvasWidth: gl.width, canvasHeight: gl.height, pageWidth: document.documentElement.scrollWidth };
})()`)
assert.equal(boxes.value, 'fixture-result')
assert.equal(boxes.selectors, 1)
assert.equal(boxes.headingSelectors, 0)
assert(boxes.atRoot, 'Root All breadcrumb stays independent of review history')
assert.equal(boxes.pickers, 1, 'Only the hierarchy drill picker remains')
assert(boxes.top >= boxes.context.top && boxes.bottom <= boxes.context.bottom, 'History is inside shared review context')
assert(boxes.context.bottom <= boxes.toolbar.top, 'Context precedes graph tools')
assert(boxes.left >= 0 && boxes.right <= boxes.width && boxes.pageWidth <= boxes.width, 'History selector fits without horizontal overflow')
assert(boxes.canvasWidth > 0 && boxes.canvasHeight > 0, 'WebGL canvas is laid out')
assert((await checkSpiral()).coloredGroups > 0, 'Interval fixture paints the actual tube in both themes and all sizes')
palettes.set(theme, await js('JSON.parse(document.documentElement.dataset.helix).timeline.colors'))
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
await wait('!!document.querySelector(".supervisor-workspace__map")')
assert.equal(await js('getComputedStyle(document.querySelector(".supervisor-workspace__canvas-heading > strong")).fontSize'), '13px', 'Flat and spiral All use identical typography')
await js('document.querySelector(".supervisor-workspace__canvas-heading button[title=图例]").click()')
await wait('!!document.querySelector(".inline-help__content")')
await js('window.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
await wait('!document.querySelector(".inline-help__content")')
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.textContent === "时间螺旋").click()')
await wait('!!document.querySelector(".story-graph-3d__gl")')
await js('document.querySelector("#supervisor-tab-overview").click(); document.querySelector(".page-shell").scrollTop = 0')
await settle()
const overview = await js(`(() => { const r = document.querySelector('.supervisor-workspace__review-context').getBoundingClientRect();
return {top:r.top,bottom:r.bottom,forms:document.querySelectorAll('.supervisor-workspace__toolbar').length}; })()`)
assert.equal(overview.top, boxes.context.top, 'Review bar keeps its top across tabs')
assert.equal(overview.bottom, boxes.context.bottom, 'Review bar keeps its height across tabs')
assert.equal(overview.forms, 0, 'New review inputs collapsed by default')
await js('document.querySelector("#supervisor-tab-graph").click()')
await wait('!!document.querySelector(".story-graph-3d__gl")')
}
await js(`(() => { const e = document.querySelector('.supervisor-workspace__result-navigation select');
e.value = 'older-result'; e.dispatchEvent(new Event('change', {bubbles:true})); })()`)
assert.notDeepEqual(palettes.get('light'), palettes.get('dark'), 'WebGL palette follows theme tokens')
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.textContent === "时间螺旋").click()')
await wait('!!document.querySelector(".story-graph-3d__gl")')
const cameraBefore = await js('document.documentElement.dataset.camera')
await js('document.querySelector(".story-graph-3d__viewport canvas[tabindex]").dispatchEvent(new KeyboardEvent("keydown", {key:"ArrowLeft",bubbles:true}))')
await wait(`document.documentElement.dataset.camera !== ${JSON.stringify(cameraBefore)}`)
win.show(); win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
await js('document.querySelector(".story-graph-3d__toolbar button[title=图例]").focus()')
win.webContents.sendInputEvent({type:'keyDown', keyCode:'Space'})
win.webContents.sendInputEvent({type:'keyUp', keyCode:'Space'})
await wait('document.activeElement.getAttribute("aria-expanded") === "true"')
win.webContents.sendInputEvent({type:'keyDown', keyCode:'Escape'})
win.webContents.sendInputEvent({type:'keyUp', keyCode:'Escape'})
await wait('!document.querySelector(".inline-help__content")')
const dragAt = await js(`(() => { const c = document.querySelector('.story-graph-3d__viewport canvas[tabindex]'); c.scrollIntoView();
const r = c.getBoundingClientRect(); return {x:Math.round(r.left+r.width/2), y:Math.round(r.top+r.height/2)}; })()`)
const beforeDrag = await js('document.documentElement.dataset.camera')
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...dragAt})
win.webContents.sendInputEvent({type:'mouseMove',button:'left',modifiers:['leftButtonDown'],x:dragAt.x+30,y:dragAt.y+12})
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:dragAt.x+30,y:dragAt.y+12})
await wait(`document.documentElement.dataset.camera !== ${JSON.stringify(beforeDrag)}`)
win.setContentSize(1024, 900)
await settle()
assert(await js('!document.querySelector(".supervisor-workspace__detail").getClientRects().length'), 'Medium details closed by default')
await js('document.querySelector(".supervisor-workspace__list-panel > button").click()')
await wait('document.activeElement === document.querySelector(".supervisor-workspace__detail")')
assert(await js(`(() => { const panel = document.querySelector('.supervisor-workspace__detail').getBoundingClientRect();
const graph = document.querySelector('.supervisor-workspace__graph-layout').getBoundingClientRect();
return panel.top >= graph.top && panel.bottom <= graph.bottom + 1; })()`), 'Medium details stay inside graph row')
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
await wait('!document.querySelector(".supervisor-workspace__detail").getClientRects().length')
assert(await js('document.activeElement.matches(".supervisor-workspace__detail-toggle")'), 'Details restore toggle focus')
win.setContentSize(1440, 700)
await settle()
assert(await js('document.querySelector(".supervisor-workspace__graph-layout").getBoundingClientRect().height < 660'), 'Short spiral does not inherit flat graph minimum')
for (const name of ['Fixture project', 'Fixture story']) {
await js('document.querySelector(".story-graph-3d__picker").click()')
await wait('!!document.querySelector(".story-graph-3d__menu [role=menuitem]")')
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"End",bubbles:true}))')
assert(await js('document.activeElement === [...document.querySelectorAll(".story-graph-3d__menu [role=menuitem]")].at(-1)'), 'Hierarchy menu keyboard End')
await js(`([...document.querySelectorAll('.story-graph-3d__menu [role=menuitem]')].find(e => e.textContent.includes('${name}'))).click()`)
await wait(`document.querySelector('.story-graph-3d__breadcrumb strong')?.textContent === '${name}'`)
if (name === 'Fixture project') await checkSpiral()
assert.equal(await js('document.querySelectorAll(".story-graph-3d__breadcrumb select").length'), 0)
}
await js('[...document.querySelectorAll(".story-graph-3d__breadcrumb button")].find(b => b.textContent === "Fixture project").click()')
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "Fixture project"')
await js('document.querySelector(".story-graph-3d__breadcrumb button").click()')
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "全部"')
assert.equal(await js('document.querySelectorAll(".story-graph-3d__breadcrumb > span").length'), 1)
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
await wait('!!document.querySelector(".supervisor-workspace__map")')
assert.equal(await js('document.querySelector(".supervisor-workspace__result-navigation button").value'), 'fixture-result')
assert.equal(await js('document.querySelectorAll(".supervisor-workspace__result-navigation button").length'), 1)
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
await wait('!!document.querySelector(".story-graph-3d__gl")')
await selectHistory('older-result')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 1')
await js('document.querySelector("#supervisor-tab-overview").click()')
await wait('document.querySelector(".supervisor-workspace__result-navigation select")?.value === "older-result"')
await wait('document.querySelector(".supervisor-workspace__result-navigation button")?.value === "older-result"')
assert.deepEqual(errors, [])
console.log(JSON.stringify({ spiral: 'passed', vertices: geometry.points.length, radiusRange: [Math.min(...radii), Math.max(...radii)], widths: [1440, 390], errors }))
win.destroy(); app.quit(); return
@@ -249,12 +439,15 @@ app
win.show()
const controlsOnly = process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT === 'controls'
for (const view of controlsOnly ? ['activity', 'pending'] : ['recap', 'empty', 'activity', 'pending', 'failure']) {
console.log(`Checking content layout: ${view}`)
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"')
if (view === 'activity') {
await js('document.querySelector("#supervisor-tab-activity").click()')
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
} else if (view === 'pending' || view === 'failure') {
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
await wait('!!document.querySelector(".supervisor-workspace__toolbar .primary-button")')
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click()')
await wait(`document.documentElement.dataset.reviewNoticeTone === '${view === 'pending' ? 'info' : 'error'}'`)
}
@@ -268,8 +461,10 @@ app
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, top:r.top, bottom:r.bottom }; };
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__review-context'), recap:box('.supervisor-workspace__recap'),
prose:box('.supervisor-workspace__prose'), activity:box('.supervisor-activity'),
readingColumns:document.querySelector('.supervisor-workspace__recap') && getComputedStyle(document.querySelector('.supervisor-workspace__recap')).gridTemplateColumns.split(' ').map(parseFloat),
sidebar:box('.supervisor-workspace__recap-sidebar'),
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),
@@ -288,7 +483,9 @@ app
} else if (view !== 'activity') {
assert(report.summaryLength > 2000 && report.tail, 'Long summary retained')
assert(Math.abs(report.recap.width - report.panel.width) < 1, 'Recap fills the page panel')
for (const control of [report.toolbar, report.history]) {
if (width >= 1024) assert(Math.abs(report.readingColumns[0] / report.readingColumns[1] - 2) < 0.02, 'Overview uses a 2:1 reading layout')
else assert(report.sidebar.top >= report.prose.bottom, 'Narrow overview puts supporting content after summary')
for (const control of [report.toolbar]) {
assert(Math.abs(control.left - report.recap.left) < 1 && Math.abs(control.right - report.recap.right) < 1, 'Toolbar, history and result share edges')
}
assert(report.prose.left - report.recap.left <= 25, 'No separately centered inner body')
@@ -308,11 +505,11 @@ app
}
}
reports.push({ view, theme, ...report })
await writeFile(join(artifacts, `${view}-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
await writeFile(join(artifacts, `${view}-${theme}-${width}.png`), (await capturePage()).toPNG())
if (view === 'recap') {
await js('document.querySelector(".supervisor-workspace__prose").lastElementChild.scrollIntoView({block:"end"})')
await settle()
await writeFile(join(artifacts, `${view}-tail-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
await writeFile(join(artifacts, `${view}-tail-${theme}-${width}.png`), (await capturePage()).toPNG())
}
}
}
@@ -419,7 +616,7 @@ app
if (scenario !== 'empty') {
assert.equal(report.paragraphs, 4)
assert(report.text.includes('外部评审时间') && report.text.includes('未解决事项'))
assert(report.clientWidth - report.proseWidth <= 50, 'Prose fills the panel within card padding')
assert(report.proseWidth > report.clientWidth * (width > 1000 ? 0.55 : 0.8), 'Prose uses the primary reading column')
} else assert(report.text.includes('还没有成功回顾'))
} else {
assert(!report.text.includes('暂无历史心跳报告'))
@@ -461,14 +658,15 @@ app
assert(empty.titleTop > empty.boxTop + 60 && Math.abs(empty.titleTop - middle) < (empty.boxBottom - empty.boxTop) / 4, 'Graph empty state is vertically centred')
assert(empty.shellBottom - empty.boxBottom <= 48, 'Graph empty state reaches the bottom margin')
assert.equal(empty.pageScroll, false, 'Graph empty state does not scroll the page')
assert.deepEqual(empty.buttons, ['工作回顾', '刷新'])
assert.equal(empty.outsideRefresh, 0)
assert.deepEqual(empty.buttons, ['工作回顾'])
assert.equal(empty.outsideRefresh, 1)
reports.push({ scenario: 'graph-empty', width, height, ...empty })
await writeFile(join(artifacts, `graph-empty-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
}
}
if (scenario === 'populated') {
await js('document.querySelector("#supervisor-tab-overview").click()')
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
// Toolbar: incremental review button, refresh, and the more menu holding re-analysis.
const toolbar = await js(`(() => {
const bar = document.querySelector('.supervisor-workspace__toolbar');
@@ -498,7 +696,7 @@ app
await writeFile(join(artifacts, `recap-reanalyze-confirm-${win.getContentSize()[0]}.png`), (await win.webContents.capturePage()).toPNG())
await js('document.querySelector(".supervisor-workspace__confirm .secondary-button").click()')
await wait('!document.querySelector(".supervisor-workspace__confirm")')
await js(`(() => { const s = document.querySelector('.supervisor-workspace__result-navigation select'); s.value = 'older-result'; s.dispatchEvent(new Event('change', {bubbles:true})); })()`)
await selectHistory('older-result')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click(); document.querySelector(".page-shell").scrollTop = 0')
await settle()
@@ -843,7 +1041,7 @@ app
)
assert(
report.texts.every(
(text) => text.inSvg && text.painted && text.screenFontSize >= 11
(text) => text.inSvg && text.painted && (width < 1024 || text.screenFontSize >= 11)
),
`Clipped or invisible SVG text at ${width}: ${JSON.stringify(report.texts.filter(text => !(text.inSvg && text.painted && text.screenFontSize >= 11)).slice(0, 3))} svg=${JSON.stringify(report.svg)}`
)
@@ -887,15 +1085,15 @@ app
)
if (width === 390) {
assert(
report.scroller.scrollWidth > report.scroller.clientWidth,
'Keep mobile labels readable with local scrolling'
report.scroller.scrollWidth === report.scroller.clientWidth,
'Mobile canvas fits without internal scrolling; full labels remain in the list'
)
await js(
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft = 600'
)
assert(
await js(
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft > 0'
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft === 0'
)
)
await writeFile(
@@ -1021,7 +1219,7 @@ app
shell.scrollTop = 0;
const layout = document.querySelector('.supervisor-workspace__graph-layout');
const shellBox = shell.getBoundingClientRect(), box = layout.getBoundingClientRect();
const columns = [...layout.children].map(column => {
const columns = [...layout.children].filter(column => column.getClientRects().length).map(column => {
const scroller = column.matches('.supervisor-workspace__graph-list') ? column.querySelector('[role=tabpanel]') : column;
const r = column.getBoundingClientRect();
return { name: column.className.split(' ')[0], canvas: column.matches('.supervisor-workspace__graph-canvas'), top: r.top, bottom: r.bottom,
@@ -1053,11 +1251,12 @@ app
}
if (width === 1440) {
const heading = await js(`(() => {
const title = document.querySelector('.supervisor-workspace__canvas-title'), [name, meta] = title.children;
return { name: name.textContent, meta: meta.textContent, metaBelow: meta.getBoundingClientRect().top >= name.getBoundingClientRect().bottom - 1,
actionBarAbove: !!document.querySelector('[role=tabpanel] > .supervisor-workspace__action-bar'), height: document.querySelector('.supervisor-workspace__canvas-heading').getBoundingClientRect().height };
const context = document.querySelector('.supervisor-workspace__review-context');
return { meta: context.querySelector('.sr-only').textContent,
above: context.getBoundingClientRect().bottom <= document.querySelector('.supervisor-workspace__graph-layout').getBoundingClientRect().top,
heading: document.querySelector('.supervisor-workspace__canvas-heading').textContent };
})()`)
assert(heading.metaBelow && heading.meta.includes('图谱范围') && !heading.actionBarAbove && heading.height <= 80, `Scope line belongs under the canvas title: ${JSON.stringify(heading)}`)
assert(heading.above && heading.meta.includes('全局') && !heading.heading.includes('事件与知识'), `Review context shared above graph: ${JSON.stringify(heading)}`)
reports.push({ heading })
}
assert(Math.abs((fill.graphArea.playbackBottom ?? fill.graphArea.map.bottom) - fill.graphArea.canvasBottom) <= 1,
@@ -1218,15 +1417,17 @@ app
}
await js('document.querySelector("#supervisor-tab-overview").click()')
await settle()
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
await wait('!!document.querySelector(".supervisor-workspace__toolbar .primary-button")')
await js(
'document.querySelector(".supervisor-workspace__toolbar .primary-button").click()'
)
await wait(
'!!document.querySelector(".supervisor-workspace__inline-error")'
'document.documentElement.dataset.reviewNoticeTone === "error"'
)
assert(
await js(
'document.querySelector("[role=alert]").textContent.includes("模型暂时不可用")'
'document.documentElement.dataset.reviewNoticeMessage.includes("回顾未能完成")'
)
)
await writeFile(
+34 -15
View File
@@ -22,28 +22,44 @@ if (params.has('spiral')) {
const events = new Set<string>()
const dots = new Set<string>()
let clusters = 0
let timeline: unknown
let marks: Array<{ ids: string[]; storyId?: string; color: string }> = []
let staves: Array<{ id: string; color: string }> = []
THREE.Group.prototype.clear = function () {
events.clear(); dots.clear(); clusters = 0
events.clear(); dots.clear(); clusters = 0; marks = []; staves = []
return clear.call(this)
}
THREE.Group.prototype.add = function (...objects) {
for (const object of objects) {
const path = ((object as import('three').Mesh).geometry as import('three').TubeGeometry)?.parameters?.path as import('three').CatmullRomCurve3 | undefined
if (path?.points?.length === 1401) {
points = path.points.map(point => [Math.hypot(point.x, point.z), point.y])
events.clear()
}
for (const event of object.userData.node?.events ?? []) events.add(event.id)
if (object.userData.cluster) {
clusters++
for (const event of object.userData.cluster.events) dots.add(event.id)
marks.push({ ids: object.userData.cluster.events.map((event: { id: string }) => event.id), storyId: object.userData.cluster.events[0]?.storyId,
color: ((object as import('three').Mesh).material as import('three').MeshStandardMaterial).color.getHexString() })
}
if (object.userData.timeline) {
const mesh = object as import('three').Mesh<import('three').TubeGeometry, import('three').MeshStandardMaterial[]>
const positions = mesh.geometry.getAttribute('position'), count = mesh.geometry.parameters.tubularSegments
const center = (ring: number) => {
const sum = new THREE.Vector3()
for (let i = 0; i < 8; i++) sum.add(new THREE.Vector3().fromBufferAttribute(positions, ring * 9 + i))
return sum.divideScalar(8)
}
points = Array.from({length: count + 1}, (_, i) => { const p = center(i); return [Math.hypot(p.x, p.z), p.y] })
const intervals = object.userData.timeline
const timeAt = (ring: number) => intervals[0].from + (center(ring).y / 430 + 0.5) * (intervals.at(-1).to - intervals[0].from)
timeline = { intervals, groups: mesh.geometry.groups.map(group => ({...group, from:timeAt(group.start / 48), to:timeAt((group.start + group.count) / 48)})),
colors: mesh.material.map(material => material.color.getHexString()), indices: mesh.geometry.index!.count }
}
if (object.userData.node && !object.userData.cluster) staves.push({ id: object.userData.node.id,
color: ((object as import('three').Mesh).material as import('three').MeshBasicMaterial).color.getHexString() })
}
document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events], dots: [...dots], clusters })
document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events], dots: [...dots], clusters, timeline, marks, staves })
return add.apply(this, objects)
}
if (params.has('portable')) {
THREE.Scene.prototype.onAfterRender = function (_renderer, scene, camera) {
document.documentElement.dataset.camera = JSON.stringify(camera.position.toArray())
const point = new THREE.Vector3(), bounds = { left: 1, right: -1, top: -1, bottom: 1 }
scene.traverse(object => {
const positions = (object as import('three').Mesh).geometry?.getAttribute('position')
@@ -56,7 +72,6 @@ if (params.has('spiral')) {
})
document.documentElement.dataset.sceneBounds = JSON.stringify(bounds)
}
}
}
const stories = [
{
@@ -193,13 +208,13 @@ Object.defineProperty(window, 'goodbuddy', {
? {}
: {
supervision: {
...(params.has('spiral') ? { stories: async () => ({ stories: [{
id: 'feature', projectId: 'p', projectName: 'Fixture project', parentId: null, level: 'feature', name: 'Fixture story',
...(params.has('spiral') ? { stories: async () => ({ stories: [0, 1].map(storyIndex => ({
id: storyIndex ? 'secondary' : 'feature', projectId: 'p', projectName: 'Fixture project', parentId: null, level: 'feature', name: storyIndex ? 'Secondary story' : 'Fixture story',
description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: null, endedAt: null,
events: [...graph.events, { id: 'outside-review', title: 'Outside review', occurred_at: '2026-08-01T00:00:00Z' }].map(event => ({
events: [...graph.events, { id: 'outside-review', title: 'Outside review', occurred_at: '2026-08-01T00:00:00Z' }].filter((_event, index) => index % 2 === storyIndex).map(event => ({
id: event.id, title: event.title, startedAt: event.occurred_at, endedAt: event.occurred_at, projectId: 'p', primary: true, userSet: false
}))
}], experiences: [], unassigned: 0, canUndo: false }) } : {}),
})), experiences: [], unassigned: 0, canUndo: false }) } : {}),
continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }),
continue: async () => { throw new Error('Preview fixture must not send a message') },
execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }),
@@ -253,7 +268,8 @@ Object.defineProperty(window, 'goodbuddy', {
eventSources: []
}
: params.has('spiral') ? { ...graph,
events: input?.resultId === 'older-result' ? graph.events.slice(0, 1) : graph.events,
events: (input?.resultId === 'older-result' ? graph.events.slice(0, 1) : graph.events).map((event, index) => ({ ...event,
started_at: event.occurred_at, ended_at: new Date(Date.parse(event.occurred_at) + (index % 3 ? 3_600_000 : 0)).toISOString() })),
attention: [1, 1, 1, 100, 100, 4, 4].map((turns, index) => ({
start: `2026-09-${String(1 + index * 3).padStart(2, '0')}T01:00:00.000Z`, turns, characters: turns * 20
})).filter(slot => input?.resultId !== 'older-result' || slot.start < '2026-09-15')
@@ -333,7 +349,10 @@ createRoot(document.getElementById('root')!).render(
/>
</div> : <PageShell variant="supervisor">
<HeartbeatCenter
onNotify={(notice) => { document.documentElement.dataset.reviewNoticeTone = notice.tone }}
onNotify={(notice) => {
document.documentElement.dataset.reviewNoticeTone = notice.tone
document.documentElement.dataset.reviewNoticeMessage = notice.message
}}
applicationSettings={applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })}
onUpdateApplicationSettings={async () => true}
configs={menu || params.has('plan-only') ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}