diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 565ce695..6dc560e3 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -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,不能依靠进入页面时的当前项目推断。 - 状态卡片使用统一状态令牌,不只依赖颜色。 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 2f1f272a..0723cc2a 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -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-展示与使用)。历史选择移入中间画布标题区,视角改用共享分段控件,并修正默认相机底部裁切。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 9f1bcb67..40146bef 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -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`。 diff --git a/docs/features/story-graph/3d-concept.md b/docs/features/story-graph/3d-concept.md index cd17318e..879572ce 100644 --- a/docs/features/story-graph/3d-concept.md +++ b/docs/features/story-graph/3d-concept.md @@ -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) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。 diff --git a/src/main/workspace/workspace-import.test.ts b/src/main/workspace/workspace-import.test.ts index d43f6657..6d912b5e 100644 --- a/src/main/workspace/workspace-import.test.ts +++ b/src/main/workspace/workspace-import.test.ts @@ -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) diff --git a/src/renderer/src/AnchoredMenu.test.tsx b/src/renderer/src/AnchoredMenu.test.tsx new file mode 100644 index 00000000..8e165c78 --- /dev/null +++ b/src/renderer/src/AnchoredMenu.test.tsx @@ -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(null) + const [open, setOpen] = useState(false) + return <> + + {open && setOpen(false)}> + + + + + } + + } + render() + 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() +}) diff --git a/src/renderer/src/AnchoredMenu.tsx b/src/renderer/src/AnchoredMenu.tsx index 1413b02e..eaf30325 100644 --- a/src/renderer/src/AnchoredMenu.tsx +++ b/src/renderer/src/AnchoredMenu.tsx @@ -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('[role="menuitem"]:not(:disabled)') ?? menu).focus() + (menu.querySelector('[role="menuitemradio"][aria-checked="true"]:not(:disabled), [role="menuitem"][aria-current="true"]:not(:disabled)') + ?? menu.querySelector(':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('[role="menuitem"]:not(:disabled)')) + const items = Array.from(menu.querySelectorAll(':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 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 7c4e8fd2..f02946fc 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(); 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'); diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 7e8faca2..11a7e71c 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -202,9 +202,9 @@ describe('HeartbeatCenter', () => { renderComponent() 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() 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: '回顾' })) diff --git a/src/renderer/src/InlineHelp.tsx b/src/renderer/src/InlineHelp.tsx index bca59725..01a6e748 100644 --- a/src/renderer/src/InlineHelp.tsx +++ b/src/renderer/src/InlineHelp.tsx @@ -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 <> - {/* Referenced hidden text remains an accessible description without a second visible copy. */} {!open && id && } diff --git a/src/renderer/src/StoryGraph3D.tsx b/src/renderer/src/StoryGraph3D.tsx index 463a5def..fd572435 100644 --- a/src/renderer/src/StoryGraph3D.tsx +++ b/src/renderer/src/StoryGraph3D.tsx @@ -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 @@ -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(null) const overlayRef = useRef(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('oblique') const [hovered, setHovered] = useState() - 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 { + 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) => { 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

{t('supervisor.graph3d.unsupported')}

- if (failure) return
+ if (!supported) return <>{toolbar}

{t('supervisor.graph3d.unsupported')}

+ if (failure) return <>{toolbar}
{t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')} -
- if (!range) return

{t('supervisor.stories.empty')}

+
+ if (!range) return <>{toolbar}

{t('supervisor.stories.empty')}

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
-
-
} diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index c6a29afc..9beec8a1 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -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() + 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() 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() + 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() expect(screen.getByLabelText('历史结果')).toHaveValue(result.id) @@ -44,8 +95,8 @@ describe('SupervisorWorkspace', () => { const view = render() 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() await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' }) @@ -103,7 +154,7 @@ describe('SupervisorWorkspace', () => { render() 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() 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() 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() await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story' })) @@ -254,16 +313,13 @@ describe('SupervisorWorkspace', () => { const onTabChange = vi.fn() const view = render() 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() 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() + view.rerender() 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() 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() 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() 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() 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() 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() 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() 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('决定') diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index b9499c80..f719355f 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -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(graphNavigation?.focus) const [listTab, setListTab] = useState(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(null) + const detailRef = useRef(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(null) + const historyId = useId() const [confirmReanalyze, setConfirmReanalyze] = useState(false) const moreRef = useRef(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(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(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 &&
- - {tab === 'overview' && latest && } + {t('supervisor.history')} + + {latest && `${t('supervisor.generatedAt', { time: date(latest.createdAt) })} · ${historyDetails(latest)}`} + {historyOpen && !busy && setHistoryOpen(false)}> + {results.map(item => )} + }
+ const graphTools = <> + {storyState.available &&
({ value, label: t(`supervisor.graph3d.modes.${value}`) }))} />
} + + return (
@@ -515,6 +544,12 @@ export function SupervisorWorkspace({
) : ( <> + {(tab === 'overview' || tab === 'graph') &&
+ {historySelector} + + +
} {loading && (
)} - {tab === 'overview' && ( + {newReviewOpen && (tab === 'overview' || tab === 'graph') && ( <> -
+